From 377ede4a00cd53a0426c9de005e425a33f73b75f Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 25 Aug 2026 14:43:40 +0100 Subject: [PATCH 01/33] Unblock Gloas submissions through the existing bid-submission wire shape helix's ExecutionPayload/SignedBidSubmission is its own bounded-list builder<->relay wire shape, shared unchanged across Bellatrix-Fulu; it is not a mirror of the real per-fork consensus SSZ types. Route the Gloas fork through the same decode path Fulu already uses instead of erroring, and add conversion functions producing the real, progressive-list Gloas consensus types (ExecutionPayloadGloas, ExecutionRequestsGloas) for use at the outbound bid/envelope boundary. block_access_list, builder_deposits, and builder_exits are left empty (TODO(gloas): EIP-7928/EIP-8282, no producer path yet). --- crates/types/src/execution_payload.rs | 88 +++++++++++++++++++++++++-- crates/types/src/fields.rs | 54 +++++++++++++++- crates/types/src/hydration.rs | 51 +++++++++++++--- 3 files changed, 180 insertions(+), 13 deletions(-) diff --git a/crates/types/src/execution_payload.rs b/crates/types/src/execution_payload.rs index eff1535e6..fe2968593 100644 --- a/crates/types/src/execution_payload.rs +++ b/crates/types/src/execution_payload.rs @@ -8,7 +8,7 @@ use tree_hash_derive::TreeHash; use crate::{ BlockValidationError, SszError, TestRandom, convert_bloom_to_lighthouse, - convert_transactions_to_lighthouse, + convert_transactions_to_lighthouse, convert_transactions_to_progressive, fields::{Bloom, ExtraData, Transactions, Withdrawals}, }; @@ -155,10 +155,42 @@ impl ExecutionPayload { excess_blob_gas: self.excess_blob_gas, }) } + + /// Converts to the real, progressive-list Gloas execution payload shape used on-chain. + /// `block_access_list` is left empty -- TODO(gloas): populate once EIP-7928 block-access-list + /// tracking exists. + pub fn to_lighthouse_gloas_payload( + &self, + slot: lh_types::Slot, + ) -> Result, SszError> { + Ok(lh_types::ExecutionPayloadGloas { + parent_hash: self.parent_hash.into(), + fee_recipient: self.fee_recipient, + state_root: self.state_root, + receipts_root: self.receipts_root, + logs_bloom: convert_bloom_to_lighthouse(&self.logs_bloom), + prev_randao: self.prev_randao, + block_number: self.block_number, + gas_limit: self.gas_limit, + gas_used: self.gas_used, + timestamp: self.timestamp, + extra_data: self.extra_data.to_ssz_type()?, + base_fee_per_gas: self.base_fee_per_gas, + block_hash: self.block_hash.into(), + transactions: convert_transactions_to_progressive(&self.transactions), + withdrawals: self.withdrawals.iter().cloned().collect(), + blob_gas_used: self.blob_gas_used, + excess_blob_gas: self.excess_blob_gas, + block_access_list: Default::default(), + slot_number: slot, + }) + } } impl ForkVersionDecode for ExecutionPayload { - /// SSZ decode with explicit fork variant. + /// SSZ decode with explicit fork variant. Gloas uses the same bounded-list wire shape as + /// Fulu here -- this is helix's own builder<->relay representation, not the real, + /// progressive-list consensus `ExecutionPayloadGloas`; see `to_lighthouse_gloas_payload`. fn from_ssz_bytes_by_fork(bytes: &[u8], fork_name: ForkName) -> Result { let builder_bid = match fork_name { ForkName::Altair | @@ -167,13 +199,12 @@ impl ForkVersionDecode for ExecutionPayload { ForkName::Capella | ForkName::Deneb | ForkName::Electra | - ForkName::Gloas | ForkName::Heze => { return Err(ssz::DecodeError::BytesInvalid(format!( "unsupported fork for ExecutionPayloadHeader: {fork_name}", ))); } - ForkName::Fulu => ExecutionPayload::from_ssz_bytes(bytes)?, + ForkName::Fulu | ForkName::Gloas => ExecutionPayload::from_ssz_bytes(bytes)?, }; Ok(builder_bid) } @@ -328,6 +359,55 @@ mod tests { assert_eq!(our_payload.tree_hash_root(), lh_json_str.tree_hash_root()); } + #[test] + fn test_execution_payload_decodes_under_gloas_fork() { + let our_payload = ExecutionPayload::test_random(); + let ssz_bytes = our_payload.as_ssz_bytes(); + + let decoded = ExecutionPayload::from_ssz_bytes_by_fork(&ssz_bytes, ForkName::Gloas) + .expect("Gloas should decode via the same shape as Fulu"); + + assert_eq!(our_payload.tree_hash_root(), decoded.tree_hash_root()); + } + + #[test] + fn to_lighthouse_gloas_payload_preserves_fields() { + let our_payload = ExecutionPayload::test_random(); + let slot = lh_types::Slot::new(42); + + let gloas = our_payload.to_lighthouse_gloas_payload(slot).unwrap(); + + assert_eq!(gloas.parent_hash.0, our_payload.parent_hash); + assert_eq!(gloas.block_hash.0, our_payload.block_hash); + assert_eq!(gloas.fee_recipient, our_payload.fee_recipient); + assert_eq!(gloas.state_root, our_payload.state_root); + assert_eq!(gloas.receipts_root, our_payload.receipts_root); + assert_eq!(gloas.prev_randao, our_payload.prev_randao); + assert_eq!(gloas.block_number, our_payload.block_number); + assert_eq!(gloas.gas_limit, our_payload.gas_limit); + assert_eq!(gloas.gas_used, our_payload.gas_used); + assert_eq!(gloas.timestamp, our_payload.timestamp); + assert_eq!(gloas.base_fee_per_gas, our_payload.base_fee_per_gas); + assert_eq!(gloas.blob_gas_used, our_payload.blob_gas_used); + assert_eq!(gloas.excess_blob_gas, our_payload.excess_blob_gas); + assert_eq!(gloas.slot_number, slot); + assert!(gloas.block_access_list.is_empty()); + + assert_eq!(gloas.transactions.len(), our_payload.transactions.len()); + for (converted, original) in + gloas.transactions.as_slice().iter().zip(our_payload.transactions.iter()) + { + assert_eq!(converted.as_slice(), original.as_ref()); + } + + assert_eq!(gloas.withdrawals.len(), our_payload.withdrawals.len()); + for (converted, original) in + gloas.withdrawals.as_slice().iter().zip(our_payload.withdrawals.iter()) + { + assert_eq!(converted, original); + } + } + #[test] fn test_execution_payload_header() { test_execution_payload_header_variant(ForkName::Fulu); diff --git a/crates/types/src/fields.rs b/crates/types/src/fields.rs index 2c7fa64bc..7c1486568 100644 --- a/crates/types/src/fields.rs +++ b/crates/types/src/fields.rs @@ -1,9 +1,11 @@ +use std::marker::PhantomData; + use alloy_primitives::FixedBytes; use lh_types::{EthSpec, MainnetEthSpec}; use rand::Rng; -use ssz_types::{FixedVector, VariableList}; +use ssz_types::{FixedVector, ProgressiveVariableList, VariableList}; -use crate::{SszError, TestRandom, ssz_bytes_wrapper}; +use crate::{ExecutionRequestsGloas, SszError, TestRandom, ssz_bytes_wrapper}; pub type Withdrawal = lh_types::Withdrawal; pub type Withdrawals = lh_types::Withdrawals; @@ -32,6 +34,29 @@ pub fn convert_transactions_to_lighthouse( VariableList::new(new) } +/// Real, progressive-list Gloas transactions shape, per EIP-7688. +pub fn convert_transactions_to_progressive( + txs: &Transactions, +) -> lh_types::ProgressiveTransactions { + ProgressiveVariableList::new( + txs.iter().map(|tx| ProgressiveVariableList::new(tx.as_ref().to_vec())).collect(), + ) +} + +/// Converts helix's Electra-shaped builder-submission execution requests into the real, +/// progressive-list Gloas shape. `builder_deposits`/`builder_exits` are left empty -- +/// TODO(gloas): populate once EIP-8282 builder deposit/exit submission exists. +pub fn execution_requests_to_gloas(requests: &ExecutionRequests) -> ExecutionRequestsGloas { + ExecutionRequestsGloas { + deposits: requests.deposits.iter().cloned().collect(), + withdrawals: requests.withdrawals.iter().cloned().collect(), + consolidations: requests.consolidations.iter().cloned().collect(), + builder_deposits: Default::default(), + builder_exits: Default::default(), + _phantom: PhantomData, + } +} + const LOGS_BLOOM_SIZE: usize = 256; pub type Bloom = FixedBytes; // FixedVector; @@ -124,4 +149,29 @@ mod tests { let lh_tree_hash = lh_transaction.tree_hash_root(); assert_eq!(our_tree_hash, lh_tree_hash, "Tree hash root should match lighthouse"); } + + #[test] + fn convert_transactions_to_progressive_preserves_bytes() { + let txs = Transactions::random_for_test(&mut rand::rng()); + + let progressive = convert_transactions_to_progressive(&txs); + + assert_eq!(progressive.len(), txs.len()); + for (converted, original) in progressive.as_slice().iter().zip(txs.iter()) { + assert_eq!(converted.as_slice(), original.as_ref()); + } + } + + #[test] + fn execution_requests_to_gloas_preserves_lists_and_defaults_builder_requests() { + let requests = ExecutionRequests::random_for_test(&mut rand::rng()); + + let gloas = execution_requests_to_gloas(&requests); + + assert!(gloas.deposits.iter().eq(requests.deposits.iter())); + assert!(gloas.withdrawals.iter().eq(requests.withdrawals.iter())); + assert!(gloas.consolidations.iter().eq(requests.consolidations.iter())); + assert!(gloas.builder_deposits.is_empty()); + assert!(gloas.builder_exits.is_empty()); + } } diff --git a/crates/types/src/hydration.rs b/crates/types/src/hydration.rs index 20fde4fb8..eee5798d6 100644 --- a/crates/types/src/hydration.rs +++ b/crates/types/src/hydration.rs @@ -26,6 +26,8 @@ pub enum DehydratedBidSubmission { } impl ForkVersionDecode for DehydratedBidSubmission { + /// Gloas uses the same bounded-list wire shape as Fulu here -- this is helix's own + /// builder<->relay representation, not the real Gloas consensus shape. fn from_ssz_bytes_by_fork(bytes: &[u8], fork: ForkName) -> Result { match fork { ForkName::Base | @@ -34,10 +36,11 @@ impl ForkVersionDecode for DehydratedBidSubmission { ForkName::Capella | ForkName::Deneb | ForkName::Electra | - ForkName::Gloas | ForkName::Heze => Err(DecodeError::NoMatchingVariant), - ForkName::Fulu => DehydratedBidSubmissionFuluV1::from_ssz_bytes(bytes) - .map(|v1| DehydratedBidSubmission::Fulu(v1.into())), + ForkName::Fulu | ForkName::Gloas => { + DehydratedBidSubmissionFuluV1::from_ssz_bytes(bytes) + .map(|v1| DehydratedBidSubmission::Fulu(v1.into())) + } } } } @@ -283,10 +286,11 @@ impl ForkVersionDecode for DehydratedBidSubmissionFuluWithAdjustments { ForkName::Bellatrix | ForkName::Capella | ForkName::Deneb | - ForkName::Gloas | ForkName::Heze | ForkName::Electra => Err(DecodeError::NoMatchingVariant), - ForkName::Fulu => DehydratedBidSubmissionFuluWithAdjustments::from_ssz_bytes(bytes), + ForkName::Fulu | ForkName::Gloas => { + DehydratedBidSubmissionFuluWithAdjustments::from_ssz_bytes(bytes) + } } } } @@ -329,10 +333,11 @@ impl ForkVersionDecode for DehydratedBidSubmissionFuluWithMergingData { ForkName::Bellatrix | ForkName::Capella | ForkName::Deneb | - ForkName::Gloas | ForkName::Heze | ForkName::Electra => Err(DecodeError::NoMatchingVariant), - ForkName::Fulu => DehydratedBidSubmissionFuluWithMergingData::from_ssz_bytes(bytes), + ForkName::Fulu | ForkName::Gloas => { + DehydratedBidSubmissionFuluWithMergingData::from_ssz_bytes(bytes) + } } } } @@ -881,4 +886,36 @@ mod tests { assert_eq!(split_merging_data, expected_merging_data); assert!(matches!(dehydrated, DehydratedBidSubmission::Fulu(_))); } + + #[test] + fn dehydrated_with_merging_data_decodes_under_gloas_fork() { + let submission = + DehydratedBidSubmissionFuluWithMergingData::random_for_test(&mut rand::rng()); + let bytes = submission.as_ssz_bytes(); + + let decoded = DehydratedBidSubmissionFuluWithMergingData::from_ssz_bytes_by_fork( + &bytes, + ForkName::Gloas, + ) + .expect("Gloas should decode via the same shape as Fulu"); + + assert_eq!(decoded.message, submission.message); + assert_eq!(decoded.merging_data, submission.merging_data); + } + + #[test] + fn dehydrated_bid_submission_decodes_under_gloas_fork() { + let (dehydrated, _) = + DehydratedBidSubmissionFuluWithMergingData::random_for_test(&mut rand::rng()).split(); + let inner_bytes = match &dehydrated { + DehydratedBidSubmission::Fulu(inner) => inner.as_ssz_bytes(), + }; + + let decoded = + DehydratedBidSubmission::from_ssz_bytes_by_fork(&inner_bytes, ForkName::Gloas) + .expect("Gloas should decode via the same shape as Fulu"); + + assert!(matches!(decoded, DehydratedBidSubmission::Fulu(_))); + assert_eq!(decoded.bid_trace(), dehydrated.bid_trace()); + } } From 4819897303cd0be5e102dd3b0a37fd94ccbb5694 Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 25 Aug 2026 15:16:17 +0100 Subject: [PATCH 02/33] Serve real Gloas bids and envelopes from the auctioneer's existing submissions getExecutionPayloadBid now reads the winning submission out of the same bid-sorter/payloads map get_header already reads, converts it via the prior step's Gloas conversion functions, and signs a real SignedExecutionPayloadBid under helix's own configured builder identity. submitSignedBeaconBlock's GloasPayloadStore placeholder is replaced by a real auctioneer round trip (Event::TakeHeldGloasPayload) that looks up the same payload by block hash and converts it for envelope construction. GloasBuilderIdentity is now constructed once in main.rs and shared between the proposer API and the auctioneer. execution_payment is set equal to value (no payment-split product need yet, per gattaca-com/helix#489). --- crates/relay/src/api/proposer/mod.rs | 5 +- .../proposer/submit_signed_beacon_block.rs | 89 +++++----- crates/relay/src/api/service.rs | 1 - crates/relay/src/auctioneer/context.rs | 7 +- .../auctioneer/get_execution_payload_bid.rs | 153 ++++++++++++++++-- crates/relay/src/auctioneer/gloas_payload.rs | 39 +++++ crates/relay/src/auctioneer/handle.rs | 18 ++- crates/relay/src/auctioneer/mod.rs | 18 ++- crates/relay/src/auctioneer/types.rs | 11 +- crates/relay/src/lib.rs | 2 +- crates/relay/src/main.rs | 12 +- crates/types/src/fields.rs | 23 +++ 12 files changed, 306 insertions(+), 72 deletions(-) create mode 100644 crates/relay/src/auctioneer/gloas_payload.rs diff --git a/crates/relay/src/api/proposer/mod.rs b/crates/relay/src/api/proposer/mod.rs index 19efc8d8c..636896e3d 100644 --- a/crates/relay/src/api/proposer/mod.rs +++ b/crates/relay/src/api/proposer/mod.rs @@ -20,7 +20,7 @@ use helix_common::{ use helix_database::handle::DbHandle; use helix_operator::OperatorPubSub; use hyper::StatusCode; -pub use submit_signed_beacon_block::{GloasBuilderIdentity, GloasPayloadStore}; +pub use submit_signed_beacon_block::{GloasBuilderIdentity, HeldGloasPayload}; use crate::{ api::{Api, proposer::ip_tracker::IpTracker, router::Terminating}, @@ -48,7 +48,6 @@ pub struct ProposerApi { pub operator_api: Option>, pub ip_tracker: IpTracker, pub gloas_builder_identity: Arc, - pub gloas_payload_store: Arc, } impl ProposerApi { @@ -67,7 +66,6 @@ impl ProposerApi { reg_handle: RegWorkerHandle, alert_manager: Arc, operator_api: Option>, - gloas_payload_store: Arc, ) -> Self { let gloas_builder_identity = Arc::new(GloasBuilderIdentity { builder_index: relay_config.gloas_builder_index, @@ -90,7 +88,6 @@ impl ProposerApi { operator_api, ip_tracker: IpTracker::default(), gloas_builder_identity, - gloas_payload_store, } } } diff --git a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs index 7ff785ec2..b29a3d5a9 100644 --- a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs +++ b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use alloy_primitives::B256; use axum::{Extension, http::HeaderMap}; -use dashmap::DashMap; use helix_common::{chain_info::ChainInfo, decoder::Encoding, utils::extract_request_id}; use helix_types::{ BeaconBlockRef, BlobsBundle, BlsKeypair, BlsPublicKey, BlsPublicKeyBytes, Domain, EthSpec, @@ -12,7 +11,7 @@ use helix_types::{ }; use hyper::StatusCode; use ssz::Decode; -use tracing::info; +use tracing::{info, warn}; use tree_hash::TreeHash; use super::{ProposerApi, get_payload::fork_name_from_header}; @@ -26,21 +25,6 @@ pub struct HeldGloasPayload { pub blobs_bundle: Arc, } -/// Payloads held by a bid's committed block hash, removed once the envelope is broadcast. -// TODO(gloas): populate from the auctioneer; see gattaca-com/helix#489 step 3. -#[derive(Default)] -pub struct GloasPayloadStore(DashMap); - -impl GloasPayloadStore { - pub fn held_payload(&self, block_hash: B256) -> Option { - self.0.get(&block_hash).map(|payload| payload.clone()) - } - - pub fn remove(&self, block_hash: B256) { - self.0.remove(&block_hash); - } -} - /// Helix's own on-chain Gloas builder identity: `builder_index` plus signing key. // TODO(gloas): support external builder-signed bids/envelopes; see gattaca-com/helix#489 step 5. pub struct GloasBuilderIdentity { @@ -67,12 +51,31 @@ impl GloasBuilderIdentity { let signature = self.keypair.sk.sign(message.signing_root(domain)); SignedExecutionPayloadEnvelope { message, signature } } + + /// Signs a `SignedExecutionPayloadBid` under the same domain as `sign_envelope`. + pub fn sign_bid( + &self, + message: helix_types::ExecutionPayloadBid, + chain_info: &ChainInfo, + ) -> helix_types::SignedExecutionPayloadBid { + let epoch = message.slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::BeaconBuilder, + &fork, + chain_info.genesis_validators_root, + ); + let signature = self.keypair.sk.sign(message.signing_root(domain)); + helix_types::SignedExecutionPayloadBid { message, signature } + } } /// Constructs and signs the `SignedExecutionPayloadEnvelope` fulfilling `block`'s committed bid. +/// `held` is the payload the auctioneer has stored for the bid's committed block hash, if any. pub(super) fn construct_signed_envelope( block: &SignedBeaconBlockGloas, - store: &GloasPayloadStore, + held: Option, identity: &GloasBuilderIdentity, chain_info: &ChainInfo, ) -> Result { @@ -86,9 +89,7 @@ pub(super) fn construct_signed_envelope( }); } - let held = store - .held_payload(bid_block_hash) - .ok_or(ProposerApiError::NoHeldPayloadForBlock(bid_block_hash))?; + let held = held.ok_or(ProposerApiError::NoHeldPayloadForBlock(bid_block_hash))?; let held_block_hash: B256 = held.payload.block_hash.0; if held_block_hash != bid_block_hash { @@ -173,9 +174,25 @@ impl ProposerApi { &proposer_api.chain_info, )?; + let bid_block_hash: B256 = + block.message.body.signed_execution_payload_bid.message.block_hash.0; + let Ok(rx) = proposer_api + .auctioneer_handle + .take_held_gloas_payload(bid_block_hash, block.message.slot) + else { + return Err(ProposerApiError::InternalServerError); + }; + let held = match rx.await { + Ok(held) => held, + Err(err) => { + warn!(%err, "failed to fetch held Gloas payload from auctioneer"); + return Err(ProposerApiError::InternalServerError); + } + }; + let signed_envelope = construct_signed_envelope( &block, - &proposer_api.gloas_payload_store, + held, &proposer_api.gloas_builder_identity, &proposer_api.chain_info, )?; @@ -184,9 +201,6 @@ impl ProposerApi { .multi_beacon_client .publish_execution_payload_envelope(Arc::new(signed_envelope), ForkName::Gloas) .await?; - proposer_api - .gloas_payload_store - .remove(block.message.body.signed_execution_payload_bid.message.block_hash.0); Ok(StatusCode::ACCEPTED) } @@ -199,12 +213,6 @@ mod construct_signed_envelope_tests { use super::*; - fn store_holding(block_hash: B256, payload: HeldGloasPayload) -> GloasPayloadStore { - let store = GloasPayloadStore::default(); - store.0.insert(block_hash, payload); - store - } - fn held_payload(block_hash: B256) -> HeldGloasPayload { let mut payload = ExecutionPayloadGloas::default(); payload.block_hash = ExecutionBlockHash(block_hash); @@ -240,11 +248,11 @@ mod construct_signed_envelope_tests { let block_hash = B256::repeat_byte(0x11); let parent_root = B256::repeat_byte(0x22); let block = test_block(block_hash, 7, parent_root); - let store = store_holding(block_hash, held_payload(block_hash)); + let held = Some(held_payload(block_hash)); let identity = identity(7); let signed_envelope = - construct_signed_envelope(&block, &store, &identity, &chain_info).unwrap(); + construct_signed_envelope(&block, held, &identity, &chain_info).unwrap(); assert_eq!(signed_envelope.signed_execution_payload_envelope.message.builder_index, 7); assert_eq!( @@ -266,11 +274,11 @@ mod construct_signed_envelope_tests { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x33); let block = test_block(block_hash, 3, B256::ZERO); - let store = store_holding(block_hash, held_payload(block_hash)); + let held = Some(held_payload(block_hash)); let identity = identity(3); let signed_envelope = - construct_signed_envelope(&block, &store, &identity, &chain_info).unwrap(); + construct_signed_envelope(&block, held, &identity, &chain_info).unwrap(); let epoch = signed_envelope .signed_execution_payload_envelope @@ -291,10 +299,9 @@ mod construct_signed_envelope_tests { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x44); let block = test_block(block_hash, 1, B256::ZERO); - let store = GloasPayloadStore::default(); let identity = identity(1); - let result = construct_signed_envelope(&block, &store, &identity, &chain_info); + let result = construct_signed_envelope(&block, None, &identity, &chain_info); assert!( matches!(result, Err(ProposerApiError::NoHeldPayloadForBlock(hash)) if hash == block_hash) @@ -307,10 +314,10 @@ mod construct_signed_envelope_tests { let bid_block_hash = B256::repeat_byte(0x55); let wrong_held_hash = B256::repeat_byte(0x66); let block = test_block(bid_block_hash, 1, B256::ZERO); - let store = store_holding(bid_block_hash, held_payload(wrong_held_hash)); + let held = Some(held_payload(wrong_held_hash)); let identity = identity(1); - let result = construct_signed_envelope(&block, &store, &identity, &chain_info); + let result = construct_signed_envelope(&block, held, &identity, &chain_info); assert!(matches!( result, @@ -324,10 +331,10 @@ mod construct_signed_envelope_tests { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x77); let block = test_block(block_hash, 9, B256::ZERO); - let store = store_holding(block_hash, held_payload(block_hash)); + let held = Some(held_payload(block_hash)); let identity = identity(1); - let result = construct_signed_envelope(&block, &store, &identity, &chain_info); + let result = construct_signed_envelope(&block, held, &identity, &chain_info); assert!(matches!( result, diff --git a/crates/relay/src/api/service.rs b/crates/relay/src/api/service.rs index 3441b1c1e..b6ed27a26 100644 --- a/crates/relay/src/api/service.rs +++ b/crates/relay/src/api/service.rs @@ -146,7 +146,6 @@ pub async fn run_api_service( registrations_handle, alert_manager, operator_api, - Arc::default(), )); tokio::spawn(process_gossip_messages( diff --git a/crates/relay/src/auctioneer/context.rs b/crates/relay/src/auctioneer/context.rs index 8535cbbd4..a9195189b 100644 --- a/crates/relay/src/auctioneer/context.rs +++ b/crates/relay/src/auctioneer/context.rs @@ -38,7 +38,9 @@ use uuid::Uuid; use crate::{ SubmissionDataWithSpan, - api::{FutureBidSubmissionResult, builder::error::BuilderApiError}, + api::{ + FutureBidSubmissionResult, builder::error::BuilderApiError, proposer::GloasBuilderIdentity, + }, auctioneer::{ AuctioneerHandle, BlockMergeResponse, bid_adjustor::BidAdjustor, @@ -88,6 +90,7 @@ pub struct Context { discord_addr: Option, discord_alert: Option, pub builder_preferences: BuilderPreferencesStore, + pub gloas_builder_identity: Arc, } const EXPECTED_PAYLOADS_PER_SLOT: usize = 5000; @@ -111,6 +114,7 @@ impl Context { auctioneer_handle: AuctioneerHandle, alert_manager: Arc, operator_api: Option>, + gloas_builder_identity: Arc, ) -> Self { // Local dev builders have random keys, so none is ever in config. let local_dev = is_local_dev(); @@ -166,6 +170,7 @@ impl Context { }), discord_alert: None, builder_preferences: BuilderPreferencesStore::default(), + gloas_builder_identity, } } diff --git a/crates/relay/src/auctioneer/get_execution_payload_bid.rs b/crates/relay/src/auctioneer/get_execution_payload_bid.rs index 883db63aa..a47d501e4 100644 --- a/crates/relay/src/auctioneer/get_execution_payload_bid.rs +++ b/crates/relay/src/auctioneer/get_execution_payload_bid.rs @@ -1,13 +1,18 @@ -use helix_common::api::proposer_api::GetExecutionPayloadBidParams; +use helix_common::{api::proposer_api::GetExecutionPayloadBidParams, chain_info::ChainInfo}; +use helix_types::{ + ExecutionBlockHash, ExecutionPayloadBid, SignedExecutionPayloadBid, Slot, + convert_kzg_commitments_to_progressive, execution_requests_to_gloas, +}; use tokio::sync::oneshot; use tracing::warn; +use tree_hash::TreeHash; use crate::{ - api::proposer::ProposerApiError, + api::proposer::{GloasBuilderIdentity, ProposerApiError}, auctioneer::{ bid_adjustor::BidAdjustor, context::Context, - types::{GetExecutionPayloadBidResult, SlotData}, + types::{GetExecutionPayloadBidResult, PayloadEntry, SlotData}, }, }; @@ -18,17 +23,24 @@ impl Context { slot_data: &SlotData, res_tx: oneshot::Sender, ) { - let _ = res_tx.send(get_execution_payload_bid(¶ms, slot_data)); + let result = check_execution_payload_bid_liveness(¶ms, slot_data).and_then(|()| { + let best_block_hash = self + .bid_sorter + .get_header(¶ms.parent_hash) + .ok_or(ProposerApiError::NoBidPrepared)?; + let entry = + self.payloads.get(&best_block_hash).ok_or(ProposerApiError::NoBidPrepared)?; + build_signed_bid(entry, ¶ms, &self.gloas_builder_identity, &self.chain_info) + }); + let _ = res_tx.send(result); } } -/// Checks `params.parent_hash`/`params.parent_root` against currently-live payload attributes, -/// then reports "no bid available" -- serving a real Gloas bid needs step 5's builder->relay -/// submission wire format, not landed yet. -pub(super) fn get_execution_payload_bid( +/// Checks `params.parent_hash`/`params.parent_root` against currently-live payload attributes. +pub(super) fn check_execution_payload_bid_liveness( params: &GetExecutionPayloadBidParams, slot_data: &SlotData, -) -> GetExecutionPayloadBidResult { +) -> Result<(), ProposerApiError> { let Some(attrs) = slot_data.payload_attributes_map.get(¶ms.parent_hash) else { warn!( req =% params.parent_hash, @@ -47,14 +59,56 @@ pub(super) fn get_execution_payload_bid( return Err(ProposerApiError::NoBidPrepared); } - Err(ProposerApiError::NoBidPrepared) + Ok(()) +} + +/// Builds and signs the `SignedExecutionPayloadBid` for the winning submission held in `entry`, +/// under helix's own configured Gloas builder identity. +pub(super) fn build_signed_bid( + entry: &PayloadEntry, + params: &GetExecutionPayloadBidParams, + identity: &GloasBuilderIdentity, + chain_info: &ChainInfo, +) -> Result { + let slot = Slot::new(params.slot); + + let payload = entry.execution_payload().to_lighthouse_gloas_payload(slot).map_err(|err| { + warn!(%err, block_hash =% entry.block_hash(), "failed to convert held payload to Gloas shape for bid"); + ProposerApiError::InternalServerError + })?; + + let execution_requests = execution_requests_to_gloas(entry.bid_data_ref().execution_requests); + let execution_requests_root = execution_requests.tree_hash_root(); + + // Per gattaca-com/helix#489: no payment-split product need yet, so execution_payment = value. + let value = entry.value().saturating_to::(); + + let bid = ExecutionPayloadBid { + parent_block_hash: ExecutionBlockHash(params.parent_hash), + parent_block_root: params.parent_root, + block_hash: ExecutionBlockHash(*entry.block_hash()), + prev_randao: payload.prev_randao, + fee_recipient: payload.fee_recipient, + gas_limit: payload.gas_limit, + builder_index: identity.builder_index, + slot, + value, + execution_payment: value, + blob_kzg_commitments: convert_kzg_commitments_to_progressive( + &entry.payload_and_blobs().blobs_bundle.commitments, + ), + execution_requests_root, + _phantom: std::marker::PhantomData, + }; + + Ok(identity.sign_bid(bid, chain_info)) } #[cfg(test)] mod tests { use alloy_primitives::B256; use helix_common::PayloadAttributesUpdate; - use helix_types::ForkName; + use helix_types::{Domain, EthSpec, ForkName, SignedRoot, TestRandomSeed}; use rustc_hash::FxHashMap; use super::*; @@ -98,7 +152,7 @@ mod tests { let parent_root = B256::repeat_byte(0x22); let data = slot_data(FxHashMap::default()); - let result = get_execution_payload_bid(¶ms(parent_hash, parent_root), &data); + let result = check_execution_payload_bid_liveness(¶ms(parent_hash, parent_root), &data); assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); } @@ -112,7 +166,8 @@ mod tests { map.insert(parent_hash, attrs_update(parent_hash, Some(live_root))); let data = slot_data(map); - let result = get_execution_payload_bid(¶ms(parent_hash, requested_root), &data); + let result = + check_execution_payload_bid_liveness(¶ms(parent_hash, requested_root), &data); assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); } @@ -125,21 +180,85 @@ mod tests { map.insert(parent_hash, attrs_update(parent_hash, None)); let data = slot_data(map); - let result = get_execution_payload_bid(¶ms(parent_hash, requested_root), &data); + let result = + check_execution_payload_bid_liveness(¶ms(parent_hash, requested_root), &data); assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); } #[test] - fn matching_parent_still_reports_no_bid_until_step_5() { + fn matching_parent_passes_liveness_check() { let parent_hash = B256::repeat_byte(0x11); let parent_root = B256::repeat_byte(0x22); let mut map = FxHashMap::default(); map.insert(parent_hash, attrs_update(parent_hash, Some(parent_root))); let data = slot_data(map); - let result = get_execution_payload_bid(¶ms(parent_hash, parent_root), &data); + let result = check_execution_payload_bid_liveness(¶ms(parent_hash, parent_root), &data); - assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); + assert!(result.is_ok()); + } + + fn payload_entry(block_hash: B256, value: u64) -> PayloadEntry { + use std::sync::Arc; + + use alloy_primitives::U256; + use helix_types::{BlobsBundle, ExecutionPayload, ExecutionRequests, PayloadAndBlobs}; + + let mut payload = ExecutionPayload::test_random(); + payload.block_hash = block_hash; + + PayloadEntry::new_gossip( + PayloadAndBlobs { + execution_payload: Arc::new(payload), + blobs_bundle: Arc::new(BlobsBundle::default()), + }, + helix_types::PayloadBidData { + withdrawals_root: B256::ZERO, + tx_root: None, + execution_requests: Arc::new(ExecutionRequests::default()), + value: U256::from(value), + builder_pubkey: Default::default(), + }, + ) + } + + fn bid_identity(builder_index: u64) -> GloasBuilderIdentity { + helix_common::utils::install_default_crypto_provider(); + GloasBuilderIdentity { builder_index, keypair: helix_types::BlsKeypair::random() } + } + + #[test] + fn build_signed_bid_uses_the_entrys_data_and_configured_identity() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x99); + let parent_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let entry = payload_entry(block_hash, 42); + let identity = bid_identity(7); + let params = params(parent_hash, parent_root); + + let signed_bid = build_signed_bid(&entry, ¶ms, &identity, &chain_info).unwrap(); + + assert_eq!(signed_bid.message.block_hash.0, block_hash); + assert_eq!(signed_bid.message.parent_block_hash.0, parent_hash); + assert_eq!(signed_bid.message.parent_block_root, parent_root); + assert_eq!(signed_bid.message.builder_index, 7); + assert_eq!(signed_bid.message.value, 42); + assert_eq!(signed_bid.message.execution_payment, 42); + + let epoch = signed_bid.message.slot.epoch(helix_types::MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::BeaconBuilder, + &fork, + chain_info.genesis_validators_root, + ); + assert!( + signed_bid + .signature + .verify(&identity.keypair.pk, signed_bid.message.signing_root(domain)) + ); } } diff --git a/crates/relay/src/auctioneer/gloas_payload.rs b/crates/relay/src/auctioneer/gloas_payload.rs new file mode 100644 index 000000000..55913fa47 --- /dev/null +++ b/crates/relay/src/auctioneer/gloas_payload.rs @@ -0,0 +1,39 @@ +use alloy_primitives::B256; +use helix_types::{Slot, execution_requests_to_gloas}; +use tokio::sync::oneshot; +use tracing::warn; + +use crate::{ + api::proposer::HeldGloasPayload, + auctioneer::{bid_adjustor::BidAdjustor, context::Context}, +}; + +impl Context { + /// Looks up the payload a submission held for `block_hash`, converting it to the real Gloas + /// consensus shape for `submitSignedBeaconBlock`'s envelope construction. + pub(super) fn handle_take_held_gloas_payload( + &self, + block_hash: B256, + slot: Slot, + res_tx: oneshot::Sender>, + ) { + let held = self.payloads.get(&block_hash).and_then(|entry| { + let payload = match entry.execution_payload().to_lighthouse_gloas_payload(slot) { + Ok(payload) => payload, + Err(err) => { + warn!(%block_hash, %err, "failed to convert held payload to Gloas shape"); + return None; + } + }; + let execution_requests = + execution_requests_to_gloas(entry.bid_data_ref().execution_requests); + Some(HeldGloasPayload { + payload, + execution_requests, + blobs_bundle: entry.payload_and_blobs().blobs_bundle.clone(), + }) + }); + + let _ = res_tx.send(held); + } +} diff --git a/crates/relay/src/auctioneer/handle.rs b/crates/relay/src/auctioneer/handle.rs index 9f567d6a5..30495fd39 100644 --- a/crates/relay/src/auctioneer/handle.rs +++ b/crates/relay/src/auctioneer/handle.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use alloy_primitives::B256; use dashmap::DashMap; use futures::{FutureExt, future::Shared}; use helix_common::{ @@ -10,13 +11,13 @@ use helix_common::{ }; use helix_types::{ BlsPublicKey, BlsPublicKeyBytes, ExecPayload, GetPayloadResponse, SigError, - SignedBlindedBeaconBlock, + SignedBlindedBeaconBlock, Slot, }; use tokio::sync::oneshot::{self, Receiver}; use tracing::trace; use crate::{ - api::proposer::{ProposerApiError, get_payload::ProposerApiVersion}, + api::proposer::{HeldGloasPayload, ProposerApiError, get_payload::ProposerApiVersion}, auctioneer::types::{ Event, GetExecutionPayloadBidResult, GetHeaderResult, GetPayloadResult, SubmitBuilderPreferencesResult, @@ -108,6 +109,19 @@ impl AuctioneerHandle { Ok(rx) } + pub fn take_held_gloas_payload( + &self, + block_hash: B256, + slot: Slot, + ) -> Result>, ChannelFull> { + let (tx, rx) = oneshot::channel(); + trace!("sending to auctioneer"); + self.auctioneer + .try_send(Event::TakeHeldGloasPayload { block_hash, slot, res_tx: tx }) + .map_err(|_| ChannelFull)?; + Ok(rx) + } + pub fn get_payload( &self, chain_info: &ChainInfo, diff --git a/crates/relay/src/auctioneer/mod.rs b/crates/relay/src/auctioneer/mod.rs index 5fc27b6e7..73e03b1a3 100644 --- a/crates/relay/src/auctioneer/mod.rs +++ b/crates/relay/src/auctioneer/mod.rs @@ -6,6 +6,7 @@ mod context; mod get_execution_payload_bid; mod get_header; mod get_payload; +mod gloas_payload; mod handle; mod submit_block; pub(crate) mod types; @@ -46,7 +47,11 @@ pub use types::{ use crate::{ HelixSpine, SubmissionDataWithSpan, - api::{FutureBidSubmissionResult, builder::error::BuilderApiError, proposer::ProposerApiError}, + api::{ + FutureBidSubmissionResult, + builder::error::BuilderApiError, + proposer::{GloasBuilderIdentity, ProposerApiError}, + }, auctioneer::{context::merged_validation_request, types::PendingPayload}, housekeeper::SlotUpdate, simulator::{SimDone, SimResult, Simulators, sim_finish_events}, @@ -95,6 +100,7 @@ impl Auctioneer { merged_blocks: Arc>, alert_manager: Arc, operator_api: Option>, + gloas_builder_identity: Arc, ) -> Self { let ctx = Context::new( chain_info, @@ -111,6 +117,7 @@ impl Auctioneer { auctioneer_handle, alert_manager, operator_api, + gloas_builder_identity, ); Self { ctx, @@ -735,6 +742,15 @@ impl State { let _ = res_tx.send(Ok(())); } } + + // take_held_gloas_payload (Gloas), valid regardless of state -- payloads are + // block-hash-keyed, so a lookup after the slot has moved on just misses. + ( + State::Slot { .. } | State::Sorting(_) | State::Broadcasting { .. }, + Event::TakeHeldGloasPayload { block_hash, slot, res_tx }, + ) => { + ctx.handle_take_held_gloas_payload(block_hash, slot, res_tx); + } } } diff --git a/crates/relay/src/auctioneer/types.rs b/crates/relay/src/auctioneer/types.rs index fff714f04..1bfa498ae 100644 --- a/crates/relay/src/auctioneer/types.rs +++ b/crates/relay/src/auctioneer/types.rs @@ -33,7 +33,8 @@ use crate::{ SubmissionDataWithSpan, api::{ HEADER_API_KEY, HEADER_API_TOKEN, HEADER_HYDRATE, HEADER_IS_MERGEABLE, HEADER_MERGE_TYPE, - HEADER_PESSIMISTIC, HEADER_SEQUENCE, HEADER_WITH_ADJUSTMENTS, proposer::ProposerApiError, + HEADER_PESSIMISTIC, HEADER_SEQUENCE, HEADER_WITH_ADJUSTMENTS, + proposer::{HeldGloasPayload, ProposerApiError}, }, auctioneer::MergeResult, gossip::BroadcastPayloadParams, @@ -478,6 +479,13 @@ pub enum Event { max_execution_payment: u64, res_tx: oneshot::Sender, }, + /// Looks up the execution payload a submission held for `block_hash`, so + /// `submitSignedBeaconBlock` can build the envelope fulfilling a proposer's committed bid. + TakeHeldGloasPayload { + block_hash: B256, + slot: Slot, + res_tx: oneshot::Sender>, + }, // Receive multiple of these potentially, assume some light validation GetPayload { block_hash: B256, @@ -505,6 +513,7 @@ impl Event { Event::GetHeader { .. } => "GetHeader", Event::GetExecutionPayloadBid { .. } => "GetExecutionPayloadBid", Event::SubmitBuilderPreferences { .. } => "SubmitBuilderPreferences", + Event::TakeHeldGloasPayload { .. } => "TakeHeldGloasPayload", Event::GetPayload { .. } => "GetPayload", Event::GossipPayload(_) => "GossipPayload", Event::SimResult(_) => "SimResult", diff --git a/crates/relay/src/lib.rs b/crates/relay/src/lib.rs index cb58367e5..40068d887 100644 --- a/crates/relay/src/lib.rs +++ b/crates/relay/src/lib.rs @@ -28,7 +28,7 @@ pub use crate::block_merging::{OrderTxs, find_unbundled_txs}; pub use crate::{ api::{ Api, BidAdjustor, DefaultBidAdjustor, FutureBidSubmissionResult, builder::TopBidTile, - start_admin_service, start_api_service, + proposer::GloasBuilderIdentity, start_admin_service, start_api_service, }, auctioneer::{ Auctioneer, AuctioneerHandle, BidSorter, Context, Event, InternalBidSubmissionHeader, diff --git a/crates/relay/src/main.rs b/crates/relay/src/main.rs index 6acba77b5..444cd01c6 100644 --- a/crates/relay/src/main.rs +++ b/crates/relay/src/main.rs @@ -28,8 +28,8 @@ use helix_operator::spawn_operator_connection; use helix_relay::{ Api, Auctioneer, AuctioneerHandle, BidSorter, BidSubmissionTcpListener, BlockMergeResponse, BlockMergingTile, BroadcastPayloadParams, DbHandle, DecoderTile, DefaultBidAdjustor, - FutureBidSubmissionResult, GossipedMessage, HelixSpine, HelixSpineConfig, HousekeeperTile, - Lane, NewTcpBidSubmission, RegWorkerHandle, RegistrationTile, RelayConfigExt, + FutureBidSubmissionResult, GloasBuilderIdentity, GossipedMessage, HelixSpine, HelixSpineConfig, + HousekeeperTile, Lane, NewTcpBidSubmission, RegWorkerHandle, RegistrationTile, RelayConfigExt, RelayNetworkManager, Simulators, SlotUpdate, SubmissionDataWithSpan, TopBidTile, UdpTopBidTile, spawn_tokio_monitoring, spine_epoch_path, start_admin_service, start_api_service, start_db_service, @@ -240,7 +240,7 @@ async fn run( local_cache.clone(), current_slot_info, chain_info.clone(), - relay_signing_context, + relay_signing_context.clone(), beacon_client, Arc::new(DefaultApiProvider {}), known_validators_loaded, @@ -354,6 +354,11 @@ async fn run( attach_tile(merging_tile, spine, TileConfig::new(config.cores.block_merging, None)); } + let gloas_builder_identity = Arc::new(GloasBuilderIdentity { + builder_index: config.gloas_builder_index, + keypair: relay_signing_context.keypair.clone(), + }); + let auctioneer_core = config.cores.auctioneer; let auctioneer = Auctioneer::new( chain_info.as_ref().clone(), @@ -374,6 +379,7 @@ async fn run( merged_blocks, alert_manager.clone(), operator_api.clone(), + gloas_builder_identity, ); attach_tile(auctioneer, spine, TileConfig::new(auctioneer_core, None)); } diff --git a/crates/types/src/fields.rs b/crates/types/src/fields.rs index 7c1486568..90bc481ba 100644 --- a/crates/types/src/fields.rs +++ b/crates/types/src/fields.rs @@ -43,6 +43,13 @@ pub fn convert_transactions_to_progressive( ) } +/// Real, progressive-list Gloas KZG commitments shape, per EIP-7688. +pub fn convert_kzg_commitments_to_progressive( + commitments: &KzgCommitments, +) -> lh_types::ProgressiveKzgCommitments { + ProgressiveVariableList::new(commitments.iter().map(|c| lh_types::KzgCommitment(c.0)).collect()) +} + /// Converts helix's Electra-shaped builder-submission execution requests into the real, /// progressive-list Gloas shape. `builder_deposits`/`builder_exits` are left empty -- /// TODO(gloas): populate once EIP-8282 builder deposit/exit submission exists. @@ -162,6 +169,22 @@ mod tests { } } + #[test] + fn convert_kzg_commitments_to_progressive_preserves_bytes() { + let commitments = KzgCommitments::new(vec![ + KzgCommitment::repeat_byte(0x11), + KzgCommitment::repeat_byte(0x22), + ]) + .unwrap(); + + let progressive = convert_kzg_commitments_to_progressive(&commitments); + + assert_eq!(progressive.len(), commitments.len()); + for (converted, original) in progressive.as_slice().iter().zip(commitments.iter()) { + assert_eq!(converted.0, original.0); + } + } + #[test] fn execution_requests_to_gloas_preserves_lists_and_defaults_builder_requests() { let requests = ExecutionRequests::random_for_test(&mut rand::rng()); From e610e1306aecdc3b4b4ab3320cf7692f9508e5d4 Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 1 Sep 2026 19:20:53 +0100 Subject: [PATCH 03/33] Refuse an unsupported fork on the SSZ validation path The SSZ dispatch short-circuited above the fork gate #517 added, so a Gloas submission reaching an SSZ simulator was validated as Fulu. Gate both dispatch kinds, and have the simulation role refuse a fork it does not understand instead of discarding `decoder_params.fork_name`. Answer 501, not 400. The relay maps a 400 body to a demotable error, and this is helix's own limitation. --- crates/builder/src/validation/server.rs | 35 ++++++++- crates/builder/src/validation/server_tests.rs | 74 ++++++++++++++++++ crates/relay/src/simulator/client.rs | 34 ++++++++- crates/relay/src/simulator/mod.rs | 76 ++++++++++--------- 4 files changed, 179 insertions(+), 40 deletions(-) diff --git a/crates/builder/src/validation/server.rs b/crates/builder/src/validation/server.rs index b7addda67..23938f270 100644 --- a/crates/builder/src/validation/server.rs +++ b/crates/builder/src/validation/server.rs @@ -16,10 +16,10 @@ use helix_common::{ decoder::{DecoderError, SubmissionDecoder, SubmissionDecoderParams}, simulator::{SszMergedValidationRequest, SszValidationRequest, SszValidationResponse}, }; -use helix_types::Submission; +use helix_types::{ForkName, Submission}; use ssz::{Decode, Encode}; use tokio::{net::TcpListener, sync::Semaphore, time}; -use tracing::{error, info}; +use tracing::{error, info, warn}; use crate::{ engine::convert::eblobs, @@ -58,6 +58,21 @@ pub async fn run(validator: BlockValidator, addr: SocketAddr, max_concurrent: us } } +/// Forks this validator understands. A submission from any other fork is +/// refused rather than validated under the wrong rules. +fn supported_fork(params: &Option) -> bool { + // No params means raw Fulu-shaped SSZ bytes. + params.as_ref().is_none_or(|params| matches!(params.fork_name, ForkName::Fulu)) +} + +/// 501, not 400: the relay maps a 400 body to `BlockValidationFailed`, which +/// demotes the builder. This is helix's own limitation, not the builder's. +fn unsupported_fork(params: &Option) -> Response { + let fork = params.as_ref().map(|params| params.fork_name); + warn!(?fork, "refusing a submission from an unsupported fork"); + (StatusCode::NOT_IMPLEMENTED, format!("unsupported fork: {fork:?}")).into_response() +} + /// A dehydrated submission needs transactions this simulator does not cache. /// The relay answers a 424 by retrying with full SSZ bytes. fn decode_submission( @@ -85,6 +100,14 @@ async fn validate(State(state): State, body: axum::body::Bytes) -> return finish("validate", "bad_request", start, bad_request(format!("{err:?}"))) } }; + if !supported_fork(&request.decoder_params) { + return finish( + "validate", + "unsupported_fork", + start, + unsupported_fork(&request.decoder_params), + ); + } let t = metrics::sim_lap("decode_request", start); let submission = match decode_submission(request.decoder_params, &request.signed_bid_submission) { @@ -124,6 +147,14 @@ async fn validate_merged(State(state): State, body: axum::body::Byt return finish("validate_merged", "bad_request", start, bad_request(format!("{err:?}"))) } }; + if !supported_fork(&request.decoder_params) { + return finish( + "validate_merged", + "unsupported_fork", + start, + unsupported_fork(&request.decoder_params), + ); + } let t = metrics::sim_lap("decode_request", start); let submission = match decode_submission(request.decoder_params, &request.signed_bid_submission) { diff --git a/crates/builder/src/validation/server_tests.rs b/crates/builder/src/validation/server_tests.rs index 648fab594..8552c206b 100644 --- a/crates/builder/src/validation/server_tests.rs +++ b/crates/builder/src/validation/server_tests.rs @@ -318,3 +318,77 @@ fn an_unchanged_list_reports_no_new_digest() { "the wrapped shape yields the same list" ); } + +/// Decoder params naming a fork, with everything else at its default. +fn params_for(fork: helix_types::ForkName) -> helix_common::decoder::SubmissionDecoderParams { + helix_common::decoder::SubmissionDecoderParams { + compression: Default::default(), + encoding: helix_common::decoder::Encoding::Ssz, + merge_type: Default::default(), + is_dehydrated: false, + with_mergeable_data: false, + with_adjustments: false, + mark_all_txs_mergeable: false, + dehydrated_v2: false, + merging_v2: false, + fork_name: fork, + } +} + +#[tokio::test] +async fn a_gloas_validation_request_is_refused() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut request = fixture.ssz_request(&built, true); + request.decoder_params = Some(params_for(helix_types::ForkName::Gloas)); + + let (status, body) = post(&fixture, "/validate", request.as_ssz_bytes()).await; + + assert_eq!( + status, + StatusCode::NOT_IMPLEMENTED, + "a Gloas block must not be validated under Fulu rules: {body}", + ); +} + +#[tokio::test] +async fn a_merged_gloas_request_is_refused() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let base = fixture.ssz_request(&built, true); + let request = SszMergedValidationRequest { + apply_blacklist: base.apply_blacklist, + registered_gas_limit: base.registered_gas_limit, + parent_beacon_block_root: base.parent_beacon_block_root, + inclusion_list: base.inclusion_list, + decoder_params: Some(params_for(helix_types::ForkName::Gloas)), + signed_bid_submission: base.signed_bid_submission, + base_payment_tx_index: 0, + }; + + let (status, body) = post(&fixture, "/validate_merged", request.as_ssz_bytes()).await; + + assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "the merged route has the same hole: {body}"); +} + +#[tokio::test] +async fn a_fulu_request_is_still_validated() { + let fixture = Fixture::new().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let mut request = fixture.ssz_request(&built, true); + request.decoder_params = Some(params_for(helix_types::ForkName::Fulu)); + + let (status, body) = post(&fixture, "/validate", request.as_ssz_bytes()).await; + + assert_eq!(status, StatusCode::OK, "{body}"); +} + +#[test] +fn refusing_an_unsupported_fork_cannot_demote_a_builder() { + // `SimulatorClient::ssz_request` maps 400 to `BlockValidationFailed`, which + // demotes, and everything else to `RpcError`, which does not. Refusing a + // fork is helix's own limitation, so it must not cost a builder its + // optimistic status. + assert_ne!(StatusCode::NOT_IMPLEMENTED, StatusCode::BAD_REQUEST); + assert!(!helix_common::simulator::BlockSimError::RpcError.is_demotable()); +} diff --git a/crates/relay/src/simulator/client.rs b/crates/relay/src/simulator/client.rs index 5fc14834b..3dac534c7 100644 --- a/crates/relay/src/simulator/client.rs +++ b/crates/relay/src/simulator/client.rs @@ -57,10 +57,20 @@ impl SimulatorClient { &self.config.url } - pub fn ssz_request_builder(&self) -> Option { + /// `None` for a fork the SSZ validator cannot handle, mirroring + /// [`Self::sim_request_builder`]. The two dispatch kinds have separate fork + /// lists: an SSZ validator is a different implementation from the JSON one. + pub fn ssz_request_builder(&self, fork: ForkName) -> Option { + if !Self::ssz_supports(fork) { + return None; + } self.ssz_url.as_ref().map(|url| self.client.post(format!("{url}/validate"))) } + fn ssz_supports(fork: ForkName) -> bool { + matches!(fork, ForkName::Fulu) + } + /// Relay-internal merged-block SSZ route; see `sim_method_merged_v5`. pub fn ssz_merged_request_builder(&self) -> Option { self.ssz_url.as_ref().map(|url| self.client.post(format!("{url}/validate_merged"))) @@ -270,6 +280,28 @@ mod test { assert!(sim_client().sim_request_builder(ForkName::Gloas).is_none()); } + fn ssz_client() -> super::SimulatorClient { + super::SimulatorClient::new(reqwest::Client::new(), SimulatorConfig { + url: "http://localhost:8545".into(), + namespace: "relay".into(), + max_concurrent_tasks: 1, + ssz_url: Some("http://localhost:8552".into()), + }) + } + + #[test] + fn ssz_request_builder_routes_fulu() { + assert!(ssz_client().ssz_request_builder(ForkName::Fulu).is_some()); + } + + #[test] + fn ssz_request_builder_refuses_gloas() { + assert!( + ssz_client().ssz_request_builder(ForkName::Gloas).is_none(), + "the SSZ path short-circuited above the fork gate #517 added", + ); + } + #[tokio::test] async fn balance_request() { let sim_client = super::SimulatorClient::new(reqwest::Client::new(), SimulatorConfig { diff --git a/crates/relay/src/simulator/mod.rs b/crates/relay/src/simulator/mod.rs index 6a7084cdb..e648330b6 100644 --- a/crates/relay/src/simulator/mod.rs +++ b/crates/relay/src/simulator/mod.rs @@ -552,45 +552,47 @@ impl Simulators { .or_insert_with(|| SeenBlock::InFlight(Vec::new())); let sim = &mut self.simulators[id]; - let dispatch = if let Some(url) = &sim.client.ssz_url { - SimDispatch::Ssz { - to_send: sim.client.client.post(format!("{url}/validate")), + let fork = submission.fork_name(); + let dispatch = match &sim.client.ssz_url { + Some(url) => sim.client.ssz_request_builder(fork).map(|to_send| SimDispatch::Ssz { + to_send, ssz_url: url.clone(), http: sim.client.client.clone(), - } - } else { - let fork = submission.fork_name(); - let Some((builder, method)) = sim.client.sim_request_builder(fork) else { - warn!(%fork, "no validation RPC method for fork, dropping submission"); - sim.pending += 1; - let result = SimResult::Validate(( - id, - Some(SimulationResultInner { - submission_ref: req.submission_ref, - optimistic_version: req.optimistic_version(), - bid: None, - result: Err(BlockSimError::UnsupportedFork(fork)), - submission_id: req.submission_id, - block_hash: *submission.block_hash(), - txs: Vec::new(), - retried: false, - }), - )); - let started = sim_started_event( - req.submission_id, - *submission.block_hash(), - false, - req.is_top_bid, - ); - let _ = self.task_tx.send(SimulatorsEvent::TaskDone { - id, - error: None, - result: Box::new(result), - elapsed: None, - }); - return (*submission.block_hash() != B256::ZERO).then_some(started); - }; - SimDispatch::Json { to_send: builder, method: method.to_owned() } + }), + None => sim + .client + .sim_request_builder(fork) + .map(|(to_send, method)| SimDispatch::Json { to_send, method: method.to_owned() }), + }; + let Some(dispatch) = dispatch else { + warn!(%fork, "no validation method for fork, dropping submission"); + sim.pending += 1; + let result = SimResult::Validate(( + id, + Some(SimulationResultInner { + submission_ref: req.submission_ref, + optimistic_version: req.optimistic_version(), + bid: None, + result: Err(BlockSimError::UnsupportedFork(fork)), + submission_id: req.submission_id, + block_hash: *submission.block_hash(), + txs: Vec::new(), + retried: false, + }), + )); + let started = sim_started_event( + req.submission_id, + *submission.block_hash(), + false, + req.is_top_bid, + ); + let _ = self.task_tx.send(SimulatorsEvent::TaskDone { + id, + error: None, + result: Box::new(result), + elapsed: None, + }); + return (*submission.block_hash() != B256::ZERO).then_some(started); }; sim.pending += 1; From 9c851a82db34119e7b2d4cc935c68b826183f4ff Mon Sep 17 00:00:00 2001 From: owen Date: Wed, 2 Sep 2026 23:10:02 +0100 Subject: [PATCH 04/33] Carry the builder's block access list on a Gloas submission `to_lighthouse_gloas_payload` left the EIP-7928 list empty, so the envelope the relay broadcasts could never be valid. Only an execution client can produce the list, so it travels on the submission. A separate `SignedBidSubmissionGloas` rather than a fork-gated field: `Encode` is derived on `SignedBidSubmission`, so an extra field would change the bytes for every fork. Refuse Gloas with bid adjustments. Combining every extension with Gloas multiplies the wire shapes, and a testnet does not need adjustments. --- crates/builder/src/validation/server.rs | 2 +- crates/common/src/decoder.rs | 176 +++++++++++++----- .../auctioneer/get_execution_payload_bid.rs | 47 ++++- crates/relay/src/auctioneer/gloas_payload.rs | 5 +- crates/relay/src/auctioneer/submit_block.rs | 2 + crates/relay/src/auctioneer/types.rs | 25 ++- crates/relay/src/bid_decoder/tile.rs | 10 +- crates/relay/src/block_merging/tile.rs | 1 + crates/simulator/src/ssz_server.rs | 2 +- crates/types/src/bid_submission.rs | 109 ++++++++++- crates/types/src/execution_payload.rs | 38 +++- crates/types/src/fields.rs | 17 ++ 12 files changed, 368 insertions(+), 66 deletions(-) diff --git a/crates/builder/src/validation/server.rs b/crates/builder/src/validation/server.rs index 23938f270..582f970dd 100644 --- a/crates/builder/src/validation/server.rs +++ b/crates/builder/src/validation/server.rs @@ -82,7 +82,7 @@ fn decode_submission( match params { Some(params) => { let mut buf = Vec::new(); - let (submission, _, _) = SubmissionDecoder::new(¶ms).decode(bytes, &mut buf)?; + let (submission, _, _, _) = SubmissionDecoder::new(¶ms).decode(bytes, &mut buf)?; match submission { Submission::Full(submission) => Ok(Some(submission.into())), Submission::Dehydrated(_) => Ok(None), diff --git a/crates/common/src/decoder.rs b/crates/common/src/decoder.rs index 45e17dd00..ae77b01c2 100644 --- a/crates/common/src/decoder.rs +++ b/crates/common/src/decoder.rs @@ -8,12 +8,12 @@ use flate2::read::GzDecoder; use flux_profiler::timed; use flux_versioned_types::ByteStable; use helix_types::{ - BidAdjustmentData, BlockMergingData, BlockMergingDataV2, Compression, DehydratedBidSubmission, - DehydratedBidSubmissionFulu, DehydratedBidSubmissionFuluV1, + BidAdjustmentData, BlockAccessListBytes, BlockMergingData, BlockMergingDataV2, Compression, + DehydratedBidSubmission, DehydratedBidSubmissionFulu, DehydratedBidSubmissionFuluV1, DehydratedBidSubmissionFuluWithAdjustments, DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData, DehydratedBidSubmissionFuluWithMergingData, ForkName, ForkVersionDecode, MergeType, - SignedBidSubmission, SignedBidSubmissionWithAdjustments, + SignedBidSubmission, SignedBidSubmissionGloas, SignedBidSubmissionWithAdjustments, SignedBidSubmissionWithAdjustmentsAndMergingData, SignedBidSubmissionWithMergingData, Submission, WithAdjustments, WithAdjustmentsAndMergingData, WithMergingData, }; @@ -41,6 +41,15 @@ use crate::{ }, }; +/// What one submission decodes into: the submission itself plus the sidecars +/// only some forks and headers carry. +pub type DecodedParts = ( + Submission, + Option, + Option, + Option, +); + #[derive(Debug, thiserror::Error)] pub enum DecoderError { #[error("json decode error: {0}")] @@ -57,6 +66,9 @@ pub enum DecoderError { #[error("v2 shapes are SSZ over TCP; merging v2 requires Mergeable")] V2Unsupported, + + #[error("unsupported combination: {0}")] + UnsupportedCombination(&'static str), } impl IntoResponse for DecoderError { @@ -84,7 +96,8 @@ impl DecoderError { DecoderError::SszDecode(_) | DecoderError::IOError(_) | DecoderError::PayloadDecode | - DecoderError::V2Unsupported => StatusCode::BAD_REQUEST, + DecoderError::V2Unsupported | + DecoderError::UnsupportedCombination(_) => StatusCode::BAD_REQUEST, } } } @@ -275,8 +288,7 @@ impl SubmissionDecoder { &mut self, payload: &[u8], buf: &mut Vec, - ) -> Result<(Submission, Option, Option), DecoderError> - { + ) -> Result { let body: &[u8] = match self.decompress(payload, buf) { None => payload, Some(Ok(())) => buf, @@ -298,11 +310,7 @@ impl SubmissionDecoder { /// header flags. `dehydrated_v2` selects the v2 submission shape, /// `merging_v2` the v2 merging shape; each pairs with the other's v1. #[timed] - fn decode_v2( - &mut self, - body: &[u8], - ) -> Result<(Submission, Option, Option), DecoderError> - { + fn decode_v2(&mut self, body: &[u8]) -> Result { let mergeable = self.merge_type == MergeType::Mergeable; if !matches!(self.encoding, Encoding::Ssz) || self.fork_name != ForkName::Fulu || @@ -340,7 +348,7 @@ impl SubmissionDecoder { } MergeType::None | MergeType::Pause => None, }; - Ok((submission, merging_data, adjustments)) + Ok((submission, merging_data, adjustments, None)) } fn v2_parts( @@ -379,11 +387,7 @@ impl SubmissionDecoder { } #[timed] - fn decode_dehydrated( - &mut self, - body: &[u8], - ) -> Result<(Submission, Option, Option), DecoderError> - { + fn decode_dehydrated(&mut self, body: &[u8]) -> Result { if self.merge_type == MergeType::Mergeable { if self.with_adjustments { let sub: DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData = @@ -394,6 +398,7 @@ impl SubmissionDecoder { Submission::Dehydrated(submission), Some(merging_data.into()), Some(adjustment_data), + None, )); } @@ -401,7 +406,7 @@ impl SubmissionDecoder { self.decode_by_fork(body, self.fork_name)?; let (submission, merging_data) = sub_with_merging.split(); - return Ok((Submission::Dehydrated(submission), Some(merging_data.into()), None)); + return Ok((Submission::Dehydrated(submission), Some(merging_data.into()), None, None)); } let (submission, bid_adjustment) = if self.with_adjustments { @@ -434,15 +439,11 @@ impl SubmissionDecoder { MergeType::Pause => None, }; - Ok((Submission::Dehydrated(submission), merging_data, bid_adjustment)) + Ok((Submission::Dehydrated(submission), merging_data, bid_adjustment, None)) } #[timed] - fn decode_merge( - &mut self, - body: &[u8], - ) -> Result<(Submission, Option, Option), DecoderError> - { + fn decode_merge(&mut self, body: &[u8]) -> Result { let decoded = if self.with_adjustments { self._decode::(body).map(|sub| { let (submission, adjustment_data, merging_data) = sub.split(); @@ -474,24 +475,32 @@ impl SubmissionDecoder { MergeType::None => Some(merging_data), MergeType::Pause => None, }; - Ok((Submission::Full(submission), merging_data.map(Into::into), bid_adjustment)) + Ok((Submission::Full(submission), merging_data.map(Into::into), bid_adjustment, None)) } #[timed] - fn decode_default( - &mut self, - body: &[u8], - ) -> Result<(Submission, Option, Option), DecoderError> - { - let (submission, bid_adjustment) = if self.with_adjustments { + fn decode_default(&mut self, body: &[u8]) -> Result { + let is_gloas = self.fork_name == ForkName::Gloas; + let (submission, bid_adjustment, block_access_list) = if self.with_adjustments { + if is_gloas { + // Refused rather than decoded into the wrong shape. Adjustments + // are a BuilderNet feature and Gloas does not need them yet. + return Err(DecoderError::UnsupportedCombination("Gloas with bid adjustments")); + } let sub_with_adjustment: SignedBidSubmissionWithAdjustments = self._decode(body)?; let (sub, adjustment_data) = sub_with_adjustment.split(); - (sub, Some(adjustment_data)) + (sub, Some(adjustment_data), None) + } else if is_gloas { + // Gloas carries the builder's EIP-7928 block access list. + let gloas: SignedBidSubmissionGloas = self._decode(body)?; + let (submission, block_access_list) = gloas.split(); + + (submission, None, Some(block_access_list)) } else { let submission: SignedBidSubmission = self._decode(body)?; - (submission, None) + (submission, None, None) }; let merging_data = match self.merge_type { @@ -515,7 +524,7 @@ impl SubmissionDecoder { } MergeType::Pause => None, }; - Ok((Submission::Full(submission), merging_data, bid_adjustment)) + Ok((Submission::Full(submission), merging_data, bid_adjustment, block_access_list)) } // TODO: pass a buffer pool to avoid allocations @@ -621,7 +630,7 @@ mod tests { BidAdjData, BidAdjustmentDataV1, BlobsBundle, BundleOrder, DehydratedBidSubmissionFuluV1, DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData, DehydratedBidSubmissionFuluWithMergingData, MergeType, Order, - SignedBidSubmissionWithAdjustmentsAndMergingData, TestRandom, + SignedBidSubmissionWithAdjustmentsAndMergingData, TestRandom, TestRandomSeed, }; use ssz::Encode; @@ -656,6 +665,75 @@ mod tests { assert_eq!(MergeType::AppendOnly.as_ref(), "append_only"); } + /// Plain SSZ params for `fork`, nothing else enabled. + fn plain_params(fork: ForkName) -> SubmissionDecoderParams { + SubmissionDecoderParams { + compression: Compression::None, + encoding: Encoding::Ssz, + merge_type: MergeType::None, + is_dehydrated: false, + with_mergeable_data: false, + with_adjustments: false, + mark_all_txs_mergeable: false, + dehydrated_v2: false, + merging_v2: false, + fork_name: fork, + } + } + + #[test] + fn the_decoder_selects_the_gloas_shape_by_fork() { + let mut submission = SignedBidSubmissionGloas::test_random(); + submission.blobs_bundle = Default::default(); + submission.block_access_list = BlockAccessListBytes(vec![3u8; 32].into()); + let body = submission.as_ssz_bytes(); + + let params = plain_params(ForkName::Gloas); + let mut buf = Vec::new(); + let (_, _, _, block_access_list) = SubmissionDecoder::new(¶ms) + .decode(&body, &mut buf) + .expect("a Gloas submission must decode"); + + assert_eq!(block_access_list.expect("Gloas carries a block access list").to_vec(), vec![ + 3u8; + 32 + ],); + } + + #[test] + fn the_decoder_keeps_the_fulu_shape_for_fulu() { + let mut submission = SignedBidSubmission::test_random(); + submission.blobs_bundle = Default::default(); + let body = submission.as_ssz_bytes(); + + let params = plain_params(ForkName::Fulu); + let mut buf = Vec::new(); + let (_, _, _, block_access_list) = SubmissionDecoder::new(¶ms) + .decode(&body, &mut buf) + .expect("the Fulu shape must be unchanged"); + + assert!(block_access_list.is_none(), "only Gloas carries one"); + } + + #[test] + fn gloas_with_adjustments_is_refused() { + let mut submission = SignedBidSubmissionGloas::test_random(); + submission.blobs_bundle = Default::default(); + let body = submission.as_ssz_bytes(); + + let mut params = plain_params(ForkName::Gloas); + params.with_adjustments = true; + let mut buf = Vec::new(); + let err = SubmissionDecoder::new(¶ms) + .decode(&body, &mut buf) + .expect_err("the combination has no wire shape"); + + assert!( + matches!(err, DecoderError::UnsupportedCombination(_)), + "refused explicitly, not decoded into the wrong shape: {err}", + ); + } + #[test] fn test_merge_type_deserialization() { assert_eq!("mergeable".parse::().unwrap(), MergeType::Mergeable); @@ -702,7 +780,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Dehydrated(_))); @@ -736,7 +814,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -763,7 +841,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Dehydrated(_))); @@ -808,7 +886,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment) = + let (decoded_submission, merging_data, bid_adjustment, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -839,7 +917,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, decoded_merging_data, bid_adjustment) = + let (decoded_submission, decoded_merging_data, bid_adjustment, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -887,7 +965,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, decoded_merging_data, _) = + let (decoded_submission, decoded_merging_data, _, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -918,7 +996,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, _) = + let (decoded_submission, merging_data, _, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -958,7 +1036,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, _) = + let (decoded_submission, merging_data, _, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -1002,7 +1080,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment) = + let (decoded_submission, merging_data, bid_adjustment, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Dehydrated(_))); @@ -1030,7 +1108,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment) = + let (decoded_submission, merging_data, bid_adjustment, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); @@ -1057,7 +1135,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment) = + let (decoded_submission, merging_data, bid_adjustment, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Dehydrated(_))); @@ -1087,7 +1165,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Dehydrated(_))); @@ -1123,7 +1201,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); match decoded_submission { @@ -1179,7 +1257,7 @@ mod tests { }; let mut decoder = SubmissionDecoder::new(¶ms); let mut buf = Vec::new(); - let (decoded_submission, merging_data, bid_adjustment_data) = + let (decoded_submission, merging_data, bid_adjustment_data, _) = decoder.decode(&body, &mut buf).expect("decode should succeed"); assert!(matches!(decoded_submission, Submission::Full(_))); diff --git a/crates/relay/src/auctioneer/get_execution_payload_bid.rs b/crates/relay/src/auctioneer/get_execution_payload_bid.rs index a47d501e4..edbca72cd 100644 --- a/crates/relay/src/auctioneer/get_execution_payload_bid.rs +++ b/crates/relay/src/auctioneer/get_execution_payload_bid.rs @@ -72,7 +72,7 @@ pub(super) fn build_signed_bid( ) -> Result { let slot = Slot::new(params.slot); - let payload = entry.execution_payload().to_lighthouse_gloas_payload(slot).map_err(|err| { + let payload = entry.execution_payload().to_lighthouse_gloas_payload(slot, &entry.block_access_list()).map_err(|err| { warn!(%err, block_hash =% entry.block_hash(), "failed to convert held payload to Gloas shape for bid"); ProposerApiError::InternalServerError })?; @@ -228,6 +228,51 @@ mod tests { GloasBuilderIdentity { builder_index, keypair: helix_types::BlsKeypair::random() } } + /// A submission entry carrying a block access list, as Gloas requires. + fn gloas_submission_entry(block_access_list: Vec) -> PayloadEntry { + use helix_types::{BlockAccessListBytes, SignedBidSubmission, TestRandomSeed}; + + let mut submission = SignedBidSubmission::test_random(); + submission.blobs_bundle = Default::default(); + + PayloadEntry::new_submission( + submission, + B256::ZERO, + None, + None, + Some(BlockAccessListBytes(block_access_list.into())), + helix_types::SubmissionVersion::new(0, None), + Default::default(), + None, + ) + } + + #[test] + fn the_stored_payload_keeps_the_block_access_list_for_the_bid() { + let entry = gloas_submission_entry(vec![5u8; 96]); + + let bal = entry.block_access_list(); + + assert_eq!(bal.to_vec(), vec![5u8; 96], "the bid's payload would be invalid without it"); + + // And it reaches the converted Gloas payload. + let payload = entry + .execution_payload() + .to_lighthouse_gloas_payload(Slot::new(1), &bal) + .expect("conversion must succeed"); + assert_eq!(payload.block_access_list.len(), 96); + } + + #[test] + fn a_gossip_entry_has_no_block_access_list() { + let entry = payload_entry(B256::repeat_byte(0x11), 1); + + assert!( + entry.block_access_list().is_empty(), + "a gossiped payload carries none, so Gloas cannot be served from one", + ); + } + #[test] fn build_signed_bid_uses_the_entrys_data_and_configured_identity() { let chain_info = ChainInfo::default(); diff --git a/crates/relay/src/auctioneer/gloas_payload.rs b/crates/relay/src/auctioneer/gloas_payload.rs index 55913fa47..88d2dd60c 100644 --- a/crates/relay/src/auctioneer/gloas_payload.rs +++ b/crates/relay/src/auctioneer/gloas_payload.rs @@ -18,7 +18,10 @@ impl Context { res_tx: oneshot::Sender>, ) { let held = self.payloads.get(&block_hash).and_then(|entry| { - let payload = match entry.execution_payload().to_lighthouse_gloas_payload(slot) { + let payload = match entry + .execution_payload() + .to_lighthouse_gloas_payload(slot, &entry.block_access_list()) + { Ok(payload) => payload, Err(err) => { warn!(%block_hash, %err, "failed to convert held payload to Gloas shape"); diff --git a/crates/relay/src/auctioneer/submit_block.rs b/crates/relay/src/auctioneer/submit_block.rs index 76590f803..e81657992 100644 --- a/crates/relay/src/auctioneer/submit_block.rs +++ b/crates/relay/src/auctioneer/submit_block.rs @@ -74,6 +74,7 @@ impl Context { let version = submission_data.version; let is_pessimistic = submission_data.is_pessimistic; let bid_adjustment_data = submission_data.bid_adjustment_data.clone(); + let block_access_list = submission_data.block_access_list.clone(); let mut trace = submission_data.trace; let (submission, maybe_tx_root) = match self.hydrate(submission_data.submission.clone()) { @@ -154,6 +155,7 @@ impl Context { payload_attributes.withdrawals_root, maybe_tx_root, bid_adjustment_data, + block_access_list, version, trace, payload_attributes.parent_beacon_block_root, diff --git a/crates/relay/src/auctioneer/types.rs b/crates/relay/src/auctioneer/types.rs index 1bfa498ae..b10b4bc72 100644 --- a/crates/relay/src/auctioneer/types.rs +++ b/crates/relay/src/auctioneer/types.rs @@ -15,10 +15,11 @@ use helix_common::{ }; use helix_tcp_types::{BidSubmissionFlags, BidSubmissionHeader}; use helix_types::{ - BidAdjustmentData, BlockMergingDataV2, BlsPublicKeyBytes, BuilderBid, Compression, - ExecutionPayload, ForkName, GetPayloadResponse, MergeType, PayloadAndBlobs, PayloadBidData, - PayloadBidDataRef, SignedBidSubmission, SignedBlindedBeaconBlock, SignedExecutionPayloadBid, - Slot, Submission, SubmissionVersion, VersionedSignedProposal, mock_public_key_bytes, + BidAdjustmentData, BlockAccessListBytes, BlockMergingDataV2, BlsPublicKeyBytes, BuilderBid, + Compression, ExecutionPayload, ForkName, GetPayloadResponse, MergeType, PayloadAndBlobs, + PayloadBidData, PayloadBidDataRef, SignedBidSubmission, SignedBlindedBeaconBlock, + SignedExecutionPayloadBid, Slot, Submission, SubmissionVersion, VersionedSignedProposal, + mock_public_key_bytes, }; use http::{ HeaderMap, HeaderValue, @@ -248,6 +249,7 @@ pub struct SubmissionData { pub submission: Submission, pub merging_data: Option, pub bid_adjustment_data: Option, + pub block_access_list: Option, pub version: SubmissionVersion, pub withdrawals_root: B256, pub trace: SubmissionTrace, @@ -275,6 +277,8 @@ pub struct SubmissionPayload { pub withdrawals_root: B256, pub tx_root: Option, pub bid_adjustment_data: Option, + /// The builder's EIP-7928 list, present only for Gloas submissions. + pub block_access_list: Option, pub is_adjusted: bool, pub submission_version: SubmissionVersion, pub submission_trace: SubmissionTrace, @@ -289,16 +293,19 @@ pub enum PayloadEntry { } impl PayloadEntry { + #[allow(clippy::too_many_arguments)] pub fn new_submission( signed_bid_submission: SignedBidSubmission, withdrawals_root: B256, tx_root: Option, bid_adjustment_data: Option, + block_access_list: Option, submission_version: SubmissionVersion, submission_trace: SubmissionTrace, parent_beacon_block_root: Option, ) -> Self { Self::Submission(SubmissionPayload { + block_access_list, signed_bid_submission, withdrawals_root, tx_root, @@ -363,6 +370,16 @@ impl PayloadEntry { } } + /// The submitted EIP-7928 list. Empty when the fork does not carry one, in + /// which case a Gloas conversion would produce an invalid payload -- the + /// caller is expected to only reach this on a Gloas submission. + pub fn block_access_list(&self) -> BlockAccessListBytes { + match self { + Self::Submission(bid) => bid.block_access_list.clone().unwrap_or_default(), + Self::Gossip(_) => BlockAccessListBytes::default(), + } + } + pub fn execution_payload_make_mut(&mut self) -> &mut ExecutionPayload { match self { Self::Submission(bid) => bid.signed_bid_submission.execution_payload_make_mut(), diff --git a/crates/relay/src/bid_decoder/tile.rs b/crates/relay/src/bid_decoder/tile.rs index cf2d51540..acc65dbf1 100644 --- a/crates/relay/src/bid_decoder/tile.rs +++ b/crates/relay/src/bid_decoder/tile.rs @@ -19,8 +19,8 @@ use helix_common::{ utils::utcnow_ns, }; use helix_types::{ - BidAdjustmentData, BlockMergingDataV2, BlsPublicKeyBytes, MergeType, SignedBidSubmission, - Submission, SubmissionVersion, + BidAdjustmentData, BlockAccessListBytes, BlockMergingDataV2, BlsPublicKeyBytes, MergeType, + SignedBidSubmission, Submission, SubmissionVersion, }; use rustc_hash::FxHashMap; use tracing::{info, trace, warn}; @@ -268,6 +268,7 @@ impl DecoderTile { version, merging_data, bid_adjustment_data, + block_access_list, decoder_params, ) = Self::try_handle_block_submission( cache, @@ -316,6 +317,7 @@ impl DecoderTile { version, merging_data, bid_adjustment_data, + block_access_list, withdrawals_root, trace, decoder_params, @@ -343,6 +345,7 @@ impl DecoderTile { SubmissionVersion, Option, Option, + Option, SubmissionDecoderParams, ), BuilderApiError, @@ -367,7 +370,7 @@ impl DecoderTile { }; let mut decoder = SubmissionDecoder::new(&decoder_params); - let (mut submission, merging_data, bid_adjustment_data) = + let (mut submission, merging_data, bid_adjustment_data, block_access_list) = decoder.decode(payload, buffer)?; trace.decoded_ns = Nanos::now(); @@ -414,6 +417,7 @@ impl DecoderTile { version, merging_data, bid_adjustment_data, + block_access_list, decoder_params, )) } diff --git a/crates/relay/src/block_merging/tile.rs b/crates/relay/src/block_merging/tile.rs index d555c61be..ce99fef37 100644 --- a/crates/relay/src/block_merging/tile.rs +++ b/crates/relay/src/block_merging/tile.rs @@ -1226,6 +1226,7 @@ mod tests { signed.blobs_bundle = Arc::new(Default::default()); let submission_data = SubmissionData { submission_id: Uuid::nil(), + block_access_list: None, submission_ref: SubmissionRef::default(), submission: Submission::Full(signed), merging_data: Some(BlockMergingDataV2 { diff --git a/crates/simulator/src/ssz_server.rs b/crates/simulator/src/ssz_server.rs index 77d3ef4c9..d46b8edd7 100644 --- a/crates/simulator/src/ssz_server.rs +++ b/crates/simulator/src/ssz_server.rs @@ -48,7 +48,7 @@ fn decode_submission( Some(decode_params) => { let mut buf = vec![]; let mut decoder = SubmissionDecoder::new(&decode_params); - let (submission, _, _) = decoder.decode(signed_bid_submission, &mut buf)?; + let (submission, _, _, _) = decoder.decode(signed_bid_submission, &mut buf)?; match submission { Submission::Full(s) => Ok(s.into()), Submission::Dehydrated(_) => { diff --git a/crates/types/src/bid_submission.rs b/crates/types/src/bid_submission.rs index 2faf29d13..a1bda1562 100644 --- a/crates/types/src/bid_submission.rs +++ b/crates/types/src/bid_submission.rs @@ -20,7 +20,7 @@ use crate::{ PayloadAndBlobs, SszError, TestRandom, bid_adjustment_data::{BidAdjData, BidAdjustmentData, BidAdjustmentDataV1}, error::SigError, - fields::ExecutionRequests, + fields::{BlockAccessListBytes, ExecutionRequests}, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, TreeHash)] @@ -736,6 +736,50 @@ impl SignedBidSubmissionWithAdjustments { } } +/// Gloas carries the block access list the builder produced (EIP-7928): +/// core fields ++ block_access_list. +/// +/// A separate type rather than a fork-gated field, because `Encode` is derived +/// on [`SignedBidSubmission`] and an extra field would change the bytes for +/// every fork. +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] +pub struct SignedBidSubmissionGloas { + pub message: BidTrace, + pub execution_payload: Arc, + pub blobs_bundle: Arc, + pub execution_requests: Arc, + pub signature: BlsSignatureBytes, + pub block_access_list: BlockAccessListBytes, +} + +impl TestRandom for SignedBidSubmissionGloas { + fn random_for_test(rng: &mut impl rand::RngCore) -> Self { + Self { + message: BidTrace::random_for_test(rng), + execution_payload: ExecutionPayload::random_for_test(rng).into(), + blobs_bundle: BlobsBundle::random_for_test(rng).into(), + execution_requests: ExecutionRequests::random_for_test(rng).into(), + signature: BlsSignatureBytes::random(), + block_access_list: BlockAccessListBytes::random_for_test(rng), + } + } +} + +impl SignedBidSubmissionGloas { + pub fn split(self) -> (SignedBidSubmission, BlockAccessListBytes) { + ( + SignedBidSubmission { + message: self.message, + execution_payload: self.execution_payload, + blobs_bundle: self.blobs_bundle, + execution_requests: self.execution_requests, + signature: self.signature, + }, + self.block_access_list, + ) + } +} + /// Flat combination of [`SignedBidSubmissionWithAdjustments`] and merging data: /// core fields ++ bid_adjustment_data ++ merging_data. #[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] @@ -937,3 +981,66 @@ mod tests { assert!(bytes.ends_with(&tail)); } } + +#[cfg(test)] +mod gloas_submission_tests { + use ssz::{Decode, Encode}; + + use super::*; + use crate::TestRandomSeed; + + /// `BlobsBundle::random_for_test` makes one proof per blob while `Decode` + /// demands 128, so a random bundle cannot round-trip. These tests are about + /// the block access list, so they use an empty one. + fn decodable_gloas_submission() -> SignedBidSubmissionGloas { + let mut submission = SignedBidSubmissionGloas::test_random(); + submission.blobs_bundle = Default::default(); + submission + } + + #[test] + fn a_gloas_submission_round_trips_through_ssz() { + let submission = decodable_gloas_submission(); + + let bytes = submission.as_ssz_bytes(); + let decoded = SignedBidSubmissionGloas::from_ssz_bytes(&bytes).unwrap(); + + assert_eq!(decoded.message, submission.message); + assert_eq!(decoded.block_access_list, submission.block_access_list); + assert_eq!(bytes, decoded.as_ssz_bytes()); + } + + #[test] + fn splitting_a_gloas_submission_yields_the_base_and_the_bal() { + let submission = decodable_gloas_submission(); + let expected_bal = submission.block_access_list.clone(); + let expected_hash = submission.message.block_hash; + + let (base, bal) = submission.split(); + + assert_eq!(bal, expected_bal); + assert_eq!(base.message.block_hash, expected_hash); + } + + #[test] + fn the_gloas_shape_is_distinct_from_fulu() { + // Neither decodes as the other, so no existing Fulu path can silently + // accept a Gloas submission or vice versa. + let mut gloas = decodable_gloas_submission(); + gloas.block_access_list = BlockAccessListBytes(vec![7u8; 64].into()); + + assert!( + SignedBidSubmission::from_ssz_bytes(&gloas.as_ssz_bytes()).is_err(), + "a Gloas submission must not decode as Fulu", + ); + assert!( + SignedBidSubmissionGloas::from_ssz_bytes(&{ + let mut fulu = SignedBidSubmission::test_random(); + fulu.blobs_bundle = Default::default(); + fulu.as_ssz_bytes() + }) + .is_err(), + "a Fulu submission must not decode as Gloas", + ); + } +} diff --git a/crates/types/src/execution_payload.rs b/crates/types/src/execution_payload.rs index fe2968593..782b1d879 100644 --- a/crates/types/src/execution_payload.rs +++ b/crates/types/src/execution_payload.rs @@ -157,11 +157,13 @@ impl ExecutionPayload { } /// Converts to the real, progressive-list Gloas execution payload shape used on-chain. - /// `block_access_list` is left empty -- TODO(gloas): populate once EIP-7928 block-access-list - /// tracking exists. + /// + /// `block_access_list` is the opaque EIP-7928 list the builder submitted: only + /// an execution client can produce it, so it travels on the submission. pub fn to_lighthouse_gloas_payload( &self, slot: lh_types::Slot, + block_access_list: &crate::fields::BlockAccessListBytes, ) -> Result, SszError> { Ok(lh_types::ExecutionPayloadGloas { parent_hash: self.parent_hash.into(), @@ -181,7 +183,7 @@ impl ExecutionPayload { withdrawals: self.withdrawals.iter().cloned().collect(), blob_gas_used: self.blob_gas_used, excess_blob_gas: self.excess_blob_gas, - block_access_list: Default::default(), + block_access_list: block_access_list.iter().copied().collect(), slot_number: slot, }) } @@ -370,12 +372,39 @@ mod tests { assert_eq!(our_payload.tree_hash_root(), decoded.tree_hash_root()); } + #[test] + fn the_gloas_payload_carries_the_submitted_block_access_list() { + let payload = ExecutionPayload::test_random(); + let bal = crate::fields::BlockAccessListBytes(vec![9u8; 128].into()); + + let gloas = payload.to_lighthouse_gloas_payload(lh_types::Slot::new(1), &bal).unwrap(); + + // Before this it was always empty, which made the envelope invalid. + assert_eq!(gloas.block_access_list.len(), 128); + assert_eq!(gloas.block_access_list.to_vec(), bal.to_vec()); + } + + #[test] + fn an_empty_block_access_list_still_converts() { + let payload = ExecutionPayload::test_random(); + + let gloas = payload + .to_lighthouse_gloas_payload( + lh_types::Slot::new(1), + &crate::fields::BlockAccessListBytes::default(), + ) + .unwrap(); + + assert!(gloas.block_access_list.is_empty()); + } + #[test] fn to_lighthouse_gloas_payload_preserves_fields() { let our_payload = ExecutionPayload::test_random(); let slot = lh_types::Slot::new(42); - let gloas = our_payload.to_lighthouse_gloas_payload(slot).unwrap(); + let bal = crate::fields::BlockAccessListBytes(vec![1u8, 2, 3].into()); + let gloas = our_payload.to_lighthouse_gloas_payload(slot, &bal).unwrap(); assert_eq!(gloas.parent_hash.0, our_payload.parent_hash); assert_eq!(gloas.block_hash.0, our_payload.block_hash); @@ -391,7 +420,6 @@ mod tests { assert_eq!(gloas.blob_gas_used, our_payload.blob_gas_used); assert_eq!(gloas.excess_blob_gas, our_payload.excess_blob_gas); assert_eq!(gloas.slot_number, slot); - assert!(gloas.block_access_list.is_empty()); assert_eq!(gloas.transactions.len(), our_payload.transactions.len()); for (converted, original) in diff --git a/crates/types/src/fields.rs b/crates/types/src/fields.rs index 90bc481ba..058938ba8 100644 --- a/crates/types/src/fields.rs +++ b/crates/types/src/fields.rs @@ -85,6 +85,23 @@ ssz_bytes_wrapper! { max = ::MaxBytesPerTransaction; } +ssz_bytes_wrapper! { + /// The opaque encoded EIP-7928 block access list, as the builder produced + /// it. Gloas's own `BlockAccessList` is a `ProgressiveVariableList`, + /// so nothing here mirrors its structure. + pub struct BlockAccessListBytes; + max = ::MaxBytesPerTransaction; +} + +impl TestRandom for BlockAccessListBytes { + fn random_for_test(rng: &mut impl rand::RngCore) -> Self { + let n = rng.random_range(0..=1000) as usize; + let mut bytes = vec![0u8; n]; + rng.fill_bytes(&mut bytes); + Self(bytes.into()) + } +} + impl TestRandom for Transaction { fn random_for_test(rng: &mut impl rand::RngCore) -> Self { let n = rng.random_range(0..=1000) as usize; From 29d460fc13986999a2c1f39322cf0dde49384dc0 Mon Sep 17 00:00:00 2001 From: owen Date: Thu, 3 Sep 2026 13:04:36 +0100 Subject: [PATCH 05/33] Validate a Gloas/Amsterdam block in the simulation role Set the header's EIP-7928 block access list hash and EIP-7843 slot number from the submission, and compare the list against the one execution recomputes. Route Gloas to SSZ simulators again. --- crates/builder/src/engine/convert.rs | 19 + crates/builder/src/testing.rs | 24 + crates/builder/src/validation/error.rs | 3 + crates/builder/src/validation/mod.rs | 76 ++- crates/builder/src/validation/server.rs | 98 +-- crates/builder/src/validation/server_tests.rs | 29 +- crates/builder/src/validation/tests.rs | 628 ++++++++++++++++-- crates/common/src/decoder.rs | 76 ++- crates/relay/src/auctioneer/submit_block.rs | 1 + crates/relay/src/simulator/client.rs | 11 +- crates/relay/src/simulator/mod.rs | 63 +- crates/types/src/bid_submission.rs | 13 + 12 files changed, 896 insertions(+), 145 deletions(-) diff --git a/crates/builder/src/engine/convert.rs b/crates/builder/src/engine/convert.rs index e5f907242..b4103247a 100644 --- a/crates/builder/src/engine/convert.rs +++ b/crates/builder/src/engine/convert.rs @@ -95,6 +95,16 @@ pub fn block_to_payload_v3(block: &Block) -> ExecutionPayloadV3 { } } +/// The Amsterdam header fields no `ExecutionPayloadV3` carries: the EIP-7928 +/// block access list and the EIP-7843 slot number. `None` for an earlier fork. +#[derive(Clone, Copy)] +pub struct Amsterdam<'a> { + /// The list as the builder encoded it. It is hashed as received and never + /// re-encoded, because the block hash commits to these exact bytes. + pub block_access_list: &'a [u8], + pub slot: u64, +} + /// Inverse of [`block_to_payload_v3`]. The roots the payload omits are /// recomputed, so the hash this yields is the one the submission is bound to. #[allow(dead_code)] @@ -102,7 +112,13 @@ pub fn payload_v3_to_block( payload: &ExecutionPayloadV3, parent_beacon_block_root: B256, requests: &ExecutionRequestsV4, + amsterdam: Option>, ) -> Result { + // An empty list hashes to something no builder committed to, which would + // surface as a block hash mismatch. Name the real fault instead. + if amsterdam.is_some_and(|a| a.block_access_list.is_empty()) { + return Err(ValidationError::EmptyBlockAccessList); + } let inner = &payload.payload_inner.payload_inner; let transactions = inner @@ -139,6 +155,9 @@ pub fn payload_v3_to_block( excess_blob_gas: Some(payload.excess_blob_gas), parent_beacon_block_root: Some(h256(parent_beacon_block_root)), requests_hash: Some(compute_requests_hash(&encoded_requests(requests))), + block_access_list_hash: amsterdam + .map(|a| ethrex_common::utils::keccak(a.block_access_list)), + slot_number: amsterdam.map(|a| a.slot), ..Default::default() }; diff --git a/crates/builder/src/testing.rs b/crates/builder/src/testing.rs index 52aec8f76..9f564b8ce 100644 --- a/crates/builder/src/testing.rs +++ b/crates/builder/src/testing.rs @@ -123,6 +123,30 @@ pub fn deploy_payment_forwarder(genesis: &mut Genesis) { }); } +/// The EIP-8282 builder deposit and exit predeploys, from ethrex's +/// `fixtures/genesis/l1-bal.json`. Empty code at either address invalidates +/// every Amsterdam block, so an Amsterdam fixture cannot build without them. +pub fn deploy_amsterdam_predeploys(genesis: &mut Genesis) { + for (address, code) in [ + ("0000884d2aa32eaa155f59a2f24efa73d9008282", BUILDER_DEPOSIT_CODE), + ("000014574a74c805590aff9499fc7a690f008282", BUILDER_EXIT_CODE), + ] { + genesis.alloc.insert( + ethrex_common::Address::from_slice(&hex::decode(address).unwrap()), + GenesisAccount { + code: hex::decode(code).unwrap().into(), + storage: Default::default(), + balance: ethrex_common::U256::zero(), + nonce: 0, + }, + ); + } +} + +const BUILDER_DEPOSIT_CODE: &str = "3373fffffffffffffffffffffffffffffffffffffffe146101065760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461023457600182026001905f5b5f82111560695781019083028483029004916001019190604e565b90939004925050503660b814608957366102345734610234575f5260205ff35b8034106102345760383567ffffffffffffffff1680633b9aca001161023457633b9aca00029034031061023457600154600101600155600354806006026004015f358155600101602035815560010160403581556001016060358155600101608035815560010160a035905560b85f5f3760b85fa0600101600355005b600354600254808203806101001161011d57506101005b5f5b8181146101c3578281016006026004018160b8028154815260200181600101548152602001816002015480825260401c67ffffffffffffffff16816010018160381c81600701538160301c81600601538160281c81600501538160201c81600401538160181c81600301538160101c81600201538160081c81600101535360200181600301548152602001816004015481526020019060050154905260010161011f565b91018092146101d557906002556101e0565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561020d57505f5b6001546020828201116102225750505f610228565b01602090035b5f555f60015560b8025ff35b5f5ffd"; + +const BUILDER_EXIT_CODE: &str = "3373fffffffffffffffffffffffffffffffffffffffe1460cb5760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461018857600182026001905f5b5f82111560685781019083028483029004916001019190604d565b909390049250505036603014608857366101885734610188575f5260205ff35b341061018857600154600101600155600354806003026004013381556001015f35815560010160203590553360601b5f5260305f60143760445fa0600101600355005b6003546002548082038060101160df575060105b5f5b8181146101175782810160030260040181604402815460601b8152601401816001015481526020019060020154905260010160e1565b91018092146101295790600255610134565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561016157505f5b6001546002828201116101765750505f61017c565b01600290035b5f555f6001556044025ff35b5f5ffd"; + /// Runtime that reads the balance of the address in its calldata and stops: /// PUSH0 CALLDATALOAD BALANCE POP STOP. Reads an account without touching it. pub fn deploy_balance_probe(genesis: &mut Genesis, address: Address) { diff --git a/crates/builder/src/validation/error.rs b/crates/builder/src/validation/error.rs index a5b7b0887..9d09c7f25 100644 --- a/crates/builder/src/validation/error.rs +++ b/crates/builder/src/validation/error.rs @@ -39,6 +39,8 @@ pub enum ValidationError { Blacklist(Address), #[error("invalid blobs bundle")] InvalidBlobsBundle, + #[error("submission carries an empty block access list")] + EmptyBlockAccessList, } impl ValidationError { @@ -61,6 +63,7 @@ impl ValidationError { ValidationError::ProposerPayment => "proposer_payment", ValidationError::Blacklist(_) => "blacklist", ValidationError::InvalidBlobsBundle => "invalid_blobs_bundle", + ValidationError::EmptyBlockAccessList => "empty_block_access_list", } } } diff --git a/crates/builder/src/validation/mod.rs b/crates/builder/src/validation/mod.rs index 52d3f4c92..6fb1cb046 100644 --- a/crates/builder/src/validation/mod.rs +++ b/crates/builder/src/validation/mod.rs @@ -27,8 +27,8 @@ use ethrex_common::{ Transaction, TxKind, }, validation::{ - validate_block_pre_execution, validate_gas_used, validate_receipts_root_and_logs_bloom, - validate_requests_hash, + validate_block_access_list_hash, validate_block_pre_execution, validate_gas_used, + validate_receipts_root_and_logs_bloom, validate_requests_hash, }, }; use ethrex_crypto::NativeCrypto; @@ -42,7 +42,7 @@ use tokio::sync::watch; use crate::{ engine::{ - convert::{aaddr, au256, b256, eaddr, eu256, h256, payload_v3_to_block}, + convert::{Amsterdam, aaddr, au256, b256, eaddr, eu256, h256, payload_v3_to_block}, simulate::balance_of, }, metrics, @@ -107,9 +107,10 @@ impl BlockValidator { message: &BidTrace, parent_beacon_block_root: B256, requests: &ExecutionRequestsV4, + amsterdam: Option>, ) -> Result { let t = Instant::now(); - let block = self.to_block(payload, parent_beacon_block_root, requests)?; + let block = self.to_block(payload, parent_beacon_block_root, requests, amsterdam)?; let t = metrics::sim_lap("to_block", t); self.validate_message_against_header(&block, message)?; let t = metrics::sim_lap("check_trace", t); @@ -118,6 +119,9 @@ impl BlockValidator { Ok(PreparedBlock { block, parent_header }) } + // A request struct would read better at nine fields. That refactor touches + // every caller, so it is not this change's job. + #[allow(clippy::too_many_arguments)] pub fn validate( &self, payload: &ExecutionPayloadV3, @@ -126,8 +130,10 @@ impl BlockValidator { requests: &ExecutionRequestsV4, blobs: &BlobsBundle, apply_blacklist: bool, + amsterdam: Option>, ) -> Result { - let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; + let prepared = + self.prepare(payload, message, parent_beacon_block_root, requests, amsterdam)?; let t = Instant::now(); self.validate_blobs_bundle(&prepared.block, blobs)?; metrics::sim_lap("blobs", t); @@ -154,8 +160,10 @@ impl BlockValidator { blobs: &BlobsBundle, apply_blacklist: bool, base_payment_tx_index: u64, + amsterdam: Option>, ) -> Result { - let prepared = self.prepare(payload, message, parent_beacon_block_root, requests)?; + let prepared = + self.prepare(payload, message, parent_beacon_block_root, requests, amsterdam)?; let t = Instant::now(); self.validate_blobs_bundle(&prepared.block, blobs)?; metrics::sim_lap("blobs", t); @@ -276,7 +284,8 @@ impl BlockValidator { vm.db.store = parent_reads; metrics::sim_lap("vm_setup", t); - let merkle_pool = self.merkle_pools.checkout(); + let is_amsterdam = chain_config.is_amsterdam_activated(block.header.timestamp); + let merkle_pool = if is_amsterdam { None } else { self.merkle_pools.checkout() }; let queue_length = AtomicUsize::new(0); let (receipts, tx_details, account_updates) = std::thread::scope(|scope| { let (mut stream, merkleizer) = merkle_pool @@ -291,17 +300,26 @@ impl BlockValidator { }) .unzip(); - let (receipts, tx_details, gas_used) = - Self::execute_transactions(&mut vm, &block, stream.as_mut())?; - let t = Instant::now(); - let requests = vm - .extract_requests(&receipts, &block.header) - .map_err(|e| ValidationError::Execution(e.to_string()))?; - if let Some(withdrawals) = &block.body.withdrawals { - vm.process_withdrawals(withdrawals) + let (receipts, requests, gas_used, tx_details, bal) = if is_amsterdam { + let (result, bal) = vm + .execute_block(&block) .map_err(|e| ValidationError::Execution(e.to_string()))?; - } - let t = metrics::sim_lap("requests_withdrawals", t); + (result.receipts, result.requests, result.block_gas_used, Vec::new(), bal) + } else { + let (receipts, tx_details, gas_used) = + Self::execute_transactions(&mut vm, &block, stream.as_mut())?; + let t = Instant::now(); + let requests = vm + .extract_requests(&receipts, &block.header) + .map_err(|e| ValidationError::Execution(e.to_string()))?; + if let Some(withdrawals) = &block.body.withdrawals { + vm.process_withdrawals(withdrawals) + .map_err(|e| ValidationError::Execution(e.to_string()))?; + } + metrics::sim_lap("requests_withdrawals", t); + (receipts, requests, gas_used, tx_details, None) + }; + let t = Instant::now(); let serial_updates = match stream.take() { Some(mut stream) => { @@ -321,6 +339,25 @@ impl BlockValidator { .map_err(|e| ValidationError::PostExecution(e.to_string()))?; validate_requests_hash(&block.header, &chain_config, &requests) .map_err(|e| ValidationError::PostExecution(e.to_string()))?; + + // The header commits to the list the builder submitted, so this compares + // that list against what execution produced. ethrex skips the check when + // the VM returns no list; a relay simulator must fail closed instead. + if is_amsterdam { + let bal = bal.ok_or_else(|| { + ValidationError::PostExecution( + "no block access list from execution".to_string(), + ) + })?; + validate_block_access_list_hash( + &block.header, + &chain_config, + &bal, + block.body.transactions.len(), + &NativeCrypto, + ) + .map_err(|e| ValidationError::PostExecution(e.to_string()))?; + } let t = metrics::sim_lap("post_execution", t); let (account_updates, state_root) = match (merkleizer, serial_updates) { @@ -362,7 +399,7 @@ impl BlockValidator { /// The transaction loop of ethrex's `execute_block`, reading the coinbase /// balance after each transaction. Mirrors the pre-Amsterdam gas accounting - /// only: the V3 payloads this server decodes predate that fork. + /// only; Amsterdam blocks go through `execute_block` itself. fn execute_transactions( vm: &mut Evm, block: &Block, @@ -582,8 +619,9 @@ impl BlockValidator { payload: &ExecutionPayloadV3, parent_beacon_block_root: B256, requests: &ExecutionRequestsV4, + amsterdam: Option>, ) -> Result { - payload_v3_to_block(payload, parent_beacon_block_root, requests) + payload_v3_to_block(payload, parent_beacon_block_root, requests, amsterdam) } /// The relay serves the trace's fields, so a trace that misdescribes a valid diff --git a/crates/builder/src/validation/server.rs b/crates/builder/src/validation/server.rs index 582f970dd..1beb7b8e5 100644 --- a/crates/builder/src/validation/server.rs +++ b/crates/builder/src/validation/server.rs @@ -16,13 +16,13 @@ use helix_common::{ decoder::{DecoderError, SubmissionDecoder, SubmissionDecoderParams}, simulator::{SszMergedValidationRequest, SszValidationRequest, SszValidationResponse}, }; -use helix_types::{ForkName, Submission}; +use helix_types::{BlockAccessListBytes, ForkName, Submission}; use ssz::{Decode, Encode}; use tokio::{net::TcpListener, sync::Semaphore, time}; use tracing::{error, info, warn}; use crate::{ - engine::convert::eblobs, + engine::convert::{Amsterdam, eblobs}, metrics, validation::{BlockValidator, error::ValidationError}, }; @@ -62,6 +62,13 @@ pub async fn run(validator: BlockValidator, addr: SocketAddr, max_concurrent: us /// refused rather than validated under the wrong rules. fn supported_fork(params: &Option) -> bool { // No params means raw Fulu-shaped SSZ bytes. + params + .as_ref() + .is_none_or(|params| matches!(params.fork_name, ForkName::Fulu | ForkName::Gloas)) +} + +/// The merged route has no Gloas shape yet, so its fork list is shorter. +fn supported_merged_fork(params: &Option) -> bool { params.as_ref().is_none_or(|params| matches!(params.fork_name, ForkName::Fulu)) } @@ -73,22 +80,26 @@ fn unsupported_fork(params: &Option) -> Response { (StatusCode::NOT_IMPLEMENTED, format!("unsupported fork: {fork:?}")).into_response() } +/// A decoded submission, with the block access list a Gloas one carries. +type Decoded = (SignedBidSubmissionV5, Option); + /// A dehydrated submission needs transactions this simulator does not cache. /// The relay answers a 424 by retrying with full SSZ bytes. fn decode_submission( params: Option, bytes: &[u8], -) -> Result, DecoderError> { +) -> Result, DecoderError> { match params { Some(params) => { let mut buf = Vec::new(); - let (submission, _, _, _) = SubmissionDecoder::new(¶ms).decode(bytes, &mut buf)?; + let (submission, _, _, bal) = + SubmissionDecoder::new(¶ms).decode(bytes, &mut buf)?; match submission { - Submission::Full(submission) => Ok(Some(submission.into())), + Submission::Full(submission) => Ok(Some((submission.into(), bal))), Submission::Dehydrated(_) => Ok(None), } } - None => Ok(Some(SignedBidSubmissionV5::from_ssz_bytes(bytes)?)), + None => Ok(Some((SignedBidSubmissionV5::from_ssz_bytes(bytes)?, None))), } } @@ -109,21 +120,21 @@ async fn validate(State(state): State, body: axum::body::Bytes) -> ); } let t = metrics::sim_lap("decode_request", start); - let submission = match decode_submission(request.decoder_params, &request.signed_bid_submission) - { - Ok(Some(submission)) => submission, - Ok(None) => { - return finish( - "validate", - "dehydrated", - start, - StatusCode::FAILED_DEPENDENCY.into_response(), - ); - } - Err(err) => { - return finish("validate", "bad_submission", start, bad_request(err.to_string())) - } - }; + let (submission, bal) = + match decode_submission(request.decoder_params, &request.signed_bid_submission) { + Ok(Some(decoded)) => decoded, + Ok(None) => { + return finish( + "validate", + "dehydrated", + start, + StatusCode::FAILED_DEPENDENCY.into_response(), + ); + } + Err(err) => { + return finish("validate", "bad_submission", start, bad_request(err.to_string())) + } + }; metrics::sim_lap("decode_submission", t); run_validation(state, "validate", start, move |validator| { @@ -134,11 +145,18 @@ async fn validate(State(state): State, body: axum::body::Bytes) -> &submission.execution_requests, &eblobs(&submission.blobs_bundle), request.apply_blacklist, + amsterdam(bal.as_ref(), submission.message.slot), ) }) .await } +/// A block access list marks the submission as Amsterdam, and the slot number +/// the header needs is the one the trace already carries. +fn amsterdam(block_access_list: Option<&BlockAccessListBytes>, slot: u64) -> Option> { + block_access_list.map(|bal| Amsterdam { block_access_list: &bal.0, slot }) +} + async fn validate_merged(State(state): State, body: axum::body::Bytes) -> Response { let start = Instant::now(); let request = match SszMergedValidationRequest::from_ssz_bytes(&body) { @@ -147,7 +165,7 @@ async fn validate_merged(State(state): State, body: axum::body::Byt return finish("validate_merged", "bad_request", start, bad_request(format!("{err:?}"))) } }; - if !supported_fork(&request.decoder_params) { + if !supported_merged_fork(&request.decoder_params) { return finish( "validate_merged", "unsupported_fork", @@ -156,21 +174,26 @@ async fn validate_merged(State(state): State, body: axum::body::Byt ); } let t = metrics::sim_lap("decode_request", start); - let submission = match decode_submission(request.decoder_params, &request.signed_bid_submission) - { - Ok(Some(submission)) => submission, - Ok(None) => { - return finish( - "validate_merged", - "dehydrated", - start, - StatusCode::FAILED_DEPENDENCY.into_response(), - ); - } - Err(err) => { - return finish("validate_merged", "bad_submission", start, bad_request(err.to_string())) - } - }; + let (submission, _) = + match decode_submission(request.decoder_params, &request.signed_bid_submission) { + Ok(Some(decoded)) => decoded, + Ok(None) => { + return finish( + "validate_merged", + "dehydrated", + start, + StatusCode::FAILED_DEPENDENCY.into_response(), + ); + } + Err(err) => { + return finish( + "validate_merged", + "bad_submission", + start, + bad_request(err.to_string()), + ) + } + }; metrics::sim_lap("decode_submission", t); run_validation(state, "validate_merged", start, move |validator| { @@ -182,6 +205,7 @@ async fn validate_merged(State(state): State, body: axum::body::Byt &eblobs(&submission.blobs_bundle), request.apply_blacklist, request.base_payment_tx_index, + None, ) }) .await diff --git a/crates/builder/src/validation/server_tests.rs b/crates/builder/src/validation/server_tests.rs index 8552c206b..fd1b0710c 100644 --- a/crates/builder/src/validation/server_tests.rs +++ b/crates/builder/src/validation/server_tests.rs @@ -336,19 +336,28 @@ fn params_for(fork: helix_types::ForkName) -> helix_common::decoder::SubmissionD } #[tokio::test] -async fn a_gloas_validation_request_is_refused() { +async fn a_gloas_validation_request_is_accepted() { + let fixture = Fixture::amsterdam().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let request = fixture.gloas_ssz_request(&built); + + let (status, body) = post(&fixture, "/validate", request.as_ssz_bytes()).await; + + assert_eq!(status, StatusCode::OK, "{body}"); +} + +/// The fork gate stays shut for a fork with no wire shape here, so a later fork +/// cannot be validated under Gloas rules. +#[tokio::test] +async fn an_unscheduled_fork_is_still_refused() { let fixture = Fixture::new().await; let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); let mut request = fixture.ssz_request(&built, true); - request.decoder_params = Some(params_for(helix_types::ForkName::Gloas)); + request.decoder_params = Some(params_for(helix_types::ForkName::Heze)); let (status, body) = post(&fixture, "/validate", request.as_ssz_bytes()).await; - assert_eq!( - status, - StatusCode::NOT_IMPLEMENTED, - "a Gloas block must not be validated under Fulu rules: {body}", - ); + assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{body}"); } #[tokio::test] @@ -368,7 +377,11 @@ async fn a_merged_gloas_request_is_refused() { let (status, body) = post(&fixture, "/validate_merged", request.as_ssz_bytes()).await; - assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "the merged route has the same hole: {body}"); + assert_eq!( + status, + StatusCode::NOT_IMPLEMENTED, + "the merged route has no Gloas shape until step 5: {body}", + ); } #[tokio::test] diff --git a/crates/builder/src/validation/tests.rs b/crates/builder/src/validation/tests.rs index e32ee6bb4..4b783de6a 100644 --- a/crates/builder/src/validation/tests.rs +++ b/crates/builder/src/validation/tests.rs @@ -12,25 +12,31 @@ use ethrex_blockchain::{ payload::{BuildPayloadArgs, create_payload}, }; use ethrex_common::{H256, types::ELASTICITY_MULTIPLIER}; +use ethrex_rlp::encode::RLPEncode; use ethrex_storage::Store; use helix_common::simulator::BlockSimError; use tokio::sync::watch; use crate::{ engine::convert::{ - b256, block_to_payload_v3, eaddr, eblobs, h256, payload_v3_to_block, requests_to_v4, + Amsterdam, b256, block_to_payload_v3, eaddr, eblobs, h256, payload_v3_to_block, + requests_to_v4, }, node::HeadInfo, testing::{ - ETH, GWEI, blob_bundle, deploy_balance_probe, deploy_payment_forwarder, - dev_genesis_store_with, funded_signers, signed_blob_transfer, signed_transfer, - signed_unprotected_transfer, + ETH, GWEI, blob_bundle, deploy_amsterdam_predeploys, deploy_balance_probe, + deploy_payment_forwarder, dev_genesis_store_with, funded_signers, signed_blob_transfer, + signed_transfer, signed_unprotected_transfer, }, validation::{BlockValidator, error::ValidationError}, }; const WINDOW: u64 = 3; +/// The proposal slot every fixture block is built for. Amsterdam puts it in the +/// header, so the bid trace and the header have to agree on it. +const SLOT: u64 = 1; + fn empty_bundle() -> ethrex_common::types::BlobsBundle { ethrex_common::types::BlobsBundle::default() } @@ -38,12 +44,22 @@ fn empty_bundle() -> ethrex_common::types::BlobsBundle { pub(crate) struct Built { payload: ExecutionPayloadV3, requests: ExecutionRequestsV4, + /// The encoded EIP-7928 list, as a Gloas submission would carry it. `None` + /// before Amsterdam, which is what every pre-Gloas test wants. + pub(crate) block_access_list: Option>, + slot: u64, } impl Built { fn block_hash(&self) -> B256 { self.payload.payload_inner.payload_inner.block_hash } + + pub(crate) fn amsterdam(&self) -> Option> { + self.block_access_list + .as_deref() + .map(|block_access_list| Amsterdam { block_access_list, slot: self.slot }) + } } pub(crate) struct Fixture { @@ -57,6 +73,7 @@ pub(crate) struct Fixture { pub(crate) proposer: Address, head: watch::Sender, disallow: Arc>, + is_amsterdam: bool, } impl Fixture { @@ -64,6 +81,16 @@ impl Fixture { Self::with_genesis(|_| {}).await } + /// Amsterdam from genesis, so every block this fixture builds carries a + /// block access list and a slot number. + pub(crate) async fn amsterdam() -> Self { + Self::with_genesis(|genesis| { + genesis.config.amsterdam_time = Some(0); + deploy_amsterdam_predeploys(genesis); + }) + .await + } + pub(crate) async fn with_forwarder() -> Self { Self::with_genesis(deploy_payment_forwarder).await } @@ -118,6 +145,7 @@ impl Fixture { proposer: signers[3].address(), signers, disallow: Arc::new(DashSet::new()), + is_amsterdam: genesis.config.is_amsterdam_activated(genesis_block.header.timestamp), } } @@ -175,7 +203,7 @@ impl Fixture { random: H256::zero(), withdrawals: Some(withdrawals), beacon_root: Some(H256::zero()), - slot_number: None, + slot_number: self.is_amsterdam.then_some(SLOT), version: 3, elasticity_multiplier: ELASTICITY_MULTIPLIER, gas_ceil: self.gas_limit, @@ -190,6 +218,8 @@ impl Fixture { Built { payload: block_to_payload_v3(&built.payload), requests: requests_to_v4(&built.requests).unwrap(), + block_access_list: built.block_access_list.as_ref().map(|bal| bal.encode_to_vec()), + slot: SLOT, } } @@ -202,7 +232,7 @@ impl Fixture { let built = self.build_on(parent, timestamp, i as u64); let block = self .validator() - .to_block(&built.payload, B256::ZERO, &built.requests) + .to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) .expect("the fixture builds a convertible block"); parent = block.hash(); let number = block.header.number; @@ -268,10 +298,11 @@ impl Fixture { pub(crate) fn bid_trace(&self, built: &Built) -> BidTrace { let header = &built.payload.payload_inner.payload_inner; - let block = payload_v3_to_block(&built.payload, B256::ZERO, &built.requests) - .expect("the fixture builds a convertible payload"); + let block = + payload_v3_to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) + .expect("the fixture builds a convertible payload"); BidTrace { - slot: 1, + slot: built.slot, parent_hash: header.parent_hash, block_hash: b256(block.hash()), builder_pubkey: Default::default(), @@ -292,7 +323,7 @@ async fn a_valid_submission_prepares_its_block() { let prepared = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect("a block the fixture built must prepare"); assert_eq!(b256(prepared.block.hash()), built.block_hash()); @@ -308,7 +339,7 @@ async fn the_payload_round_trips_to_the_same_block_hash() { let block = fixture .validator() - .to_block(&built.payload, B256::ZERO, &built.requests) + .to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) .expect("a block the fixture built must convert"); assert_eq!(b256(block.hash()), built.block_hash()); @@ -323,7 +354,7 @@ async fn a_bid_trace_with_the_wrong_block_hash_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("a mismatched block hash must be rejected"); assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); @@ -338,7 +369,7 @@ async fn a_bid_trace_with_the_wrong_parent_hash_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("a mismatched parent hash must be rejected"); assert!(matches!(error, ValidationError::ParentHashMismatch { .. }), "{error}"); @@ -353,7 +384,7 @@ async fn a_bid_trace_with_the_wrong_gas_limit_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("a mismatched gas limit must be rejected"); assert!(matches!(error, ValidationError::GasLimitMismatch { .. }), "{error}"); @@ -368,7 +399,7 @@ async fn a_bid_trace_with_the_wrong_gas_used_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("a mismatched gas used must be rejected"); assert!(matches!(error, ValidationError::GasUsedMismatch { .. }), "{error}"); @@ -385,7 +416,7 @@ async fn a_tampered_payload_fails_the_block_hash_check() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("an edited payload must be rejected"); assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); @@ -400,7 +431,7 @@ async fn an_undecodable_transaction_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("an undecodable transaction must be rejected"); assert!(matches!(error, ValidationError::DecodeTransaction(_)), "{error}"); @@ -414,12 +445,17 @@ async fn an_unknown_parent_is_rejected() { let mut payload = built.payload.clone(); payload.payload_inner.payload_inner.parent_hash = B256::repeat_byte(0xcc); message.parent_hash = B256::repeat_byte(0xcc); - message.block_hash = - b256(fixture.validator().to_block(&payload, B256::ZERO, &built.requests).unwrap().hash()); + message.block_hash = b256( + fixture + .validator() + .to_block(&payload, B256::ZERO, &built.requests, built.amsterdam()) + .unwrap() + .hash(), + ); let error = fixture .validator() - .prepare(&payload, &message, B256::ZERO, &built.requests) + .prepare(&payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("an unknown parent must be rejected"); assert!(matches!(error, ValidationError::MissingParentBlock), "{error}"); @@ -435,7 +471,7 @@ async fn a_parent_inside_the_validation_window_is_accepted() { fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect("a block built on the head must prepare"); } @@ -450,7 +486,7 @@ async fn a_parent_outside_the_validation_window_is_rejected() { let error = fixture .validator() - .prepare(&built.payload, &message, B256::ZERO, &built.requests) + .prepare(&built.payload, &message, B256::ZERO, &built.requests, built.amsterdam()) .expect_err("a parent outside the window must be rejected"); assert!(matches!(error, ValidationError::BlockTooOld), "{error}"); @@ -489,7 +525,15 @@ async fn a_valid_block_passes_execution() { let executed = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("a block the fixture built must validate"); assert_eq!(executed.receipts.len(), 1); @@ -506,7 +550,15 @@ async fn validating_a_block_does_not_store_it() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("a block the fixture built must validate"); assert_eq!(fixture.store.get_latest_block_number().unwrap(), 0); @@ -525,7 +577,15 @@ async fn a_tampered_state_root_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a wrong state root must be rejected"); assert!(matches!(error, ValidationError::StateRootMismatch { .. }), "{error}"); @@ -540,7 +600,15 @@ async fn a_tampered_gas_used_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a wrong gas used must be rejected"); assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); @@ -555,7 +623,15 @@ async fn a_tampered_receipts_root_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a wrong receipts root must be rejected"); assert!(matches!(error, ValidationError::PostExecution(_)), "{error}"); @@ -568,7 +644,7 @@ async fn execution_requests_that_the_block_did_not_produce_are_rejected() { let fixture = Fixture::new().await; let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); let requests = withdrawal_request(); - let tampered = Built { payload: built.payload.clone(), requests }; + let tampered = Built { payload: built.payload.clone(), requests, ..built }; let message = fixture.bid_trace(&tampered); let error = fixture @@ -580,6 +656,7 @@ async fn execution_requests_that_the_block_did_not_produce_are_rejected() { &tampered.requests, &empty_bundle(), false, + tampered.amsterdam(), ) .expect_err("unproduced requests must be rejected"); @@ -597,7 +674,15 @@ async fn a_tampered_base_fee_is_rejected_before_execution() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a wrong base fee must be rejected"); assert!(matches!(error, ValidationError::PreExecution(_)), "{error}"); @@ -621,7 +706,15 @@ async fn a_block_with_an_unexecutable_transaction_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("an unexecutable transaction must be rejected"); assert!(matches!(error, ValidationError::Execution(_)), "{error}"); @@ -660,7 +753,15 @@ async fn a_payment_by_balance_delta_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("a balance delta covering the bid must be accepted"); } @@ -687,7 +788,15 @@ async fn a_trailing_direct_transfer_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("a trailing direct transfer must be accepted"); } @@ -714,7 +823,15 @@ async fn an_underpaid_block_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("paying less than the bid must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -745,7 +862,15 @@ async fn a_payment_tx_with_a_priority_fee_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a tipping payment tx must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -768,7 +893,15 @@ async fn an_unprotected_payment_tx_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("an unprotected payment tx must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -797,7 +930,15 @@ async fn a_withdrawal_does_not_pay_the_bid() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a withdrawal must not count as the bid payment"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -830,7 +971,15 @@ async fn a_payment_through_the_forwarder_is_accepted() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("a forwarder payment must be accepted where the forwarder is deployed"); } @@ -857,7 +1006,15 @@ async fn a_forwarder_payment_is_rejected_where_the_forwarder_is_absent() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("an undeployed forwarder must not be trusted"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -886,7 +1043,15 @@ async fn a_reverted_payment_tx_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a reverted payment must be rejected"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -945,6 +1110,7 @@ async fn a_merged_payment_split_across_two_txs_is_accepted() { &empty_bundle(), false, 1, + built.amsterdam(), ) .expect("both payment positions must count"); } @@ -1001,6 +1167,7 @@ async fn a_merged_payment_with_a_wrong_base_index_is_rejected() { &empty_bundle(), false, 2, + built.amsterdam(), ) .expect_err("a wrong base payment index must fail closed"); @@ -1042,7 +1209,15 @@ async fn a_split_payment_is_not_accepted_on_the_regular_path() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("the regular path must not sum two positions"); assert!(matches!(error, ValidationError::ProposerPayment), "{error}"); @@ -1071,7 +1246,15 @@ async fn a_blacklisted_sender_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed sender must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1095,7 +1278,15 @@ async fn a_blacklisted_recipient_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed recipient must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1121,7 +1312,15 @@ async fn a_blacklisted_coinbase_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed coinbase must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1136,7 +1335,15 @@ async fn a_blacklisted_proposer_fee_recipient_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed fee recipient must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1160,7 +1367,15 @@ async fn a_blacklisted_internal_value_target_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed internal value target must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1178,7 +1393,15 @@ async fn a_blacklisted_created_account_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect_err("a listed created account must be rejected"); assert!(matches!(error, ValidationError::Blacklist(_)), "{error}"); @@ -1200,7 +1423,15 @@ async fn an_account_that_is_only_read_is_not_blacklisted() { let executed = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect("a read alone must not reject the block"); assert!(executed.receipts[0].succeeded, "the probe must have run for this to prove anything"); @@ -1214,7 +1445,15 @@ async fn a_block_touching_no_listed_account_passes() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), true) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + true, + built.amsterdam(), + ) .expect("a block touching nothing listed must pass"); } @@ -1239,7 +1478,15 @@ async fn a_non_filtering_proposer_bypasses_the_blacklist() { fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect("apply_blacklist = false must skip the check"); } @@ -1264,7 +1511,15 @@ async fn a_block_with_a_valid_blobs_bundle_passes() { let executed = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &bundle, + false, + built.amsterdam(), + ) .expect("a valid blobs bundle must pass"); assert!( @@ -1299,7 +1554,15 @@ async fn a_bundle_whose_commitments_do_not_match_the_block_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &bundle, + false, + built.amsterdam(), + ) .expect_err("commitments not matching the block's hashes must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1313,7 +1576,15 @@ async fn a_bundle_with_a_wrong_proof_count_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &bundle, + false, + built.amsterdam(), + ) .expect_err("a short proof list must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1327,7 +1598,15 @@ async fn a_bundle_with_an_invalid_cell_proof_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &bundle, + false, + built.amsterdam(), + ) .expect_err("a corrupt cell proof must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1341,7 +1620,15 @@ async fn a_bundle_carrying_more_blobs_than_the_block_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &bundle, false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &bundle, + false, + built.amsterdam(), + ) .expect_err("a bundle with a spare blob must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1354,7 +1641,15 @@ async fn a_blob_tx_with_an_empty_bundle_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &empty_bundle(), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) .expect_err("a blob tx without its blobs must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1369,7 +1664,15 @@ async fn a_bundle_for_a_block_with_no_blob_txs_is_rejected() { let error = fixture .validator() - .validate(&built.payload, &message, B256::ZERO, &built.requests, &blob_bundle(1), false) + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &blob_bundle(1), + false, + built.amsterdam(), + ) .expect_err("a bundle for a blobless block must be rejected"); assert!(matches!(error, ValidationError::InvalidBlobsBundle), "{error}"); @@ -1426,6 +1729,31 @@ impl Fixture { ssz::Encode::as_ssz_bytes(&submission) } + /// The request shape the relay sends for a Gloas submission: the Gloas + /// wire shape, named by its decoder params, with the list inside. + pub(crate) fn gloas_ssz_request( + &self, + built: &Built, + ) -> helix_common::simulator::SszValidationRequest { + let submission: helix_types::SignedBidSubmission = + self.submission(built).try_into().expect("the fixture builds a convertible submission"); + let bal = built.block_access_list.clone().expect("a Gloas request carries a list"); + let gloas = helix_types::SignedBidSubmissionGloas::join( + submission, + helix_types::BlockAccessListBytes(bal.into()), + ); + helix_common::simulator::SszValidationRequest { + apply_blacklist: false, + registered_gas_limit: 0, + parent_beacon_block_root: B256::ZERO, + inclusion_list: Default::default(), + decoder_params: Some(helix_common::decoder::SubmissionDecoderParams::plain( + helix_types::ForkName::Gloas, + )), + signed_bid_submission: ssz::Encode::as_ssz_bytes(&gloas), + } + } + pub(crate) fn ssz_request( &self, built: &Built, @@ -1445,3 +1773,191 @@ impl Fixture { } } } + +/// Proves the fixture really is on Amsterdam: without the fork the tests below +/// would silently pass under Fulu rules. +#[tokio::test] +async fn an_amsterdam_fixture_builds_a_block_with_a_block_access_list() { + let fixture = Fixture::amsterdam().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + + let bal = built.block_access_list.as_deref().expect("Amsterdam builds a block access list"); + assert!(!bal.is_empty(), "even an idle block touches accounts"); + + let block = payload_v3_to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) + .expect("the fixture builds a convertible payload"); + assert_eq!(block.header.slot_number, Some(SLOT)); + assert_eq!(block.header.block_access_list_hash, Some(ethrex_common::utils::keccak(bal))); +} + +#[tokio::test] +async fn an_amsterdam_payload_round_trips_to_the_same_block_hash() { + let fixture = Fixture::amsterdam().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + + let block = fixture + .validator() + .to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) + .expect("a block the fixture built must convert"); + + assert_eq!(b256(block.hash()), built.block_hash()); +} + +#[tokio::test] +async fn a_valid_amsterdam_block_is_accepted() { + let fixture = Fixture::amsterdam().await; + let built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + + fixture + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) + .expect("an Amsterdam block the fixture built must validate"); +} + +/// The block hash covers the list's hash, so changing a byte breaks the +/// commitment the builder signed. +#[tokio::test] +async fn a_tampered_block_access_list_is_rejected() { + let fixture = Fixture::amsterdam().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + built.block_access_list.as_mut().expect("Amsterdam builds one")[0] ^= 0xff; + + let error = fixture + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) + .expect_err("a changed block access list must be rejected"); + + assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); +} + +/// The sharper case: a well-formed list, committed to consistently, that is not +/// the list execution produces. Only re-running the block catches this. +#[tokio::test] +async fn a_block_access_list_that_contradicts_execution_is_rejected() { + let fixture = Fixture::amsterdam().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + + // Another block's list: valid bytes, wrong block. + let other = fixture.build_block( + fixture.genesis_hash, + fixture.genesis_timestamp + 12, + vec![signed_transfer( + &fixture.signers[1], + fixture.chain_id, + 0, + Address::repeat_byte(0x77), + U256::from(GWEI), + 100 * GWEI, + 0, + )], + Vec::new(), + ); + built.block_access_list = other.block_access_list; + assert!(built.block_access_list.is_some(), "the substitute list must exist"); + + // Commit to the substituted list, so the hash checks all pass and only + // execution can tell the difference. + let block = payload_v3_to_block(&built.payload, B256::ZERO, &built.requests, built.amsterdam()) + .expect("the substituted list still converts"); + let mut message = fixture.bid_trace(&built); + message.block_hash = b256(block.hash()); + + let error = fixture + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) + .expect_err("a list execution did not produce must be rejected"); + + let ValidationError::PostExecution(reason) = &error else { + panic!("{error}"); + }; + assert!(reason.to_lowercase().contains("access list"), "rejected for another reason: {reason}"); +} + +#[tokio::test] +async fn an_empty_block_access_list_is_rejected() { + let fixture = Fixture::amsterdam().await; + let mut built = fixture.build_on(fixture.genesis_hash, fixture.genesis_timestamp + 12, 0); + let message = fixture.bid_trace(&built); + built.block_access_list = Some(Vec::new()); + + let error = fixture + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + built.amsterdam(), + ) + .expect_err("an empty block access list must be rejected"); + + assert!(matches!(error, ValidationError::EmptyBlockAccessList), "{error}"); +} + +/// A Gloas submission whose payload predates Amsterdam, and the reverse. The +/// header fields are fork-gated, so ethrex's pre-execution checks catch both +/// without a fork check of our own. +#[tokio::test] +async fn a_fork_and_payload_that_disagree_are_rejected() { + let amsterdam = Fixture::amsterdam().await; + let built = amsterdam.build_on(amsterdam.genesis_hash, amsterdam.genesis_timestamp + 12, 0); + let message = amsterdam.bid_trace(&built); + let error = amsterdam + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + None, + ) + .expect_err("an Amsterdam block with no list must be rejected"); + assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); + + let fulu = Fixture::new().await; + let built = fulu.build_on(fulu.genesis_hash, fulu.genesis_timestamp + 12, 0); + let message = fulu.bid_trace(&built); + let error = fulu + .validator() + .validate( + &built.payload, + &message, + B256::ZERO, + &built.requests, + &empty_bundle(), + false, + Some(Amsterdam { block_access_list: &[0xc0], slot: SLOT }), + ) + .expect_err("a Fulu block carrying Amsterdam fields must be rejected"); + assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); +} diff --git a/crates/common/src/decoder.rs b/crates/common/src/decoder.rs index ae77b01c2..779a13ef7 100644 --- a/crates/common/src/decoder.rs +++ b/crates/common/src/decoder.rs @@ -198,6 +198,25 @@ pub struct SubmissionDecoderParams { pub fork_name: ForkName, } +impl SubmissionDecoderParams { + /// Uncompressed SSZ bytes of the fork's plain submission shape, with no + /// sidecars. This is what the relay re-encodes for an SSZ simulator. + pub fn plain(fork_name: ForkName) -> Self { + Self { + compression: Compression::None, + encoding: Encoding::Ssz, + merge_type: MergeType::None, + is_dehydrated: false, + with_mergeable_data: false, + with_adjustments: false, + mark_all_txs_mergeable: false, + dehydrated_v2: false, + merging_v2: false, + fork_name, + } + } +} + #[derive(Debug)] pub struct SubmissionDecoder { compression: Compression, @@ -388,6 +407,12 @@ impl SubmissionDecoder { #[timed] fn decode_dehydrated(&mut self, body: &[u8]) -> Result { + // Every shape below is Fulu's, which has no block access list. Decoding + // a Gloas submission into one would drop it and simulate a block the + // builder never committed to. + if self.fork_name == ForkName::Gloas { + return Err(DecoderError::UnsupportedCombination("dehydrated Gloas")); + } if self.merge_type == MergeType::Mergeable { if self.with_adjustments { let sub: DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData = @@ -444,6 +469,11 @@ impl SubmissionDecoder { #[timed] fn decode_merge(&mut self, body: &[u8]) -> Result { + // Gloas merging data comes with the merge builder's own step; until then + // these Fulu shapes would drop the block access list. + if self.fork_name == ForkName::Gloas { + return Err(DecoderError::UnsupportedCombination("Gloas with merging data")); + } let decoded = if self.with_adjustments { self._decode::(body).map(|sub| { let (submission, adjustment_data, merging_data) = sub.split(); @@ -666,21 +696,6 @@ mod tests { } /// Plain SSZ params for `fork`, nothing else enabled. - fn plain_params(fork: ForkName) -> SubmissionDecoderParams { - SubmissionDecoderParams { - compression: Compression::None, - encoding: Encoding::Ssz, - merge_type: MergeType::None, - is_dehydrated: false, - with_mergeable_data: false, - with_adjustments: false, - mark_all_txs_mergeable: false, - dehydrated_v2: false, - merging_v2: false, - fork_name: fork, - } - } - #[test] fn the_decoder_selects_the_gloas_shape_by_fork() { let mut submission = SignedBidSubmissionGloas::test_random(); @@ -688,7 +703,7 @@ mod tests { submission.block_access_list = BlockAccessListBytes(vec![3u8; 32].into()); let body = submission.as_ssz_bytes(); - let params = plain_params(ForkName::Gloas); + let params = SubmissionDecoderParams::plain(ForkName::Gloas); let mut buf = Vec::new(); let (_, _, _, block_access_list) = SubmissionDecoder::new(¶ms) .decode(&body, &mut buf) @@ -706,7 +721,7 @@ mod tests { submission.blobs_bundle = Default::default(); let body = submission.as_ssz_bytes(); - let params = plain_params(ForkName::Fulu); + let params = SubmissionDecoderParams::plain(ForkName::Fulu); let mut buf = Vec::new(); let (_, _, _, block_access_list) = SubmissionDecoder::new(¶ms) .decode(&body, &mut buf) @@ -721,7 +736,7 @@ mod tests { submission.blobs_bundle = Default::default(); let body = submission.as_ssz_bytes(); - let mut params = plain_params(ForkName::Gloas); + let mut params = SubmissionDecoderParams::plain(ForkName::Gloas); params.with_adjustments = true; let mut buf = Vec::new(); let err = SubmissionDecoder::new(¶ms) @@ -734,6 +749,31 @@ mod tests { ); } + /// Both wire shapes below are Fulu's, with no room for a block access list. + #[test] + fn gloas_with_the_other_submission_types_is_refused() { + let mut submission = SignedBidSubmissionGloas::test_random(); + submission.blobs_bundle = Default::default(); + let body = submission.as_ssz_bytes(); + + for adjust in [ + |params: &mut SubmissionDecoderParams| params.is_dehydrated = true, + |params: &mut SubmissionDecoderParams| params.with_mergeable_data = true, + ] { + let mut params = SubmissionDecoderParams::plain(ForkName::Gloas); + adjust(&mut params); + let mut buf = Vec::new(); + let err = SubmissionDecoder::new(¶ms) + .decode(&body, &mut buf) + .expect_err("the combination has no wire shape"); + + assert!( + matches!(err, DecoderError::UnsupportedCombination(_)), + "refused explicitly, not decoded into a shape that drops the list: {err}", + ); + } + } + #[test] fn test_merge_type_deserialization() { assert_eq!("mergeable".parse::().unwrap(), MergeType::Mergeable); diff --git a/crates/relay/src/auctioneer/submit_block.rs b/crates/relay/src/auctioneer/submit_block.rs index e81657992..60b5c995b 100644 --- a/crates/relay/src/auctioneer/submit_block.rs +++ b/crates/relay/src/auctioneer/submit_block.rs @@ -141,6 +141,7 @@ impl Context { .unwrap_or_default(), inclusion_list: slot_data.il.clone().unwrap_or_default(), submission: submission.clone(), + block_access_list: block_access_list.clone(), tx_root: maybe_tx_root, version, trace, diff --git a/crates/relay/src/simulator/client.rs b/crates/relay/src/simulator/client.rs index 3dac534c7..593701b37 100644 --- a/crates/relay/src/simulator/client.rs +++ b/crates/relay/src/simulator/client.rs @@ -68,7 +68,7 @@ impl SimulatorClient { } fn ssz_supports(fork: ForkName) -> bool { - matches!(fork, ForkName::Fulu) + matches!(fork, ForkName::Fulu | ForkName::Gloas) } /// Relay-internal merged-block SSZ route; see `sim_method_merged_v5`. @@ -295,9 +295,14 @@ mod test { } #[test] - fn ssz_request_builder_refuses_gloas() { + fn ssz_request_builder_routes_gloas() { + assert!(ssz_client().ssz_request_builder(ForkName::Gloas).is_some()); + } + + #[test] + fn ssz_request_builder_refuses_an_unscheduled_fork() { assert!( - ssz_client().ssz_request_builder(ForkName::Gloas).is_none(), + ssz_client().ssz_request_builder(ForkName::Heze).is_none(), "the SSZ path short-circuited above the fork gate #517 added", ); } diff --git a/crates/relay/src/simulator/mod.rs b/crates/relay/src/simulator/mod.rs index e648330b6..bc2f5f4dc 100644 --- a/crates/relay/src/simulator/mod.rs +++ b/crates/relay/src/simulator/mod.rs @@ -10,6 +10,7 @@ use helix_common::{ SimulatorConfig, SubmissionTrace, api::builder_api::InclusionListWithMetadata, bid_submission::OptimisticVersion, + decoder::SubmissionDecoderParams, metrics::SimulatorMetrics, record_submission_step, simulator::{ @@ -21,8 +22,9 @@ use helix_common::{ validator_preferences::{Filtering, ValidatorPreferences}, }; use helix_types::{ - BidTrace, BlobsBundle, BlsPublicKeyBytes, BlsSignatureBytes, BuilderInclusionResult, - ExecutionPayload, ExecutionRequests, MergedBlockTrace, SignedBidSubmission, SubmissionVersion, + BidTrace, BlobsBundle, BlockAccessListBytes, BlsPublicKeyBytes, BlsSignatureBytes, + BuilderInclusionResult, ExecutionPayload, ExecutionRequests, ForkName, MergedBlockTrace, + SignedBidSubmission, SignedBidSubmissionGloas, SubmissionVersion, }; use rustc_hash::FxHashMap; use ssz::Encode as _; @@ -74,6 +76,7 @@ pub struct ValidationRequest { pub parent_beacon_block_root: B256, pub inclusion_list: InclusionListWithMetadata, pub submission: SignedBidSubmission, + pub block_access_list: Option, pub tx_root: Option, pub version: SubmissionVersion, pub trace: SubmissionTrace, @@ -1409,23 +1412,36 @@ fn create_ssz_request( req.parent_beacon_block_root, req.inclusion_list.clone(), submission, + req.block_access_list.clone(), ) } +/// A hydrated submission is re-encoded here, so a Gloas one has to go in its own +/// shape: `SignedBidSubmission` has nowhere to put the block access list. The +/// decoder params name that shape, because nothing else on the wire does. +#[allow(clippy::too_many_arguments)] fn ssz_request( apply_blacklist: bool, registered_gas_limit: u64, parent_beacon_block_root: B256, inclusion_list: InclusionListWithMetadata, submission: &SignedBidSubmission, + block_access_list: Option, ) -> SszValidationRequest { + let (decoder_params, signed_bid_submission) = match block_access_list { + Some(bal) => ( + Some(SubmissionDecoderParams::plain(ForkName::Gloas)), + SignedBidSubmissionGloas::join(submission.clone(), bal).as_ssz_bytes(), + ), + None => (None, submission.as_ssz_bytes()), + }; SszValidationRequest { apply_blacklist, registered_gas_limit, parent_beacon_block_root, inclusion_list, - decoder_params: None, - signed_bid_submission: submission.as_ssz_bytes(), + decoder_params, + signed_bid_submission, } } @@ -1448,3 +1464,42 @@ fn ssz_merged_request( base_payment_tx_index, } } + +#[cfg(test)] +mod tests { + use helix_types::TestRandomSeed; + + use super::*; + + /// The SSZ path re-encodes the submission, so this is where a Gloas one + /// would lose its block access list. + #[test] + fn a_gloas_ssz_request_carries_the_block_access_list() { + let mut submission = SignedBidSubmission::test_random(); + submission.blobs_bundle = Default::default(); + let bal = BlockAccessListBytes(vec![7u8; 48].into()); + + let request = + ssz_request(false, 0, B256::ZERO, Default::default(), &submission, Some(bal.clone())); + + let params = request.decoder_params.expect("a Gloas request names its shape"); + assert_eq!(params.fork_name, ForkName::Gloas); + let mut buf = Vec::new(); + let (_, _, _, decoded) = helix_common::decoder::SubmissionDecoder::new(¶ms) + .decode(&request.signed_bid_submission, &mut buf) + .expect("the simulator must be able to decode what the relay sends"); + assert_eq!(decoded.expect("the list must survive the re-encode"), bal); + } + + /// Every other fork keeps the bare shape, so no simulator sees a new one. + #[test] + fn a_non_gloas_ssz_request_is_unchanged() { + let mut submission = SignedBidSubmission::test_random(); + submission.blobs_bundle = Default::default(); + + let request = ssz_request(false, 0, B256::ZERO, Default::default(), &submission, None); + + assert!(request.decoder_params.is_none()); + assert_eq!(request.signed_bid_submission, submission.as_ssz_bytes()); + } +} diff --git a/crates/types/src/bid_submission.rs b/crates/types/src/bid_submission.rs index a1bda1562..3cde657fa 100644 --- a/crates/types/src/bid_submission.rs +++ b/crates/types/src/bid_submission.rs @@ -766,6 +766,19 @@ impl TestRandom for SignedBidSubmissionGloas { } impl SignedBidSubmissionGloas { + /// Inverse of [`Self::split`], for re-encoding a hydrated submission on the + /// way to an SSZ simulator. + pub fn join(submission: SignedBidSubmission, block_access_list: BlockAccessListBytes) -> Self { + Self { + message: submission.message, + execution_payload: submission.execution_payload, + blobs_bundle: submission.blobs_bundle, + execution_requests: submission.execution_requests, + signature: submission.signature, + block_access_list, + } + } + pub fn split(self) -> (SignedBidSubmission, BlockAccessListBytes) { ( SignedBidSubmission { From c90ca822f355e678009a83f2a4cf1dacfb2ce700 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 4 Sep 2026 14:29:03 +0100 Subject: [PATCH 06/33] Build and submit a Gloas/Amsterdam block Set the header's slot number from the proposal slot and carry the block access list ethrex records, then submit it in the Gloas shape. Size the payout reserve for EIP-8037's account-creation state gas, without which a fee recipient's first payment runs out of gas. --- crates/builder/README.md | 12 +- crates/builder/build-config.example.yml | 4 +- crates/builder/src/building/assemble.rs | 70 ++++- crates/builder/src/building/assemble/tests.rs | 286 +++++++++++++++++- crates/builder/src/building/mod.rs | 8 +- crates/builder/src/building/submit.rs | 59 +++- crates/builder/src/building/submit/tests.rs | 112 ++++++- crates/builder/src/config.rs | 4 +- crates/builder/src/testing.rs | 4 - 9 files changed, 515 insertions(+), 44 deletions(-) diff --git a/crates/builder/README.md b/crates/builder/README.md index 86f0172f0..b5c7f14cd 100644 --- a/crates/builder/README.md +++ b/crates/builder/README.md @@ -170,9 +170,11 @@ with `ssz_url` set to this role's `ssz_addr` (see the repo-root ## Limitations - Merging protocol v1 carries `ExecutionPayloadV3`; post-Amsterdam blocks - (EIP-7928 block access lists) are rejected as merge bases. The simulation - role has the same gap: the payload carries no block-access-list hash, so - Amsterdam needs a newer payload version. + (EIP-7928 block access lists) are rejected as merge bases, and the merged + validation route refuses Gloas for the same reason. The simulation and + building roles do handle Amsterdam: the block access list travels beside the + payload on a Gloas submission, and the header's list hash and slot number are + derived from it. - The simulation role serves no JSON-RPC, so a relay without `ssz_url` cannot use it. - A blob is 128 KiB and crosses the stack several times in a debug build, which @@ -180,7 +182,9 @@ with `ssz_url` set to this role's `ssz_addr` (see the repo-root the simulation and building roles in release. - The building role's payout uses a fixed `payout_gas_reserve`, 21000 by default. A contract fee recipient needing more makes the payout fail and the - slot is skipped. + slot is skipped. From Amsterdam the builder adds EIP-8037's state gas for a + recipient that does not exist yet (183600 at the pinned ethrex revision), + because a fee recipient's first payment creates its account. - The building role includes a blob transaction only when its sidecar reached the node's mempool over devp2p; it does not rebuild sidecars. - The base block's declared `block_hash` is trusted as the pool key; the wire diff --git a/crates/builder/build-config.example.yml b/crates/builder/build-config.example.yml index 2df42ef41..45cc2c251 100644 --- a/crates/builder/build-config.example.yml +++ b/crates/builder/build-config.example.yml @@ -20,7 +20,9 @@ beacon_url: "http://localhost:3500" subsidy_wei: 1000000000000000 # Gas held back from the fill for the trailing payout transaction. A contract -# fee recipient that needs more than this makes the payout fail. +# fee recipient that needs more than this makes the payout fail. From Amsterdam +# the builder adds EIP-8037's state gas on top when the recipient does not exist +# yet, so this covers the transfer alone. payout_gas_reserve: 21000 extra_data: "helix-builder" diff --git a/crates/builder/src/building/assemble.rs b/crates/builder/src/building/assemble.rs index ccaa12de7..58c323d19 100644 --- a/crates/builder/src/building/assemble.rs +++ b/crates/builder/src/building/assemble.rs @@ -15,6 +15,7 @@ use ethrex_common::{ }, }; use ethrex_crypto::native::NativeCrypto; +use ethrex_rlp::encode::RLPEncode; use ethrex_storage::Store; use thiserror::Error; @@ -37,6 +38,8 @@ pub enum BuildError { PayoutReverted, #[error("the payout recipient is the builder itself")] PayoutToSelf, + #[error("an Amsterdam block was built without a block access list")] + MissingBlockAccessList, #[error("build failed: {0}")] Internal(String), } @@ -53,6 +56,9 @@ pub struct BuiltBlock { /// The changed accounts, for checking the payment the way the relay does. #[allow(dead_code)] pub account_updates: Vec, + /// The encoded EIP-7928 list, from Amsterdam onwards. The header commits to + /// these exact bytes, so the submission has to carry them unchanged. + pub block_access_list: Option>, /// Paid to the proposer by the trailing transaction, and the value the /// `BidTrace` claims. pub value: U256, @@ -83,6 +89,10 @@ pub fn build( return Err(BuildError::MissingParent); } + // EIP-7843 puts the proposal slot in the header, and only from Amsterdam: + // setting it earlier would change every pre-Amsterdam block hash. + let is_amsterdam = store.get_chain_config().is_amsterdam_activated(slot.timestamp); + let args = BuildPayloadArgs { parent: h256(slot.parent_hash), timestamp: slot.timestamp, @@ -90,7 +100,7 @@ pub fn build( random: h256(slot.prev_randao), withdrawals: Some(slot.withdrawals.iter().map(ewithdrawal_lh).collect()), beacon_root: Some(h256(slot.parent_beacon_block_root)), - slot_number: None, + slot_number: is_amsterdam.then_some(slot.slot), version: 3, elasticity_multiplier: ELASTICITY_MULTIPLIER, // `create_payload` runs this through `calc_gas_limit`, which applies @@ -107,16 +117,20 @@ pub fn build( .map_err(|e| BuildError::Internal(format!("system operations: {e}")))?; // `fill_transactions` spends every last drop of `remaining_gas`, so hold - // the payout's share back and restore it once the fill is done. - let reserve = config.payout_gas_reserve.min(ctx.remaining_gas); + // the payout's share back and restore it once the fill is done. The reserve + // is sized before the fill and the transaction after it, so a recipient the + // fill creates is not charged for twice. + let reserve = payout_gas_limit(config, is_amsterdam, recipient_exists(&mut ctx, slot)?) + .min(ctx.remaining_gas); ctx.remaining_gas -= reserve; blockchain .fill_transactions(&mut ctx) .map_err(|e| BuildError::Internal(format!("fill transactions: {e}")))?; ctx.remaining_gas += reserve; + let payout_gas = payout_gas_limit(config, is_amsterdam, recipient_exists(&mut ctx, slot)?); let base_fee = ctx.payload.header.base_fee_per_gas.unwrap_or_default(); - let payout = payout_value(&ctx, config, base_fee)?; + let payout = payout_value(&ctx, config, payout_gas, base_fee)?; let nonce = ctx .vm @@ -132,7 +146,7 @@ pub fn build( .map_err(|e| BuildError::Internal(e.to_string()))? .info .balance; - let gas_cost = EU256::from(config.payout_gas_reserve) * EU256::from(base_fee); + let gas_cost = EU256::from(payout_gas) * EU256::from(base_fee); if balance < eu256(payout) + gas_cost { return Err(BuildError::PayoutUnaffordable); } @@ -143,7 +157,7 @@ pub fn build( nonce, slot.proposer_fee_recipient, payout, - config.payout_gas_reserve, + payout_gas, base_fee as u128, )?; let sender = payout_tx @@ -169,22 +183,64 @@ pub fn build( .finalize_payload(&mut ctx) .map_err(|e| BuildError::Internal(format!("finalize: {e}")))?; + // `finalize_payload` hashed this into the header, so the submission has to + // carry the same encoding rather than re-deriving one. + let block_access_list = ctx.block_access_list.as_ref().map(|bal| bal.encode_to_vec()); + if is_amsterdam && block_access_list.is_none() { + return Err(BuildError::MissingBlockAccessList); + } + Ok(BuiltBlock { block: ctx.payload, blobs_bundle: ctx.blobs_bundle, requests: ctx.requests.unwrap_or_default(), account_updates: ctx.account_updates, + block_access_list, value: payout, }) } +/// Whether the payout recipient already has an account, read from in-block +/// state so a payment earlier in the same block counts. +fn recipient_exists(ctx: &mut PayloadBuildContext, slot: &SlotContext) -> Result { + Ok(ctx + .vm + .db + .get_account(eaddr(slot.proposer_fee_recipient)) + .map_err(|e| BuildError::Internal(e.to_string()))? + .info != + Default::default()) +} + +/// The gas the payout transaction needs. `payout_gas_reserve` covers the +/// transfer; paying an address for the first time also creates it, and from +/// Amsterdam that costs state gas (EIP-8037). A fee recipient's first payment +/// is exactly that case, and it is the normal case on a fresh testnet, so the +/// reserve has to cover it or every block is lost. +fn payout_gas_limit(config: &BuildingConfig, is_amsterdam: bool, recipient_exists: bool) -> u64 { + if is_amsterdam && !recipient_exists { + config.payout_gas_reserve + new_account_state_gas() + } else { + config.payout_gas_reserve + } +} + +/// EIP-8037's charge for the state a new account occupies. Read from ethrex so +/// it cannot drift from the rules the simulator enforces. +fn new_account_state_gas() -> u64 { + use ethrex_levm::gas_cost::{STATE_BYTES_PER_NEW_ACCOUNT, cost_per_state_byte}; + // `cost_per_state_byte` ignores its argument at this ethrex revision. + STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte(0) +} + /// Tips earned plus the subsidy, less the gas the payout itself will burn. fn payout_value( ctx: &PayloadBuildContext, config: &BuildingConfig, + payout_gas: u64, base_fee: u64, ) -> Result { - let gas_cost = EU256::from(config.payout_gas_reserve) * EU256::from(base_fee); + let gas_cost = EU256::from(payout_gas) * EU256::from(base_fee); let funded = ctx.block_value + EU256::from(config.subsidy_wei); let payout = funded.checked_sub(gas_cost).ok_or(BuildError::NoPayout)?; if payout.is_zero() { diff --git a/crates/builder/src/building/assemble/tests.rs b/crates/builder/src/building/assemble/tests.rs index 6abb8178e..410f02f1d 100644 --- a/crates/builder/src/building/assemble/tests.rs +++ b/crates/builder/src/building/assemble/tests.rs @@ -8,7 +8,9 @@ use ethrex_vm::VmDatabase; use helix_types::{BlsPublicKeyBytes, Withdrawal, Withdrawals}; use super::*; -use crate::testing::{ETH, GWEI, dev_genesis_store, funded_signers, signed_transfer}; +use crate::testing::{ + ETH, GWEI, deploy_amsterdam_predeploys, dev_genesis_store_with, funded_signers, signed_transfer, +}; const PROPOSER: Address = Address::repeat_byte(0x77); /// Must clear the payout's own gas cost, ~21000 * base fee. @@ -22,11 +24,26 @@ struct Fixture { parent_timestamp: u64, parent_gas_limit: u64, chain_id: u64, + is_amsterdam: bool, } impl Fixture { async fn new() -> Self { - let (store, genesis) = dev_genesis_store().await; + Self::with_genesis(|_| {}).await + } + + /// Amsterdam from genesis, so every block carries a block access list and a + /// slot number. The EIP-8282 predeploys are required, not optional. + async fn amsterdam() -> Self { + Self::with_genesis(|genesis| { + genesis.config.amsterdam_time = Some(0); + deploy_amsterdam_predeploys(genesis); + }) + .await + } + + async fn with_genesis(edit: impl FnOnce(&mut ethrex_common::types::Genesis)) -> Self { + let (store, genesis) = dev_genesis_store_with(edit).await; let genesis_block = genesis.get_block(); let blockchain = Arc::new(Blockchain::new(store.clone(), BlockchainOptions { r#type: BlockchainType::L1, @@ -38,6 +55,7 @@ impl Fixture { parent_timestamp: genesis_block.header.timestamp, parent_gas_limit: genesis_block.header.gas_limit, chain_id: genesis.config.chain_id, + is_amsterdam: genesis.config.is_amsterdam_activated(genesis_block.header.timestamp), store, blockchain, } @@ -49,11 +67,7 @@ impl Fixture { } fn config(&self) -> BuildingConfig { - serde_yaml::from_str(&format!( - "relay_url: \"http://localhost:4040\"\napi_key: \"key\"\n\ - beacon_url: \"http://localhost:3500\"\nsubsidy_wei: {SUBSIDY}\n" - )) - .unwrap() + test_config() } fn slot(&self) -> SlotContext { @@ -86,6 +100,30 @@ impl Fixture { self.blockchain.add_transaction_to_pool(tx).await.unwrap(); } + /// A transfer with enough gas to create its recipient under Amsterdam, + /// which the flat 21000 of `pool_transfer` cannot do. + async fn pool_transfer_creating(&self, index: usize, to: Address) { + use alloy_consensus::SignableTransaction; + use alloy_signer::SignerSync; + let tx = alloy_consensus::TxEip1559 { + chain_id: self.chain_id, + nonce: 0, + gas_limit: 300_000, + max_fee_per_gas: 100 * GWEI, + max_priority_fee_per_gas: GWEI, + to: to.into(), + value: U256::from(GWEI), + access_list: Default::default(), + input: Default::default(), + }; + let signature = self.signers[index].sign_hash_sync(&tx.signature_hash()).unwrap(); + let encoded = alloy_eips::eip2718::Encodable2718::encoded_2718( + &alloy_consensus::TxEnvelope::from(tx.into_signed(signature)), + ); + let tx = Transaction::decode_canonical(&encoded).unwrap(); + self.blockchain.add_transaction_to_pool(tx).await.unwrap(); + } + fn build(&self, slot: &SlotContext, config: &BuildingConfig) -> Result { build(&self.store, &self.blockchain, slot, config, self.builder(), self.chain_id) } @@ -94,6 +132,44 @@ impl Fixture { self.build(&self.slot(), &self.config()) } + /// A signing context whose spec puts the fixture's fork at genesis, so the + /// submission shape matches the block shape. + fn signing(&self) -> helix_common::signing::RelaySigningContext { + let mut spec = helix_types::ChainSpec::mainnet(); + match self.fork() { + helix_types::ForkName::Gloas => { + spec.gloas_fork_epoch = Some(helix_types::Epoch::new(0)) + } + _ => spec.fulu_fork_epoch = Some(helix_types::Epoch::new(0)), + } + helix_common::signing::RelaySigningContext::new( + helix_types::BlsKeypair::random(), + Arc::new(helix_common::chain_info::ChainInfo::new(spec, B256::ZERO, 0)), + ) + } + + fn fork(&self) -> helix_types::ForkName { + if self.is_amsterdam { helix_types::ForkName::Gloas } else { helix_types::ForkName::Fulu } + } + + /// The simulation role, over the same store the block was built on. + fn validator(&self) -> crate::validation::BlockValidator { + let parent = self.parent_header(); + let (head, _) = tokio::sync::watch::channel(crate::node::HeadInfo { + number: parent.number, + hash: parent.hash(), + timestamp: parent.timestamp, + is_synced: true, + }); + crate::validation::BlockValidator::new( + self.store.clone(), + head.subscribe(), + 8, + Arc::new(dashmap::DashSet::new()), + 1, + ) + } + fn parent_header(&self) -> ethrex_common::types::BlockHeader { self.store .get_block_header_by_hash(crate::engine::convert::h256(self.parent_hash)) @@ -102,6 +178,14 @@ impl Fixture { } } +fn test_config() -> BuildingConfig { + serde_yaml::from_str(&format!( + "relay_url: \"http://localhost:4040\"\napi_key: \"key\"\n\ + beacon_url: \"http://localhost:3500\"\nsubsidy_wei: {SUBSIDY}\n" + )) + .unwrap() +} + /// The trailing transaction, decoded. fn payout_tx(built: &BuiltBlock) -> &Transaction { built.block.body.transactions.last().expect("a block always ends with the payout") @@ -336,3 +420,191 @@ async fn blob_transactions_carry_their_sidecar() { Some(u64::from(ethrex_common::constants::GAS_PER_BLOB)) ); } + +// --- Amsterdam --- + +#[tokio::test] +async fn an_amsterdam_block_carries_a_block_access_list_and_a_slot_number() { + let fixture = Fixture::amsterdam().await; + + let built = fixture.build_default().unwrap(); + + let bal = built.block_access_list.as_deref().expect("Amsterdam records a list"); + assert!(!bal.is_empty(), "even an idle block touches accounts"); + assert_eq!( + built.block.header.block_access_list_hash, + Some(ethrex_common::utils::keccak(bal)), + "the submission has to carry the bytes the header committed to", + ); + assert_eq!(built.block.header.slot_number, Some(fixture.slot().slot)); +} + +/// Setting either field before Amsterdam would change every block hash. +#[tokio::test] +async fn a_pre_amsterdam_block_carries_neither() { + let fixture = Fixture::new().await; + + let built = fixture.build_default().unwrap(); + + assert!(built.block_access_list.is_none()); + assert!(built.block.header.block_access_list_hash.is_none()); + assert!(built.block.header.slot_number.is_none()); +} + +/// EIP-7843 wants the proposal slot, which is neither the block number nor zero. +#[tokio::test] +async fn the_header_slot_number_is_the_proposal_slot() { + let fixture = Fixture::amsterdam().await; + let mut slot = fixture.slot(); + slot.slot = 4_242; + + let built = fixture.build(&slot, &fixture.config()).unwrap(); + + assert_eq!(built.block.header.slot_number, Some(4_242)); + assert_ne!(built.block.header.number, 4_242, "not the block number"); +} + +/// The proposer keeps being paid in-block under Gloas, so the trailing payout +/// is unchanged. +#[tokio::test] +async fn an_amsterdam_block_still_pays_the_proposer_in_block() { + let fixture = Fixture::amsterdam().await; + fixture.pool_transfer(1, 0, GWEI).await; + + let built = fixture.build_default().unwrap(); + + let payout = payout_tx(&built); + assert_eq!(payout.to(), ethrex_common::types::TxKind::Call(eaddr(PROPOSER))); + assert_eq!(payout.value(), eu256(built.value)); + assert!(!built.value.is_zero(), "the relay rejects a zero-value block"); +} + +/// Amsterdam charges gas for the list's items out of the same block budget the +/// payout reserve is taken from, so the fill can still starve the payout. +#[tokio::test] +async fn an_amsterdam_block_includes_mempool_transactions() { + let fixture = Fixture::amsterdam().await; + fixture.pool_transfer(1, 0, GWEI).await; + fixture.pool_transfer(2, 0, GWEI).await; + + let built = fixture.build_default().unwrap(); + + assert_eq!(built.block.body.transactions.len(), 3, "two mempool txs plus the payout"); +} + +/// A fee recipient's first payment creates its account, and EIP-8037 charges +/// state gas for that. It is the normal case on a fresh testnet: with only the +/// transfer's own 21000 reserved, the payout runs out of gas and every block is +/// lost. Pre-Amsterdam the same payment costs nothing extra. +#[tokio::test] +async fn paying_a_new_account_reserves_the_amsterdam_state_gas() { + let config = BuildingConfig { payout_gas_reserve: 21_000, ..test_config() }; + + assert_eq!(payout_gas_limit(&config, false, false), 21_000, "no such charge before Amsterdam"); + assert_eq!(payout_gas_limit(&config, true, true), 21_000, "an existing account is a transfer"); + assert_eq!( + payout_gas_limit(&config, true, false), + 204_600, + "measured against ethrex: 21000 transfer + 120 state bytes at 1530 each", + ); +} + +/// The reserve is only useful if the payment it sizes actually succeeds. +#[tokio::test] +async fn a_first_payment_to_a_new_account_succeeds_under_amsterdam() { + let fixture = Fixture::amsterdam().await; + let slot = fixture.slot(); + assert!( + fixture + .store + .get_account_info_by_hash( + crate::engine::convert::h256(fixture.parent_hash), + eaddr(slot.proposer_fee_recipient), + ) + .unwrap() + .is_none(), + "the fixture's proposer must start absent, or this proves nothing", + ); + + let built = fixture.build(&slot, &fixture.config()).unwrap(); + + let payout = payout_tx(&built); + assert_eq!(payout.gas_limit(), 204_600); + assert_eq!(payout.value(), eu256(built.value)); +} + +/// The recipient's account is read from in-block state, so a payment earlier in +/// the same block already counts as creating it. +#[tokio::test] +async fn a_recipient_created_earlier_in_the_block_needs_no_extra_reserve() { + let fixture = Fixture::amsterdam().await; + let mut slot = fixture.slot(); + slot.proposer_fee_recipient = Address::repeat_byte(0x55); + fixture.pool_transfer_creating(1, slot.proposer_fee_recipient).await; + + let built = fixture.build(&slot, &fixture.config()).unwrap(); + + assert_eq!(built.block.body.transactions.len(), 2, "the transfer plus the payout"); + let balance = built + .account_updates + .iter() + .find(|update| update.address == eaddr(slot.proposer_fee_recipient)) + .and_then(|update| update.info.as_ref().map(|info| info.balance)) + .expect("both payments must touch the recipient"); + assert_eq!( + balance, + eu256(built.value) + ethrex_common::U256::from(GWEI), + "the earlier transfer has to land, or it created nothing", + ); + assert_eq!(payout_tx(&built).gas_limit(), 21_000, "already created, so no state charge"); +} + +/// The strongest check available without a relay: build a block, submit it the +/// way the relay would receive it, and validate it with our own simulation +/// role. A disagreement between steps 3 and 4 shows up here and nowhere else. +async fn our_simulator_accepts_our_own_block(fixture: &Fixture) { + use axum::http::StatusCode; + use tower::ServiceExt; + + let slot = fixture.slot(); + let built = fixture.build(&slot, &fixture.config()).unwrap(); + let bid = crate::building::submit::Submitter::new( + "http://localhost:1", + "key".to_string(), + fixture.signing(), + ) + .sign(&built, &slot) + .expect("a block we built must be submittable"); + + let request = helix_common::simulator::SszValidationRequest { + apply_blacklist: false, + registered_gas_limit: slot.registered_gas_limit, + parent_beacon_block_root: slot.parent_beacon_block_root, + inclusion_list: Default::default(), + decoder_params: Some(helix_common::decoder::SubmissionDecoderParams::plain(fixture.fork())), + signed_bid_submission: bid.as_ssz_bytes(), + }; + + let response = crate::validation::server::router(fixture.validator(), 1) + .oneshot( + axum::http::Request::post("/validate") + .body(axum::body::Body::from(ssz::Encode::as_ssz_bytes(&request))) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); +} + +#[tokio::test] +async fn our_simulation_role_accepts_our_gloas_block() { + our_simulator_accepts_our_own_block(&Fixture::amsterdam().await).await; +} + +#[tokio::test] +async fn our_simulation_role_accepts_our_fulu_block() { + our_simulator_accepts_our_own_block(&Fixture::new().await).await; +} diff --git a/crates/builder/src/building/mod.rs b/crates/builder/src/building/mod.rs index 16c578c48..0462259fb 100644 --- a/crates/builder/src/building/mod.rs +++ b/crates/builder/src/building/mod.rs @@ -90,18 +90,18 @@ pub async fn build_blocks( continue; } - let submission = match submitter.sign(&built, &slot) { - Ok(submission) => submission, + let bid = match submitter.sign(&built, &slot) { + Ok(bid) => bid, Err(e) => { warn!(slot = slot.slot, err = %e, "cannot sign the block"); continue; } }; - match submitter.submit(&submission).await { + match submitter.submit(&bid).await { Ok(()) => info!( slot = slot.slot, - block_hash = %submission.message.block_hash, + block_hash = %bid.message().block_hash, txs = built.block.body.transactions.len(), value = %built.value, "submitted a block", diff --git a/crates/builder/src/building/submit.rs b/crates/builder/src/building/submit.rs index 578b0ce36..3a4106c12 100644 --- a/crates/builder/src/building/submit.rs +++ b/crates/builder/src/building/submit.rs @@ -6,7 +6,8 @@ use helix_common::{ signing::RelaySigningContext, }; use helix_types::{ - BidTrace, BlobsBundle, KzgCommitments, SignedBidSubmission, payload_from_v3, requests_from_v4, + BidTrace, BlobsBundle, BlockAccessListBytes, ForkName, KzgCommitments, SignedBidSubmission, + SignedBidSubmissionGloas, Slot, payload_from_v3, requests_from_v4, }; use ssz::Encode; use thiserror::Error; @@ -20,6 +21,10 @@ use crate::{ pub enum SubmitError { #[error("blobs bundle: {0}")] Blobs(String), + #[error("a Gloas submission needs a block access list, and the block has none")] + MissingBlockAccessList, + #[error("the {0} submission shape cannot carry a block access list")] + UnsubmittableBlockAccessList(ForkName), #[error("payload exceeds the consensus limits")] OversizedPayload, #[error("requests: {0}")] @@ -30,6 +35,30 @@ pub enum SubmitError { Transport(String), } +/// The submission in the shape its fork uses. Only Gloas has room for the +/// block access list, so the choice of shape is the choice of fork. +#[derive(Debug)] +pub enum Bid { + Fulu(SignedBidSubmission), + Gloas(SignedBidSubmissionGloas), +} + +impl Bid { + pub fn message(&self) -> &BidTrace { + match self { + Bid::Fulu(bid) => &bid.message, + Bid::Gloas(bid) => &bid.message, + } + } + + pub fn as_ssz_bytes(&self) -> Vec { + match self { + Bid::Fulu(bid) => bid.as_ssz_bytes(), + Bid::Gloas(bid) => bid.as_ssz_bytes(), + } + } +} + pub struct Submitter { http: reqwest::Client, url: String, @@ -51,11 +80,7 @@ impl Submitter { } /// Builds the `BidTrace` and signs it under the builder domain. - pub fn sign( - &self, - built: &BuiltBlock, - slot: &SlotContext, - ) -> Result { + pub fn sign(&self, built: &BuiltBlock, slot: &SlotContext) -> Result { let payload_v3 = block_to_payload_v3(&built.block); let payload = payload_from_v3(payload_v3).ok_or(SubmitError::OversizedPayload)?; @@ -80,16 +105,32 @@ impl Submitter { }; let signature = self.signing.sign_builder_message(&message); - Ok(SignedBidSubmission { + let submission = SignedBidSubmission { message, execution_payload: Arc::new(payload), blobs_bundle: Arc::new(blobs), execution_requests: Arc::new(requests), signature: signature.serialize().into(), - }) + }; + + // The relay decodes by the fork its own clock reports, so the shape has + // to be chosen from the same spec rather than from the block. + let fork = self.signing.chain_info.fork_at_slot(Slot::new(slot.slot)); + match (fork, &built.block_access_list) { + (ForkName::Gloas, Some(bal)) => Ok(Bid::Gloas(SignedBidSubmissionGloas::join( + submission, + BlockAccessListBytes(bal.clone().into()), + ))), + (ForkName::Gloas, None) => Err(SubmitError::MissingBlockAccessList), + // Sending it anyway drops the list, and the simulator then rebuilds + // a header whose hash is not the one signed here. Every bid would + // die as a hash mismatch, with nothing to point at. + (fork, Some(_)) => Err(SubmitError::UnsubmittableBlockAccessList(fork)), + (_, None) => Ok(Bid::Fulu(submission)), + } } - pub async fn submit(&self, submission: &SignedBidSubmission) -> Result<(), SubmitError> { + pub async fn submit(&self, submission: &Bid) -> Result<(), SubmitError> { let response = self .http .post(&self.url) diff --git a/crates/builder/src/building/submit/tests.rs b/crates/builder/src/building/submit/tests.rs index aa6bfdb89..fc85ac379 100644 --- a/crates/builder/src/building/submit/tests.rs +++ b/crates/builder/src/building/submit/tests.rs @@ -46,10 +46,19 @@ fn built_block(bundle: EthrexBlobsBundle) -> BuiltBlock { blobs_bundle: bundle, requests: Vec::new(), account_updates: Vec::new(), + block_access_list: None, value: U256::from(1_234_567_u64), } } +/// The pre-Gloas shape, which is what a default `ChainInfo` selects. +fn fulu(bid: Bid) -> SignedBidSubmission { + match bid { + Bid::Fulu(bid) => bid, + Bid::Gloas(_) => panic!("a mainnet spec is not on Gloas"), + } +} + // --- blobs conversion --- #[test] @@ -102,7 +111,7 @@ fn the_bid_trace_mirrors_the_payload() { let submitter = submitter("http://localhost:1"); let built = built_block(EthrexBlobsBundle::default()); - let submission = submitter.sign(&built, &slot_context()).unwrap(); + let submission = fulu(submitter.sign(&built, &slot_context()).unwrap()); // `payload.validate()` on the relay rejects any disagreement here. let payload = &submission.execution_payload; @@ -118,7 +127,7 @@ fn the_bid_trace_carries_the_slot_and_proposer() { let built = built_block(EthrexBlobsBundle::default()); let slot = slot_context(); - let submission = submitter.sign(&built, &slot).unwrap(); + let submission = fulu(submitter.sign(&built, &slot).unwrap()); assert_eq!(submission.message.slot, 42); assert_eq!(submission.message.proposer_pubkey, slot.proposer_pubkey); @@ -134,7 +143,7 @@ fn the_signature_verifies_under_the_builder_domain() { let submitter = Submitter::new("http://localhost:1", "key".to_string(), signing); let built = built_block(EthrexBlobsBundle::default()); - let submission = submitter.sign(&built, &slot_context()).unwrap(); + let submission = fulu(submitter.sign(&built, &slot_context()).unwrap()); // The exact check the relay's decoder tile makes. submission.verify_signature(domain).expect("the relay must accept our signature"); @@ -144,9 +153,9 @@ fn the_signature_verifies_under_the_builder_domain() { fn the_submission_round_trips_through_ssz() { let submitter = submitter("http://localhost:1"); let built = built_block(EthrexBlobsBundle::default()); - let submission = submitter.sign(&built, &slot_context()).unwrap(); + let submission = fulu(submitter.sign(&built, &slot_context()).unwrap()); - let encoded = submission.as_ssz_bytes(); + let encoded = ssz::Encode::as_ssz_bytes(&submission); let decoded = SignedBidSubmission::from_ssz_bytes(&encoded).expect("the wire format"); assert_eq!(decoded.message.block_hash, submission.message.block_hash); @@ -159,8 +168,8 @@ fn a_submission_with_blobs_round_trips() { let submitter = submitter("http://localhost:1"); let built = built_block(blob_bundle(1)); - let submission = submitter.sign(&built, &slot_context()).unwrap(); - let encoded = submission.as_ssz_bytes(); + let submission = fulu(submitter.sign(&built, &slot_context()).unwrap()); + let encoded = ssz::Encode::as_ssz_bytes(&submission); let decoded = SignedBidSubmission::from_ssz_bytes(&encoded) .expect("a bundle decodes only with 128 proofs per blob"); @@ -240,3 +249,92 @@ async fn a_relay_rejection_is_reported_with_its_body() { assert!(err.to_string().contains("invalid state root"), "got: {err}"); assert!(matches!(err, SubmitError::Rejected { status: 400, .. }), "got: {err}"); } + +// --- the Gloas shape --- + +/// Gloas from genesis, so `fork_at_slot` reports it for every slot. +fn gloas_signing() -> RelaySigningContext { + let mut spec = helix_types::ChainSpec::mainnet(); + spec.gloas_fork_epoch = Some(helix_types::Epoch::new(0)); + RelaySigningContext::new(BlsKeypair::random(), Arc::new(ChainInfo::new(spec, B256::ZERO, 0))) +} + +fn built_with_bal(bal: Vec) -> BuiltBlock { + BuiltBlock { block_access_list: Some(bal), ..built_block(EthrexBlobsBundle::default()) } +} + +#[test] +fn a_gloas_submission_carries_the_block_access_list() { + let submitter = Submitter::new("http://localhost:1", "key".to_string(), gloas_signing()); + let bal = vec![9u8; 64]; + + let bid = submitter.sign(&built_with_bal(bal.clone()), &slot_context()).unwrap(); + + // Decoded the way the relay's own decoder will, rather than by field access. + let params = helix_common::decoder::SubmissionDecoderParams::plain(ForkName::Gloas); + let mut buf = Vec::new(); + let (_, _, _, decoded) = helix_common::decoder::SubmissionDecoder::new(¶ms) + .decode(&bid.as_ssz_bytes(), &mut buf) + .expect("the relay must be able to decode what we send"); + assert_eq!(decoded.expect("Gloas carries a list").to_vec(), bal); +} + +#[test] +fn a_fulu_submission_keeps_its_shape() { + let submitter = submitter("http://localhost:1"); + let built = built_block(EthrexBlobsBundle::default()); + + let bid = submitter.sign(&built, &slot_context()).unwrap(); + + assert!(matches!(bid, Bid::Fulu(_))); + let params = helix_common::decoder::SubmissionDecoderParams::plain(ForkName::Fulu); + let mut buf = Vec::new(); + let (_, _, _, decoded) = helix_common::decoder::SubmissionDecoder::new(¶ms) + .decode(&bid.as_ssz_bytes(), &mut buf) + .expect("the Fulu shape must be unchanged"); + assert!(decoded.is_none(), "only Gloas carries one"); +} + +/// Refused rather than sent with an empty list, which the simulator would +/// reject as a block hash mismatch with nothing to point at. +#[test] +fn a_gloas_submission_without_a_list_is_refused() { + let submitter = Submitter::new("http://localhost:1", "key".to_string(), gloas_signing()); + + let err = submitter + .sign(&built_block(EthrexBlobsBundle::default()), &slot_context()) + .expect_err("Gloas needs a list"); + + assert!(matches!(err, SubmitError::MissingBlockAccessList), "got: {err}"); +} + +/// The other direction: an Amsterdam block cannot go out in a pre-Gloas +/// submission, because the shape has nowhere to put its list. +#[test] +fn an_amsterdam_block_in_a_fulu_submission_is_refused() { + let mut spec = helix_types::ChainSpec::mainnet(); + spec.fulu_fork_epoch = Some(helix_types::Epoch::new(0)); + let signing = RelaySigningContext::new( + BlsKeypair::random(), + Arc::new(ChainInfo::new(spec, B256::ZERO, 0)), + ); + let submitter = Submitter::new("http://localhost:1", "key".to_string(), signing); + + let err = submitter + .sign(&built_with_bal(vec![1u8; 32]), &slot_context()) + .expect_err("the list would be dropped"); + + assert!(matches!(err, SubmitError::UnsubmittableBlockAccessList(ForkName::Fulu)), "got: {err}"); +} + +/// Step 3 derives the header's slot number from the bid trace, so these two +/// must name the same slot or the simulator rebuilds a different block. +#[test] +fn the_bid_trace_slot_matches_the_slot_the_block_was_built_for() { + let submitter = Submitter::new("http://localhost:1", "key".to_string(), gloas_signing()); + let slot = slot_context(); + + let bid = submitter.sign(&built_with_bal(vec![2u8; 16]), &slot).unwrap(); + + assert_eq!(bid.message().slot, slot.slot); +} diff --git a/crates/builder/src/config.rs b/crates/builder/src/config.rs index 8d601a21b..66644066c 100644 --- a/crates/builder/src/config.rs +++ b/crates/builder/src/config.rs @@ -225,7 +225,9 @@ pub struct BuildingConfig { #[allow(dead_code)] #[serde(default = "default_subsidy_wei")] pub subsidy_wei: u128, - /// Gas held back from the fill for the trailing payout transaction. + /// Gas held back from the fill for the trailing payout transaction. Covers + /// the transfer only: from Amsterdam, creating a recipient that does not + /// exist yet is charged on top. See `assemble::payout_gas_limit`. #[serde(default = "default_payout_gas_reserve")] pub payout_gas_reserve: u64, #[serde(default = "default_extra_data")] diff --git a/crates/builder/src/testing.rs b/crates/builder/src/testing.rs index 9f564b8ce..6a9cc6531 100644 --- a/crates/builder/src/testing.rs +++ b/crates/builder/src/testing.rs @@ -64,10 +64,6 @@ pub fn signed_transfer( alloy_consensus::TxEnvelope::from(tx.into_signed(signature)).encoded_2718() } -pub async fn dev_genesis_store() -> (Store, Genesis) { - dev_genesis_store_with(|_| {}).await -} - /// `edit` may add allocations before the genesis state root is computed. pub async fn dev_genesis_store_with(edit: impl FnOnce(&mut Genesis)) -> (Store, Genesis) { let mut genesis = Network::LocalDevnet.get_genesis().unwrap(); From 06ca77edae422b340ce7bc11792af524b30b28d5 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 4 Sep 2026 17:27:11 +0100 Subject: [PATCH 07/33] Document Gloas support and add a testnet runbook Records the genesis, relay and builder configuration a Gloas testnet needs, including the requirements that are not discoverable from the code: the EIP-8282 predeploys, ssz_url on every simulator, and the Amsterdam gas cost of a first payment. --- README.md | 6 + crates/builder/README.md | 16 ++- docs/gloas-testnet.md | 251 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 docs/gloas-testnet.md diff --git a/README.md b/README.md index a50560c74..b80c58f50 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Helix is a MEV-Boost Relay designed with three key foundational principles: fast, simple, contained. +## Documentation + +- [Architecture](docs/architecture.md) +- [Running on a Gloas testnet](docs/gloas-testnet.md) +- [Local development with Kurtosis](scripts/devnet/README.md) + ## Audits Audit conducted by Spearbit, with [Alex Stokes](https://github.com/ralexstokes) (EF) and [Matthias Seitz](https://github.com/mattsse) (Reth, Foundry, ethers-rs) leading as security researchers. See the report [here](audits/spearbit-audit.pdf). diff --git a/crates/builder/README.md b/crates/builder/README.md index b5c7f14cd..372bbd0ff 100644 --- a/crates/builder/README.md +++ b/crates/builder/README.md @@ -50,7 +50,10 @@ the relay must reach it through the simulator's `ssz_url`. Differences from - The disallow list rejects interaction by effect -- a state change, or a transaction addressed to a listed account. `crates/simulator` also rejects a block that merely *reads* one, so it rejects strictly more blocks. -- Only Fulu (V5) and the relay-internal merged method are served. +- `/validate` serves Fulu and Gloas; `/validate_merged` serves Fulu only, + because merging protocol v1 cannot carry an Amsterdam block. A fork with no + shape here is refused with `501`, not `400`, so a helix limitation cannot + demote a builder. ## The building role @@ -68,9 +71,14 @@ The block itself is ethrex's own payload machinery, with the builder as the coinbase, so tips accrue to the builder and the bid is funded from them: ``` -payout = tips + subsidy_wei - payout_gas_reserve * base_fee +payout = tips + subsidy_wei - payout_gas * base_fee ``` +`payout_gas` is `payout_gas_reserve` (21000 by default), plus EIP-8037's state +gas for creating the recipient when it has no account yet -- which is the normal +case the first time a fee recipient is paid. See +[the Gloas testnet runbook](../../docs/gloas-testnet.md). + The block ends with a plain transfer of `payout` to the proposer's registered fee recipient. `subsidy_wei` exists because the relay rejects a zero-value block: without it an idle testnet would produce no bids at all, which is when @@ -85,6 +93,10 @@ genesis, never a compiled-in fork version. It builds at each what it already sent for that slot and parent -- a new parent, after a re-org, starts a fresh auction. +From Amsterdam the block also carries the EIP-7928 block access list ethrex +records and the EIP-7843 slot number, and the submission goes out in the Gloas +shape so the list travels with it. + What it does not do: no bundles or `eth_sendBundle`, no ordering of its own (ethrex's tip-sorted fill), no cancellations, no bidding strategy (it always bids the full block value), and one relay only. diff --git a/docs/gloas-testnet.md b/docs/gloas-testnet.md new file mode 100644 index 000000000..9dc4b5a4e --- /dev/null +++ b/docs/gloas-testnet.md @@ -0,0 +1,251 @@ +# Running helix on a Gloas testnet + +How to exercise the relay's whole path — submit, simulate, `get_header`, +`get_payload`, publish — on a Gloas consensus layer with an Amsterdam execution +layer, using the ethrex-based `helix-builder` for both the builder and the +simulator. + +Three roles take part. Block merging does not: see +[What is not supported](#what-is-not-supported). + +| component | what runs it | +| --- | --- | +| relay | `helix-relay` | +| simulator | `helix-builder --sim.config` | +| builder | `helix-builder --build.config` | + +## What Gloas changes + +Two forks arrive together and both matter here. + +**Gloas (consensus, EIP-7732)** moves the execution payload into a separately +bid and revealed envelope. helix's builder-facing wire shape is unchanged except +for one addition: a Gloas submission carries the EIP-7928 block access list +beside `blobs_bundle` and `execution_requests`. + +**Amsterdam (execution)** adds two header fields no `ExecutionPayloadV3` carries: + +- `block_access_list_hash` (EIP-7928), the keccak of the encoded access list +- `slot_number` (EIP-7843), the proposal slot + +The block hash commits to both, so the simulator has to reconstruct them from +the submission. The list arrives as opaque bytes and is **hashed exactly as +received, never re-encoded** — re-encoding could change the hash and break the +commitment the builder signed. The slot number comes from `BidTrace.slot`, so +the bid trace and the header must name the same slot. + +The builder computes the list while building and the simulator recomputes it +during validation, so a list that does not describe execution is rejected rather +than trusted. + +**The two forks are selected independently and must agree.** The block's shape +follows the execution layer (`amsterdamTime` in the EL genesis) and the +submission's shape follows the consensus layer (the Gloas fork epoch in the CL +config). A submission whose shape cannot carry its block's list is refused +before it is sent, because sending it would drop the list and every bid would +die as an unexplained block hash mismatch. + +## Execution layer genesis + +### The EIP-8282 predeploys are mandatory + +Amsterdam runs two system contracts — the EIP-8282 builder deposit and builder +exit predeploys. **Empty code at either address invalidates every Amsterdam +block**, with `SystemContractCallFailed: ... has no code after deployment`. Both +must be allocated in genesis: + +| address | contract | +| --- | --- | +| `0x0000884d2AA32eAa155F59A2f24eFa73D9008282` | builder deposit | +| `0x000014574A74c805590AFF9499fc7A690f008282` | builder exit | + +Their runtime bytecode is in ethrex's own `fixtures/genesis/l1-bal.json`, and in +this repo in `crates/builder/src/testing.rs` +(`deploy_amsterdam_predeploys`), which is what the Amsterdam test fixtures use. + +### Fork activation + +```json +{ + "config": { + "shanghaiTime": 0, + "cancunTime": 0, + "pragueTime": 0, + "osakaTime": 0, + "amsterdamTime": 0 + } +} +``` + +An explicit `blobSchedule.amsterdam` entry is optional: with none, ethrex falls +through the BPO chain to Osaka's schedule. + +## Consensus layer + +Set the Gloas fork epoch in the beacon chain config. The builder reads the spec +and genesis from the beacon node (`get_chain_info`) and derives both the builder +signing domain and the fork at each slot from it, so there is no compiled-in +fork version to keep in step — but the beacon node's spec must actually schedule +Gloas, or the builder will send pre-Gloas submissions for Amsterdam blocks and +refuse them itself. + +The relay picks the decode fork from `ChainInfo::current_fork_name()`, i.e. from +the same spec by wall clock. Relay and builder must read the same beacon node, +or the same config. + +## Relay configuration + +### Every simulator needs `ssz_url` + +The ethrex simulation role serves the SSZ routes (`/validate`, +`/validate_merged`) and no JSON-RPC validation method. Simulator dispatch is +per-simulator: a simulator entry without `ssz_url` takes the JSON-RPC path, and +`sim_request_builder` returns `None` for Gloas, so **that simulator silently +drops every Gloas submission** with `BlockSimError::UnsupportedFork`. + +Any reth-based simulator in the pool has the same effect, because reth has no +Amsterdam support yet (see #518). For a Gloas testnet, every `simulators` entry +must point at an ethrex simulation role. + +```yaml +simulators: + # `url` is still required: the relay uses it for eth_syncing and + # eth_getBalance. Point it at the same node's ethrex JSON-RPC + # (--http.addr/--http.port, default 127.0.0.1:8545). + - url: http://127.0.0.1:8545 + ssz_url: http://127.0.0.1:8552 +``` + +`ssz_url` is the simulation role's `ssz_addr`. + +### Merging off + +`block_merging_config.is_enabled` defaults to `false`; leave it there. With it +on, the merging role declines every Amsterdam base block by name and the merge +tile does no useful work. + +### The builder must be registered + +Add the building role's BLS pubkey to `builders` with an `api_key`, which the +builder sends as `x-api-key`. + +## Running the simulator and builder + +Both roles share one embedded node, so they can run in one process: + +```bash +helix-builder --sim.config sim-config.yml --build.config build-config.yml \ + --http.port 8545 --authrpc.port 8551 +``` + +The node needs a beacon node driving its Engine API on `--authrpc.port`, exactly +as any execution client does. + +**Release builds only for these two roles.** A blob is 128 KiB and crosses the +stack several times in a debug build, which overflows tokio's default worker +stack. + +### Keys + +| variable | needed by | +| --- | --- | +| `BUILDER_BLS_KEY` | building — signs the bid, and is the pubkey to register | +| `BUILDER_PAYOUT_KEY` | building — secp256k1, funds the proposer payment | +| `RELAY_KEY` | merging only, so not needed here | + +The payout key's account must hold enough to cover the bid plus the payout's own +gas. Both keys are loaded at startup, before the node boots, so a bad key is a +startup error rather than a first-slot failure. + +### `payout_gas_reserve` under Amsterdam + +The building role ends each block with a plain transfer to the proposer's +registered fee recipient: + +``` +payout = tips + subsidy_wei - payout_gas * base_fee +``` + +`payout_gas_reserve` (default 21000) covers that transfer. **Paying an address +for the first time also creates its account, and EIP-8037 charges state gas for +that** — 120 state bytes at 1530 gas each, so 183600 on top, for a total of +204600. A fee recipient that has never been paid is the normal case on a fresh +testnet. + +The builder handles this: it reads the recipient from in-block state and adds +the creation charge only when the account is absent. `payout_gas_reserve` should +stay at 21000 unless the fee recipient is a contract that needs more for the +transfer itself. A contract needing more than the reserve makes the payout fail +and the slot is skipped. + +`subsidy_wei` (default 1e15) exists because the relay rejects a zero-value +block. It must exceed the payout's own gas cost or every idle-mempool block is +skipped with `NoPayout`; at 204600 gas the default covers base fees up to +roughly 4.9 gwei. Set it to 0 only if you want bids solely from real tips. + +## Generating traffic + +**A flat 21000-gas transfer to a fresh address fails under Amsterdam.** This is +EIP-8037 applying to all traffic, not something helix can change: the intrinsic +cost of the transfer is still 21000, but creating the recipient adds 183600 in +state gas. Such transactions are included in blocks with failed receipts, which +is correct behaviour and looks like a broken builder if you are not expecting +it. + +Funding scripts and traffic generators aimed at a Gloas testnet need a gas limit +above 204600 for any transfer to an address that does not exist yet. + +## Verifying it works + +1. **The simulator accepts a block.** The relay logs a successful simulation, or + the simulation role returns `200` on `/validate`. A `501` means the fork gate + refused the submission: the relay sent a fork this role does not serve. +2. **The relay accepts the submission.** `200 OK` on + `/relay/v1/builder/blocks`. A `400` carries the reason in its body, which is + how an operator learns the blocks are bad. +3. **The bid is visible** through the relay's data API. +4. **`get_header` returns the bid** for that slot. +5. **`get_payload` and publication complete**, which is the point of the whole + exercise. + +If step 1 fails with a block hash mismatch, suspect the genesis: a missing +Amsterdam predeploy, or an `amsterdamTime` that disagrees with the CL's Gloas +epoch. + +## What is not supported + +- **Block merging.** Merging protocol v1 carries an `ExecutionPayloadV3`, which + has nowhere to put the access list or slot number, so the merging role + declines Amsterdam bases by name and the merged validation route refuses + Gloas. Tracked in #576. This is a missing capability, not a broken one: the + role refuses rather than merging into a block whose hash it cannot reproduce. +- **The reth simulator.** No Amsterdam support; tracked in #518. +- **Bid adjustments on Gloas, and dehydrated or mergeable Gloas submissions.** + Each is refused explicitly rather than decoded into a Fulu shape that would + drop the access list. +- **Inclusion lists** in the ethrex simulation role. A block violating a + submitted list passes here and fails in `crates/simulator`. + +## Reference: the access list's size + +Measured against ethrex's encoding at the pinned revision, since it decides +whether the builder sending the list is affordable. Per item, counted the way +EIP-7928's cap counts: + +| item | bytes | +| --- | --- | +| account (address, balance and nonce change) | ~70 | +| storage read (slot only) | ~33 | +| storage change (slot, value, index) | ~71 | + +A realistic busy block is small: 400 transfers to 400 distinct recipients +produced a 29.7 KB list. The worst case is bounded by EIP-7928 itself, which +caps items at `gas_limit / 2000` — roughly 0.4–0.9 MB at a 25M gas limit, +0.7–1.6 MB at 45M and 1.0–2.1 MB at 60M, spanning an all-reads and an +all-changes list. + +Even that maximum is an order of magnitude inside the relay's 20 MB +`MAX_PAYLOAD_LENGTH`, and far inside `BlockAccessListBytes`'s own SSZ bound of +1 GB. This is why the builder sends the list rather than the simulator returning +it: the bandwidth is affordable, and a list the builder committed to is worth +more than one nobody signed. From 755cf269599a724eddb4ab6204cc95963f18884f Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 7 Sep 2026 11:00:29 +0100 Subject: [PATCH 08/33] Read the EIP-8282 predeploy addresses from ethrex, document glamsterdam-devnet-8 Develop already pins an ethrex past v26.0.0 (#590), so the bump from #580 is dropped. The two predeploys moved between devnet-7 and devnet-8, so the test fixture now reads them from ethrex instead of hardcoding them. --- crates/builder/README.md | 4 +++ crates/builder/src/testing.rs | 35 +++++++++++--------- docs/gloas-testnet.md | 62 +++++++++++++++++++++++++++-------- 3 files changed, 72 insertions(+), 29 deletions(-) diff --git a/crates/builder/README.md b/crates/builder/README.md index 372bbd0ff..28ab6d002 100644 --- a/crates/builder/README.md +++ b/crates/builder/README.md @@ -179,6 +179,10 @@ On the relay side, add a merging builder to with `ssz_url` set to this role's `ssz_addr` (see the repo-root `config.example.yml`). +The embedded node is ethrex, pinned by rev in the workspace `Cargo.toml`. +Currently v26.0.0, which supports glamsterdam-devnet-8 +(`--network plataberget`). + ## Limitations - Merging protocol v1 carries `ExecutionPayloadV3`; post-Amsterdam blocks diff --git a/crates/builder/src/testing.rs b/crates/builder/src/testing.rs index 6a9cc6531..7c573a174 100644 --- a/crates/builder/src/testing.rs +++ b/crates/builder/src/testing.rs @@ -119,23 +119,28 @@ pub fn deploy_payment_forwarder(genesis: &mut Genesis) { }); } -/// The EIP-8282 builder deposit and exit predeploys, from ethrex's -/// `fixtures/genesis/l1-bal.json`. Empty code at either address invalidates -/// every Amsterdam block, so an Amsterdam fixture cannot build without them. +/// The EIP-8282 builder deposit and exit predeploys. Empty code at either +/// address invalidates every Amsterdam block, so an Amsterdam fixture cannot +/// build without them. +/// +/// The addresses come from ethrex rather than a literal: they are Nick's-method +/// addresses and they moved between devnet-7 and devnet-8, so a hardcoded pair +/// silently breaks on an ethrex bump. The runtime bytecode is unchanged across +/// that move and is copied from `fixtures/genesis/l1-bal.json`. pub fn deploy_amsterdam_predeploys(genesis: &mut Genesis) { - for (address, code) in [ - ("0000884d2aa32eaa155f59a2f24efa73d9008282", BUILDER_DEPOSIT_CODE), - ("000014574a74c805590aff9499fc7a690f008282", BUILDER_EXIT_CODE), + use ethrex_vm::system_contracts::{ + BUILDER_DEPOSIT_CONTRACT_ADDRESS, BUILDER_EXIT_CONTRACT_ADDRESS, + }; + for (contract, code) in [ + (BUILDER_DEPOSIT_CONTRACT_ADDRESS, BUILDER_DEPOSIT_CODE), + (BUILDER_EXIT_CONTRACT_ADDRESS, BUILDER_EXIT_CODE), ] { - genesis.alloc.insert( - ethrex_common::Address::from_slice(&hex::decode(address).unwrap()), - GenesisAccount { - code: hex::decode(code).unwrap().into(), - storage: Default::default(), - balance: ethrex_common::U256::zero(), - nonce: 0, - }, - ); + genesis.alloc.insert(contract.address, GenesisAccount { + code: hex::decode(code).unwrap().into(), + storage: Default::default(), + balance: ethrex_common::U256::zero(), + nonce: 0, + }); } } diff --git a/docs/gloas-testnet.md b/docs/gloas-testnet.md index 9dc4b5a4e..2c8adc24d 100644 --- a/docs/gloas-testnet.md +++ b/docs/gloas-testnet.md @@ -45,25 +45,58 @@ config). A submission whose shape cannot carry its block's list is refused before it is sent, because sending it would drop the list and every bid would die as an unexplained block hash mismatch. -## Execution layer genesis +## Execution layer -### The EIP-8282 predeploys are mandatory +### glamsterdam-devnet-8 (`plataberget`) -Amsterdam runs two system contracts — the EIP-8282 builder deposit and builder +The ethrex pin supports it by name: + +``` +--network plataberget +``` + +| | | +| --- | --- | +| chain id | `7091047534` | +| Amsterdam activation | timestamp `1787212224` (2026-08-20T07:50:24Z) | +| network configs | [ethpandaops/glamsterdam-devnets, `network-configs/devnet-8`](https://github.com/ethpandaops/glamsterdam-devnets/tree/master/network-configs/devnet-8) | + +An unrecognised `--network` value is **not** an error: ethrex falls back to +treating it as a path to a genesis file (`Network::GenesisPath`). A misspelling +therefore fails as a missing file rather than as an unknown network, so check +the spelling — it is `plataberget`, with an `l`. + +### The EIP-8282 predeploys + +Amsterdam runs two system contracts, the EIP-8282 builder deposit and builder exit predeploys. **Empty code at either address invalidates every Amsterdam -block**, with `SystemContractCallFailed: ... has no code after deployment`. Both -must be allocated in genesis: +block**, with `SystemContractCallFailed: ... has no code after deployment`. + +They are Nick's-method addresses, so on a real devnet they are deployed on +chain by an ordinary transaction before the fork activates — devnet-8's own +genesis does not allocate them. On a synced node there is nothing to do; if +Amsterdam blocks are failing, confirm with `eth_getCode` at both addresses +before looking anywhere else. + +**The addresses moved between devnet-7 and devnet-8**, so do not copy them from +older notes. Read them from the ethrex you are building against — +`ethrex_vm::system_contracts::{BUILDER_DEPOSIT_CONTRACT_ADDRESS, +BUILDER_EXIT_CONTRACT_ADDRESS}` — which is what +`crates/builder/src/testing.rs::deploy_amsterdam_predeploys` does, precisely so +a pin bump cannot silently invalidate every block. At v26.0.0 they are: | address | contract | | --- | --- | -| `0x0000884d2AA32eAa155F59A2f24eFa73D9008282` | builder deposit | -| `0x000014574A74c805590AFF9499fc7A690f008282` | builder exit | +| `0x0000BFF46984E3725691FA540A8C7589300D8282` | builder deposit | +| `0x000064D678505AD48F8CCB093BC65613800E8282` | builder exit | + +The runtime bytecode did not change across that move. -Their runtime bytecode is in ethrex's own `fixtures/genesis/l1-bal.json`, and in -this repo in `crates/builder/src/testing.rs` -(`deploy_amsterdam_predeploys`), which is what the Amsterdam test fixtures use. +### A local genesis instead -### Fork activation +Only a genesis you build yourself needs the predeploys allocated, because it +has no pre-fork history in which to deploy them. Activate the fork and allocate +both contracts: ```json { @@ -208,9 +241,10 @@ above 204600 for any transfer to an address that does not exist yet. 5. **`get_payload` and publication complete**, which is the point of the whole exercise. -If step 1 fails with a block hash mismatch, suspect the genesis: a missing -Amsterdam predeploy, or an `amsterdamTime` that disagrees with the CL's Gloas -epoch. +If step 1 fails with a block hash mismatch, suspect fork disagreement first: an +`amsterdamTime` that does not line up with the CL's Gloas epoch. A missing +EIP-8282 predeploy shows up differently, as `SystemContractCallFailed` during +execution rather than as a hash mismatch. ## What is not supported From 4be20af2807551a13fc8655cdbd9caf464268202 Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 8 Sep 2026 18:30:27 +0100 Subject: [PATCH 09/33] Enable ethrex's `rayon` feature on the embedded node `rayon` is a default feature of ethrex-blockchain, ethrex-common and ethrex-vm. The pins here set `default-features = false`, and only the `ethrex` cmd crate (deliberately not a dependency) adds it back, so the embedded node has never had it. It is not optional. ethrex gates the parallel BAL execution path on it (`crates/vm/backends/levm/mod.rs:485`) but not the caller that decides whether to create the merkleizer channel (`crates/blockchain/blockchain.rs:1026`). With a BAL supplied and both `bal_parallel_*` options at their defaults, the caller skips the channel and LEVM then drops the BAL and takes the sequential path, which needs that Sender: Error executing block: sequential execution path called without a merkleizer Sender So every `newPayload` that carries a BAL fails and the node cannot follow the head. P2P sync is unaffected because it passes no BAL. Also restores the mempool prewarm and the block warmer, both of which log or no-op when the feature is off. --- Cargo.lock | 3 +++ Cargo.toml | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dffc1c910..1876844eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4355,6 +4355,7 @@ dependencies = [ "libssz-types", "lru 0.16.4", "once_cell", + "rayon", "rkyv", "rustc-hash", "secp256k1 0.30.0", @@ -4453,6 +4454,7 @@ dependencies = [ "ethrex-rlp", "libssz", "malachite", + "rayon", "rustc-hash", "serde", "strum 0.27.2", @@ -4624,6 +4626,7 @@ dependencies = [ "ethrex-crypto", "ethrex-levm", "ethrex-rlp", + "rayon", "rustc-hash", "serde", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index ac7e1b53f..69dada9a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,9 +54,10 @@ ethereum_ssz_derive = "0.10" # NOTE: the `ethrex` cmd-lib is deliberately NOT a dependency: it force-enables # ethrex-crypto's `aws-lc-rs` feature, whose cc >=1.2.26 requirement conflicts with # reth-mdbx-sys's exact `cc = 1.2.15` pin (via helix-simulator). The node is -# assembled from the library crates instead. -ethrex-blockchain = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "metrics"] } -ethrex-common = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg"] } +# assembled from the library crates instead, so `rayon` must be listed by hand: +# the parallel BAL execution path is cfg-gated on it. +ethrex-blockchain = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "metrics", "rayon"] } +ethrex-common = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "rayon"] } ethrex-config = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a" } # default features minus `aws-lc-rs` (see note above); P-256 verify uses the portable fallback ethrex-crypto = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["std", "kzg-rs", "secp256k1", "blst", "c-kzg"] } @@ -66,7 +67,7 @@ ethrex-p2p = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec61 ethrex-rlp = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a" } ethrex-rpc = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a" } ethrex-storage = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", features = ["rocksdb"] } -ethrex-vm = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg"] } +ethrex-vm = { git = "https://github.com/gattaca-com/ethrex", rev = "923cae9ec6156e485d9d768882bcb510f545d49a", default-features = false, features = ["secp256k1", "c-kzg", "rayon"] } eyre = "0.6.12" clickhouse = "0.14.2" From f9bb4d92f9e67d955dbeb366699c70b8ec97cfc2 Mon Sep 17 00:00:00 2001 From: owen Date: Wed, 9 Sep 2026 18:34:34 +0100 Subject: [PATCH 10/33] Accept a payload_attributes event with no parent block number Gloas drops `parent_block_number` from the event, so every event failed to parse and the relay never got a slot's attributes. The field is now optional, and stays quoted on the wire for the forks that still send it. Nothing reads it yet; a future consumer must take the number from the execution layer, because Gloas will never supply it. --- crates/builder/src/building/assemble/tests.rs | 2 +- crates/builder/src/building/slot.rs | 37 +++++++++++++++++-- crates/builder/src/building/submit/tests.rs | 2 +- crates/common/src/beacon/types/chain.rs | 20 +++++++++- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/builder/src/building/assemble/tests.rs b/crates/builder/src/building/assemble/tests.rs index 410f02f1d..e417fe00e 100644 --- a/crates/builder/src/building/assemble/tests.rs +++ b/crates/builder/src/building/assemble/tests.rs @@ -74,7 +74,7 @@ impl Fixture { SlotContext { slot: 1, parent_hash: self.parent_hash, - parent_block_number: 0, + parent_block_number: Some(0), timestamp: self.parent_timestamp + 12, prev_randao: B256::repeat_byte(0xcc), withdrawals: Withdrawals::default(), diff --git a/crates/builder/src/building/slot.rs b/crates/builder/src/building/slot.rs index 30064f5d4..fb3f23dbc 100644 --- a/crates/builder/src/building/slot.rs +++ b/crates/builder/src/building/slot.rs @@ -22,7 +22,7 @@ pub struct ProposerDuty { pub struct SlotContext { pub slot: u64, pub parent_hash: B256, - pub parent_block_number: u64, + pub parent_block_number: Option, pub timestamp: u64, pub prev_randao: B256, pub withdrawals: Withdrawals, @@ -143,7 +143,7 @@ mod tests { data: PayloadAttributesEventData { proposer_index: 1, proposal_slot: slot.into(), - parent_block_number: slot - 1, + parent_block_number: Some(slot - 1), parent_block_root: String::new(), parent_block_hash: parent, payload_attributes: PayloadAttributes { @@ -173,7 +173,7 @@ mod tests { assert_eq!(context.slot, 10); assert_eq!(context.parent_hash, B256::repeat_byte(0x11)); - assert_eq!(context.parent_block_number, 9); + assert_eq!(context.parent_block_number, Some(9)); assert_eq!(context.timestamp, 1_700_000_000 + 120); assert_eq!(context.prev_randao, B256::repeat_byte(0xcc)); assert_eq!(context.parent_beacon_block_root, B256::repeat_byte(0xdd)); @@ -336,11 +336,40 @@ mod tests { let context = tracker.on_payload_attributes(event).expect("a complete event must build"); assert_eq!(context.slot, 11111); - assert_eq!(context.parent_block_number, 999); + assert_eq!(context.parent_block_number, Some(999)); assert_eq!(context.timestamp, 1_700_000_000); assert_eq!(context.parent_hash, B256::repeat_byte(0x22)); assert_eq!(context.parent_beacon_block_root, B256::repeat_byte(0x44)); assert_eq!(context.withdrawals.len(), 1); assert_eq!(context.withdrawals[0].amount, 32_000_000_000); } + + #[test] + fn parses_a_gloas_payload_attributes_event_without_a_parent_block_number() { + let json = r#"{ + "version": "gloas", + "data": { + "proposer_index": "123", + "proposal_slot": "11111", + "parent_block_root": "0x1111111111111111111111111111111111111111111111111111111111111111", + "parent_block_hash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "payload_attributes": { + "timestamp": "1700000000", + "prev_randao": "0x3333333333333333333333333333333333333333333333333333333333333333", + "suggested_fee_recipient": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "withdrawals": [], + "parent_beacon_block_root": "0x4444444444444444444444444444444444444444444444444444444444444444" + } + } + }"#; + + let event: PayloadAttributesEvent = serde_json::from_str(json).unwrap(); + let mut tracker = SlotTracker::default(); + tracker.on_duties(vec![duty_response(11111)]); + + let context = tracker.on_payload_attributes(event).expect("a Gloas event must build"); + + assert_eq!(context.slot, 11111); + assert_eq!(context.parent_block_number, None); + } } diff --git a/crates/builder/src/building/submit/tests.rs b/crates/builder/src/building/submit/tests.rs index fc85ac379..aece93ef6 100644 --- a/crates/builder/src/building/submit/tests.rs +++ b/crates/builder/src/building/submit/tests.rs @@ -23,7 +23,7 @@ fn slot_context() -> SlotContext { SlotContext { slot: 42, parent_hash: B256::repeat_byte(0x11), - parent_block_number: 41, + parent_block_number: Some(41), timestamp: 1_700_000_000, prev_randao: B256::repeat_byte(0xcc), withdrawals: Withdrawals::default(), diff --git a/crates/common/src/beacon/types/chain.rs b/crates/common/src/beacon/types/chain.rs index b68fbe244..490eddca8 100644 --- a/crates/common/src/beacon/types/chain.rs +++ b/crates/common/src/beacon/types/chain.rs @@ -81,13 +81,29 @@ pub struct PayloadAttributesEventData { #[serde(with = "serde_utils::quoted_u64")] pub proposer_index: u64, pub proposal_slot: Slot, - #[serde(with = "serde_utils::quoted_u64")] - pub parent_block_number: u64, + /// Absent from Gloas, which no longer carries it. + #[serde(default, with = "quoted_u64_opt")] + pub parent_block_number: Option, pub parent_block_root: String, pub parent_block_hash: B256, pub payload_attributes: PayloadAttributes, } +mod quoted_u64_opt { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use serde_utils::quoted_u64::Quoted; + + pub fn serialize(value: &Option, serializer: S) -> Result { + value.map(|value| Quoted { value }).serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + Ok(Option::>::deserialize(deserializer)?.map(|quoted| quoted.value)) + } +} + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct PayloadAttributes { #[serde(with = "serde_utils::quoted_u64")] From b4dffb4f76713ad3cb6951e8a5c77e2f9b35020c Mon Sep 17 00:00:00 2001 From: owen Date: Wed, 9 Sep 2026 18:34:34 +0100 Subject: [PATCH 11/33] Skip the proposer duties write when there are no duties An empty duty list built `VALUES ON CONFLICT`, which Postgres refused, so a relay with no registered proposer logged a failure every slot. The aborted transaction changed nothing, so returning early keeps the same result without the error. --- crates/database/src/postgres/postgres_db_service.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/database/src/postgres/postgres_db_service.rs b/crates/database/src/postgres/postgres_db_service.rs index 10a6af436..148e9de85 100644 --- a/crates/database/src/postgres/postgres_db_service.rs +++ b/crates/database/src/postgres/postgres_db_service.rs @@ -1258,6 +1258,11 @@ impl PostgresDatabaseService { ) -> Result<(), DatabaseError> { let mut record = DbMetricRecord::new("set_proposer_duties"); + if proposer_duties.is_empty() { + record.record_success(); + return Ok(()); + } + let mut client = self.high_priority_pool.get().await?; let transaction = client.transaction().await?; From 3a2718888e103ffa01c42d297ff75c741ac97b86 Mon Sep 17 00:00:00 2001 From: owen Date: Thu, 10 Sep 2026 01:03:29 +0100 Subject: [PATCH 12/33] Install the crypto provider before the builder boots The building role's HTTP client panicked on the main thread with "Could not automatically determine the process-level CryptoProvider", which killed the process and took the simulation role and the embedded node with it. The relay and the data API already install the provider at startup; the builder now does the same. --- crates/builder/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/builder/src/main.rs b/crates/builder/src/main.rs index f1e2b171e..4233f25d0 100644 --- a/crates/builder/src/main.rs +++ b/crates/builder/src/main.rs @@ -3,6 +3,7 @@ use flux::{ spine::FluxSpine, tile::{TileConfig, attach_tile}, }; +use helix_common::utils::install_default_crypto_provider; use tracing::info; use tracing_subscriber::EnvFilter; @@ -31,6 +32,8 @@ use validation::{BlockValidator, server as validation_server}; static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; fn main() -> eyre::Result<()> { + install_default_crypto_provider(); + let cli = BuilderCli::parse(); init_tracing(&cli); From 3cacb6395d2f831b86c8119dfef6edd440125bac Mon Sep 17 00:00:00 2001 From: owen Date: Thu, 10 Sep 2026 01:25:53 +0100 Subject: [PATCH 13/33] Announce the payment in gwei and leave the enshrined value at zero The bid's `value` and `execution_payment` are gwei, checked against the builder's on-chain balance, but the relay put a wei figure in both. A 0.001 ETH bid claimed a million ETH, and anything above 18.4 ETH pinned to u64::MAX. The proposer is paid in-block, so `value` is now 0 and only `execution_payment` carries the amount, converted to gwei. --- .../src/auctioneer/get_execution_payload_bid.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/relay/src/auctioneer/get_execution_payload_bid.rs b/crates/relay/src/auctioneer/get_execution_payload_bid.rs index edbca72cd..429fecbf8 100644 --- a/crates/relay/src/auctioneer/get_execution_payload_bid.rs +++ b/crates/relay/src/auctioneer/get_execution_payload_bid.rs @@ -7,6 +7,8 @@ use tokio::sync::oneshot; use tracing::warn; use tree_hash::TreeHash; +const WEI_PER_GWEI: u64 = 1_000_000_000; + use crate::{ api::proposer::{GloasBuilderIdentity, ProposerApiError}, auctioneer::{ @@ -80,8 +82,9 @@ pub(super) fn build_signed_bid( let execution_requests = execution_requests_to_gloas(entry.bid_data_ref().execution_requests); let execution_requests_root = execution_requests.tree_hash_root(); - // Per gattaca-com/helix#489: no payment-split product need yet, so execution_payment = value. - let value = entry.value().saturating_to::(); + // The proposer is paid in-block, so the enshrined `value` stays 0. + let execution_payment = + (entry.value() / alloy_primitives::U256::from(WEI_PER_GWEI)).saturating_to::(); let bid = ExecutionPayloadBid { parent_block_hash: ExecutionBlockHash(params.parent_hash), @@ -92,8 +95,8 @@ pub(super) fn build_signed_bid( gas_limit: payload.gas_limit, builder_index: identity.builder_index, slot, - value, - execution_payment: value, + value: 0, + execution_payment, blob_kzg_commitments: convert_kzg_commitments_to_progressive( &entry.payload_and_blobs().blobs_bundle.commitments, ), @@ -279,7 +282,7 @@ mod tests { let block_hash = B256::repeat_byte(0x99); let parent_hash = B256::repeat_byte(0x11); let parent_root = B256::repeat_byte(0x22); - let entry = payload_entry(block_hash, 42); + let entry = payload_entry(block_hash, 42 * WEI_PER_GWEI); let identity = bid_identity(7); let params = params(parent_hash, parent_root); @@ -289,8 +292,8 @@ mod tests { assert_eq!(signed_bid.message.parent_block_hash.0, parent_hash); assert_eq!(signed_bid.message.parent_block_root, parent_root); assert_eq!(signed_bid.message.builder_index, 7); - assert_eq!(signed_bid.message.value, 42); - assert_eq!(signed_bid.message.execution_payment, 42); + assert_eq!(signed_bid.message.value, 0, "the proposer is paid in-block"); + assert_eq!(signed_bid.message.execution_payment, 42, "wei converts to gwei"); let epoch = signed_bid.message.slot.epoch(helix_types::MainnetEthSpec::slots_per_epoch()); let fork = chain_info.spec.fork_at_epoch(epoch); From 1618d7745529705d022b34cb003213e097560e9f Mon Sep 17 00:00:00 2001 From: owen Date: Thu, 24 Sep 2026 14:47:49 +0100 Subject: [PATCH 14/33] Key payload attributes and bids by parent hash and beacon root --- crates/common/src/lib.rs | 2 +- crates/common/src/slot_info.rs | 16 +++++ crates/relay/src/auctioneer/bid_sorter.rs | 36 +++++++---- crates/relay/src/auctioneer/context.rs | 10 +-- .../auctioneer/get_execution_payload_bid.rs | 62 ++++++++++--------- crates/relay/src/auctioneer/get_header.rs | 5 +- crates/relay/src/auctioneer/mod.rs | 18 +++--- crates/relay/src/auctioneer/submit_block.rs | 8 +-- crates/relay/src/auctioneer/types.rs | 61 +++++++++++++++++- crates/relay/src/auctioneer/validation.rs | 4 +- crates/relay/src/housekeeper/payload_attrs.rs | 28 +++++++-- crates/relay/src/housekeeper/tile.rs | 8 +-- crates/relay/src/simulator/mod.rs | 4 +- 13 files changed, 184 insertions(+), 78 deletions(-) diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 7f2bc6c14..b4b3000e6 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -30,7 +30,7 @@ pub use adjustments::*; pub use builder_info::*; pub use config::*; pub use proposer::*; -pub use slot_info::{CurrentSlotInfo, PayloadAttributesUpdate, SlotDuties}; +pub use slot_info::{CurrentSlotInfo, ForkKey, PayloadAttributesUpdate, SlotDuties}; pub use traces::*; pub use validator::*; pub use validator_preferences::*; diff --git a/crates/common/src/slot_info.rs b/crates/common/src/slot_info.rs index b02024bd1..de023e8d9 100644 --- a/crates/common/src/slot_info.rs +++ b/crates/common/src/slot_info.rs @@ -21,6 +21,22 @@ pub struct PayloadAttributesUpdate { pub payload_attributes: PayloadAttributes, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct ForkKey { + pub parent_hash: B256, + pub parent_root: B256, +} + +impl PayloadAttributesUpdate { + pub fn parent_root(&self) -> B256 { + self.payload_attributes.parent_beacon_block_root.unwrap_or_default() + } + + pub fn fork(&self) -> ForkKey { + ForkKey { parent_hash: self.parent_hash, parent_root: self.parent_root() } + } +} + impl Deref for PayloadAttributesUpdate { type Target = PayloadAttributes; diff --git a/crates/relay/src/auctioneer/bid_sorter.rs b/crates/relay/src/auctioneer/bid_sorter.rs index c1d10ed5f..e66ad8ff4 100644 --- a/crates/relay/src/auctioneer/bid_sorter.rs +++ b/crates/relay/src/auctioneer/bid_sorter.rs @@ -7,7 +7,7 @@ use alloy_primitives::{Address, B256, U256}; use flux::spine::SpineProducers; use flux_profiler::timed; use helix_common::{ - SubmissionTrace, + ForkKey, SubmissionTrace, api::builder_api::TopBidUpdate, metrics::{BID_SORTER_PROCESS_LATENCY_US, TopBidMetrics}, record_submission_step_ns, @@ -28,11 +28,16 @@ pub struct Bid { pub builder_pubkey: BlsPublicKeyBytes, pub block_number: u64, pub parent_hash: B256, + pub parent_root: B256, pub fee_recipient: Address, } impl Bid { - pub fn new(version: SubmissionVersion, submission: &SignedBidSubmission) -> Self { + pub fn new( + version: SubmissionVersion, + submission: &SignedBidSubmission, + parent_root: B256, + ) -> Self { let bid_trace = submission.bid_trace(); Self { @@ -43,11 +48,12 @@ impl Bid { builder_pubkey: bid_trace.builder_pubkey, block_number: submission.block_number(), parent_hash: bid_trace.parent_hash, + parent_root, fee_recipient: submission.fee_recipient(), } } - pub fn from_submission_data(submission: &SubmissionData) -> Self { + pub fn from_submission_data(submission: &SubmissionData, parent_root: B256) -> Self { let bid_trace = submission.bid_trace(); Self { @@ -58,6 +64,7 @@ impl Bid { builder_pubkey: bid_trace.builder_pubkey, block_number: submission.block_number(), parent_hash: bid_trace.parent_hash, + parent_root, fee_recipient: submission.fee_recipient(), } } @@ -165,7 +172,7 @@ pub struct BidSorter { /// Head slot + 1 curr_bid_slot: u64, /// Parent hash -> fork state - forks: FxHashMap, + forks: FxHashMap, /// Demoted builders in this slot for live demotions demotions: FxHashSet, local_telemetry: BidSorterTelemetry, @@ -219,13 +226,13 @@ impl BidSorter { self.process_demotion(demoted, producers); } - /// Value of the bid that would be served for this parent right now. - pub fn top_bid_value(&self, parent_hash: &B256) -> Option { - self.forks.get(parent_hash).and_then(|s| s.curr_bid.as_ref().map(|b| b.value)) + /// Value of the bid that would be served for this fork right now. + pub fn top_bid_value(&self, fork: &ForkKey) -> Option { + self.forks.get(fork).and_then(|s| s.curr_bid.as_ref().map(|b| b.value)) } - pub fn get_header(&self, parent_hash: &B256) -> Option { - self.forks.get(parent_hash).and_then(|s| s.curr_bid.as_ref().map(|b| b.block_hash)) + pub fn get_header(&self, fork: &ForkKey) -> Option { + self.forks.get(fork).and_then(|s| s.curr_bid.as_ref().map(|b| b.block_hash)) } #[timed] @@ -236,7 +243,8 @@ impl BidSorter { is_optimistic: bool, producers: &mut HelixSpineProducers, ) -> bool { - let state = self.forks.entry(new_bid.parent_hash).or_default(); + let fork = ForkKey { parent_hash: new_bid.parent_hash, parent_root: new_bid.parent_root }; + let state = self.forks.entry(fork).or_default(); match state.bids.entry(new_bid.builder_pubkey) { Entry::Occupied(mut entry) => { let entry = entry.get_mut(); @@ -321,7 +329,12 @@ impl BidSorter { let fork_report: Vec<_> = self .forks .iter() - .map(|(k, s)| format!("parent: {k}, subs: {}, top_bids: {}", s.subs, s.top_bids)) + .map(|(k, s)| { + format!( + "parent: {}, root: {}, subs: {}, top_bids: {}", + k.parent_hash, k.parent_root, s.subs, s.top_bids + ) + }) .collect(); info!( @@ -353,6 +366,7 @@ mod tests { }, block_number: 1, parent_hash: B256::repeat_byte(1), + parent_root: B256::ZERO, fee_recipient: Address::ZERO, } } diff --git a/crates/relay/src/auctioneer/context.rs b/crates/relay/src/auctioneer/context.rs index a9195189b..b55b6c81c 100644 --- a/crates/relay/src/auctioneer/context.rs +++ b/crates/relay/src/auctioneer/context.rs @@ -398,12 +398,12 @@ pub(crate) fn merged_validation_request( response: &BlockMergeResponse, slot_data: &SlotData, ) -> Option { - let parent_hash = response.execution_payload.parent_hash; let parent_beacon_block_root = slot_data - .payload_attributes_map - .get(&parent_hash)? - .parent_beacon_block_root - .unwrap_or_default(); + .attrs_for_submission( + &response.execution_payload.parent_hash, + &response.execution_payload.prev_randao, + )? + .parent_root(); Some(MergedValidationRequest { submission_id: Uuid::new_v4(), base_block_hash: response.base_block_hash, diff --git a/crates/relay/src/auctioneer/get_execution_payload_bid.rs b/crates/relay/src/auctioneer/get_execution_payload_bid.rs index 429fecbf8..1843f135e 100644 --- a/crates/relay/src/auctioneer/get_execution_payload_bid.rs +++ b/crates/relay/src/auctioneer/get_execution_payload_bid.rs @@ -1,4 +1,6 @@ -use helix_common::{api::proposer_api::GetExecutionPayloadBidParams, chain_info::ChainInfo}; +use helix_common::{ + ForkKey, api::proposer_api::GetExecutionPayloadBidParams, chain_info::ChainInfo, +}; use helix_types::{ ExecutionBlockHash, ExecutionPayloadBid, SignedExecutionPayloadBid, Slot, convert_kzg_commitments_to_progressive, execution_requests_to_gloas, @@ -26,10 +28,9 @@ impl Context { res_tx: oneshot::Sender, ) { let result = check_execution_payload_bid_liveness(¶ms, slot_data).and_then(|()| { - let best_block_hash = self - .bid_sorter - .get_header(¶ms.parent_hash) - .ok_or(ProposerApiError::NoBidPrepared)?; + let fork = ForkKey { parent_hash: params.parent_hash, parent_root: params.parent_root }; + let best_block_hash = + self.bid_sorter.get_header(&fork).ok_or(ProposerApiError::NoBidPrepared)?; let entry = self.payloads.get(&best_block_hash).ok_or(ProposerApiError::NoBidPrepared)?; build_signed_bid(entry, ¶ms, &self.gloas_builder_identity, &self.chain_info) @@ -43,20 +44,12 @@ pub(super) fn check_execution_payload_bid_liveness( params: &GetExecutionPayloadBidParams, slot_data: &SlotData, ) -> Result<(), ProposerApiError> { - let Some(attrs) = slot_data.payload_attributes_map.get(¶ms.parent_hash) else { + let fork = ForkKey { parent_hash: params.parent_hash, parent_root: params.parent_root }; + if !slot_data.payload_attributes_map.contains_key(&fork) { warn!( - req =% params.parent_hash, + req =? fork, have =? slot_data.payload_attributes_map.keys(), - "get execution payload bid for unknown parent hash" - ); - return Err(ProposerApiError::NoBidPrepared); - }; - - if attrs.parent_beacon_block_root != Some(params.parent_root) { - warn!( - req =% params.parent_root, - have =? attrs.parent_beacon_block_root, - "get execution payload bid for mismatched parent root" + "get execution payload bid for unknown fork" ); return Err(ProposerApiError::NoBidPrepared); } @@ -112,16 +105,15 @@ mod tests { use alloy_primitives::B256; use helix_common::PayloadAttributesUpdate; use helix_types::{Domain, EthSpec, ForkName, SignedRoot, TestRandomSeed}; - use rustc_hash::FxHashMap; use super::*; - fn slot_data(payload_attributes_map: FxHashMap) -> SlotData { + fn slot_data(attrs: Vec) -> SlotData { SlotData { bid_slot: Default::default(), registration_data: Default::default(), current_fork: ForkName::Gloas, - payload_attributes_map, + payload_attributes_map: attrs.into_iter().map(|a| (a.fork(), a)).collect(), il: Default::default(), } } @@ -153,7 +145,7 @@ mod tests { fn unknown_parent_hash_is_no_bid() { let parent_hash = B256::repeat_byte(0x11); let parent_root = B256::repeat_byte(0x22); - let data = slot_data(FxHashMap::default()); + let data = slot_data(vec![]); let result = check_execution_payload_bid_liveness(¶ms(parent_hash, parent_root), &data); @@ -165,9 +157,7 @@ mod tests { let parent_hash = B256::repeat_byte(0x11); let live_root = B256::repeat_byte(0x22); let requested_root = B256::repeat_byte(0x33); - let mut map = FxHashMap::default(); - map.insert(parent_hash, attrs_update(parent_hash, Some(live_root))); - let data = slot_data(map); + let data = slot_data(vec![attrs_update(parent_hash, Some(live_root))]); let result = check_execution_payload_bid_liveness(¶ms(parent_hash, requested_root), &data); @@ -179,9 +169,7 @@ mod tests { fn missing_parent_beacon_block_root_is_no_bid() { let parent_hash = B256::repeat_byte(0x11); let requested_root = B256::repeat_byte(0x33); - let mut map = FxHashMap::default(); - map.insert(parent_hash, attrs_update(parent_hash, None)); - let data = slot_data(map); + let data = slot_data(vec![attrs_update(parent_hash, None)]); let result = check_execution_payload_bid_liveness(¶ms(parent_hash, requested_root), &data); @@ -193,15 +181,29 @@ mod tests { fn matching_parent_passes_liveness_check() { let parent_hash = B256::repeat_byte(0x11); let parent_root = B256::repeat_byte(0x22); - let mut map = FxHashMap::default(); - map.insert(parent_hash, attrs_update(parent_hash, Some(parent_root))); - let data = slot_data(map); + let data = slot_data(vec![attrs_update(parent_hash, Some(parent_root))]); let result = check_execution_payload_bid_liveness(¶ms(parent_hash, parent_root), &data); assert!(result.is_ok()); } + #[test] + fn each_parent_root_on_a_shared_parent_hash_passes_liveness_check() { + let parent_hash = B256::repeat_byte(0x11); + let missed_root = B256::repeat_byte(0x22); + let empty_root = B256::repeat_byte(0x33); + let data = slot_data(vec![ + attrs_update(parent_hash, Some(missed_root)), + attrs_update(parent_hash, Some(empty_root)), + ]); + + for root in [missed_root, empty_root] { + let result = check_execution_payload_bid_liveness(¶ms(parent_hash, root), &data); + assert!(result.is_ok()); + } + } + fn payload_entry(block_hash: B256, value: u64) -> PayloadEntry { use std::sync::Arc; diff --git a/crates/relay/src/auctioneer/get_header.rs b/crates/relay/src/auctioneer/get_header.rs index 5ba87f9a4..b6de91251 100644 --- a/crates/relay/src/auctioneer/get_header.rs +++ b/crates/relay/src/auctioneer/get_header.rs @@ -38,7 +38,10 @@ impl Context { producers: &mut HelixSpineProducers, is_mev_boost: bool, ) -> GetHeaderResult { - let Some(best_block_hash) = self.bid_sorter.get_header(&parent_hash) else { + let Some(best_block_hash) = slot_data + .fork_for_parent_hash(&parent_hash) + .and_then(|fork| self.bid_sorter.get_header(&fork)) + else { warn!(%parent_hash, "no bids for this fork"); return Err(ProposerApiError::NoBidPrepared); }; diff --git a/crates/relay/src/auctioneer/mod.rs b/crates/relay/src/auctioneer/mod.rs index 73e03b1a3..0138de239 100644 --- a/crates/relay/src/auctioneer/mod.rs +++ b/crates/relay/src/auctioneer/mod.rs @@ -24,7 +24,7 @@ use flux_profiler::timed; use flux_utils::SharedVector; pub use handle::{AuctioneerHandle, GetPayloadKind}; use helix_common::{ - PayloadAttributesUpdate, RelayConfig, + ForkKey, PayloadAttributesUpdate, RelayConfig, alerts::AlertManager, api::builder_api::{BuilderGetValidatorsResponseEntry, InclusionListWithMetadata}, chain_info::ChainInfo, @@ -235,7 +235,7 @@ enum State { Slot { bid_slot: Slot, registration_data: Option, - payload_attributes_map: FxHashMap, + payload_attributes_map: FxHashMap, il: Option, }, @@ -349,14 +349,14 @@ impl State { // check fork let new_forks: Vec<_> = payload_attributes .into_iter() - .filter(|u| !slot_data.payload_attributes_map.contains_key(&u.parent_hash)) + .filter(|u| !slot_data.payload_attributes_map.contains_key(&u.fork())) .collect(); if !new_forks.is_empty() { // ugly clone but should be relatively rare let mut slot_data = slot_data.clone(); for update in new_forks { - info!(bid_slot =% slot_data.bid_slot, received =? update.parent_hash, sorting =? slot_data.payload_attributes_map.keys(), "sorting for an additional fork"); - slot_data.payload_attributes_map.insert(update.parent_hash, update); + info!(bid_slot =% slot_data.bid_slot, received =? update.fork(), sorting =? slot_data.payload_attributes_map.keys(), "sorting for an additional fork"); + slot_data.payload_attributes_map.insert(update.fork(), update); } *self = State::Sorting(slot_data); } else if slot_data.il.is_none() && il.is_some() { @@ -467,9 +467,9 @@ impl State { request_slot: params.slot, bid_slot: slot_data.bid_slot.into(), })); - } else if !slot_data.payload_attributes_map.contains_key(¶ms.parent_hash) { + } else if slot_data.fork_for_parent_hash(¶ms.parent_hash).is_none() { // proposer is on a different fork - warn!(req =% params.parent_hash, have =? slot_data.payload_attributes_map.keys(), "get header for unknown parent hash"); + warn!(req =% params.parent_hash, have =? slot_data.parent_hashes(), "get header for unknown parent hash"); let _ = res_tx.send(Err(ProposerApiError::NoBidPrepared)); } else if slot_data.registration_data.entry.registration.message.pubkey != params.pubkey @@ -757,14 +757,14 @@ impl State { #[timed] fn process_slot_data( bid_slot: Slot, - mut payload_attributes_map: FxHashMap, + mut payload_attributes_map: FxHashMap, registration_data: Option, payload_attributes: Vec, il: Option, ctx: &mut Context, ) -> Self { for update in payload_attributes { - payload_attributes_map.insert(update.parent_hash, update); + payload_attributes_map.insert(update.fork(), update); } match (registration_data, payload_attributes_map.is_empty()) { diff --git a/crates/relay/src/auctioneer/submit_block.rs b/crates/relay/src/auctioneer/submit_block.rs index 60b5c995b..fc0847898 100644 --- a/crates/relay/src/auctioneer/submit_block.rs +++ b/crates/relay/src/auctioneer/submit_block.rs @@ -107,13 +107,13 @@ impl Context { &builder_info, slot_data, ) { - let bid = Bid::new(version, &submission); + let bid = Bid::new(version, &submission, payload_attributes.parent_root()); let is_top_bid = self.bid_sorter.sort(bid, &mut trace, true, producers); (OptimisticVersion::V1, is_top_bid) } else { let beats_top_bid = self .bid_sorter - .top_bid_value(&submission.message.parent_hash) + .top_bid_value(&payload_attributes.fork()) .is_none_or(|top| submission.message.value > top); (OptimisticVersion::NotOptimistic, beats_top_bid) }; @@ -136,9 +136,7 @@ impl Context { is_optimistic, apply_blacklist: slot_data.registration_data.entry.preferences.filtering.is_regional(), registered_gas_limit: slot_data.registration_data.entry.registration.message.gas_limit, - parent_beacon_block_root: payload_attributes - .parent_beacon_block_root - .unwrap_or_default(), + parent_beacon_block_root: payload_attributes.parent_root(), inclusion_list: slot_data.il.clone().unwrap_or_default(), submission: submission.clone(), block_access_list: block_access_list.clone(), diff --git a/crates/relay/src/auctioneer/types.rs b/crates/relay/src/auctioneer/types.rs index b10b4bc72..f867925af 100644 --- a/crates/relay/src/auctioneer/types.rs +++ b/crates/relay/src/auctioneer/types.rs @@ -5,7 +5,7 @@ use flux::type_hash_derive::type_hash_lock; use flux_utils::ArrayStr; use flux_versioned_types::{versioned_enum, versioned_struct}; use helix_common::{ - GetPayloadTrace, PayloadAttributesUpdate, SubmissionTrace, + ForkKey, GetPayloadTrace, PayloadAttributesUpdate, SubmissionTrace, api::{ builder_api::{BuilderGetValidatorsResponseEntry, InclusionListWithMetadata}, proposer_api::{GetExecutionPayloadBidParams, GetHeaderParams}, @@ -433,8 +433,8 @@ pub struct SlotData { pub bid_slot: Slot, /// Data about the validator registration pub registration_data: BuilderGetValidatorsResponseEntry, - /// Parent hash -> payload attributes for the incoming blocks - pub payload_attributes_map: FxHashMap, + /// Fork -> payload attributes for the incoming blocks + pub payload_attributes_map: FxHashMap, /// Current fork pub current_fork: ForkName, /// Inclusion list @@ -456,6 +456,28 @@ impl SlotData { pub fn proposer_pubkey(&self) -> &BlsPublicKeyBytes { &self.registration_data.entry.registration.message.pubkey } + + pub fn parent_hashes(&self) -> Vec { + self.payload_attributes_map.keys().map(|k| k.parent_hash).collect() + } + + pub fn fork_for_parent_hash(&self, parent_hash: &B256) -> Option { + self.payload_attributes_map.keys().find(|k| k.parent_hash == *parent_hash).copied() + } + + pub fn attrs_for_submission( + &self, + parent_hash: &B256, + prev_randao: &B256, + ) -> Option<&PayloadAttributesUpdate> { + let mut candidates = + self.payload_attributes_map.values().filter(|a| a.parent_hash == *parent_hash); + let first = candidates.next()?; + if first.prev_randao == *prev_randao { + return Some(first); + } + candidates.find(|a| a.prev_randao == *prev_randao).or(Some(first)) + } } #[allow(clippy::large_enum_variant)] @@ -539,3 +561,36 @@ impl Event { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn attrs(parent_hash: B256, parent_root: B256, prev_randao: B256) -> PayloadAttributesUpdate { + let mut update = PayloadAttributesUpdate { parent_hash, ..Default::default() }; + update.payload_attributes.parent_beacon_block_root = Some(parent_root); + update.payload_attributes.prev_randao = prev_randao; + update + } + + #[test] + fn a_submission_gets_the_fork_whose_prev_randao_it_matches() { + let parent_hash = B256::repeat_byte(1); + let forks = [ + attrs(parent_hash, B256::repeat_byte(2), B256::repeat_byte(3)), + attrs(parent_hash, B256::repeat_byte(4), B256::repeat_byte(5)), + ]; + let slot_data = SlotData { + bid_slot: Default::default(), + registration_data: Default::default(), + payload_attributes_map: forks.iter().map(|a| (a.fork(), a.clone())).collect(), + current_fork: ForkName::Gloas, + il: None, + }; + + for fork in &forks { + let got = slot_data.attrs_for_submission(&parent_hash, &fork.prev_randao).unwrap(); + assert_eq!(got.parent_root(), fork.parent_root()); + } + } +} diff --git a/crates/relay/src/auctioneer/validation.rs b/crates/relay/src/auctioneer/validation.rs index caf5ea6e9..4be5f737d 100644 --- a/crates/relay/src/auctioneer/validation.rs +++ b/crates/relay/src/auctioneer/validation.rs @@ -27,11 +27,11 @@ impl Context { } let Some(payload_attributes) = - slot_data.payload_attributes_map.get(submission.parent_hash()) + slot_data.attrs_for_submission(submission.parent_hash(), submission.prev_randao()) else { return Err(BlockValidationError::UknnownParentHash { submission: *submission.parent_hash(), - have: slot_data.payload_attributes_map.keys().cloned().collect(), + have: slot_data.parent_hashes(), }); }; diff --git a/crates/relay/src/housekeeper/payload_attrs.rs b/crates/relay/src/housekeeper/payload_attrs.rs index 70ce85c26..f9ac4b254 100644 --- a/crates/relay/src/housekeeper/payload_attrs.rs +++ b/crates/relay/src/housekeeper/payload_attrs.rs @@ -1,5 +1,4 @@ -use alloy_primitives::B256; -use helix_common::{PayloadAttributesUpdate, beacon::types::PayloadAttributesEvent}; +use helix_common::{ForkKey, PayloadAttributesUpdate, beacon::types::PayloadAttributesEvent}; use helix_types::Slot; use rustc_hash::FxHashMap; use tracing::info; @@ -10,16 +9,20 @@ use crate::housekeeper::chain_head::ChainHead; pub fn process_payload_attributes( chain_head: &mut ChainHead, event: PayloadAttributesEvent, - known_payload_attributes: &mut FxHashMap<(B256, Slot), PayloadAttributesUpdate>, + known_payload_attributes: &mut FxHashMap<(ForkKey, Slot), PayloadAttributesUpdate>, ) { // Drop stale payload attributes if chain_head.head() >= event.data.proposal_slot { return; } - // Drop duplicates for the same parent and slot. We may receive multiple events for the same + // Drop duplicates for the same fork and slot. We may receive multiple events for the same // slot - let payload_attributes_key = (event.data.parent_block_hash, event.data.proposal_slot); + let fork = ForkKey { + parent_hash: event.data.parent_block_hash, + parent_root: event.data.payload_attributes.parent_beacon_block_root.unwrap_or_default(), + }; + let payload_attributes_key = (fork, event.data.proposal_slot); if known_payload_attributes.contains_key(&payload_attributes_key) { return; } @@ -28,6 +31,7 @@ pub fn process_payload_attributes( head_slot =% chain_head.head(), payload_attribute_slot =% event.data.proposal_slot, payload_attribute_parent = ?event.data.parent_block_hash, + payload_attribute_parent_root = ?fork.parent_root, "processing payload attribute event", ); @@ -185,4 +189,18 @@ mod tests { process_payload_attributes(&mut ch, make_event(slot, B256::from([8u8; 32])), &mut known); assert_eq!(known.len(), 2); } + + #[test] + fn events_on_the_same_parent_hash_with_different_roots_both_stored() { + let (mut ch, head_slot) = make_chain_head(); + let mut known = FxHashMap::default(); + let slot = head_slot + 1; + let parent = B256::from([9u8; 32]); + for root in [B256::from([10u8; 32]), B256::from([11u8; 32])] { + let mut event = make_event(slot, parent); + event.data.payload_attributes.parent_beacon_block_root = Some(root); + process_payload_attributes(&mut ch, event, &mut known); + } + assert_eq!(known.len(), 2); + } } diff --git a/crates/relay/src/housekeeper/tile.rs b/crates/relay/src/housekeeper/tile.rs index b9450aba8..6ac30f4c7 100644 --- a/crates/relay/src/housekeeper/tile.rs +++ b/crates/relay/src/housekeeper/tile.rs @@ -10,8 +10,8 @@ use flux::{ }; use flux_utils::SharedVector; use helix_common::{ - CurrentSlotInfo, InclusionListConfig, PayloadAttributesUpdate, PrimevConfig, ProposerDuty, - RelayConfig, SlotDuties, ValidatorSummary, + CurrentSlotInfo, ForkKey, InclusionListConfig, PayloadAttributesUpdate, PrimevConfig, + ProposerDuty, RelayConfig, SlotDuties, ValidatorSummary, api::builder_api::{BuilderGetValidatorsResponseEntry, InclusionListWithMetadata}, beacon::{ MultiBeaconClient, @@ -105,7 +105,7 @@ pub struct HousekeeperTile { relay_network_api: Arc, db: DbHandle, - known_payload_attributes: FxHashMap<(B256, Slot), PayloadAttributesUpdate>, + known_payload_attributes: FxHashMap<(ForkKey, Slot), PayloadAttributesUpdate>, duties: Vec, requested_registrations: RequestedRegistrations, last_dependent_root: Option, @@ -601,7 +601,7 @@ fn send_slot_event( local_cache: &LocalCache, curr_slot_info: &CurrentSlotInfo, slot_events: &SharedVector, - known_payload_attributes: &FxHashMap<(B256, Slot), PayloadAttributesUpdate>, + known_payload_attributes: &FxHashMap<(ForkKey, Slot), PayloadAttributesUpdate>, il: Option, stats: HousekeeperStats, ) { diff --git a/crates/relay/src/simulator/mod.rs b/crates/relay/src/simulator/mod.rs index bc2f5f4dc..648455303 100644 --- a/crates/relay/src/simulator/mod.rs +++ b/crates/relay/src/simulator/mod.rs @@ -366,7 +366,7 @@ impl Simulators { Some(SimulationResultInner { submission_ref: req.submission_ref, optimistic_version: req.optimistic_version(), - bid: Some(Bid::new(req.version, &req.submission)), + bid: Some(Bid::new(req.version, &req.submission, req.parent_beacon_block_root)), result: result.map(|()| req.trace), submission_id: req.submission_id, // Never dispatched: zero skips sim telemetry in `emit_sim_outcome`. @@ -683,7 +683,7 @@ impl Simulators { record_submission_step("simulation", start_sim.elapsed()); let error = res.as_ref().err().cloned(); - let bid = Bid::new(version, &submission); + let bid = Bid::new(version, &submission, req.parent_beacon_block_root); SimulatorMetrics::sim_builder_outcome( &bid.builder_pubkey.to_string(), req.priority.label(), From b566013807245a35ae52d781d1b1633f3eba49d5 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 25 Sep 2026 13:23:40 +0100 Subject: [PATCH 15/33] Encode the V1 dehydrated shape in the Gloas dehydrated decode test --- crates/types/src/hydration.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/types/src/hydration.rs b/crates/types/src/hydration.rs index eee5798d6..fb11ea5a5 100644 --- a/crates/types/src/hydration.rs +++ b/crates/types/src/hydration.rs @@ -905,15 +905,12 @@ mod tests { #[test] fn dehydrated_bid_submission_decodes_under_gloas_fork() { - let (dehydrated, _) = - DehydratedBidSubmissionFuluWithMergingData::random_for_test(&mut rand::rng()).split(); - let inner_bytes = match &dehydrated { - DehydratedBidSubmission::Fulu(inner) => inner.as_ssz_bytes(), - }; + let v1 = DehydratedBidSubmissionFuluV1::random_for_test(&mut rand::rng()); + let bytes = v1.as_ssz_bytes(); + let dehydrated = DehydratedBidSubmission::Fulu(v1.into()); - let decoded = - DehydratedBidSubmission::from_ssz_bytes_by_fork(&inner_bytes, ForkName::Gloas) - .expect("Gloas should decode via the same shape as Fulu"); + let decoded = DehydratedBidSubmission::from_ssz_bytes_by_fork(&bytes, ForkName::Gloas) + .expect("Gloas should decode via the same shape as Fulu"); assert!(matches!(decoded, DehydratedBidSubmission::Fulu(_))); assert_eq!(decoded.bid_trace(), dehydrated.bid_trace()); From 2baef6f29a73541d6d11f10a3eb1e5a6ddced2f7 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 18 Sep 2026 16:54:59 +0100 Subject: [PATCH 16/33] Track Gloas proposer preferences and bind the bid request to the slot --- crates/common/src/beacon/types/chain.rs | 29 ++- crates/relay/src/api/proposer/error.rs | 10 +- .../auctioneer/get_execution_payload_bid.rs | 72 +++++- crates/relay/src/auctioneer/validation.rs | 52 +++- crates/relay/src/housekeeper/mod.rs | 1 + .../relay/src/housekeeper/proposer_prefs.rs | 244 ++++++++++++++++++ crates/relay/src/housekeeper/tile.rs | 90 ++++++- 7 files changed, 481 insertions(+), 17 deletions(-) create mode 100644 crates/relay/src/housekeeper/proposer_prefs.rs diff --git a/crates/common/src/beacon/types/chain.rs b/crates/common/src/beacon/types/chain.rs index 490eddca8..27ae3e6aa 100644 --- a/crates/common/src/beacon/types/chain.rs +++ b/crates/common/src/beacon/types/chain.rs @@ -1,5 +1,5 @@ -use alloy_primitives::{B256, hex}; -use helix_types::{Slot, Withdrawals}; +use alloy_primitives::{Address, B256, hex}; +use helix_types::{BlsSignatureBytes, Slot, Withdrawals}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] @@ -70,6 +70,31 @@ pub struct HeadEventData { pub state: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProposerPreferencesEvent { + pub version: String, + pub data: SignedProposerPreferences, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SignedProposerPreferences { + pub message: ProposerPreferences, + pub signature: BlsSignatureBytes, +} + +/// Gossiped once per proposal slot, per +/// . +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] +pub struct ProposerPreferences { + pub dependent_root: B256, + pub proposal_slot: Slot, + #[serde(with = "serde_utils::quoted_u64")] + pub validator_index: u64, + pub fee_recipient: Address, + #[serde(with = "serde_utils::quoted_u64")] + pub target_gas_limit: u64, +} + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct PayloadAttributesEvent { pub version: String, diff --git a/crates/relay/src/api/proposer/error.rs b/crates/relay/src/api/proposer/error.rs index 155d863e2..cffb37f3a 100644 --- a/crates/relay/src/api/proposer/error.rs +++ b/crates/relay/src/api/proposer/error.rs @@ -5,7 +5,7 @@ use axum::{ }; use helix_common::{beacon::BeaconClientError, local_cache::AuctioneerError}; use helix_database::error::DatabaseError; -use helix_types::{SigError, Slot, SszError}; +use helix_types::{BlsPublicKeyBytes, SigError, Slot, SszError}; use hyper::StatusCode; use ssz::DecodeError; use thiserror::Error; @@ -27,6 +27,12 @@ pub enum ProposerApiError { #[error("not the expected proposer index. expected {expected}, got {actual}")] UnexpectedProposerIndex { expected: u64, actual: u64 }, + #[error("bid requested for slot {actual}, the relay is bidding for {expected}")] + BidRequestSlotMismatch { expected: u64, actual: u64 }, + + #[error("bid requested by {actual}, the proposer for the slot is {expected}")] + UnexpectedProposerPubkey { expected: BlsPublicKeyBytes, actual: BlsPublicKeyBytes }, + #[error("no validators could be registered")] NoValidatorsCouldBeRegistered, @@ -185,6 +191,8 @@ impl IntoResponse for ProposerApiError { ProposerApiError::AxumError(_) | ProposerApiError::ToStrError(_) | ProposerApiError::UnexpectedProposerIndex { .. } | + ProposerApiError::BidRequestSlotMismatch { .. } | + ProposerApiError::UnexpectedProposerPubkey { .. } | ProposerApiError::NoValidatorsCouldBeRegistered | ProposerApiError::InvalidFork | ProposerApiError::SerdeDecodeError(_) | diff --git a/crates/relay/src/auctioneer/get_execution_payload_bid.rs b/crates/relay/src/auctioneer/get_execution_payload_bid.rs index 1843f135e..3729aa2a2 100644 --- a/crates/relay/src/auctioneer/get_execution_payload_bid.rs +++ b/crates/relay/src/auctioneer/get_execution_payload_bid.rs @@ -44,6 +44,21 @@ pub(super) fn check_execution_payload_bid_liveness( params: &GetExecutionPayloadBidParams, slot_data: &SlotData, ) -> Result<(), ProposerApiError> { + if params.slot != slot_data.bid_slot.as_u64() { + return Err(ProposerApiError::BidRequestSlotMismatch { + expected: slot_data.bid_slot.as_u64(), + actual: params.slot, + }); + } + + let expected_proposer = *slot_data.proposer_pubkey(); + if params.proposer_pubkey != expected_proposer { + return Err(ProposerApiError::UnexpectedProposerPubkey { + expected: expected_proposer, + actual: params.proposer_pubkey, + }); + } + let fork = ForkKey { parent_hash: params.parent_hash, parent_root: params.parent_root }; if !slot_data.payload_attributes_map.contains_key(&fork) { warn!( @@ -104,13 +119,15 @@ pub(super) fn build_signed_bid( mod tests { use alloy_primitives::B256; use helix_common::PayloadAttributesUpdate; - use helix_types::{Domain, EthSpec, ForkName, SignedRoot, TestRandomSeed}; + use helix_types::{BlsPublicKeyBytes, Domain, EthSpec, ForkName, SignedRoot, TestRandomSeed}; use super::*; + const BID_SLOT: u64 = 1; + fn slot_data(attrs: Vec) -> SlotData { SlotData { - bid_slot: Default::default(), + bid_slot: Slot::new(BID_SLOT), registration_data: Default::default(), current_fork: ForkName::Gloas, payload_attributes_map: attrs.into_iter().map(|a| (a.fork(), a)).collect(), @@ -118,6 +135,10 @@ mod tests { } } + fn live_slot_data(parent_hash: B256, parent_root: B256) -> SlotData { + slot_data(vec![attrs_update(parent_hash, Some(parent_root))]) + } + fn attrs_update( parent_hash: B256, parent_beacon_block_root: Option, @@ -311,4 +332,51 @@ mod tests { .verify(&identity.keypair.pk, signed_bid.message.signing_root(domain)) ); } + + #[test] + fn refuses_a_bid_request_for_another_slot() { + let parent_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let data = live_slot_data(parent_hash, parent_root); + let mut params = params(parent_hash, parent_root); + params.slot = BID_SLOT + 1; + + let result = check_execution_payload_bid_liveness(¶ms, &data); + + assert!( + matches!( + result, + Err(ProposerApiError::BidRequestSlotMismatch { expected, actual }) + if expected == BID_SLOT && actual == BID_SLOT + 1 + ), + "the relay must not sign a bid for a slot it is not bidding for", + ); + } + + #[test] + fn refuses_a_bid_request_from_another_proposer() { + let parent_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let data = live_slot_data(parent_hash, parent_root); + let mut params = params(parent_hash, parent_root); + params.proposer_pubkey = BlsPublicKeyBytes::from([7u8; 48]); + + let result = check_execution_payload_bid_liveness(¶ms, &data); + + assert!( + matches!(result, Err(ProposerApiError::UnexpectedProposerPubkey { .. })), + "a valid request auth proves the key, not the right to this slot", + ); + } + + #[test] + fn accepts_the_slots_own_proposer() { + let parent_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let data = live_slot_data(parent_hash, parent_root); + + let result = check_execution_payload_bid_liveness(¶ms(parent_hash, parent_root), &data); + + assert!(result.is_ok(), "the expected slot and proposer must pass"); + } } diff --git a/crates/relay/src/auctioneer/validation.rs b/crates/relay/src/auctioneer/validation.rs index 4be5f737d..d36fdc14b 100644 --- a/crates/relay/src/auctioneer/validation.rs +++ b/crates/relay/src/auctioneer/validation.rs @@ -1,7 +1,9 @@ use alloy_primitives::B256; use flux_profiler::timed; use helix_common::{BuilderInfo, PayloadAttributesUpdate, is_local_dev}; -use helix_types::{BlockValidationError, BlsPublicKeyBytes, Submission, SubmissionVersion}; +use helix_types::{ + BlockValidationError, BlsPublicKeyBytes, ForkName, Submission, SubmissionVersion, +}; use crate::auctioneer::{ bid_adjustor::BidAdjustor, @@ -51,6 +53,7 @@ impl Context { self.staleness_check(submission.builder_pubkey(), submission_data.version)?; self.validate_submission_data( submission, + submission_data.decoder_params.fork_name, &withdrawals_root, slot_data, payload_attributes, @@ -64,15 +67,12 @@ impl Context { fn validate_submission_data( &self, payload: &Submission, + decoded_fork: ForkName, withdrawals_root: &B256, slot_data: &SlotData, payload_attributes: &PayloadAttributesUpdate, ) -> Result<(), BlockValidationError> { - if slot_data.current_fork != payload.fork_name() { - return Err(BlockValidationError::InvalidPayloadType { - fork_name: slot_data.current_fork, - }); - } + check_submission_fork(decoded_fork, slot_data.current_fork)?; // checks internal consistency of the payload payload.validate()?; @@ -167,6 +167,18 @@ pub fn check_if_trusted_builder( } } +/// The decoder records the fork it decoded with; a submission shaped for another fork cannot be +/// validated against this slot. +pub(super) fn check_submission_fork( + decoded: ForkName, + slot_fork: ForkName, +) -> Result<(), BlockValidationError> { + if decoded != slot_fork { + return Err(BlockValidationError::InvalidPayloadType { fork_name: slot_fork }); + } + Ok(()) +} + #[cfg(test)] mod tests { use helix_common::{ @@ -175,9 +187,12 @@ mod tests { builder_api::BuilderGetValidatorsResponseEntry, proposer_api::ValidatorRegistrationInfo, }, }; - use helix_types::ForkName; + use helix_types::{BlockValidationError, ForkName}; - use crate::auctioneer::{types::SlotData, validation::check_if_trusted_builder}; + use crate::auctioneer::{ + types::SlotData, + validation::{check_if_trusted_builder, check_submission_fork}, + }; #[test] fn test_check_if_trusted_builder_empty_list() { @@ -276,4 +291,25 @@ mod tests { assert!(check_if_trusted_builder(&builder_info, &slot_data).is_err()); } + + #[test] + fn a_gloas_submission_passes_the_fork_gate_on_a_gloas_slot() { + assert!( + check_submission_fork(ForkName::Gloas, ForkName::Gloas).is_ok(), + "the decoder decoded a Gloas submission for a Gloas slot", + ); + } + + #[test] + fn a_submission_decoded_for_another_fork_is_refused() { + let result = check_submission_fork(ForkName::Fulu, ForkName::Gloas); + + assert!( + matches!( + result, + Err(BlockValidationError::InvalidPayloadType { fork_name: ForkName::Gloas }) + ), + "the slot's fork is the one the submission had to match", + ); + } } diff --git a/crates/relay/src/housekeeper/mod.rs b/crates/relay/src/housekeeper/mod.rs index dde96804e..abca87572 100644 --- a/crates/relay/src/housekeeper/mod.rs +++ b/crates/relay/src/housekeeper/mod.rs @@ -2,6 +2,7 @@ mod chain_head; mod duties; mod payload_attrs; pub mod primev_service; +mod proposer_prefs; pub mod tile; pub mod inclusion_list_service; diff --git a/crates/relay/src/housekeeper/proposer_prefs.rs b/crates/relay/src/housekeeper/proposer_prefs.rs new file mode 100644 index 000000000..c7d54504f --- /dev/null +++ b/crates/relay/src/housekeeper/proposer_prefs.rs @@ -0,0 +1,244 @@ +use helix_common::{ + ProposerDuty, ValidatorPreferences, + api::{ + builder_api::BuilderGetValidatorsResponseEntry, proposer_api::ValidatorRegistrationInfo, + }, + beacon::types::{ProposerPreferences, ProposerPreferencesEvent}, +}; +use helix_types::{SignedValidatorRegistration, Slot}; +use rustc_hash::FxHashMap; + +#[derive(Default)] +pub struct ProposerPreferencesStore { + by_slot: FxHashMap, +} + +impl ProposerPreferencesStore { + /// The proposer may resubmit up to an epoch ahead, so a later event wins. + pub fn process(&mut self, head: Slot, event: ProposerPreferencesEvent) { + let prefs = event.data.message; + if prefs.proposal_slot <= head { + return; + } + self.by_slot.insert(prefs.proposal_slot, prefs); + } + + pub fn get(&self, slot: Slot) -> Option<&ProposerPreferences> { + self.by_slot.get(&slot) + } + + pub fn on_new_slot(&mut self, bid_slot: Slot) { + self.by_slot.retain(|slot, _| *slot >= bid_slot); + } + + #[cfg(test)] + fn len(&self) -> usize { + self.by_slot.len() + } +} + +/// Gloas has no `registerValidator`, so the entry comes from the beacon duty and the gossiped +/// preferences, with this relay's configured preferences as the proposer cannot express its own. +pub fn synthesize_registration( + duty: &ProposerDuty, + prefs: &ProposerPreferences, + defaults: &ValidatorPreferences, +) -> BuilderGetValidatorsResponseEntry { + let mut registration = SignedValidatorRegistration::default(); + registration.message.pubkey = duty.pubkey; + registration.message.fee_recipient = prefs.fee_recipient; + registration.message.gas_limit = prefs.target_gas_limit; + + BuilderGetValidatorsResponseEntry { + slot: duty.slot, + validator_index: duty.validator_index, + entry: ValidatorRegistrationInfo { registration, preferences: defaults.clone() }, + } +} + +/// The duty feed builders poll. Gloas has no registrations, so a slot is served only once its +/// proposer has gossiped preferences for it. +pub fn synthesize_duty_feed( + beacon_duties: &[ProposerDuty], + prefs: &ProposerPreferencesStore, + from_slot: Slot, + defaults: &ValidatorPreferences, +) -> Vec { + beacon_duties + .iter() + .filter(|duty| duty.slot >= from_slot) + .filter_map(|duty| { + prefs.get(duty.slot).map(|prefs| synthesize_registration(duty, prefs, defaults)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256, address}; + + use super::*; + + const LIVE_EVENT: &str = r#"{ + "version": "gloas", + "data": { + "message": { + "dependent_root": "0x0771be60148934328db0a9078a555c7ce7885f54a34d8c3c998ab7aafc5dfc39", + "proposal_slot": "254171", + "validator_index": "47100", + "fee_recipient": "0xf97e180c050e5ab072211ad2c213eb5aee4df134", + "target_gas_limit": "200000000" + }, + "signature": "0xb36623b3b48d160aeaa1f088d0a60877283f32f0ff1dd375e351c0f600c56f9c888abe926258dabfeff3c67a9818955e0c2e48ce8b4a15ec59bb56cf6e9a95029df258a787191eec7e87d8d5996f8c1e4ec4524b3a37b6f05d5f78ce50d3eec9" + } + }"#; + + fn event(slot: u64, fee_recipient: Address, target_gas_limit: u64) -> ProposerPreferencesEvent { + let mut ev: ProposerPreferencesEvent = serde_json::from_str(LIVE_EVENT).unwrap(); + ev.data.message.proposal_slot = Slot::new(slot); + ev.data.message.fee_recipient = fee_recipient; + ev.data.message.target_gas_limit = target_gas_limit; + ev + } + + #[test] + fn parses_a_live_proposer_preferences_event() { + let ev: ProposerPreferencesEvent = serde_json::from_str(LIVE_EVENT).unwrap(); + + assert_eq!(ev.data.message.proposal_slot, Slot::new(254171)); + assert_eq!(ev.data.message.validator_index, 47100); + assert_eq!( + ev.data.message.fee_recipient, + address!("f97e180c050e5ab072211ad2c213eb5aee4df134") + ); + assert_eq!(ev.data.message.target_gas_limit, 200_000_000); + assert_eq!( + ev.data.message.dependent_root, + B256::from_slice(&alloy_primitives::hex!( + "0771be60148934328db0a9078a555c7ce7885f54a34d8c3c998ab7aafc5dfc39" + )) + ); + } + + #[test] + fn synthesizes_the_entry_from_the_duty_and_the_gossiped_preferences() { + let duty = ProposerDuty { + pubkey: helix_types::BlsPublicKeyBytes::from([9u8; 48]), + validator_index: 85292, + slot: Slot::new(101), + }; + let fee_recipient = address!("00000000000000000000000000000000000000aa"); + let prefs = ProposerPreferences { + fee_recipient, + target_gas_limit: 45_000_000, + ..Default::default() + }; + let defaults = ValidatorPreferences { header_delay: false, ..Default::default() }; + + let entry = synthesize_registration(&duty, &prefs, &defaults); + + assert_eq!(entry.slot, Slot::new(101)); + assert_eq!(entry.validator_index, 85292); + assert_eq!(entry.entry.registration.message.pubkey, duty.pubkey, "from the beacon duty"); + assert_eq!(entry.entry.registration.message.fee_recipient, fee_recipient, "from gossip"); + assert_eq!(entry.entry.registration.message.gas_limit, 45_000_000, "from gossip"); + assert!(!entry.entry.preferences.header_delay, "the relay's own preferences apply"); + } + + #[test] + fn keeps_the_preference_for_a_future_slot() { + let mut store = ProposerPreferencesStore::default(); + let fee_recipient = address!("00000000000000000000000000000000000000aa"); + + store.process(Slot::new(100), event(101, fee_recipient, 45_000_000)); + + let prefs = store.get(Slot::new(101)).expect("a future slot must be kept"); + assert_eq!(prefs.fee_recipient, fee_recipient); + assert_eq!(prefs.target_gas_limit, 45_000_000); + assert_eq!(prefs.validator_index, 47100); + } + + #[test] + fn drops_a_preference_for_a_passed_slot() { + let mut store = ProposerPreferencesStore::default(); + let fee_recipient = address!("00000000000000000000000000000000000000aa"); + + store.process(Slot::new(100), event(100, fee_recipient, 45_000_000)); + + assert_eq!(store.len(), 0, "the relay cannot bid for a slot that has started"); + } + + #[test] + fn a_later_event_replaces_the_slots_preference() { + let mut store = ProposerPreferencesStore::default(); + let first = address!("00000000000000000000000000000000000000aa"); + let second = address!("00000000000000000000000000000000000000bb"); + + store.process(Slot::new(100), event(101, first, 45_000_000)); + store.process(Slot::new(100), event(101, second, 60_000_000)); + + let prefs = store.get(Slot::new(101)).expect("the slot must still be known"); + assert_eq!(prefs.fee_recipient, second, "the proposer's latest word wins"); + assert_eq!(prefs.target_gas_limit, 60_000_000); + assert_eq!(store.len(), 1); + } + + #[test] + fn prunes_passed_slots_on_a_new_slot() { + let mut store = ProposerPreferencesStore::default(); + let fee_recipient = address!("00000000000000000000000000000000000000aa"); + + store.process(Slot::new(100), event(101, fee_recipient, 45_000_000)); + store.process(Slot::new(100), event(102, fee_recipient, 45_000_000)); + store.on_new_slot(Slot::new(102)); + + assert!(store.get(Slot::new(101)).is_none(), "slot 101 has passed"); + assert!(store.get(Slot::new(102)).is_some(), "slot 102 is the bid slot"); + assert_eq!(store.len(), 1); + } + + fn duty(slot: u64, index: u64) -> ProposerDuty { + ProposerDuty { + pubkey: helix_types::BlsPublicKeyBytes::from([index as u8; 48]), + validator_index: index, + slot: Slot::new(slot), + } + } + + fn store_with(slots: &[u64]) -> ProposerPreferencesStore { + let mut store = ProposerPreferencesStore::default(); + for slot in slots { + store.process( + Slot::new(0), + event(*slot, address!("00000000000000000000000000000000000000aa"), 45_000_000), + ); + } + store + } + + #[test] + fn serves_only_the_slots_with_a_gossiped_preference() { + let duties = [duty(101, 1), duty(102, 2), duty(103, 3)]; + let store = store_with(&[101, 103]); + + let feed = + synthesize_duty_feed(&duties, &store, Slot::new(101), &ValidatorPreferences::default()); + + let slots: Vec = feed.iter().map(|e| e.slot.as_u64()).collect(); + assert_eq!(slots, vec![101, 103], "a slot without preferences cannot be served"); + assert_eq!(feed[0].validator_index, 1); + assert_eq!(feed[1].validator_index, 3); + } + + #[test] + fn drops_duties_before_the_bid_slot() { + let duties = [duty(100, 1), duty(101, 2)]; + let store = store_with(&[100, 101]); + + let feed = + synthesize_duty_feed(&duties, &store, Slot::new(101), &ValidatorPreferences::default()); + + let slots: Vec = feed.iter().map(|e| e.slot.as_u64()).collect(); + assert_eq!(slots, vec![101], "builders cannot build a slot that has started"); + } +} diff --git a/crates/relay/src/housekeeper/tile.rs b/crates/relay/src/housekeeper/tile.rs index 6ac30f4c7..6bfda93dd 100644 --- a/crates/relay/src/housekeeper/tile.rs +++ b/crates/relay/src/housekeeper/tile.rs @@ -11,18 +11,21 @@ use flux::{ use flux_utils::SharedVector; use helix_common::{ CurrentSlotInfo, ForkKey, InclusionListConfig, PayloadAttributesUpdate, PrimevConfig, - ProposerDuty, RelayConfig, SlotDuties, ValidatorSummary, + ProposerDuty, RelayConfig, SlotDuties, ValidatorPreferences, ValidatorSummary, api::builder_api::{BuilderGetValidatorsResponseEntry, InclusionListWithMetadata}, beacon::{ MultiBeaconClient, - types::{BeaconResponse, HeadEventData, PayloadAttributesEvent, SyncStatus}, + types::{ + BeaconResponse, HeadEventData, PayloadAttributesEvent, ProposerPreferencesEvent, + SyncStatus, + }, }, chain_info::ChainInfo, http::client::{HttpClient, PendingResponse, SseStream}, local_cache::LocalCache, metrics::{DUTIES_AGE_SECONDS, DUTIES_FETCH, DUTIES_LOOKAHEAD_SLOTS}, }; -use helix_types::Slot; +use helix_types::{ForkName, Slot}; use rustc_hash::FxHashMap; use tracing::{debug, error, info, warn}; @@ -37,6 +40,7 @@ use crate::{ PRIMEV_BUILDER_ID, PrimevBuildersFetch, PrimevValidatorsFetch, build_primev_builder_configs, }, + proposer_prefs::{ProposerPreferencesStore, synthesize_duty_feed, synthesize_registration}, }, network::RelayNetworkManager, spine::messages::SlotMsg, @@ -66,6 +70,8 @@ struct HousekeeperStats { head_sse_events: u32, head_sse_parse_errors: u32, payload_attr_sse_events: u32, + proposer_prefs_sse_events: u32, + proposer_prefs_parse_errors: u32, payload_attr_parse_errors: u32, duties_fetch_ok: u32, duties_fetch_empty: u32, @@ -95,7 +101,10 @@ pub struct HousekeeperTile { beacon_client: Arc, head_sse: Vec, payload_attr_sse: Vec, + proposer_prefs_sse: Vec, + proposer_prefs: ProposerPreferencesStore, primev_config: Option, + validator_preferences: ValidatorPreferences, il_config: Option, // Output @@ -168,13 +177,26 @@ impl HousekeeperTile { }) .collect(); + let proposer_prefs_sse = beacon_client + .beacon_clients + .iter() + .map(|c| { + let mut url = c.config.url.join("/eth/v1/events").unwrap(); + url.set_query(Some("topics=proposer_preferences")); + http_client.sse_stream(url) + }) + .collect(); + let tile = Self { chain_head, http_client, beacon_client, head_sse, payload_attr_sse, + proposer_prefs_sse, + proposer_prefs: ProposerPreferencesStore::default(), primev_config: config.primev_config.clone(), + validator_preferences: config.validator_preferences.clone(), il_config: config.inclusion_list.clone(), slot_events, local_cache, @@ -203,11 +225,39 @@ impl HousekeeperTile { (tile, curr_slot_info) } + /// Gloas proposers never register, so the feed builders poll is filled from the beacon + /// duties and the gossiped preferences, without displacing a real registration. + fn refresh_gloas_duty_feed(&self) { + let from_slot = self.chain_head.head() + 1; + if self.chain_head.chain_info().fork_at_slot(from_slot) != ForkName::Gloas { + return; + } + + let synthesized = synthesize_duty_feed( + &self.duties, + &self.proposer_prefs, + from_slot, + &self.validator_preferences, + ); + if synthesized.is_empty() { + return; + } + + let mut feed = self.local_cache.get_proposer_duties(); + let known: rustc_hash::FxHashSet = feed.iter().map(|e| e.slot.as_u64()).collect(); + feed.extend(synthesized.into_iter().filter(|e| !known.contains(&e.slot.as_u64()))); + feed.sort_by_key(|e| e.slot.as_u64()); + self.local_cache.update_proposer_duties(feed); + } + fn on_new_slot(&mut self) { let slot = self.chain_head.head(); info!(%slot, "new slot started"); self.duties_fetch_attempt = 0; + self.proposer_prefs.on_new_slot(slot + 1); + self.refresh_gloas_duty_feed(); + self.fetch_duties(); self.maybe_fetch_il(); self.report_duties_age(); @@ -318,6 +368,19 @@ impl Tile for HousekeeperTile { // or same iteration). is_new_slot() returned false for this slot. self.on_new_slot(); } + for stream in &mut self.proposer_prefs_sse { + if let Poll::Ready(ev) = stream.poll() { + self.stats.proposer_prefs_sse_events += 1; + match serde_json::from_str::(&ev.data) { + Ok(data) => self.proposer_prefs.process(self.chain_head.head(), data), + Err(e) => { + self.stats.proposer_prefs_parse_errors += 1; + error!(err = %e, "proposer_preferences SSE parse error"); + } + } + } + } + for stream in &mut self.payload_attr_sse { if let Poll::Ready(ev) = stream.poll() { self.stats.payload_attr_sse_events += 1; @@ -373,6 +436,7 @@ impl Tile for HousekeeperTile { self.chain_head.epoch().as_u64(), ); self.duties = duties; + self.refresh_gloas_duty_feed(); self.chain_head.mark_duties_done(); if root_changed { self.stats.duties_dependent_root_changed += 1; @@ -583,6 +647,9 @@ impl Tile for HousekeeperTile { &self.curr_slot_info, &self.slot_events, &self.known_payload_attributes, + &self.proposer_prefs, + &self.duties, + &self.validator_preferences, self.pending_il.take(), std::mem::take(&mut self.stats), ); @@ -602,6 +669,9 @@ fn send_slot_event( curr_slot_info: &CurrentSlotInfo, slot_events: &SharedVector, known_payload_attributes: &FxHashMap<(ForkKey, Slot), PayloadAttributesUpdate>, + proposer_prefs: &ProposerPreferencesStore, + beacon_duties: &[ProposerDuty], + validator_preferences: &ValidatorPreferences, il: Option, stats: HousekeeperStats, ) { @@ -620,7 +690,16 @@ fn send_slot_event( } } - let next_duty = new_duties.iter().find(|d| d.slot.as_u64() == bid_slot.as_u64()).cloned(); + let mut next_duty = new_duties.iter().find(|d| d.slot.as_u64() == bid_slot.as_u64()).cloned(); + + // Gloas drops `registerValidator`, so the entry comes from the duty and the gossiped prefs. + if next_duty.is_none() && chain_head.chain_info().fork_at_slot(bid_slot) == ForkName::Gloas { + next_duty = beacon_duties + .iter() + .find(|d| d.slot.as_u64() == bid_slot.as_u64()) + .zip(proposer_prefs.get(bid_slot)) + .map(|(duty, prefs)| synthesize_registration(duty, prefs, validator_preferences)); + } let next_payload_attributes: Vec = known_payload_attributes .iter() .filter_map( @@ -633,10 +712,13 @@ fn send_slot_event( duties_available = !new_duties.is_empty(), has_next_duty = next_duty.is_some(), has_payload_attrs = !next_payload_attributes.is_empty(), + has_proposer_prefs = proposer_prefs.get(bid_slot).is_some(), head_sse_events = stats.head_sse_events, head_sse_parse_errors = stats.head_sse_parse_errors, payload_attr_sse_events = stats.payload_attr_sse_events, payload_attr_parse_errors = stats.payload_attr_parse_errors, + proposer_prefs_sse_events = stats.proposer_prefs_sse_events, + proposer_prefs_parse_errors = stats.proposer_prefs_parse_errors, duties_fetch_ok = stats.duties_fetch_ok, duties_fetch_empty = stats.duties_fetch_empty, duties_fetch_err = stats.duties_fetch_err, From 12815da6da049b7979cb7f8a4ff5046a32d43afd Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 18 Sep 2026 12:11:13 +0100 Subject: [PATCH 17/33] Set the EIP-7928 post-tx index before requests and withdrawals --- crates/builder/src/building/assemble.rs | 13 +++++++++ crates/builder/src/building/assemble/tests.rs | 29 +++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/builder/src/building/assemble.rs b/crates/builder/src/building/assemble.rs index 58c323d19..581d483df 100644 --- a/crates/builder/src/building/assemble.rs +++ b/crates/builder/src/building/assemble.rs @@ -173,6 +173,19 @@ pub fn build( return Err(BuildError::PayoutReverted); } + // EIP-7928: the requests and withdrawals phase records under index n+1, and every + // withdrawal recipient counts as touched. Mirrors ethrex's `build_payload`. + if is_amsterdam { + let post_tx_index = + u32::try_from(ctx.payload.body.transactions.len() + 1).unwrap_or(u32::MAX); + ctx.vm.set_bal_index(post_tx_index); + if let Some(recorder) = ctx.vm.db.bal_recorder_mut() && + let Some(withdrawals) = &ctx.payload.body.withdrawals + { + recorder.extend_touched_addresses(withdrawals.iter().map(|w| w.address)); + } + } + blockchain .extract_requests(&mut ctx) .map_err(|e| BuildError::Internal(format!("extract requests: {e}")))?; diff --git a/crates/builder/src/building/assemble/tests.rs b/crates/builder/src/building/assemble/tests.rs index e417fe00e..25f7a5935 100644 --- a/crates/builder/src/building/assemble/tests.rs +++ b/crates/builder/src/building/assemble/tests.rs @@ -562,18 +562,17 @@ async fn a_recipient_created_earlier_in_the_block_needs_no_extra_reserve() { /// The strongest check available without a relay: build a block, submit it the /// way the relay would receive it, and validate it with our own simulation /// role. A disagreement between steps 3 and 4 shows up here and nowhere else. -async fn our_simulator_accepts_our_own_block(fixture: &Fixture) { +async fn our_simulator_accepts_our_own_block(fixture: &Fixture, slot: &SlotContext) { use axum::http::StatusCode; use tower::ServiceExt; - let slot = fixture.slot(); - let built = fixture.build(&slot, &fixture.config()).unwrap(); + let built = fixture.build(slot, &fixture.config()).unwrap(); let bid = crate::building::submit::Submitter::new( "http://localhost:1", "key".to_string(), fixture.signing(), ) - .sign(&built, &slot) + .sign(&built, slot) .expect("a block we built must be submittable"); let request = helix_common::simulator::SszValidationRequest { @@ -601,10 +600,28 @@ async fn our_simulator_accepts_our_own_block(fixture: &Fixture) { #[tokio::test] async fn our_simulation_role_accepts_our_gloas_block() { - our_simulator_accepts_our_own_block(&Fixture::amsterdam().await).await; + let fixture = Fixture::amsterdam().await; + let slot = fixture.slot(); + our_simulator_accepts_our_own_block(&fixture, &slot).await; } #[tokio::test] async fn our_simulation_role_accepts_our_fulu_block() { - our_simulator_accepts_our_own_block(&Fixture::new().await).await; + let fixture = Fixture::new().await; + let slot = fixture.slot(); + our_simulator_accepts_our_own_block(&fixture, &slot).await; +} + +#[tokio::test] +async fn our_simulation_role_accepts_our_gloas_block_with_withdrawals() { + let fixture = Fixture::amsterdam().await; + let mut slot = fixture.slot(); + slot.withdrawals = Withdrawals::new(vec![Withdrawal { + index: 1, + validator_index: 2, + address: Address::repeat_byte(0x66), + amount: 32_000_000_000, + }]) + .unwrap(); + our_simulator_accepts_our_own_block(&fixture, &slot).await; } From e5a0594aea09888020fcbb8a3c334eb78b17f169 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 18 Sep 2026 14:25:37 +0100 Subject: [PATCH 18/33] Cancel a slot's schedule when a newer head arrives --- crates/builder/src/building/mod.rs | 57 +++++------ crates/builder/src/building/schedule.rs | 122 +++++++++++++++++++++++- 2 files changed, 151 insertions(+), 28 deletions(-) diff --git a/crates/builder/src/building/mod.rs b/crates/builder/src/building/mod.rs index 0462259fb..61d3ae370 100644 --- a/crates/builder/src/building/mod.rs +++ b/crates/builder/src/building/mod.rs @@ -46,30 +46,27 @@ pub async fn build_blocks( payout_signer: PrivateKeySigner, signing: RelaySigningContext, chain_id: u64, - mut contexts: mpsc::Receiver, + contexts: mpsc::Receiver, ) { - let submitter = submit::Submitter::new(&config.relay_url, config.api_key.clone(), signing); - let mut best = BestBid::default(); + let submitter = + Arc::new(submit::Submitter::new(&config.relay_url, config.api_key.clone(), signing)); + let best = Arc::new(std::sync::Mutex::new(BestBid::default())); + let offsets = config.submit_offsets_ms.clone(); - while let Some(slot) = contexts.recv().await { - best.prune(slot.slot); - - // Each delay is measured from the same instant, so they must not be - // slept end to end. - let base = tokio::time::Instant::now(); - for delay in schedule::delays(slot.timestamp, &config.submit_offsets_ms, now_ms()) { - tokio::time::sleep_until(base + delay).await; - - let (build_config, store, blockchain, signer, build_slot) = ( - config.clone(), - store.clone(), - blockchain.clone(), - payout_signer.clone(), - slot.clone(), - ); + let attempt = move |slot: SlotContext| { + let (config, store, blockchain, signer, submitter, best) = ( + config.clone(), + store.clone(), + blockchain.clone(), + payout_signer.clone(), + submitter.clone(), + best.clone(), + ); + async move { + let build_slot = slot.clone(); // Building is CPU-bound and must not stall the runtime. let built = tokio::task::spawn_blocking(move || { - assemble::build(&store, &blockchain, &build_slot, &build_config, &signer, chain_id) + assemble::build(&store, &blockchain, &build_slot, &config, &signer, chain_id) }) .await; @@ -77,24 +74,28 @@ pub async fn build_blocks( Ok(Ok(built)) => built, Ok(Err(e)) => { warn!(slot = slot.slot, err = %e, "skipping slot"); - continue; + return; } Err(e) => { error!(slot = slot.slot, err = %e, "build task panicked"); - continue; + return; } }; - if !best.improves(slot.slot, slot.parent_hash, built.value) { - debug!(slot = slot.slot, value = %built.value, "not an improvement"); - continue; + { + let mut best = best.lock().expect("bid tracker mutex"); + best.prune(slot.slot); + if !best.improves(slot.slot, slot.parent_hash, built.value) { + debug!(slot = slot.slot, value = %built.value, "not an improvement"); + return; + } } let bid = match submitter.sign(&built, &slot) { Ok(bid) => bid, Err(e) => { warn!(slot = slot.slot, err = %e, "cannot sign the block"); - continue; + return; } }; @@ -110,7 +111,9 @@ pub async fn build_blocks( Err(e) => warn!(slot = slot.slot, err = %e, "the relay refused the block"), } } - } + }; + + schedule::drive(contexts, &offsets, now_ms, attempt).await; } fn now_ms() -> u64 { diff --git a/crates/builder/src/building/schedule.rs b/crates/builder/src/building/schedule.rs index 904313a07..200d764c2 100644 --- a/crates/builder/src/building/schedule.rs +++ b/crates/builder/src/building/schedule.rs @@ -1,7 +1,10 @@ -use std::time::Duration; +use std::{future::Future, time::Duration}; use alloy_primitives::{B256, U256}; use rustc_hash::FxHashMap; +use tokio::sync::mpsc; + +use crate::building::slot::SlotContext; /// How long to wait before each build attempt, measured from `now_ms`. /// @@ -24,6 +27,47 @@ pub fn delays(slot_timestamp: u64, offsets: &[u64], now_ms: u64) -> Vec(slot: SlotContext, delays: Vec, attempt: F) +where + F: Fn(SlotContext) -> Fut, + Fut: Future, +{ + let base = tokio::time::Instant::now(); + for delay in delays { + tokio::time::sleep_until(base + delay).await; + attempt(slot.clone()).await; + } +} + +/// Drives one schedule at a time. A new context supersedes the one in flight: +/// the head moved, so every remaining attempt would bid on a parent the relay +/// has already replaced. +pub async fn drive( + mut contexts: mpsc::Receiver, + offsets: &[u64], + now_ms: N, + attempt: F, +) where + F: Fn(SlotContext) -> Fut + Clone + Send + 'static, + Fut: Future + Send + 'static, + N: Fn() -> u64, +{ + let mut current: Option> = None; + + while let Some(slot) = contexts.recv().await { + if let Some(handle) = current.take() { + handle.abort(); + } + let delays = delays(slot.timestamp, offsets, now_ms()); + current = Some(tokio::spawn(run_schedule(slot, delays, attempt.clone()))); + } + + if let Some(handle) = current.take() { + let _ = handle.await; + } +} + /// The best bid already sent, per slot and parent. /// /// The relay treats every submission as a new bid, so resending a lower value @@ -59,8 +103,84 @@ impl BestBid { #[cfg(test)] mod tests { + use std::sync::{Arc, Mutex}; + + use helix_types::{BlsPublicKeyBytes, Withdrawals}; + use super::*; + fn context(slot: u64, parent: u8, timestamp: u64) -> SlotContext { + SlotContext { + slot, + parent_hash: B256::repeat_byte(parent), + parent_block_number: Some(slot.saturating_sub(1)), + timestamp, + prev_randao: B256::ZERO, + withdrawals: Withdrawals::default(), + parent_beacon_block_root: B256::ZERO, + proposer_pubkey: BlsPublicKeyBytes::default(), + proposer_fee_recipient: alloy_primitives::Address::ZERO, + registered_gas_limit: 30_000_000, + } + } + + /// Drives `contexts` and reports the parent of every attempt, in order. + async fn attempts_for(contexts: Vec, offsets: &[u64], now_ms: u64) -> Vec { + let (tx, rx) = mpsc::channel(16); + for context in contexts { + tx.send(context).await.unwrap(); + } + drop(tx); + + let seen = Arc::new(Mutex::new(Vec::new())); + let recorder = seen.clone(); + drive( + rx, + offsets, + move || now_ms, + move |slot: SlotContext| { + let recorder = recorder.clone(); + async move { + recorder.lock().unwrap().push(slot.parent_hash); + } + }, + ) + .await; + + let attempts = seen.lock().unwrap().clone(); + attempts + } + + /// The head moved, so the first context's remaining attempts would bid on a + /// parent the relay has already replaced. Before the driver they ran anyway, + /// late and all at once, and the relay answered "unknown parent hash" or + /// "submission for wrong slot". + #[tokio::test] + async fn a_superseded_context_never_attempts() { + let attempts = attempts_for( + vec![context(1, 0xa1, SLOT_TIMESTAMP), context(1, 0xb2, SLOT_TIMESTAMP)], + &[20, 40], + START_MS, + ) + .await; + + assert_eq!( + attempts, + vec![B256::repeat_byte(0xb2), B256::repeat_byte(0xb2)], + "only the newest parent may be bid on", + ); + } + + /// The fallback in `delays` still has to hold: a slot learned about after + /// every offset has passed gets one attempt rather than none. + #[tokio::test] + async fn a_slot_learned_about_late_still_attempts_once() { + let attempts = + attempts_for(vec![context(1, 0xa1, SLOT_TIMESTAMP)], &[20, 40], START_MS + 9_000).await; + + assert_eq!(attempts, vec![B256::repeat_byte(0xa1)]); + } + const SLOT_TIMESTAMP: u64 = 1_700_000_000; const START_MS: u64 = SLOT_TIMESTAMP * 1_000; From 72436df467b07ae7441ea562ca9c3847c0669048 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 18 Sep 2026 18:27:52 +0100 Subject: [PATCH 19/33] Carry EIP-8282 builder deposits and exits through the bid submission --- crates/builder/src/building/submit.rs | 32 +++-- crates/builder/src/building/submit/tests.rs | 2 +- crates/builder/src/engine/convert.rs | 132 +++++++++++++++++- crates/builder/src/validation/server.rs | 28 ++-- crates/builder/src/validation/tests.rs | 15 +- crates/common/src/decoder.rs | 34 +++-- .../auctioneer/get_execution_payload_bid.rs | 22 ++- crates/relay/src/auctioneer/gloas_payload.rs | 10 +- crates/relay/src/auctioneer/submit_block.rs | 6 +- crates/relay/src/auctioneer/types.rs | 34 ++--- crates/relay/src/bid_decoder/tile.rs | 12 +- crates/relay/src/block_merging/tile.rs | 2 +- crates/relay/src/simulator/mod.rs | 30 ++-- crates/types/src/bid_submission.rs | 98 +++++++++++-- crates/types/src/fields.rs | 48 +++++-- crates/types/src/lib.rs | 3 + 16 files changed, 388 insertions(+), 120 deletions(-) diff --git a/crates/builder/src/building/submit.rs b/crates/builder/src/building/submit.rs index 3a4106c12..e75200972 100644 --- a/crates/builder/src/building/submit.rs +++ b/crates/builder/src/building/submit.rs @@ -6,15 +6,15 @@ use helix_common::{ signing::RelaySigningContext, }; use helix_types::{ - BidTrace, BlobsBundle, BlockAccessListBytes, ForkName, KzgCommitments, SignedBidSubmission, - SignedBidSubmissionGloas, Slot, payload_from_v3, requests_from_v4, + BidTrace, BlobsBundle, BlockAccessListBytes, ForkName, GloasSubmissionData, KzgCommitments, + SignedBidSubmission, SignedBidSubmissionGloas, Slot, payload_from_v3, }; use ssz::Encode; use thiserror::Error; use crate::{ building::{assemble::BuiltBlock, slot::SlotContext}, - engine::convert::{block_to_payload_v3, requests_to_v4}, + engine::convert::{block_to_payload_v3, decode_execution_requests}, }; #[derive(Debug, Error)] @@ -25,6 +25,8 @@ pub enum SubmitError { MissingBlockAccessList, #[error("the {0} submission shape cannot carry a block access list")] UnsubmittableBlockAccessList(ForkName), + #[error("the {0} submission shape cannot carry EIP-8282 builder requests")] + UnsubmittableBuilderRequests(ForkName), #[error("payload exceeds the consensus limits")] OversizedPayload, #[error("requests: {0}")] @@ -84,9 +86,8 @@ impl Submitter { let payload_v3 = block_to_payload_v3(&built.block); let payload = payload_from_v3(payload_v3).ok_or(SubmitError::OversizedPayload)?; - let requests_v4 = requests_to_v4(&built.requests).map_err(SubmitError::Requests)?; - let requests = requests_from_v4(requests_v4) - .ok_or_else(|| SubmitError::Requests("exceeds the consensus limits".into()))?; + let decoded = decode_execution_requests(&built.requests).map_err(SubmitError::Requests)?; + let requests = decoded.requests; let blobs = wire_blobs(&built.blobs_bundle)?; @@ -117,11 +118,22 @@ impl Submitter { // to be chosen from the same spec rather than from the block. let fork = self.signing.chain_info.fork_at_slot(Slot::new(slot.slot)); match (fork, &built.block_access_list) { - (ForkName::Gloas, Some(bal)) => Ok(Bid::Gloas(SignedBidSubmissionGloas::join( - submission, - BlockAccessListBytes(bal.clone().into()), - ))), + (ForkName::Gloas, Some(bal)) => { + Ok(Bid::Gloas(SignedBidSubmissionGloas::join(submission, GloasSubmissionData { + block_access_list: BlockAccessListBytes(bal.clone().into()), + builder_deposits: decoded.builder_deposits, + builder_exits: decoded.builder_exits, + }))) + } (ForkName::Gloas, None) => Err(SubmitError::MissingBlockAccessList), + // No pre-Gloas submission shape carries EIP-8282 requests, so sending + // one would drop them and the simulator would rebuild a different + // requests hash. + (fork, _) + if !decoded.builder_deposits.is_empty() || !decoded.builder_exits.is_empty() => + { + Err(SubmitError::UnsubmittableBuilderRequests(fork)) + } // Sending it anyway drops the list, and the simulator then rebuilds // a header whose hash is not the one signed here. Every bid would // die as a hash mismatch, with nothing to point at. diff --git a/crates/builder/src/building/submit/tests.rs b/crates/builder/src/building/submit/tests.rs index aece93ef6..87c511f51 100644 --- a/crates/builder/src/building/submit/tests.rs +++ b/crates/builder/src/building/submit/tests.rs @@ -276,7 +276,7 @@ fn a_gloas_submission_carries_the_block_access_list() { let (_, _, _, decoded) = helix_common::decoder::SubmissionDecoder::new(¶ms) .decode(&bid.as_ssz_bytes(), &mut buf) .expect("the relay must be able to decode what we send"); - assert_eq!(decoded.expect("Gloas carries a list").to_vec(), bal); + assert_eq!(decoded.expect("Gloas carries a list").block_access_list.to_vec(), bal); } #[test] diff --git a/crates/builder/src/engine/convert.rs b/crates/builder/src/engine/convert.rs index b4103247a..5092751e2 100644 --- a/crates/builder/src/engine/convert.rs +++ b/crates/builder/src/engine/convert.rs @@ -17,6 +17,8 @@ use ethrex_common::{ }, }; use ethrex_crypto::NativeCrypto; +use helix_types::{BuilderDepositRequests, BuilderExitRequests, ExecutionRequests, RequestType}; +use ssz::Encode; use crate::validation::error::ValidationError; @@ -95,14 +97,18 @@ pub fn block_to_payload_v3(block: &Block) -> ExecutionPayloadV3 { } } -/// The Amsterdam header fields no `ExecutionPayloadV3` carries: the EIP-7928 -/// block access list and the EIP-7843 slot number. `None` for an earlier fork. -#[derive(Clone, Copy)] +/// The Amsterdam header inputs no `ExecutionPayloadV3` carries: the EIP-7928 +/// block access list, the EIP-7843 slot number, and the EIP-8282 builder +/// request lists that the `requests_hash` commits to. `None` for an earlier +/// fork. +#[derive(Clone, Copy, Default)] pub struct Amsterdam<'a> { /// The list as the builder encoded it. It is hashed as received and never /// re-encoded, because the block hash commits to these exact bytes. pub block_access_list: &'a [u8], pub slot: u64, + pub builder_deposits: Option<&'a BuilderDepositRequests>, + pub builder_exits: Option<&'a BuilderExitRequests>, } /// Inverse of [`block_to_payload_v3`]. The roots the payload omits are @@ -154,7 +160,7 @@ pub fn payload_v3_to_block( blob_gas_used: Some(payload.blob_gas_used), excess_blob_gas: Some(payload.excess_blob_gas), parent_beacon_block_root: Some(h256(parent_beacon_block_root)), - requests_hash: Some(compute_requests_hash(&encoded_requests(requests))), + requests_hash: Some(compute_requests_hash(&encoded_requests_all(requests, amsterdam))), block_access_list_hash: amsterdam .map(|a| ethrex_common::utils::keccak(a.block_access_list)), slot_number: amsterdam.map(|a| a.slot), @@ -196,6 +202,32 @@ fn encoded_requests(requests: &ExecutionRequestsV4) -> Vec { requests.to_requests().iter().map(|request| EncodedRequests(request.clone().0)).collect() } +/// The flat EIP-7685 list the header's `requests_hash` commits to. EIP-8282's +/// builder lists carry types 3 and 4, so they follow the three alloy encodes, +/// and an empty list is omitted exactly as the EIP requires. +fn encoded_requests_all( + requests: &ExecutionRequestsV4, + amsterdam: Option>, +) -> Vec { + let mut list = encoded_requests(requests); + let mut push = |request_type: RequestType, body: Vec, is_empty: bool| { + if is_empty { + return; + } + let mut bytes = Vec::with_capacity(1 + body.len()); + bytes.push(request_type.to_u8()); + bytes.extend_from_slice(&body); + list.push(EncodedRequests(bytes.into())); + }; + if let Some(deposits) = amsterdam.and_then(|a| a.builder_deposits) { + push(RequestType::BuilderDeposit, deposits.as_ssz_bytes(), deposits.is_empty()); + } + if let Some(exits) = amsterdam.and_then(|a| a.builder_exits) { + push(RequestType::BuilderExit, exits.as_ssz_bytes(), exits.is_empty()); + } + list +} + /// Converts ethrex's encoded EIP-7685 requests into the wire /// `ExecutionRequestsV4`, dropping empty requests per the EIP. pub fn requests_to_v4(encoded: &[EncodedRequests]) -> Result { @@ -205,6 +237,56 @@ pub fn requests_to_v4(encoded: &[EncodedRequests]) -> Result Result { + let mut decoded = DecodedRequests::default(); + + for entry in encoded.iter().filter(|r| !r.is_empty()) { + let (prefix, body) = entry.0.split_first().ok_or("empty execution request")?; + let request_type = RequestType::from_u8(*prefix) + .ok_or_else(|| format!("unknown request_type prefix: {prefix}"))?; + + match request_type { + RequestType::Deposit => { + decoded.requests.deposits = ssz_decode(body, "deposits")?; + } + RequestType::Withdrawal => { + decoded.requests.withdrawals = ssz_decode(body, "withdrawals")?; + } + RequestType::Consolidation => { + decoded.requests.consolidations = ssz_decode(body, "consolidations")?; + } + RequestType::BuilderDeposit => { + decoded.builder_deposits = ssz_decode(body, "builder deposits")?; + } + RequestType::BuilderExit => { + decoded.builder_exits = ssz_decode(body, "builder exits")?; + } + } + } + + Ok(decoded) +} + +fn ssz_decode(body: &[u8], what: &str) -> Result { + T::from_ssz_bytes(body).map_err(|e| format!("{what}: {e:?}")) +} + #[cfg(test)] mod tests { use super::*; @@ -217,6 +299,48 @@ mod tests { assert_eq!(au256(EU256::max_value()), AU256::MAX); } + /// Production shape: the node emits EIP-8282 builder deposits and exits, and + /// the block was unbiddable while the decoder rejected prefix 3. + #[test] + fn the_flat_list_decodes_every_eip_7685_type() { + use ssz::Encode; + let deposits: BuilderDepositRequests = vec![helix_types::BuilderDepositRequest { + pubkey: helix_types::BlsPublicKeyBytesLh::empty(), + withdrawal_credentials: B256::repeat_byte(0x11), + amount: 32_000_000_000, + signature: helix_types::BlsSignatureBytesLh::empty(), + }] + .into(); + let exits: BuilderExitRequests = vec![helix_types::BuilderExitRequest { + source_address: AAddress::repeat_byte(0x22), + pubkey: helix_types::BlsPublicKeyBytesLh::empty(), + }] + .into(); + let encoded = vec![ + prefixed(RequestType::BuilderDeposit, deposits.as_ssz_bytes()), + prefixed(RequestType::BuilderExit, exits.as_ssz_bytes()), + ]; + + let decoded = decode_execution_requests(&encoded).expect("prefixes 3 and 4 are valid"); + + assert_eq!(decoded.builder_deposits, deposits); + assert_eq!(decoded.builder_exits, exits); + } + + #[test] + fn an_unknown_request_type_is_named() { + let err = decode_execution_requests(&[EncodedRequests(vec![9u8, 0u8].into())]) + .expect_err("prefix 9 is not an EIP-7685 type"); + + assert!(err.contains("unknown request_type prefix: 9"), "got: {err}"); + } + + fn prefixed(request_type: RequestType, body: Vec) -> EncodedRequests { + let mut bytes = vec![request_type.to_u8()]; + bytes.extend_from_slice(&body); + EncodedRequests(bytes.into()) + } + #[test] fn hash_and_address_roundtrip() { let b = B256::repeat_byte(0xab); diff --git a/crates/builder/src/validation/server.rs b/crates/builder/src/validation/server.rs index 1beb7b8e5..1afe40a21 100644 --- a/crates/builder/src/validation/server.rs +++ b/crates/builder/src/validation/server.rs @@ -16,7 +16,7 @@ use helix_common::{ decoder::{DecoderError, SubmissionDecoder, SubmissionDecoderParams}, simulator::{SszMergedValidationRequest, SszValidationRequest, SszValidationResponse}, }; -use helix_types::{BlockAccessListBytes, ForkName, Submission}; +use helix_types::{ForkName, GloasSubmissionData, Submission}; use ssz::{Decode, Encode}; use tokio::{net::TcpListener, sync::Semaphore, time}; use tracing::{error, info, warn}; @@ -80,8 +80,8 @@ fn unsupported_fork(params: &Option) -> Response { (StatusCode::NOT_IMPLEMENTED, format!("unsupported fork: {fork:?}")).into_response() } -/// A decoded submission, with the block access list a Gloas one carries. -type Decoded = (SignedBidSubmissionV5, Option); +/// A decoded submission, with the Gloas-only data a Gloas one carries. +type Decoded = (SignedBidSubmissionV5, Option); /// A dehydrated submission needs transactions this simulator does not cache. /// The relay answers a 424 by retrying with full SSZ bytes. @@ -92,10 +92,10 @@ fn decode_submission( match params { Some(params) => { let mut buf = Vec::new(); - let (submission, _, _, bal) = + let (submission, _, _, gloas_data) = SubmissionDecoder::new(¶ms).decode(bytes, &mut buf)?; match submission { - Submission::Full(submission) => Ok(Some((submission.into(), bal))), + Submission::Full(submission) => Ok(Some((submission.into(), gloas_data))), Submission::Dehydrated(_) => Ok(None), } } @@ -120,7 +120,7 @@ async fn validate(State(state): State, body: axum::body::Bytes) -> ); } let t = metrics::sim_lap("decode_request", start); - let (submission, bal) = + let (submission, gloas_data) = match decode_submission(request.decoder_params, &request.signed_bid_submission) { Ok(Some(decoded)) => decoded, Ok(None) => { @@ -145,16 +145,22 @@ async fn validate(State(state): State, body: axum::body::Bytes) -> &submission.execution_requests, &eblobs(&submission.blobs_bundle), request.apply_blacklist, - amsterdam(bal.as_ref(), submission.message.slot), + amsterdam(gloas_data.as_ref(), submission.message.slot), ) }) .await } -/// A block access list marks the submission as Amsterdam, and the slot number -/// the header needs is the one the trace already carries. -fn amsterdam(block_access_list: Option<&BlockAccessListBytes>, slot: u64) -> Option> { - block_access_list.map(|bal| Amsterdam { block_access_list: &bal.0, slot }) +/// Gloas data marks the submission as Amsterdam, and the slot number the header +/// needs is the one the trace already carries. The builder request lists go in +/// too: the `requests_hash` the block hash commits to covers them. +fn amsterdam(gloas_data: Option<&GloasSubmissionData>, slot: u64) -> Option> { + gloas_data.map(|data| Amsterdam { + block_access_list: &data.block_access_list.0, + slot, + builder_deposits: Some(&data.builder_deposits), + builder_exits: Some(&data.builder_exits), + }) } async fn validate_merged(State(state): State, body: axum::body::Bytes) -> Response { diff --git a/crates/builder/src/validation/tests.rs b/crates/builder/src/validation/tests.rs index 4b783de6a..98c775960 100644 --- a/crates/builder/src/validation/tests.rs +++ b/crates/builder/src/validation/tests.rs @@ -56,9 +56,11 @@ impl Built { } pub(crate) fn amsterdam(&self) -> Option> { - self.block_access_list - .as_deref() - .map(|block_access_list| Amsterdam { block_access_list, slot: self.slot }) + self.block_access_list.as_deref().map(|block_access_list| Amsterdam { + block_access_list, + slot: self.slot, + ..Default::default() + }) } } @@ -1740,7 +1742,10 @@ impl Fixture { let bal = built.block_access_list.clone().expect("a Gloas request carries a list"); let gloas = helix_types::SignedBidSubmissionGloas::join( submission, - helix_types::BlockAccessListBytes(bal.into()), + helix_types::GloasSubmissionData { + block_access_list: helix_types::BlockAccessListBytes(bal.into()), + ..Default::default() + }, ); helix_common::simulator::SszValidationRequest { apply_blacklist: false, @@ -1956,7 +1961,7 @@ async fn a_fork_and_payload_that_disagree_are_rejected() { &built.requests, &empty_bundle(), false, - Some(Amsterdam { block_access_list: &[0xc0], slot: SLOT }), + Some(Amsterdam { block_access_list: &[0xc0], slot: SLOT, ..Default::default() }), ) .expect_err("a Fulu block carrying Amsterdam fields must be rejected"); assert!(matches!(error, ValidationError::BlockHashMismatch { .. }), "{error}"); diff --git a/crates/common/src/decoder.rs b/crates/common/src/decoder.rs index 779a13ef7..8824f037d 100644 --- a/crates/common/src/decoder.rs +++ b/crates/common/src/decoder.rs @@ -8,12 +8,12 @@ use flate2::read::GzDecoder; use flux_profiler::timed; use flux_versioned_types::ByteStable; use helix_types::{ - BidAdjustmentData, BlockAccessListBytes, BlockMergingData, BlockMergingDataV2, Compression, - DehydratedBidSubmission, DehydratedBidSubmissionFulu, DehydratedBidSubmissionFuluV1, + BidAdjustmentData, BlockMergingData, BlockMergingDataV2, Compression, DehydratedBidSubmission, + DehydratedBidSubmissionFulu, DehydratedBidSubmissionFuluV1, DehydratedBidSubmissionFuluWithAdjustments, DehydratedBidSubmissionFuluWithAdjustmentsAndMergingData, - DehydratedBidSubmissionFuluWithMergingData, ForkName, ForkVersionDecode, MergeType, - SignedBidSubmission, SignedBidSubmissionGloas, SignedBidSubmissionWithAdjustments, + DehydratedBidSubmissionFuluWithMergingData, ForkName, ForkVersionDecode, GloasSubmissionData, + MergeType, SignedBidSubmission, SignedBidSubmissionGloas, SignedBidSubmissionWithAdjustments, SignedBidSubmissionWithAdjustmentsAndMergingData, SignedBidSubmissionWithMergingData, Submission, WithAdjustments, WithAdjustmentsAndMergingData, WithMergingData, }; @@ -47,7 +47,7 @@ pub type DecodedParts = ( Submission, Option, Option, - Option, + Option, ); #[derive(Debug, thiserror::Error)] @@ -511,7 +511,7 @@ impl SubmissionDecoder { #[timed] fn decode_default(&mut self, body: &[u8]) -> Result { let is_gloas = self.fork_name == ForkName::Gloas; - let (submission, bid_adjustment, block_access_list) = if self.with_adjustments { + let (submission, bid_adjustment, gloas_data) = if self.with_adjustments { if is_gloas { // Refused rather than decoded into the wrong shape. Adjustments // are a BuilderNet feature and Gloas does not need them yet. @@ -522,11 +522,12 @@ impl SubmissionDecoder { (sub, Some(adjustment_data), None) } else if is_gloas { - // Gloas carries the builder's EIP-7928 block access list. + // Gloas carries the builder's EIP-7928 block access list and its + // EIP-8282 builder deposit and exit requests. let gloas: SignedBidSubmissionGloas = self._decode(body)?; - let (submission, block_access_list) = gloas.split(); + let (submission, gloas_data) = gloas.split(); - (submission, None, Some(block_access_list)) + (submission, None, Some(gloas_data)) } else { let submission: SignedBidSubmission = self._decode(body)?; @@ -554,7 +555,7 @@ impl SubmissionDecoder { } MergeType::Pause => None, }; - Ok((Submission::Full(submission), merging_data, bid_adjustment, block_access_list)) + Ok((Submission::Full(submission), merging_data, bid_adjustment, gloas_data)) } // TODO: pass a buffer pool to avoid allocations @@ -700,7 +701,7 @@ mod tests { fn the_decoder_selects_the_gloas_shape_by_fork() { let mut submission = SignedBidSubmissionGloas::test_random(); submission.blobs_bundle = Default::default(); - submission.block_access_list = BlockAccessListBytes(vec![3u8; 32].into()); + submission.block_access_list = helix_types::BlockAccessListBytes(vec![3u8; 32].into()); let body = submission.as_ssz_bytes(); let params = SubmissionDecoderParams::plain(ForkName::Gloas); @@ -709,10 +710,13 @@ mod tests { .decode(&body, &mut buf) .expect("a Gloas submission must decode"); - assert_eq!(block_access_list.expect("Gloas carries a block access list").to_vec(), vec![ - 3u8; - 32 - ],); + assert_eq!( + block_access_list + .expect("Gloas carries a block access list") + .block_access_list + .to_vec(), + vec![3u8; 32], + ); } #[test] diff --git a/crates/relay/src/auctioneer/get_execution_payload_bid.rs b/crates/relay/src/auctioneer/get_execution_payload_bid.rs index 3729aa2a2..cacf73591 100644 --- a/crates/relay/src/auctioneer/get_execution_payload_bid.rs +++ b/crates/relay/src/auctioneer/get_execution_payload_bid.rs @@ -82,12 +82,17 @@ pub(super) fn build_signed_bid( ) -> Result { let slot = Slot::new(params.slot); - let payload = entry.execution_payload().to_lighthouse_gloas_payload(slot, &entry.block_access_list()).map_err(|err| { + let gloas_data = entry.gloas_data(); + let payload = entry.execution_payload().to_lighthouse_gloas_payload(slot, &gloas_data.block_access_list).map_err(|err| { warn!(%err, block_hash =% entry.block_hash(), "failed to convert held payload to Gloas shape for bid"); ProposerApiError::InternalServerError })?; - let execution_requests = execution_requests_to_gloas(entry.bid_data_ref().execution_requests); + let execution_requests = execution_requests_to_gloas( + entry.bid_data_ref().execution_requests, + &gloas_data.builder_deposits, + &gloas_data.builder_exits, + ); let execution_requests_root = execution_requests.tree_hash_root(); // The proposer is paid in-block, so the enshrined `value` stays 0. @@ -256,7 +261,9 @@ mod tests { /// A submission entry carrying a block access list, as Gloas requires. fn gloas_submission_entry(block_access_list: Vec) -> PayloadEntry { - use helix_types::{BlockAccessListBytes, SignedBidSubmission, TestRandomSeed}; + use helix_types::{ + BlockAccessListBytes, GloasSubmissionData, SignedBidSubmission, TestRandomSeed, + }; let mut submission = SignedBidSubmission::test_random(); submission.blobs_bundle = Default::default(); @@ -266,7 +273,10 @@ mod tests { B256::ZERO, None, None, - Some(BlockAccessListBytes(block_access_list.into())), + Some(GloasSubmissionData { + block_access_list: BlockAccessListBytes(block_access_list.into()), + ..Default::default() + }), helix_types::SubmissionVersion::new(0, None), Default::default(), None, @@ -277,7 +287,7 @@ mod tests { fn the_stored_payload_keeps_the_block_access_list_for_the_bid() { let entry = gloas_submission_entry(vec![5u8; 96]); - let bal = entry.block_access_list(); + let bal = entry.gloas_data().block_access_list; assert_eq!(bal.to_vec(), vec![5u8; 96], "the bid's payload would be invalid without it"); @@ -294,7 +304,7 @@ mod tests { let entry = payload_entry(B256::repeat_byte(0x11), 1); assert!( - entry.block_access_list().is_empty(), + entry.gloas_data().block_access_list.is_empty(), "a gossiped payload carries none, so Gloas cannot be served from one", ); } diff --git a/crates/relay/src/auctioneer/gloas_payload.rs b/crates/relay/src/auctioneer/gloas_payload.rs index 88d2dd60c..ea7663816 100644 --- a/crates/relay/src/auctioneer/gloas_payload.rs +++ b/crates/relay/src/auctioneer/gloas_payload.rs @@ -18,9 +18,10 @@ impl Context { res_tx: oneshot::Sender>, ) { let held = self.payloads.get(&block_hash).and_then(|entry| { + let gloas_data = entry.gloas_data(); let payload = match entry .execution_payload() - .to_lighthouse_gloas_payload(slot, &entry.block_access_list()) + .to_lighthouse_gloas_payload(slot, &gloas_data.block_access_list) { Ok(payload) => payload, Err(err) => { @@ -28,8 +29,11 @@ impl Context { return None; } }; - let execution_requests = - execution_requests_to_gloas(entry.bid_data_ref().execution_requests); + let execution_requests = execution_requests_to_gloas( + entry.bid_data_ref().execution_requests, + &gloas_data.builder_deposits, + &gloas_data.builder_exits, + ); Some(HeldGloasPayload { payload, execution_requests, diff --git a/crates/relay/src/auctioneer/submit_block.rs b/crates/relay/src/auctioneer/submit_block.rs index fc0847898..ef9a16194 100644 --- a/crates/relay/src/auctioneer/submit_block.rs +++ b/crates/relay/src/auctioneer/submit_block.rs @@ -74,7 +74,7 @@ impl Context { let version = submission_data.version; let is_pessimistic = submission_data.is_pessimistic; let bid_adjustment_data = submission_data.bid_adjustment_data.clone(); - let block_access_list = submission_data.block_access_list.clone(); + let gloas_data = submission_data.gloas_data.clone(); let mut trace = submission_data.trace; let (submission, maybe_tx_root) = match self.hydrate(submission_data.submission.clone()) { @@ -139,7 +139,7 @@ impl Context { parent_beacon_block_root: payload_attributes.parent_root(), inclusion_list: slot_data.il.clone().unwrap_or_default(), submission: submission.clone(), - block_access_list: block_access_list.clone(), + gloas_data: gloas_data.clone(), tx_root: maybe_tx_root, version, trace, @@ -154,7 +154,7 @@ impl Context { payload_attributes.withdrawals_root, maybe_tx_root, bid_adjustment_data, - block_access_list, + gloas_data, version, trace, payload_attributes.parent_beacon_block_root, diff --git a/crates/relay/src/auctioneer/types.rs b/crates/relay/src/auctioneer/types.rs index f867925af..2c723a1db 100644 --- a/crates/relay/src/auctioneer/types.rs +++ b/crates/relay/src/auctioneer/types.rs @@ -15,11 +15,11 @@ use helix_common::{ }; use helix_tcp_types::{BidSubmissionFlags, BidSubmissionHeader}; use helix_types::{ - BidAdjustmentData, BlockAccessListBytes, BlockMergingDataV2, BlsPublicKeyBytes, BuilderBid, - Compression, ExecutionPayload, ForkName, GetPayloadResponse, MergeType, PayloadAndBlobs, - PayloadBidData, PayloadBidDataRef, SignedBidSubmission, SignedBlindedBeaconBlock, - SignedExecutionPayloadBid, Slot, Submission, SubmissionVersion, VersionedSignedProposal, - mock_public_key_bytes, + BidAdjustmentData, BlockMergingDataV2, BlsPublicKeyBytes, BuilderBid, Compression, + ExecutionPayload, ForkName, GetPayloadResponse, GloasSubmissionData, MergeType, + PayloadAndBlobs, PayloadBidData, PayloadBidDataRef, SignedBidSubmission, + SignedBlindedBeaconBlock, SignedExecutionPayloadBid, Slot, Submission, SubmissionVersion, + VersionedSignedProposal, mock_public_key_bytes, }; use http::{ HeaderMap, HeaderValue, @@ -249,7 +249,7 @@ pub struct SubmissionData { pub submission: Submission, pub merging_data: Option, pub bid_adjustment_data: Option, - pub block_access_list: Option, + pub gloas_data: Option, pub version: SubmissionVersion, pub withdrawals_root: B256, pub trace: SubmissionTrace, @@ -277,8 +277,9 @@ pub struct SubmissionPayload { pub withdrawals_root: B256, pub tx_root: Option, pub bid_adjustment_data: Option, - /// The builder's EIP-7928 list, present only for Gloas submissions. - pub block_access_list: Option, + /// The builder's EIP-7928 list and EIP-8282 builder requests, present only + /// for Gloas submissions. + pub gloas_data: Option, pub is_adjusted: bool, pub submission_version: SubmissionVersion, pub submission_trace: SubmissionTrace, @@ -299,13 +300,13 @@ impl PayloadEntry { withdrawals_root: B256, tx_root: Option, bid_adjustment_data: Option, - block_access_list: Option, + gloas_data: Option, submission_version: SubmissionVersion, submission_trace: SubmissionTrace, parent_beacon_block_root: Option, ) -> Self { Self::Submission(SubmissionPayload { - block_access_list, + gloas_data, signed_bid_submission, withdrawals_root, tx_root, @@ -370,13 +371,14 @@ impl PayloadEntry { } } - /// The submitted EIP-7928 list. Empty when the fork does not carry one, in - /// which case a Gloas conversion would produce an invalid payload -- the - /// caller is expected to only reach this on a Gloas submission. - pub fn block_access_list(&self) -> BlockAccessListBytes { + /// The submitted Gloas sidecar: the EIP-7928 list and the EIP-8282 builder + /// requests. Empty when the fork does not carry one, in which case a Gloas + /// conversion would produce an invalid payload -- the caller is expected to + /// only reach this on a Gloas submission. + pub fn gloas_data(&self) -> GloasSubmissionData { match self { - Self::Submission(bid) => bid.block_access_list.clone().unwrap_or_default(), - Self::Gossip(_) => BlockAccessListBytes::default(), + Self::Submission(bid) => bid.gloas_data.clone().unwrap_or_default(), + Self::Gossip(_) => GloasSubmissionData::default(), } } diff --git a/crates/relay/src/bid_decoder/tile.rs b/crates/relay/src/bid_decoder/tile.rs index acc65dbf1..bcca418a1 100644 --- a/crates/relay/src/bid_decoder/tile.rs +++ b/crates/relay/src/bid_decoder/tile.rs @@ -19,7 +19,7 @@ use helix_common::{ utils::utcnow_ns, }; use helix_types::{ - BidAdjustmentData, BlockAccessListBytes, BlockMergingDataV2, BlsPublicKeyBytes, MergeType, + BidAdjustmentData, BlockMergingDataV2, BlsPublicKeyBytes, GloasSubmissionData, MergeType, SignedBidSubmission, Submission, SubmissionVersion, }; use rustc_hash::FxHashMap; @@ -268,7 +268,7 @@ impl DecoderTile { version, merging_data, bid_adjustment_data, - block_access_list, + gloas_data, decoder_params, ) = Self::try_handle_block_submission( cache, @@ -317,7 +317,7 @@ impl DecoderTile { version, merging_data, bid_adjustment_data, - block_access_list, + gloas_data, withdrawals_root, trace, decoder_params, @@ -345,7 +345,7 @@ impl DecoderTile { SubmissionVersion, Option, Option, - Option, + Option, SubmissionDecoderParams, ), BuilderApiError, @@ -370,7 +370,7 @@ impl DecoderTile { }; let mut decoder = SubmissionDecoder::new(&decoder_params); - let (mut submission, merging_data, bid_adjustment_data, block_access_list) = + let (mut submission, merging_data, bid_adjustment_data, gloas_data) = decoder.decode(payload, buffer)?; trace.decoded_ns = Nanos::now(); @@ -417,7 +417,7 @@ impl DecoderTile { version, merging_data, bid_adjustment_data, - block_access_list, + gloas_data, decoder_params, )) } diff --git a/crates/relay/src/block_merging/tile.rs b/crates/relay/src/block_merging/tile.rs index ce99fef37..ffbc7e2d3 100644 --- a/crates/relay/src/block_merging/tile.rs +++ b/crates/relay/src/block_merging/tile.rs @@ -1226,7 +1226,7 @@ mod tests { signed.blobs_bundle = Arc::new(Default::default()); let submission_data = SubmissionData { submission_id: Uuid::nil(), - block_access_list: None, + gloas_data: None, submission_ref: SubmissionRef::default(), submission: Submission::Full(signed), merging_data: Some(BlockMergingDataV2 { diff --git a/crates/relay/src/simulator/mod.rs b/crates/relay/src/simulator/mod.rs index 648455303..82452773b 100644 --- a/crates/relay/src/simulator/mod.rs +++ b/crates/relay/src/simulator/mod.rs @@ -22,8 +22,8 @@ use helix_common::{ validator_preferences::{Filtering, ValidatorPreferences}, }; use helix_types::{ - BidTrace, BlobsBundle, BlockAccessListBytes, BlsPublicKeyBytes, BlsSignatureBytes, - BuilderInclusionResult, ExecutionPayload, ExecutionRequests, ForkName, MergedBlockTrace, + BidTrace, BlobsBundle, BlsPublicKeyBytes, BlsSignatureBytes, BuilderInclusionResult, + ExecutionPayload, ExecutionRequests, ForkName, GloasSubmissionData, MergedBlockTrace, SignedBidSubmission, SignedBidSubmissionGloas, SubmissionVersion, }; use rustc_hash::FxHashMap; @@ -76,7 +76,7 @@ pub struct ValidationRequest { pub parent_beacon_block_root: B256, pub inclusion_list: InclusionListWithMetadata, pub submission: SignedBidSubmission, - pub block_access_list: Option, + pub gloas_data: Option, pub tx_root: Option, pub version: SubmissionVersion, pub trace: SubmissionTrace, @@ -1412,7 +1412,7 @@ fn create_ssz_request( req.parent_beacon_block_root, req.inclusion_list.clone(), submission, - req.block_access_list.clone(), + req.gloas_data.clone(), ) } @@ -1426,12 +1426,12 @@ fn ssz_request( parent_beacon_block_root: B256, inclusion_list: InclusionListWithMetadata, submission: &SignedBidSubmission, - block_access_list: Option, + gloas_data: Option, ) -> SszValidationRequest { - let (decoder_params, signed_bid_submission) = match block_access_list { - Some(bal) => ( + let (decoder_params, signed_bid_submission) = match gloas_data { + Some(gloas_data) => ( Some(SubmissionDecoderParams::plain(ForkName::Gloas)), - SignedBidSubmissionGloas::join(submission.clone(), bal).as_ssz_bytes(), + SignedBidSubmissionGloas::join(submission.clone(), gloas_data).as_ssz_bytes(), ), None => (None, submission.as_ssz_bytes()), }; @@ -1477,10 +1477,16 @@ mod tests { fn a_gloas_ssz_request_carries_the_block_access_list() { let mut submission = SignedBidSubmission::test_random(); submission.blobs_bundle = Default::default(); - let bal = BlockAccessListBytes(vec![7u8; 48].into()); + let bal = helix_types::BlockAccessListBytes(vec![7u8; 48].into()); - let request = - ssz_request(false, 0, B256::ZERO, Default::default(), &submission, Some(bal.clone())); + let request = ssz_request( + false, + 0, + B256::ZERO, + Default::default(), + &submission, + Some(GloasSubmissionData { block_access_list: bal.clone(), ..Default::default() }), + ); let params = request.decoder_params.expect("a Gloas request names its shape"); assert_eq!(params.fork_name, ForkName::Gloas); @@ -1488,7 +1494,7 @@ mod tests { let (_, _, _, decoded) = helix_common::decoder::SubmissionDecoder::new(¶ms) .decode(&request.signed_bid_submission, &mut buf) .expect("the simulator must be able to decode what the relay sends"); - assert_eq!(decoded.expect("the list must survive the re-encode"), bal); + assert_eq!(decoded.expect("the list must survive the re-encode").block_access_list, bal); } /// Every other fork keeps the bare shape, so no simulator sees a new one. diff --git a/crates/types/src/bid_submission.rs b/crates/types/src/bid_submission.rs index 3cde657fa..457fd53d1 100644 --- a/crates/types/src/bid_submission.rs +++ b/crates/types/src/bid_submission.rs @@ -20,7 +20,9 @@ use crate::{ PayloadAndBlobs, SszError, TestRandom, bid_adjustment_data::{BidAdjData, BidAdjustmentData, BidAdjustmentDataV1}, error::SigError, - fields::{BlockAccessListBytes, ExecutionRequests}, + fields::{ + BlockAccessListBytes, BuilderDepositRequests, BuilderExitRequests, ExecutionRequests, + }, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, TreeHash)] @@ -736,12 +738,26 @@ impl SignedBidSubmissionWithAdjustments { } } -/// Gloas carries the block access list the builder produced (EIP-7928): -/// core fields ++ block_access_list. +/// What a Gloas submission carries beyond the pre-Gloas core: the EIP-7928 +/// block access list and the EIP-8282 builder deposit and exit requests. Kept +/// together so every hand-off that already moved the access list moves all +/// three, rather than silently dropping the builder lists. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct GloasSubmissionData { + pub block_access_list: BlockAccessListBytes, + pub builder_deposits: BuilderDepositRequests, + pub builder_exits: BuilderExitRequests, +} + +/// Gloas carries what the builder produced that no earlier fork has: the block +/// access list (EIP-7928) and the builder deposit and exit requests (EIP-8282). +/// Core fields ++ block_access_list ++ builder_deposits ++ builder_exits. /// -/// A separate type rather than a fork-gated field, because `Encode` is derived +/// A separate type rather than fork-gated fields, because `Encode` is derived /// on [`SignedBidSubmission`] and an extra field would change the bytes for -/// every fork. +/// every fork. `execution_requests` keeps the Electra shape for the three +/// pre-Gloas lists; [`helix_types::execution_requests_to_gloas`] rejoins all +/// five for the payload the proposer signs. #[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] pub struct SignedBidSubmissionGloas { pub message: BidTrace, @@ -750,6 +766,8 @@ pub struct SignedBidSubmissionGloas { pub execution_requests: Arc, pub signature: BlsSignatureBytes, pub block_access_list: BlockAccessListBytes, + pub builder_deposits: BuilderDepositRequests, + pub builder_exits: BuilderExitRequests, } impl TestRandom for SignedBidSubmissionGloas { @@ -761,6 +779,8 @@ impl TestRandom for SignedBidSubmissionGloas { execution_requests: ExecutionRequests::random_for_test(rng).into(), signature: BlsSignatureBytes::random(), block_access_list: BlockAccessListBytes::random_for_test(rng), + builder_deposits: Default::default(), + builder_exits: Default::default(), } } } @@ -768,18 +788,20 @@ impl TestRandom for SignedBidSubmissionGloas { impl SignedBidSubmissionGloas { /// Inverse of [`Self::split`], for re-encoding a hydrated submission on the /// way to an SSZ simulator. - pub fn join(submission: SignedBidSubmission, block_access_list: BlockAccessListBytes) -> Self { + pub fn join(submission: SignedBidSubmission, gloas_data: GloasSubmissionData) -> Self { Self { message: submission.message, execution_payload: submission.execution_payload, blobs_bundle: submission.blobs_bundle, execution_requests: submission.execution_requests, signature: submission.signature, - block_access_list, + block_access_list: gloas_data.block_access_list, + builder_deposits: gloas_data.builder_deposits, + builder_exits: gloas_data.builder_exits, } } - pub fn split(self) -> (SignedBidSubmission, BlockAccessListBytes) { + pub fn split(self) -> (SignedBidSubmission, GloasSubmissionData) { ( SignedBidSubmission { message: self.message, @@ -788,7 +810,11 @@ impl SignedBidSubmissionGloas { execution_requests: self.execution_requests, signature: self.signature, }, - self.block_access_list, + GloasSubmissionData { + block_access_list: self.block_access_list, + builder_deposits: self.builder_deposits, + builder_exits: self.builder_exits, + }, ) } } @@ -1023,18 +1049,66 @@ mod gloas_submission_tests { assert_eq!(bytes, decoded.as_ssz_bytes()); } + fn builder_requests() -> (BuilderDepositRequests, BuilderExitRequests) { + ( + vec![lh_types::BuilderDepositRequest { + pubkey: lh_bls::PublicKeyBytes::empty(), + withdrawal_credentials: B256::repeat_byte(0x11), + amount: 32_000_000_000, + signature: lh_bls::SignatureBytes::empty(), + }] + .into(), + vec![lh_types::BuilderExitRequest { + source_address: alloy_primitives::Address::repeat_byte(0x22), + pubkey: lh_bls::PublicKeyBytes::empty(), + }] + .into(), + ) + } + #[test] - fn splitting_a_gloas_submission_yields_the_base_and_the_bal() { + fn splitting_a_gloas_submission_yields_the_base_and_the_gloas_data() { let submission = decodable_gloas_submission(); let expected_bal = submission.block_access_list.clone(); let expected_hash = submission.message.block_hash; - let (base, bal) = submission.split(); + let (base, gloas_data) = submission.split(); - assert_eq!(bal, expected_bal); + assert_eq!(gloas_data.block_access_list, expected_bal); assert_eq!(base.message.block_hash, expected_hash); } + /// EIP-8282: the builder lists have to survive the hand-off the relay makes + /// between decoding a submission and re-encoding it for the simulator. While + /// they did not exist on this type, a block carrying one could not be bid. + #[test] + fn a_gloas_submission_keeps_its_builder_requests_through_split_and_join() { + let (builder_deposits, builder_exits) = builder_requests(); + let mut submission = decodable_gloas_submission(); + submission.builder_deposits = builder_deposits.clone(); + submission.builder_exits = builder_exits.clone(); + + let (base, gloas_data) = submission.split(); + let rejoined = SignedBidSubmissionGloas::join(base, gloas_data); + + assert_eq!(rejoined.builder_deposits, builder_deposits); + assert_eq!(rejoined.builder_exits, builder_exits); + } + + #[test] + fn a_gloas_submission_with_builder_requests_round_trips_through_ssz() { + let (builder_deposits, builder_exits) = builder_requests(); + let mut submission = decodable_gloas_submission(); + submission.builder_deposits = builder_deposits.clone(); + submission.builder_exits = builder_exits.clone(); + + let bytes = submission.as_ssz_bytes(); + let decoded = SignedBidSubmissionGloas::from_ssz_bytes(&bytes).unwrap(); + + assert_eq!(decoded.builder_deposits, builder_deposits); + assert_eq!(decoded.builder_exits, builder_exits); + } + #[test] fn the_gloas_shape_is_distinct_from_fulu() { // Neither decodes as the other, so no existing Fulu path can silently diff --git a/crates/types/src/fields.rs b/crates/types/src/fields.rs index 058938ba8..208ab0f9b 100644 --- a/crates/types/src/fields.rs +++ b/crates/types/src/fields.rs @@ -10,11 +10,12 @@ use crate::{ExecutionRequestsGloas, SszError, TestRandom, ssz_bytes_wrapper}; pub type Withdrawal = lh_types::Withdrawal; pub type Withdrawals = lh_types::Withdrawals; // `ExecutionRequests` is a fork-versioned superstruct as of Gloas (which adds -// `builder_deposits`/`builder_exits`, EIP-8282). helix's active fork is still pre-Gloas, so -- -// mirroring how `ExecutionPayload` is pinned to a flat, non-fork-versioned shape elsewhere in -// this crate -- we pin to the Electra shape here too. Revisit once helix actually needs to -// serve Gloas submissions. +// `builder_deposits`/`builder_exits`, EIP-8282). The three pre-Gloas lists keep the Electra +// shape; a Gloas submission carries the two builder lists beside it, the way it carries the +// block access list, so the pre-Gloas bytes never move. pub type ExecutionRequests = lh_types::ExecutionRequestsElectra; +pub type BuilderDepositRequests = ProgressiveVariableList; +pub type BuilderExitRequests = ProgressiveVariableList; pub type KzgCommitment = alloy_consensus::Bytes48; pub type KzgCommitments = VariableList::MaxBlobCommitmentsPerBlock>; @@ -50,16 +51,19 @@ pub fn convert_kzg_commitments_to_progressive( ProgressiveVariableList::new(commitments.iter().map(|c| lh_types::KzgCommitment(c.0)).collect()) } -/// Converts helix's Electra-shaped builder-submission execution requests into the real, -/// progressive-list Gloas shape. `builder_deposits`/`builder_exits` are left empty -- -/// TODO(gloas): populate once EIP-8282 builder deposit/exit submission exists. -pub fn execution_requests_to_gloas(requests: &ExecutionRequests) -> ExecutionRequestsGloas { +/// Converts helix's Electra-shaped builder-submission execution requests, plus the EIP-8282 +/// builder lists a Gloas submission carries beside them, into the real Gloas shape. +pub fn execution_requests_to_gloas( + requests: &ExecutionRequests, + builder_deposits: &BuilderDepositRequests, + builder_exits: &BuilderExitRequests, +) -> ExecutionRequestsGloas { ExecutionRequestsGloas { deposits: requests.deposits.iter().cloned().collect(), withdrawals: requests.withdrawals.iter().cloned().collect(), consolidations: requests.consolidations.iter().cloned().collect(), - builder_deposits: Default::default(), - builder_exits: Default::default(), + builder_deposits: builder_deposits.iter().cloned().collect(), + builder_exits: builder_exits.iter().cloned().collect(), _phantom: PhantomData, } } @@ -202,16 +206,30 @@ mod tests { } } + /// EIP-8282's builder deposits and exits reach the consensus shape the proposer signs. + /// While they were dropped here, a block carrying one could not be bid at all. #[test] - fn execution_requests_to_gloas_preserves_lists_and_defaults_builder_requests() { + fn execution_requests_to_gloas_carries_every_list() { let requests = ExecutionRequests::random_for_test(&mut rand::rng()); - - let gloas = execution_requests_to_gloas(&requests); + let builder_deposits: BuilderDepositRequests = vec![lh_types::BuilderDepositRequest { + pubkey: lh_bls::PublicKeyBytes::empty(), + withdrawal_credentials: alloy_primitives::B256::repeat_byte(0x11), + amount: 32_000_000_000, + signature: lh_bls::SignatureBytes::empty(), + }] + .into(); + let builder_exits: BuilderExitRequests = vec![lh_types::BuilderExitRequest { + source_address: alloy_primitives::Address::repeat_byte(0x22), + pubkey: lh_bls::PublicKeyBytes::empty(), + }] + .into(); + + let gloas = execution_requests_to_gloas(&requests, &builder_deposits, &builder_exits); assert!(gloas.deposits.iter().eq(requests.deposits.iter())); assert!(gloas.withdrawals.iter().eq(requests.withdrawals.iter())); assert!(gloas.consolidations.iter().eq(requests.consolidations.iter())); - assert!(gloas.builder_deposits.is_empty()); - assert!(gloas.builder_exits.is_empty()); + assert!(gloas.builder_deposits.iter().eq(builder_deposits.iter())); + assert!(gloas.builder_exits.iter().eq(builder_exits.iter())); } } diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 3e75e99ff..bf300df15 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -55,6 +55,8 @@ pub type BlsSignature = lh_bls::Signature; pub type BlsSignatureBytes = alloy_rpc_types::beacon::BlsSignature; pub type BlsSecretKey = lh_bls::SecretKey; pub type BlsKeypair = lh_bls::Keypair; +pub type BlsPublicKeyBytesLh = lh_bls::PublicKeyBytes; +pub type BlsSignatureBytesLh = lh_bls::SignatureBytes; // Blobs // pub type BlobsBundle = lh_eth2::types::BlobsBundle; @@ -71,6 +73,7 @@ pub type BeaconBlockGloas = lh_types::BeaconBlockGloas; pub type BeaconBlockRef<'a> = lh_types::BeaconBlockRef<'a, MainnetEthSpec>; pub type ExecutionPayloadGloas = lh_types::ExecutionPayloadGloas; pub type ExecutionRequestsGloas = lh_types::ExecutionRequestsGloas; +pub use lh_types::{BuilderDepositRequest, BuilderExitRequest, RequestType}; pub type ExecutionPayloadEnvelope = lh_types::ExecutionPayloadEnvelope; pub type SignedExecutionPayloadEnvelope = lh_types::SignedExecutionPayloadEnvelope; pub type ExecutionPayloadBid = lh_types::ExecutionPayloadBid; From 51142d3109ade057b665a7407fc23fa8a0d20c96 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 18 Sep 2026 22:36:55 +0100 Subject: [PATCH 20/33] Bid the proposer's fee recipient and bind preferences to the slot's proposer --- config.example.yml | 3 +- crates/common/src/beacon/types/chain.rs | 110 +++++++++++++++++- crates/common/src/config.rs | 37 ++++++ .../auctioneer/get_execution_payload_bid.rs | 22 +++- .../relay/src/housekeeper/proposer_prefs.rs | 92 +++++++++++++-- crates/relay/src/housekeeper/tile.rs | 21 +++- 6 files changed, 267 insertions(+), 18 deletions(-) diff --git a/config.example.yml b/config.example.yml index 4b90282d1..b121556a4 100644 --- a/config.example.yml +++ b/config.example.yml @@ -61,7 +61,8 @@ discord_webhook_url: null blacklist_provider: null is_submission_instance: true is_registration_instance: true -logging: !File +logging: + type: File dir_path: /app/logs file_name: titan_relay.log otlp_server: http://localhost:4317 diff --git a/crates/common/src/beacon/types/chain.rs b/crates/common/src/beacon/types/chain.rs index 27ae3e6aa..3b4e8b113 100644 --- a/crates/common/src/beacon/types/chain.rs +++ b/crates/common/src/beacon/types/chain.rs @@ -1,6 +1,12 @@ use alloy_primitives::{Address, B256, hex}; -use helix_types::{BlsSignatureBytes, Slot, Withdrawals}; +use helix_types::{ + BlsPublicKey, BlsPublicKeyBytes, BlsSignature, BlsSignatureBytes, Domain, EthSpec, + MainnetEthSpec, SignedRoot, Slot, Withdrawals, +}; use serde::{Deserialize, Serialize}; +use tree_hash_derive::TreeHash; + +use crate::chain_info::ChainInfo; #[derive(Serialize, Deserialize, Clone, Debug)] pub enum StateId { @@ -82,9 +88,33 @@ pub struct SignedProposerPreferences { pub signature: BlsSignatureBytes, } +impl SignedRoot for ProposerPreferences {} + +impl SignedProposerPreferences { + /// Whether `pubkey` signed these preferences. The fee recipient a builder pays + /// comes from here, so an unsigned message would let anyone redirect it. + pub fn verify(&self, pubkey: &BlsPublicKeyBytes, chain_info: &ChainInfo) -> bool { + let epoch = self.message.proposal_slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::ProposerPreferences, + &fork, + chain_info.genesis_validators_root, + ); + let Ok(pubkey) = BlsPublicKey::deserialize(pubkey.as_ref()) else { + return false; + }; + let Ok(signature) = BlsSignature::deserialize(self.signature.as_ref()) else { + return false; + }; + signature.verify(&pubkey, self.message.signing_root(domain)) + } +} + /// Gossiped once per proposal slot, per /// . -#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, TreeHash)] pub struct ProposerPreferences { pub dependent_root: B256, pub proposal_slot: Slot, @@ -138,3 +168,79 @@ pub struct PayloadAttributes { pub withdrawals: Withdrawals, pub parent_beacon_block_root: Option, } + +#[cfg(test)] +mod proposer_preferences_signature_tests { + use helix_types::BlsKeypair; + + use super::*; + + fn prefs(slot: u64) -> ProposerPreferences { + ProposerPreferences { + dependent_root: B256::repeat_byte(0x11), + proposal_slot: Slot::new(slot), + validator_index: 42, + fee_recipient: Address::repeat_byte(0x22), + target_gas_limit: 45_000_000, + } + } + + fn sign( + message: &ProposerPreferences, + keypair: &BlsKeypair, + chain_info: &ChainInfo, + ) -> BlsSignatureBytes { + let epoch = message.proposal_slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::ProposerPreferences, + &fork, + chain_info.genesis_validators_root, + ); + keypair.sk.sign(message.signing_root(domain)).serialize().into() + } + + #[test] + fn the_proposers_own_signature_verifies() { + crate::utils::install_default_crypto_provider(); + let chain_info = ChainInfo::default(); + let keypair = BlsKeypair::random(); + let message = prefs(100); + let signed = + SignedProposerPreferences { signature: sign(&message, &keypair, &chain_info), message }; + + assert!(signed.verify(&keypair.pk.serialize().into(), &chain_info)); + } + + /// The whole point: preferences signed by someone else must not be accepted for + /// this proposer, or the fee recipient the builder pays can be redirected. + #[test] + fn another_keys_signature_is_refused() { + crate::utils::install_default_crypto_provider(); + let chain_info = ChainInfo::default(); + let proposer = BlsKeypair::random(); + let attacker = BlsKeypair::random(); + let message = prefs(100); + let signed = SignedProposerPreferences { + signature: sign(&message, &attacker, &chain_info), + message, + }; + + assert!(!signed.verify(&proposer.pk.serialize().into(), &chain_info)); + } + + #[test] + fn a_tampered_fee_recipient_is_refused() { + crate::utils::install_default_crypto_provider(); + let chain_info = ChainInfo::default(); + let keypair = BlsKeypair::random(); + let message = prefs(100); + let signature = sign(&message, &keypair, &chain_info); + let mut tampered = message; + tampered.fee_recipient = Address::repeat_byte(0xff); + let signed = SignedProposerPreferences { message: tampered, signature }; + + assert!(!signed.verify(&keypair.pk.serialize().into(), &chain_info)); + } +} diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 054b31c1d..6d8854f7a 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -1023,3 +1023,40 @@ mod tests { assert!(!config.treat_as_append_only(other)); } } + +#[cfg(test)] +mod logging_config_tests { + use super::*; + + /// A config the relay cannot parse crash-loops it on start, so the exact YAML + /// shape the deployed file uses has to be pinned here. + #[test] + fn the_file_logging_shape_parses() { + let yaml = " +type: File +dir_path: /app/logs +file_name: titan_relay.log +otlp_server: null +"; + let parsed: LoggingConfig = serde_yaml::from_str(yaml).expect("deployed shape must parse"); + + match parsed { + LoggingConfig::File { dir_path, file_name, otlp_server } => { + assert_eq!(dir_path, PathBuf::from("/app/logs")); + assert_eq!(file_name, "titan_relay.log"); + assert!(otlp_server.is_none()); + } + LoggingConfig::Console => panic!("parsed as Console"), + } + } + + /// `config.example.yml` carries the externally tagged `!File` form, which this + /// internally tagged enum cannot read. Pinned so the example gets fixed rather + /// than copied into a deployment. + #[test] + fn the_externally_tagged_shape_does_not_parse() { + let yaml = "!File\ndir_path: /app/logs\nfile_name: titan_relay.log\notlp_server: null\n"; + + assert!(serde_yaml::from_str::(yaml).is_err()); + } +} diff --git a/crates/relay/src/auctioneer/get_execution_payload_bid.rs b/crates/relay/src/auctioneer/get_execution_payload_bid.rs index cacf73591..8d287157e 100644 --- a/crates/relay/src/auctioneer/get_execution_payload_bid.rs +++ b/crates/relay/src/auctioneer/get_execution_payload_bid.rs @@ -33,7 +33,13 @@ impl Context { self.bid_sorter.get_header(&fork).ok_or(ProposerApiError::NoBidPrepared)?; let entry = self.payloads.get(&best_block_hash).ok_or(ProposerApiError::NoBidPrepared)?; - build_signed_bid(entry, ¶ms, &self.gloas_builder_identity, &self.chain_info) + build_signed_bid( + entry, + ¶ms, + &self.gloas_builder_identity, + &self.chain_info, + slot_data, + ) }); let _ = res_tx.send(result); } @@ -79,6 +85,7 @@ pub(super) fn build_signed_bid( params: &GetExecutionPayloadBidParams, identity: &GloasBuilderIdentity, chain_info: &ChainInfo, + slot_data: &SlotData, ) -> Result { let slot = Slot::new(params.slot); @@ -104,7 +111,10 @@ pub(super) fn build_signed_bid( parent_block_root: params.parent_root, block_hash: ExecutionBlockHash(*entry.block_hash()), prev_randao: payload.prev_randao, - fee_recipient: payload.fee_recipient, + // The proposer's, not the payload's: the builder keeps the coinbase and pays + // the proposer in-block, and consensus pays any enshrined amount to whatever + // this names. Announcing the coinbase names the builder. + fee_recipient: slot_data.registration_data.entry.registration.message.fee_recipient, gas_limit: payload.gas_limit, builder_index: identity.builder_index, slot, @@ -318,8 +328,9 @@ mod tests { let entry = payload_entry(block_hash, 42 * WEI_PER_GWEI); let identity = bid_identity(7); let params = params(parent_hash, parent_root); + let data = live_slot_data(parent_hash, parent_root); - let signed_bid = build_signed_bid(&entry, ¶ms, &identity, &chain_info).unwrap(); + let signed_bid = build_signed_bid(&entry, ¶ms, &identity, &chain_info, &data).unwrap(); assert_eq!(signed_bid.message.block_hash.0, block_hash); assert_eq!(signed_bid.message.parent_block_hash.0, parent_hash); @@ -327,6 +338,11 @@ mod tests { assert_eq!(signed_bid.message.builder_index, 7); assert_eq!(signed_bid.message.value, 0, "the proposer is paid in-block"); assert_eq!(signed_bid.message.execution_payment, 42, "wei converts to gwei"); + assert_eq!( + signed_bid.message.fee_recipient, + data.registration_data.entry.registration.message.fee_recipient, + "consensus pays any enshrined amount here, so it must be the proposer's", + ); let epoch = signed_bid.message.slot.epoch(helix_types::MainnetEthSpec::slots_per_epoch()); let fork = chain_info.spec.fork_at_epoch(epoch); diff --git a/crates/relay/src/housekeeper/proposer_prefs.rs b/crates/relay/src/housekeeper/proposer_prefs.rs index c7d54504f..fa26ed32f 100644 --- a/crates/relay/src/housekeeper/proposer_prefs.rs +++ b/crates/relay/src/housekeeper/proposer_prefs.rs @@ -3,27 +3,34 @@ use helix_common::{ api::{ builder_api::BuilderGetValidatorsResponseEntry, proposer_api::ValidatorRegistrationInfo, }, - beacon::types::{ProposerPreferences, ProposerPreferencesEvent}, + beacon::types::{ProposerPreferences, ProposerPreferencesEvent, SignedProposerPreferences}, + chain_info::ChainInfo, }; use helix_types::{SignedValidatorRegistration, Slot}; use rustc_hash::FxHashMap; #[derive(Default)] pub struct ProposerPreferencesStore { - by_slot: FxHashMap, + /// The signature is kept so the proposer's own key can be checked later, once + /// the duty that names the proposer is to hand. + by_slot: FxHashMap, } impl ProposerPreferencesStore { /// The proposer may resubmit up to an epoch ahead, so a later event wins. pub fn process(&mut self, head: Slot, event: ProposerPreferencesEvent) { - let prefs = event.data.message; - if prefs.proposal_slot <= head { + let signed = event.data; + if signed.message.proposal_slot <= head { return; } - self.by_slot.insert(prefs.proposal_slot, prefs); + self.by_slot.insert(signed.message.proposal_slot, signed); } pub fn get(&self, slot: Slot) -> Option<&ProposerPreferences> { + self.by_slot.get(&slot).map(|signed| &signed.message) + } + + pub fn get_signed(&self, slot: Slot) -> Option<&SignedProposerPreferences> { self.by_slot.get(&slot) } @@ -31,12 +38,37 @@ impl ProposerPreferencesStore { self.by_slot.retain(|slot, _| *slot >= bid_slot); } + /// Reports whether the slot's preferences carry the proposer's own signature. + /// Logged rather than enforced for now: the signing domain has not yet been + /// confirmed against live gossip, and refusing wrongly would stop every bid. + pub fn check_signature(&self, duty: &ProposerDuty, chain_info: &ChainInfo) -> bool { + let Some(signed) = self.get_signed(duty.slot) else { + return false; + }; + let ok = signed.verify(&duty.pubkey, chain_info); + if !ok { + tracing::warn!( + slot = %duty.slot, + validator_index = duty.validator_index, + "proposer preferences failed signature verification", + ); + } + ok + } + #[cfg(test)] fn len(&self) -> usize { self.by_slot.len() } } +/// Whether these preferences were gossiped by the validator that actually proposes the +/// slot. Nothing else binds the two: the fee recipient our builder pays comes straight +/// from here, so preferences from any other index would redirect the payment. +pub fn prefs_match_duty(duty: &ProposerDuty, prefs: &ProposerPreferences) -> bool { + prefs.validator_index == duty.validator_index && prefs.proposal_slot == duty.slot +} + /// Gloas has no `registerValidator`, so the entry comes from the beacon duty and the gossiped /// preferences, with this relay's configured preferences as the proposer cannot express its own. pub fn synthesize_registration( @@ -68,7 +100,10 @@ pub fn synthesize_duty_feed( .iter() .filter(|duty| duty.slot >= from_slot) .filter_map(|duty| { - prefs.get(duty.slot).map(|prefs| synthesize_registration(duty, prefs, defaults)) + prefs + .get(duty.slot) + .filter(|prefs| prefs_match_duty(duty, prefs)) + .map(|prefs| synthesize_registration(duty, prefs, defaults)) }) .collect() } @@ -101,6 +136,19 @@ mod tests { ev } + /// As `event`, but gossiped by `index` -- the feed only accepts preferences from + /// the validator that actually proposes the slot. + fn event_from( + slot: u64, + index: u64, + fee_recipient: Address, + target_gas_limit: u64, + ) -> ProposerPreferencesEvent { + let mut ev = event(slot, fee_recipient, target_gas_limit); + ev.data.message.validator_index = index; + ev + } + #[test] fn parses_a_live_proposer_preferences_event() { let ev: ProposerPreferencesEvent = serde_json::from_str(LIVE_EVENT).unwrap(); @@ -205,12 +253,18 @@ mod tests { } } - fn store_with(slots: &[u64]) -> ProposerPreferencesStore { + /// `(slot, proposer index)` pairs, matching how `duty` numbers them. + fn store_with(entries: &[(u64, u64)]) -> ProposerPreferencesStore { let mut store = ProposerPreferencesStore::default(); - for slot in slots { + for (slot, index) in entries { store.process( Slot::new(0), - event(*slot, address!("00000000000000000000000000000000000000aa"), 45_000_000), + event_from( + *slot, + *index, + address!("00000000000000000000000000000000000000aa"), + 45_000_000, + ), ); } store @@ -219,7 +273,7 @@ mod tests { #[test] fn serves_only_the_slots_with_a_gossiped_preference() { let duties = [duty(101, 1), duty(102, 2), duty(103, 3)]; - let store = store_with(&[101, 103]); + let store = store_with(&[(101, 1), (103, 3)]); let feed = synthesize_duty_feed(&duties, &store, Slot::new(101), &ValidatorPreferences::default()); @@ -233,7 +287,7 @@ mod tests { #[test] fn drops_duties_before_the_bid_slot() { let duties = [duty(100, 1), duty(101, 2)]; - let store = store_with(&[100, 101]); + let store = store_with(&[(100, 1), (101, 2)]); let feed = synthesize_duty_feed(&duties, &store, Slot::new(101), &ValidatorPreferences::default()); @@ -241,4 +295,20 @@ mod tests { let slots: Vec = feed.iter().map(|e| e.slot.as_u64()).collect(); assert_eq!(slots, vec![101], "builders cannot build a slot that has started"); } + + /// The fee recipient the builder pays comes straight from these preferences, so + /// preferences gossiped by anyone but the slot's proposer would redirect the + /// payment. Nothing else binds the two. + #[test] + fn refuses_preferences_gossiped_by_another_validator() { + let attacker = address!("00000000000000000000000000000000000000ff"); + let duties = [duty(101, 1)]; + let mut store = ProposerPreferencesStore::default(); + store.process(Slot::new(0), event_from(101, 999, attacker, 45_000_000)); + + let feed = + synthesize_duty_feed(&duties, &store, Slot::new(101), &ValidatorPreferences::default()); + + assert!(feed.is_empty(), "a slot must not be served on someone else's preferences"); + } } diff --git a/crates/relay/src/housekeeper/tile.rs b/crates/relay/src/housekeeper/tile.rs index 6bfda93dd..1dcf734f9 100644 --- a/crates/relay/src/housekeeper/tile.rs +++ b/crates/relay/src/housekeeper/tile.rs @@ -40,7 +40,10 @@ use crate::{ PRIMEV_BUILDER_ID, PrimevBuildersFetch, PrimevValidatorsFetch, build_primev_builder_configs, }, - proposer_prefs::{ProposerPreferencesStore, synthesize_duty_feed, synthesize_registration}, + proposer_prefs::{ + ProposerPreferencesStore, prefs_match_duty, synthesize_duty_feed, + synthesize_registration, + }, }, network::RelayNetworkManager, spine::messages::SlotMsg, @@ -239,6 +242,21 @@ impl HousekeeperTile { from_slot, &self.validator_preferences, ); + + // Signature checking is reported, not enforced: the domain has not been + // confirmed against live gossip yet, and refusing wrongly stops every bid. + let chain_info = self.chain_head.chain_info(); + let checked = self + .duties + .iter() + .filter(|duty| duty.slot >= from_slot && self.proposer_prefs.get(duty.slot).is_some()); + let (signed_ok, total) = checked.fold((0u32, 0u32), |(ok, total), duty| { + (ok + u32::from(self.proposer_prefs.check_signature(duty, chain_info)), total + 1) + }); + if total > 0 { + info!(signed_ok, total, "proposer preference signatures checked"); + } + if synthesized.is_empty() { return; } @@ -698,6 +716,7 @@ fn send_slot_event( .iter() .find(|d| d.slot.as_u64() == bid_slot.as_u64()) .zip(proposer_prefs.get(bid_slot)) + .filter(|(duty, prefs)| prefs_match_duty(duty, prefs)) .map(|(duty, prefs)| synthesize_registration(duty, prefs, validator_preferences)); } let next_payload_attributes: Vec = known_payload_attributes From 35e0477190cde97d4381daf344ae57eb93a92882 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 18 Sep 2026 22:38:44 +0100 Subject: [PATCH 21/33] Give the builder a log directory so a redeploy keeps the record --- Cargo.lock | 1 + crates/builder/Cargo.toml | 1 + crates/builder/src/cli.rs | 8 ++++++++ crates/builder/src/main.rs | 21 ++++++++++++++++++--- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1876844eb..7961014c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5793,6 +5793,7 @@ dependencies = [ "tokio-util", "tower 0.5.3", "tracing", + "tracing-appender", "tracing-subscriber 0.3.23", "uuid", "zstd", diff --git a/crates/builder/Cargo.toml b/crates/builder/Cargo.toml index 3d482a8c5..b0ae635aa 100644 --- a/crates/builder/Cargo.toml +++ b/crates/builder/Cargo.toml @@ -59,6 +59,7 @@ tikv-jemallocator.workspace = true tokio.workspace = true tokio-util.workspace = true tracing.workspace = true +tracing-appender.workspace = true tracing-subscriber.workspace = true uuid = { workspace = true, features = ["serde"] } zstd.workspace = true diff --git a/crates/builder/src/cli.rs b/crates/builder/src/cli.rs index ffd3dac5b..7e1ab89c8 100644 --- a/crates/builder/src/cli.rs +++ b/crates/builder/src/cli.rs @@ -74,6 +74,14 @@ pub struct NodeOptions { pub skip_genesis_validation: bool, #[arg(long = "log.level", default_value_t = Level::INFO, value_name = "LOG_LEVEL", help = "info, debug, trace, warn or error", env = "ETHREX_LOG_LEVEL", help_heading = "Node options")] pub log_level: Level, + #[arg( + long = "log.dir", + value_name = "PATH", + help = "Write daily-rotated logs here instead of stdout, so a redeploy keeps the record.", + env = "HELIX_LOG_DIR", + help_heading = "Node options" + )] + pub log_dir: Option, #[arg( long = "http.addr", diff --git a/crates/builder/src/main.rs b/crates/builder/src/main.rs index 4233f25d0..be511423d 100644 --- a/crates/builder/src/main.rs +++ b/crates/builder/src/main.rs @@ -35,7 +35,7 @@ fn main() -> eyre::Result<()> { install_default_crypto_provider(); let cli = BuilderCli::parse(); - init_tracing(&cli); + let _log_guard = init_tracing(&cli); let merging_config = cli.merging_config.as_deref().map(MergingConfig::load).transpose()?; let simulation_config = cli.sim_config.as_deref().map(SimulationConfig::load).transpose()?; @@ -189,8 +189,23 @@ fn main() -> eyre::Result<()> { Ok(()) } -fn init_tracing(cli: &BuilderCli) { +/// Returns the appender guard, which has to outlive the process's logging. +fn init_tracing(cli: &BuilderCli) -> Option { let filter = EnvFilter::builder().with_default_directive(cli.node.log_level.into()).from_env_lossy(); - tracing_subscriber::fmt().with_env_filter(filter).init(); + + let Some(dir) = cli.node.log_dir.as_ref() else { + tracing_subscriber::fmt().with_env_filter(filter).init(); + return None; + }; + + let appender = tracing_appender::rolling::Builder::new() + .filename_prefix("helix_builder.log") + .max_log_files(14) + .rotation(tracing_appender::rolling::Rotation::DAILY) + .build(dir) + .expect("failed to create the log appender"); + let (writer, guard) = tracing_appender::non_blocking(appender); + tracing_subscriber::fmt().with_env_filter(filter).with_writer(writer).with_ansi(false).init(); + Some(guard) } From 6fc2c03bfb28a208260c79a50c82581a0639faeb Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 18 Sep 2026 23:01:48 +0100 Subject: [PATCH 22/33] Refuse proposer preferences the slot's proposer did not sign --- .../relay/src/housekeeper/proposer_prefs.rs | 113 +++++++++++++----- crates/relay/src/housekeeper/tile.rs | 25 ++-- 2 files changed, 95 insertions(+), 43 deletions(-) diff --git a/crates/relay/src/housekeeper/proposer_prefs.rs b/crates/relay/src/housekeeper/proposer_prefs.rs index fa26ed32f..785216a76 100644 --- a/crates/relay/src/housekeeper/proposer_prefs.rs +++ b/crates/relay/src/housekeeper/proposer_prefs.rs @@ -38,9 +38,7 @@ impl ProposerPreferencesStore { self.by_slot.retain(|slot, _| *slot >= bid_slot); } - /// Reports whether the slot's preferences carry the proposer's own signature. - /// Logged rather than enforced for now: the signing domain has not yet been - /// confirmed against live gossip, and refusing wrongly would stop every bid. + /// Whether the slot's preferences carry the proposer's own signature. pub fn check_signature(&self, duty: &ProposerDuty, chain_info: &ChainInfo) -> bool { let Some(signed) = self.get_signed(duty.slot) else { return false; @@ -95,10 +93,12 @@ pub fn synthesize_duty_feed( prefs: &ProposerPreferencesStore, from_slot: Slot, defaults: &ValidatorPreferences, + chain_info: &ChainInfo, ) -> Vec { beacon_duties .iter() .filter(|duty| duty.slot >= from_slot) + .filter(|duty| prefs.check_signature(duty, chain_info)) .filter_map(|duty| { prefs .get(duty.slot) @@ -245,38 +245,71 @@ mod tests { assert_eq!(store.len(), 1); } - fn duty(slot: u64, index: u64) -> ProposerDuty { + /// Duties now have to carry a real key: the feed only accepts preferences the + /// proposer actually signed. + fn duty_signed_by(keypair: &helix_types::BlsKeypair, slot: u64, index: u64) -> ProposerDuty { ProposerDuty { - pubkey: helix_types::BlsPublicKeyBytes::from([index as u8; 48]), + pubkey: keypair.pk.serialize().into(), validator_index: index, slot: Slot::new(slot), } } - /// `(slot, proposer index)` pairs, matching how `duty` numbers them. - fn store_with(entries: &[(u64, u64)]) -> ProposerPreferencesStore { + fn sign_event( + keypair: &helix_types::BlsKeypair, + chain_info: &ChainInfo, + mut ev: ProposerPreferencesEvent, + ) -> ProposerPreferencesEvent { + use helix_types::{EthSpec, MainnetEthSpec, SignedRoot}; + let epoch = ev.data.message.proposal_slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + helix_types::Domain::ProposerPreferences, + &fork, + chain_info.genesis_validators_root, + ); + ev.data.signature = + keypair.sk.sign(ev.data.message.signing_root(domain)).serialize().into(); + ev + } + + /// Builds duties and a matching, properly signed store for `(slot, index)` pairs. + fn duties_and_store( + entries: &[(u64, u64)], + chain_info: &ChainInfo, + ) -> (Vec, ProposerPreferencesStore) { + helix_common::utils::install_default_crypto_provider(); + let mut duties = Vec::new(); let mut store = ProposerPreferencesStore::default(); for (slot, index) in entries { - store.process( - Slot::new(0), - event_from( - *slot, - *index, - address!("00000000000000000000000000000000000000aa"), - 45_000_000, - ), + let keypair = helix_types::BlsKeypair::random(); + duties.push(duty_signed_by(&keypair, *slot, *index)); + let ev = event_from( + *slot, + *index, + address!("00000000000000000000000000000000000000aa"), + 45_000_000, ); + store.process(Slot::new(0), sign_event(&keypair, chain_info, ev)); } - store + (duties, store) } #[test] fn serves_only_the_slots_with_a_gossiped_preference() { - let duties = [duty(101, 1), duty(102, 2), duty(103, 3)]; - let store = store_with(&[(101, 1), (103, 3)]); - - let feed = - synthesize_duty_feed(&duties, &store, Slot::new(101), &ValidatorPreferences::default()); + let chain_info = ChainInfo::default(); + let (mut duties, store) = duties_and_store(&[(101, 1), (103, 3)], &chain_info); + duties.push(duty_signed_by(&helix_types::BlsKeypair::random(), 102, 2)); + duties.sort_by_key(|d| d.slot); + + let feed = synthesize_duty_feed( + &duties, + &store, + Slot::new(101), + &ValidatorPreferences::default(), + &chain_info, + ); let slots: Vec = feed.iter().map(|e| e.slot.as_u64()).collect(); assert_eq!(slots, vec![101, 103], "a slot without preferences cannot be served"); @@ -286,11 +319,16 @@ mod tests { #[test] fn drops_duties_before_the_bid_slot() { - let duties = [duty(100, 1), duty(101, 2)]; - let store = store_with(&[(100, 1), (101, 2)]); - - let feed = - synthesize_duty_feed(&duties, &store, Slot::new(101), &ValidatorPreferences::default()); + let chain_info = ChainInfo::default(); + let (duties, store) = duties_and_store(&[(100, 1), (101, 2)], &chain_info); + + let feed = synthesize_duty_feed( + &duties, + &store, + Slot::new(101), + &ValidatorPreferences::default(), + &chain_info, + ); let slots: Vec = feed.iter().map(|e| e.slot.as_u64()).collect(); assert_eq!(slots, vec![101], "builders cannot build a slot that has started"); @@ -301,13 +339,24 @@ mod tests { /// payment. Nothing else binds the two. #[test] fn refuses_preferences_gossiped_by_another_validator() { - let attacker = address!("00000000000000000000000000000000000000ff"); - let duties = [duty(101, 1)]; - let mut store = ProposerPreferencesStore::default(); - store.process(Slot::new(0), event_from(101, 999, attacker, 45_000_000)); + helix_common::utils::install_default_crypto_provider(); + let chain_info = ChainInfo::default(); + let attacker_key = helix_types::BlsKeypair::random(); + let attacker_address = address!("00000000000000000000000000000000000000ff"); + let duties = [duty_signed_by(&helix_types::BlsKeypair::random(), 101, 1)]; - let feed = - synthesize_duty_feed(&duties, &store, Slot::new(101), &ValidatorPreferences::default()); + // Correctly signed, but by someone who does not propose this slot. + let mut store = ProposerPreferencesStore::default(); + let ev = event_from(101, 999, attacker_address, 45_000_000); + store.process(Slot::new(0), sign_event(&attacker_key, &chain_info, ev)); + + let feed = synthesize_duty_feed( + &duties, + &store, + Slot::new(101), + &ValidatorPreferences::default(), + &chain_info, + ); assert!(feed.is_empty(), "a slot must not be served on someone else's preferences"); } diff --git a/crates/relay/src/housekeeper/tile.rs b/crates/relay/src/housekeeper/tile.rs index 1dcf734f9..85e91db6a 100644 --- a/crates/relay/src/housekeeper/tile.rs +++ b/crates/relay/src/housekeeper/tile.rs @@ -236,25 +236,25 @@ impl HousekeeperTile { return; } + let chain_info = self.chain_head.chain_info(); let synthesized = synthesize_duty_feed( &self.duties, &self.proposer_prefs, from_slot, &self.validator_preferences, + chain_info, ); - // Signature checking is reported, not enforced: the domain has not been - // confirmed against live gossip yet, and refusing wrongly stops every bid. - let chain_info = self.chain_head.chain_info(); - let checked = self + let offered = self .duties .iter() - .filter(|duty| duty.slot >= from_slot && self.proposer_prefs.get(duty.slot).is_some()); - let (signed_ok, total) = checked.fold((0u32, 0u32), |(ok, total), duty| { - (ok + u32::from(self.proposer_prefs.check_signature(duty, chain_info)), total + 1) - }); - if total > 0 { - info!(signed_ok, total, "proposer preference signatures checked"); + .filter(|duty| duty.slot >= from_slot && self.proposer_prefs.get(duty.slot).is_some()) + .count(); + if offered > synthesized.len() { + warn!( + refused = offered - synthesized.len(), + offered, "proposer preferences refused: wrong proposer or bad signature", + ); } if synthesized.is_empty() { @@ -716,7 +716,10 @@ fn send_slot_event( .iter() .find(|d| d.slot.as_u64() == bid_slot.as_u64()) .zip(proposer_prefs.get(bid_slot)) - .filter(|(duty, prefs)| prefs_match_duty(duty, prefs)) + .filter(|(duty, prefs)| { + prefs_match_duty(duty, prefs) && + proposer_prefs.check_signature(duty, chain_head.chain_info()) + }) .map(|(duty, prefs)| synthesize_registration(duty, prefs, validator_preferences)); } let next_payload_attributes: Vec = known_payload_attributes From 3c73efbc20bea1b4397ae80db779ec81c37176e5 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 18 Sep 2026 23:38:14 +0100 Subject: [PATCH 23/33] Do not resubmit a slot once its offsets have passed --- crates/builder/src/building/schedule.rs | 62 +++++++++++++++++++------ 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/crates/builder/src/building/schedule.rs b/crates/builder/src/building/schedule.rs index 200d764c2..4de6cbe75 100644 --- a/crates/builder/src/building/schedule.rs +++ b/crates/builder/src/building/schedule.rs @@ -11,20 +11,19 @@ use crate::building::slot::SlotContext; /// Every entry is relative to the same instant, so a caller must sleep to an /// absolute deadline rather than sleeping each in turn. /// -/// A slot learned about late still gets one immediate attempt: dropping it -/// would mean no bid at all for that slot. +/// Empty once every offset has passed. Whether that still deserves an immediate +/// attempt is the caller's call, because it depends on whether the slot has been +/// bid on already. pub fn delays(slot_timestamp: u64, offsets: &[u64], now_ms: u64) -> Vec { let start_ms = slot_timestamp * 1_000; let mut sorted: Vec = offsets.to_vec(); sorted.sort_unstable(); - let upcoming: Vec = sorted + sorted .iter() .filter_map(|offset| start_ms.checked_add(*offset)?.checked_sub(now_ms)) .map(Duration::from_millis) - .collect(); - - if upcoming.is_empty() { vec![Duration::ZERO] } else { upcoming } + .collect() } /// Runs one slot's attempts, one per delay, measured from a single instant. @@ -54,12 +53,25 @@ pub async fn drive( N: Fn() -> u64, { let mut current: Option> = None; + let mut scheduled_slot: Option = None; while let Some(slot) = contexts.recv().await { + let mut delays = delays(slot.timestamp, offsets, now_ms()); + if delays.is_empty() { + if scheduled_slot == Some(slot.slot) { + // A new head this late only replaces a bid already sent, and the + // relay has moved on to the next bid slot by now, so an immediate + // attempt is refused as a submission for the wrong slot. + continue; + } + // Nothing has been sent for this slot, so one late attempt beats none. + delays.push(Duration::ZERO); + } + if let Some(handle) = current.take() { handle.abort(); } - let delays = delays(slot.timestamp, offsets, now_ms()); + scheduled_slot = Some(slot.slot); current = Some(tokio::spawn(run_schedule(slot, delays, attempt.clone()))); } @@ -173,6 +185,30 @@ mod tests { /// The fallback in `delays` still has to hold: a slot learned about after /// every offset has passed gets one attempt rather than none. + /// The head moved again after the last offset had passed. The relay has moved + /// on to the next bid slot by then, so submitting produced a steady stream of + /// "submission for wrong slot" -- and a bid was already sent for this slot + /// anyway. + #[tokio::test] + async fn a_late_replacement_does_not_attempt() { + let attempts = attempts_for( + vec![ + context(1, 0xa1, SLOT_TIMESTAMP), + // Arrives once both offsets are long past. + context(1, 0xb2, SLOT_TIMESTAMP), + ], + &[20, 40], + START_MS + 9_000, + ) + .await; + + assert_eq!( + attempts, + vec![B256::repeat_byte(0xa1)], + "the first context still earns its one late attempt; the replacement adds nothing", + ); + } + #[tokio::test] async fn a_slot_learned_about_late_still_attempts_once() { let attempts = @@ -209,15 +245,15 @@ mod tests { assert_eq!(delays, vec![Duration::from_millis(1000)], "only the 2000ms offset is ahead"); } + /// Whether a passed slot still deserves an attempt depends on whether anything + /// has been sent for it, which only the driver knows -- see + /// `a_slot_learned_about_late_still_attempts_once` and + /// `a_late_replacement_does_not_attempt`. #[test] - fn a_late_event_still_gets_one_attempt() { + fn every_offset_past_leaves_no_delays() { let delays = delays(SLOT_TIMESTAMP, &[500, 2000], START_MS + 5_000); - assert_eq!( - delays, - vec![Duration::ZERO], - "a late payload_attributes must not mean no bid for the slot", - ); + assert!(delays.is_empty()); } #[test] From 7960eb24493a5a5441835bf5c641066709a482e2 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 19 Sep 2026 01:42:48 +0100 Subject: [PATCH 24/33] Answer a no-bid request with a bare 204 --- crates/relay/src/api/proposer/error.rs | 41 ++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/relay/src/api/proposer/error.rs b/crates/relay/src/api/proposer/error.rs index cffb37f3a..230322ffb 100644 --- a/crates/relay/src/api/proposer/error.rs +++ b/crates/relay/src/api/proposer/error.rs @@ -237,6 +237,13 @@ impl IntoResponse for ProposerApiError { ProposerApiError::InvalidGetHeader(_) => StatusCode::UNAUTHORIZED, }; + // A 204 carries no body, and no content-type either. Attaching the message + // leaves the response malformed, and clients then report it as whatever + // their stack makes of it. + if code == StatusCode::NO_CONTENT { + return code.into_response(); + } + (code, self.to_string()).into_response() } } @@ -259,3 +266,37 @@ mod tests { assert!(ProposerApiError::InternalServerError.should_report_gossiped()); } } + +#[cfg(test)] +mod response_shape_tests { + use axum::body::to_bytes; + + use super::*; + + /// RFC 9110: a 204 carries no body, and therefore no content-type. Sending one + /// leaves the response malformed, and what a client reports it as is then + /// anyone's guess. + #[tokio::test] + async fn no_bid_is_an_empty_204() { + let response = ProposerApiError::NoBidPrepared.into_response(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!( + response.headers().get(http::header::CONTENT_TYPE), + None, + "a 204 must not declare a content type", + ); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(body.is_empty(), "a 204 must not carry a body, got: {body:?}"); + } + + /// Errors that do carry an explanation keep it. + #[tokio::test] + async fn an_error_status_keeps_its_message() { + let response = ProposerApiError::InvalidFork.into_response(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(!body.is_empty()); + } +} From f11197b3d12bf02af589c03b92968f03be583ab7 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 19 Sep 2026 02:12:42 +0100 Subject: [PATCH 25/33] Build continuously from before the slot until the relay moves on --- crates/builder/README.md | 11 +- crates/builder/build-config.example.yml | 5 +- crates/builder/src/building/mod.rs | 48 ++-- crates/builder/src/building/schedule.rs | 230 +++++++++----------- crates/builder/src/building/submit.rs | 17 ++ crates/builder/src/building/submit/tests.rs | 25 +++ crates/builder/src/config.rs | 31 +-- 7 files changed, 210 insertions(+), 157 deletions(-) diff --git a/crates/builder/README.md b/crates/builder/README.md index 28ab6d002..4d27bbcd7 100644 --- a/crates/builder/README.md +++ b/crates/builder/README.md @@ -88,10 +88,13 @@ Gas for that transfer is held back from the fill by lowering ethrex's `remaining_gas` before `fill_transactions` and restoring it afterwards. The builder signs under the domain read from the beacon node's own spec and -genesis, never a compiled-in fork version. It builds at each -`submit_offsets_ms` point in the slot and submits only when the value beats -what it already sent for that slot and parent -- a new parent, after a re-org, -starts a fresh auction. +genesis, never a compiled-in fork version. It starts building `build_lead_ms` +before the slot begins, because the proposer asks for its bid at the slot +start, and then rebuilds and resubmits without pause, picking up whatever the +mempool has gained. It submits only when the value beats what it already sent +for that slot and parent -- a new parent, after a re-org, starts a fresh +auction. It stops when the relay reports that it has moved to the next bid +slot, or when the slot's own time runs out. From Amsterdam the block also carries the EIP-7928 block access list ethrex records and the EIP-7843 slot number, and the submission goes out in the Gloas diff --git a/crates/builder/build-config.example.yml b/crates/builder/build-config.example.yml index 45cc2c251..0fd9ec228 100644 --- a/crates/builder/build-config.example.yml +++ b/crates/builder/build-config.example.yml @@ -27,5 +27,6 @@ payout_gas_reserve: 21000 extra_data: "helix-builder" -# Points into the slot, in milliseconds, at which to build and submit. -submit_offsets_ms: [500, 2000] +# How long before the slot starts to begin building, in milliseconds. From then +# the builder rebuilds and resubmits without pause until the relay moves on. +build_lead_ms: 2000 diff --git a/crates/builder/src/building/mod.rs b/crates/builder/src/building/mod.rs index 61d3ae370..b2c882fdd 100644 --- a/crates/builder/src/building/mod.rs +++ b/crates/builder/src/building/mod.rs @@ -8,7 +8,7 @@ mod slot; mod submit; mod watcher; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use alloy_signer_local::PrivateKeySigner; use ethrex_blockchain::Blockchain; @@ -20,7 +20,10 @@ use tracing::{debug, error, info, warn}; pub use watcher::run as watch_slots; use crate::{ - building::{schedule::BestBid, slot::SlotContext}, + building::{ + schedule::{Attempt, BestBid}, + slot::SlotContext, + }, config::BuildingConfig, }; @@ -48,10 +51,13 @@ pub async fn build_blocks( chain_id: u64, contexts: mpsc::Receiver, ) { + let lead_ms = config.build_lead_ms; + // A slot cannot outlive itself: without a closing answer from the relay the + // loop still has to end. + let budget = Duration::from_secs(signing.chain_info.seconds_per_slot()); let submitter = Arc::new(submit::Submitter::new(&config.relay_url, config.api_key.clone(), signing)); let best = Arc::new(std::sync::Mutex::new(BestBid::default())); - let offsets = config.submit_offsets_ms.clone(); let attempt = move |slot: SlotContext| { let (config, store, blockchain, signer, submitter, best) = ( @@ -74,11 +80,11 @@ pub async fn build_blocks( Ok(Ok(built)) => built, Ok(Err(e)) => { warn!(slot = slot.slot, err = %e, "skipping slot"); - return; + return Attempt::Continue; } Err(e) => { error!(slot = slot.slot, err = %e, "build task panicked"); - return; + return Attempt::Continue; } }; @@ -87,7 +93,7 @@ pub async fn build_blocks( best.prune(slot.slot); if !best.improves(slot.slot, slot.parent_hash, built.value) { debug!(slot = slot.slot, value = %built.value, "not an improvement"); - return; + return Attempt::Continue; } } @@ -95,25 +101,35 @@ pub async fn build_blocks( Ok(bid) => bid, Err(e) => { warn!(slot = slot.slot, err = %e, "cannot sign the block"); - return; + return Attempt::Continue; } }; match submitter.submit(&bid).await { - Ok(()) => info!( - slot = slot.slot, - block_hash = %bid.message().block_hash, - txs = built.block.body.transactions.len(), - value = %built.value, - "submitted a block", - ), + Ok(()) => { + info!( + slot = slot.slot, + block_hash = %bid.message().block_hash, + txs = built.block.body.transactions.len(), + value = %built.value, + "submitted a block", + ); + Attempt::Continue + } + Err(e) if e.is_slot_closed() => { + info!(slot = slot.slot, "the relay has moved to the next slot"); + Attempt::SlotClosed + } // The relay's reason is how an operator learns the blocks are bad. - Err(e) => warn!(slot = slot.slot, err = %e, "the relay refused the block"), + Err(e) => { + warn!(slot = slot.slot, err = %e, "the relay refused the block"); + Attempt::Continue + } } } }; - schedule::drive(contexts, &offsets, now_ms, attempt).await; + schedule::drive(contexts, lead_ms, budget, now_ms, attempt).await; } fn now_ms() -> u64 { diff --git a/crates/builder/src/building/schedule.rs b/crates/builder/src/building/schedule.rs index 4de6cbe75..710a84ae2 100644 --- a/crates/builder/src/building/schedule.rs +++ b/crates/builder/src/building/schedule.rs @@ -6,73 +6,69 @@ use tokio::sync::mpsc; use crate::building::slot::SlotContext; -/// How long to wait before each build attempt, measured from `now_ms`. -/// -/// Every entry is relative to the same instant, so a caller must sleep to an -/// absolute deadline rather than sleeping each in turn. -/// -/// Empty once every offset has passed. Whether that still deserves an immediate -/// attempt is the caller's call, because it depends on whether the slot has been -/// bid on already. -pub fn delays(slot_timestamp: u64, offsets: &[u64], now_ms: u64) -> Vec { - let start_ms = slot_timestamp * 1_000; - let mut sorted: Vec = offsets.to_vec(); - sorted.sort_unstable(); - - sorted - .iter() - .filter_map(|offset| start_ms.checked_add(*offset)?.checked_sub(now_ms)) - .map(Duration::from_millis) - .collect() +/// A floor between attempts, so a build that fails immediately cannot spin. +const MIN_ATTEMPT_INTERVAL: Duration = Duration::from_millis(25); + +/// What the relay's answer says about whether this slot is still worth bidding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Attempt { + /// Keep going: a better block may still win the slot. + Continue, + /// The relay has moved on to the next bid slot, so nothing further is + /// accepted and building again would only waste the CPU. + SlotClosed, +} + +/// When to start building for a slot: `lead_ms` before it begins, or now if that +/// moment has passed. +fn start_delay(slot_timestamp: u64, lead_ms: u64, now_ms: u64) -> Duration { + let start_ms = (slot_timestamp * 1_000).saturating_sub(lead_ms); + Duration::from_millis(start_ms.saturating_sub(now_ms)) } -/// Runs one slot's attempts, one per delay, measured from a single instant. -async fn run_schedule(slot: SlotContext, delays: Vec, attempt: F) +/// Builds and submits without pause from `lead_ms` before the slot starts, each +/// attempt picking up whatever the mempool has gained, until the relay says the +/// slot has closed or the slot itself has run out. +async fn run_schedule(slot: SlotContext, start: Duration, budget: Duration, attempt: F) where F: Fn(SlotContext) -> Fut, - Fut: Future, + Fut: Future, { let base = tokio::time::Instant::now(); - for delay in delays { - tokio::time::sleep_until(base + delay).await; - attempt(slot.clone()).await; + tokio::time::sleep_until(base + start).await; + + let deadline = base + start + budget; + while tokio::time::Instant::now() < deadline { + let attempted_at = tokio::time::Instant::now(); + if attempt(slot.clone()).await == Attempt::SlotClosed { + return; + } + tokio::time::sleep_until(attempted_at + MIN_ATTEMPT_INTERVAL).await; } } -/// Drives one schedule at a time. A new context supersedes the one in flight: -/// the head moved, so every remaining attempt would bid on a parent the relay -/// has already replaced. +/// Drives one slot at a time. A new context supersedes the one in flight: the +/// head moved, so every further attempt would bid on a parent the relay has +/// already replaced. pub async fn drive( mut contexts: mpsc::Receiver, - offsets: &[u64], + lead_ms: u64, + budget: Duration, now_ms: N, attempt: F, ) where F: Fn(SlotContext) -> Fut + Clone + Send + 'static, - Fut: Future + Send + 'static, + Fut: Future + Send + 'static, N: Fn() -> u64, { let mut current: Option> = None; - let mut scheduled_slot: Option = None; while let Some(slot) = contexts.recv().await { - let mut delays = delays(slot.timestamp, offsets, now_ms()); - if delays.is_empty() { - if scheduled_slot == Some(slot.slot) { - // A new head this late only replaces a bid already sent, and the - // relay has moved on to the next bid slot by now, so an immediate - // attempt is refused as a submission for the wrong slot. - continue; - } - // Nothing has been sent for this slot, so one late attempt beats none. - delays.push(Duration::ZERO); - } - if let Some(handle) = current.take() { handle.abort(); } - scheduled_slot = Some(slot.slot); - current = Some(tokio::spawn(run_schedule(slot, delays, attempt.clone()))); + let start = start_delay(slot.timestamp, lead_ms, now_ms()); + current = Some(tokio::spawn(run_schedule(slot, start, budget, attempt.clone()))); } if let Some(handle) = current.take() { @@ -115,12 +111,20 @@ impl BestBid { #[cfg(test)] mod tests { - use std::sync::{Arc, Mutex}; + use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }; use helix_types::{BlsPublicKeyBytes, Withdrawals}; use super::*; + const SLOT_TIMESTAMP: u64 = 1_700_000_000; + const START_MS: u64 = SLOT_TIMESTAMP * 1_000; + const LEAD_MS: u64 = 2_000; + const BUDGET: Duration = Duration::from_millis(400); + fn context(slot: u64, parent: u8, timestamp: u64) -> SlotContext { SlotContext { slot, @@ -136,8 +140,12 @@ mod tests { } } - /// Drives `contexts` and reports the parent of every attempt, in order. - async fn attempts_for(contexts: Vec, offsets: &[u64], now_ms: u64) -> Vec { + /// Drives `contexts`, answering `outcome` for each attempt, and reports the + /// parent of every attempt in order. + async fn attempts_for(contexts: Vec, now_ms: u64, outcome: F) -> Vec + where + F: Fn(usize) -> Attempt + Send + Sync + 'static, + { let (tx, rx) = mpsc::channel(16); for context in contexts { tx.send(context).await.unwrap(); @@ -145,15 +153,19 @@ mod tests { drop(tx); let seen = Arc::new(Mutex::new(Vec::new())); + let calls = Arc::new(AtomicUsize::new(0)); let recorder = seen.clone(); + let outcome = Arc::new(outcome); drive( rx, - offsets, + LEAD_MS, + BUDGET, move || now_ms, move |slot: SlotContext| { - let recorder = recorder.clone(); + let (recorder, calls, outcome) = (recorder.clone(), calls.clone(), outcome.clone()); async move { recorder.lock().unwrap().push(slot.parent_hash); + outcome(calls.fetch_add(1, Ordering::SeqCst)) } }, ) @@ -163,97 +175,71 @@ mod tests { attempts } - /// The head moved, so the first context's remaining attempts would bid on a - /// parent the relay has already replaced. Before the driver they ran anyway, - /// late and all at once, and the relay answered "unknown parent hash" or - /// "submission for wrong slot". - #[tokio::test] - async fn a_superseded_context_never_attempts() { - let attempts = attempts_for( - vec![context(1, 0xa1, SLOT_TIMESTAMP), context(1, 0xb2, SLOT_TIMESTAMP)], - &[20, 40], - START_MS, - ) - .await; + #[test] + fn building_starts_before_the_slot_does() { + let delay = start_delay(SLOT_TIMESTAMP, LEAD_MS, START_MS - 10_000); - assert_eq!( - attempts, - vec![B256::repeat_byte(0xb2), B256::repeat_byte(0xb2)], - "only the newest parent may be bid on", - ); + assert_eq!(delay, Duration::from_millis(8_000), "2s before a slot 10s away"); } - /// The fallback in `delays` still has to hold: a slot learned about after - /// every offset has passed gets one attempt rather than none. - /// The head moved again after the last offset had passed. The relay has moved - /// on to the next bid slot by then, so submitting produced a steady stream of - /// "submission for wrong slot" -- and a bid was already sent for this slot - /// anyway. - #[tokio::test] - async fn a_late_replacement_does_not_attempt() { - let attempts = attempts_for( - vec![ - context(1, 0xa1, SLOT_TIMESTAMP), - // Arrives once both offsets are long past. - context(1, 0xb2, SLOT_TIMESTAMP), - ], - &[20, 40], - START_MS + 9_000, - ) - .await; + #[test] + fn a_slot_learned_about_late_starts_at_once() { + let delay = start_delay(SLOT_TIMESTAMP, LEAD_MS, START_MS + 500); - assert_eq!( - attempts, - vec![B256::repeat_byte(0xa1)], - "the first context still earns its one late attempt; the replacement adds nothing", - ); + assert_eq!(delay, Duration::ZERO, "no waiting for a moment already gone"); } + /// The point of the loop: keep building better blocks for as long as the + /// relay will take them, rather than bidding once and stopping. #[tokio::test] - async fn a_slot_learned_about_late_still_attempts_once() { - let attempts = - attempts_for(vec![context(1, 0xa1, SLOT_TIMESTAMP)], &[20, 40], START_MS + 9_000).await; + async fn it_keeps_building_until_the_relay_closes_the_slot() { + let attempts = attempts_for(vec![context(1, 0xa1, SLOT_TIMESTAMP)], START_MS, |n| { + if n >= 3 { Attempt::SlotClosed } else { Attempt::Continue } + }) + .await; - assert_eq!(attempts, vec![B256::repeat_byte(0xa1)]); + assert_eq!(attempts.len(), 4, "three accepted attempts, then the closing one"); + assert!(attempts.iter().all(|p| *p == B256::repeat_byte(0xa1))); } - const SLOT_TIMESTAMP: u64 = 1_700_000_000; - const START_MS: u64 = SLOT_TIMESTAMP * 1_000; - - #[test] - fn offsets_become_delays_from_the_slot_start() { - let delays = delays(SLOT_TIMESTAMP, &[500, 2000], START_MS); + /// Once the relay is bidding for the next slot nothing more can be accepted, + /// so further building is wasted. + #[tokio::test] + async fn it_stops_as_soon_as_the_slot_closes() { + let attempts = + attempts_for(vec![context(1, 0xa1, SLOT_TIMESTAMP)], START_MS, |_| Attempt::SlotClosed) + .await; - assert_eq!(delays, vec![Duration::from_millis(500), Duration::from_millis(2000)]); + assert_eq!(attempts.len(), 1); } - #[test] - fn unsorted_offsets_are_ordered() { - let delays = delays(SLOT_TIMESTAMP, &[2000, 500], START_MS); + /// The head moved, so everything still to come would bid on a parent the + /// relay has already replaced. + #[tokio::test] + async fn a_superseded_context_stops_attempting() { + let attempts = attempts_for( + vec![context(1, 0xa1, SLOT_TIMESTAMP), context(1, 0xb2, SLOT_TIMESTAMP)], + START_MS, + |n| if n >= 2 { Attempt::SlotClosed } else { Attempt::Continue }, + ) + .await; - assert_eq!( - delays, - vec![Duration::from_millis(500), Duration::from_millis(2000)], - "the config is a plain list and nothing else sorts it", + assert!( + attempts.iter().all(|p| *p == B256::repeat_byte(0xb2)), + "only the newest parent may be bid on, got: {attempts:?}", ); } - #[test] - fn an_offset_already_past_is_skipped() { - let delays = delays(SLOT_TIMESTAMP, &[500, 2000], START_MS + 1_000); - - assert_eq!(delays, vec![Duration::from_millis(1000)], "only the 2000ms offset is ahead"); - } - - /// Whether a passed slot still deserves an attempt depends on whether anything - /// has been sent for it, which only the driver knows -- see - /// `a_slot_learned_about_late_still_attempts_once` and - /// `a_late_replacement_does_not_attempt`. - #[test] - fn every_offset_past_leaves_no_delays() { - let delays = delays(SLOT_TIMESTAMP, &[500, 2000], START_MS + 5_000); + /// Without a closing answer the loop still has to end, or a dead relay would + /// leave it building for ever. + #[tokio::test] + async fn the_budget_ends_a_slot_the_relay_never_closes() { + let attempts = + attempts_for(vec![context(1, 0xa1, SLOT_TIMESTAMP)], START_MS, |_| Attempt::Continue) + .await; - assert!(delays.is_empty()); + assert!(!attempts.is_empty(), "it must try"); + assert!(attempts.len() < 100, "the budget must stop it, got {} attempts", attempts.len(),); } #[test] diff --git a/crates/builder/src/building/submit.rs b/crates/builder/src/building/submit.rs index e75200972..1cdfbbc57 100644 --- a/crates/builder/src/building/submit.rs +++ b/crates/builder/src/building/submit.rs @@ -17,6 +17,23 @@ use crate::{ engine::convert::{block_to_payload_v3, decode_execution_requests}, }; +impl SubmitError { + /// Whether the relay has moved on to the next bid slot. Nothing further will + /// be accepted for this one, so there is no point building again. + /// + /// The relay answers in plain text, so this matches on the message. Both + /// come from `BlockValidationError::SubmissionForWrongSlot` and the + /// auctioneer's late-simulation path. + pub fn is_slot_closed(&self) -> bool { + match self { + Self::Rejected { body, .. } => { + body.contains("submission for wrong slot") || body.contains("already on next slot") + } + _ => false, + } + } +} + #[derive(Debug, Error)] pub enum SubmitError { #[error("blobs bundle: {0}")] diff --git a/crates/builder/src/building/submit/tests.rs b/crates/builder/src/building/submit/tests.rs index 87c511f51..ddce4f80f 100644 --- a/crates/builder/src/building/submit/tests.rs +++ b/crates/builder/src/building/submit/tests.rs @@ -338,3 +338,28 @@ fn the_bid_trace_slot_matches_the_slot_the_block_was_built_for() { assert_eq!(bid.message().slot, slot.slot); } + +/// The loop only stops when the relay says the slot has moved on, so the two +/// messages that mean exactly that have to be recognised. +#[test] +fn a_wrong_slot_rejection_closes_the_slot() { + let rejected = |body: &str| SubmitError::Rejected { status: 400, body: body.to_string() }; + + assert!( + rejected("block validation: submission for wrong slot. expected: 11, got: 10") + .is_slot_closed() + ); + assert!(rejected("late sim, already on next slot").is_slot_closed()); +} + +/// Anything else is a bad block, not a closed slot: keep building. +#[test] +fn other_rejections_do_not_close_the_slot() { + let rejected = |body: &str| SubmitError::Rejected { status: 400, body: body.to_string() }; + + assert!(!rejected("block validation: unknown parent hash").is_slot_closed()); + assert!( + !rejected("block simulation: BlockValidationFailed(\"bad state root\")").is_slot_closed() + ); + assert!(!SubmitError::Transport("connection refused".into()).is_slot_closed()); +} diff --git a/crates/builder/src/config.rs b/crates/builder/src/config.rs index 66644066c..2650c244d 100644 --- a/crates/builder/src/config.rs +++ b/crates/builder/src/config.rs @@ -232,9 +232,11 @@ pub struct BuildingConfig { pub payout_gas_reserve: u64, #[serde(default = "default_extra_data")] pub extra_data: String, - /// Points into the slot, in milliseconds, at which to build and submit. - #[serde(default = "default_submit_offsets_ms")] - pub submit_offsets_ms: Vec, + /// How long before the slot starts to begin building, in milliseconds. + /// From then the builder rebuilds and resubmits without pause, picking up + /// whatever the mempool has gained, until the relay moves to the next slot. + #[serde(default = "default_build_lead_ms")] + pub build_lead_ms: u64, } impl BuildingConfig { @@ -257,8 +259,8 @@ impl BuildingConfig { if self.payout_gas_reserve < TX_GAS_COST { eyre::bail!("building config: payout_gas_reserve must be at least {TX_GAS_COST}"); } - if self.submit_offsets_ms.is_empty() { - eyre::bail!("building config: submit_offsets_ms must not be empty"); + if self.build_lead_ms == 0 { + eyre::bail!("building config: build_lead_ms must not be zero"); } if self.extra_data.len() > MAX_EXTRA_DATA_BYTES { eyre::bail!("building config: extra_data must be at most {MAX_EXTRA_DATA_BYTES} bytes"); @@ -322,8 +324,8 @@ fn default_payout_gas_reserve() -> u64 { fn default_extra_data() -> String { "helix-builder".to_string() } -fn default_submit_offsets_ms() -> Vec { - vec![500, 2000] +fn default_build_lead_ms() -> u64 { + 2_000 } fn default_blacklist_endpoint() -> String { "http://localhost:3520/blacklist".to_string() @@ -519,7 +521,7 @@ mod building_config_tests { assert_eq!(config.subsidy_wei, 1_000_000_000_000_000); assert_eq!(config.payout_gas_reserve, 21_000); assert_eq!(config.extra_data, "helix-builder"); - assert_eq!(config.submit_offsets_ms, vec![500, 2000]); + assert_eq!(config.build_lead_ms, 2_000); } #[test] @@ -533,7 +535,10 @@ mod building_config_tests { ); assert_eq!(config.payout_gas_reserve, 21_000); assert_eq!(config.extra_data, "helix-builder"); - assert_eq!(config.submit_offsets_ms, vec![500, 2000]); + assert_eq!( + config.build_lead_ms, 2_000, + "bidding has to start before the proposer asks, which it does at the slot start", + ); } #[test] @@ -556,12 +561,12 @@ mod building_config_tests { } #[test] - fn rejects_empty_submit_offsets() { - let err = with_line("submit_offsets_ms: []") + fn rejects_a_zero_build_lead() { + let err = with_line("build_lead_ms: 0") .validate() - .expect_err("without an offset the role would start and never build"); + .expect_err("starting at the slot start is already too late for the proposer"); - assert!(err.to_string().contains("submit_offsets_ms"), "got: {err}"); + assert!(err.to_string().contains("build_lead_ms"), "got: {err}"); } #[test] From 7b0d6c15eb5b306979d5a426325f5fc2d5852f7d Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 19 Sep 2026 02:44:23 +0100 Subject: [PATCH 26/33] Keep a slot's payloads redeemable after the auction moves on --- crates/relay/src/auctioneer/context.rs | 137 +++++++++++++++++-- crates/relay/src/auctioneer/get_payload.rs | 2 +- crates/relay/src/auctioneer/gloas_payload.rs | 2 +- 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/crates/relay/src/auctioneer/context.rs b/crates/relay/src/auctioneer/context.rs index b55b6c81c..5c0e0526a 100644 --- a/crates/relay/src/auctioneer/context.rs +++ b/crates/relay/src/auctioneer/context.rs @@ -64,10 +64,70 @@ pub struct SlotContext { /// builder -> version pub version: FxHashMap, pub hydration_cache: HydrationCache, - pub payloads: FxHashMap, + pub payloads: PayloadStore, pub block_merger: BlockMerger, } +/// The payloads a slot's submissions produced, kept one slot longer than the +/// auction that made them. +/// +/// The relay moves to the next bid slot shortly after a slot begins, but a Gloas +/// proposer redeems its bid part-way through its own slot, so a payload has to +/// outlive the auction it won. Keeping the previous generation covers the whole +/// of that slot; anything older is dropped. +#[derive(Default)] +pub struct PayloadStore { + current: FxHashMap, + previous: FxHashMap, +} + +impl PayloadStore { + fn with_capacity(capacity: usize) -> Self { + Self { + current: FxHashMap::with_capacity_and_hasher(capacity, Default::default()), + previous: FxHashMap::default(), + } + } + + pub fn insert(&mut self, block_hash: B256, entry: PayloadEntry) { + self.current.insert(block_hash, entry); + } + + pub fn or_insert(&mut self, block_hash: B256, entry: PayloadEntry) { + self.current.entry(block_hash).or_insert(entry); + } + + /// The current auction's payloads only: a bid is served from this slot. + pub fn get(&self, block_hash: &B256) -> Option<&PayloadEntry> { + self.current.get(block_hash) + } + + /// As [`Self::get`], falling back to the previous slot. Redemption arrives + /// after the relay has moved on, so the bid it names is usually one back. + pub fn get_for_redemption(&self, block_hash: &B256) -> Option<&PayloadEntry> { + self.current.get(block_hash).or_else(|| self.previous.get(block_hash)) + } + + pub fn len(&self) -> usize { + self.current.len() + } + + pub fn is_empty(&self) -> bool { + self.current.is_empty() && self.previous.is_empty() + } + + /// Starts a new auction, returning the generation that is now too old to + /// redeem so the caller can drop it off the event loop. + #[must_use] + fn rotate(&mut self, capacity: usize) -> FxHashMap { + let just_finished = std::mem::replace( + &mut self.current, + FxHashMap::with_capacity_and_hasher(capacity, Default::default()), + ); + std::mem::replace(&mut self.previous, just_finished) + } +} + pub struct Context { pub chain_info: ChainInfo, pub config: RelayConfig, @@ -138,10 +198,7 @@ impl Context { Default::default(), ), hydration_cache: HydrationCache::new(), - payloads: FxHashMap::with_capacity_and_hasher( - EXPECTED_PAYLOADS_PER_SLOT, - Default::default(), - ), + payloads: PayloadStore::with_capacity(EXPECTED_PAYLOADS_PER_SLOT), block_merger, }; @@ -291,10 +348,7 @@ impl Context { // map, however that would require us to estimate a hard upper limit on // payloads received, or risk causing a missed slot - let payloads_to_drop = std::mem::replace( - &mut self.payloads, - FxHashMap::with_capacity_and_hasher(EXPECTED_PAYLOADS_PER_SLOT, Default::default()), - ); + let payloads_to_drop = self.payloads.rotate(EXPECTED_PAYLOADS_PER_SLOT); let dealloc_core = self.config.cores.dealloc; std::thread::spawn(move || { // Unpinned, this lands on whatever core the OS picks -- including a @@ -531,3 +585,68 @@ pub fn send_submission_result

( SubmissionRefKind::Internal => {} } } + +#[cfg(test)] +mod payload_store_tests { + use helix_types::{SignedBidSubmission, TestRandomSeed}; + + use super::*; + + fn entry() -> PayloadEntry { + let mut submission = SignedBidSubmission::test_random(); + submission.blobs_bundle = Default::default(); + PayloadEntry::new_submission( + submission, + B256::ZERO, + None, + None, + None, + helix_types::SubmissionVersion::new(0, None), + Default::default(), + None, + ) + } + + /// The relay moves to the next bid slot moments after a slot starts, but the + /// proposer redeems its bid part-way through that slot. Dropping the payload + /// at the auction boundary answered every redemption with "no held payload". + #[test] + fn a_served_bid_is_redeemable_after_the_auction_moves_on() { + let hash = B256::repeat_byte(0x11); + let mut store = PayloadStore::with_capacity(4); + store.insert(hash, entry()); + + let _stale = store.rotate(4); + + assert!(store.get(&hash).is_none(), "the new auction starts clean"); + assert!( + store.get_for_redemption(&hash).is_some(), + "the proposer must still be able to redeem the bid it was served", + ); + } + + /// One generation, not unbounded: a slot later it is gone. + #[test] + fn a_payload_is_dropped_after_two_auctions() { + let hash = B256::repeat_byte(0x11); + let mut store = PayloadStore::with_capacity(4); + store.insert(hash, entry()); + + let _ = store.rotate(4); + let stale = store.rotate(4); + + assert!(store.get_for_redemption(&hash).is_none(), "two slots on, it is not needed"); + assert!(stale.contains_key(&hash), "and it is handed back to be dropped off-thread"); + } + + #[test] + fn the_current_auction_is_served_from_the_new_generation() { + let hash = B256::repeat_byte(0x22); + let mut store = PayloadStore::with_capacity(4); + let _ = store.rotate(4); + store.insert(hash, entry()); + + assert!(store.get(&hash).is_some()); + assert!(store.get_for_redemption(&hash).is_some()); + } +} diff --git a/crates/relay/src/auctioneer/get_payload.rs b/crates/relay/src/auctioneer/get_payload.rs index 0643927e8..f394bcc72 100644 --- a/crates/relay/src/auctioneer/get_payload.rs +++ b/crates/relay/src/auctioneer/get_payload.rs @@ -35,7 +35,7 @@ impl Context { let block_hash = payload.execution_payload.execution_payload.block_hash; let entry = PayloadEntry::new_gossip(payload.execution_payload, payload.bid_data); - self.payloads.entry(block_hash).or_insert(entry); + self.payloads.or_insert(block_hash, entry); } /// If we start broacasting, returns the block hash of the block diff --git a/crates/relay/src/auctioneer/gloas_payload.rs b/crates/relay/src/auctioneer/gloas_payload.rs index ea7663816..b5d306169 100644 --- a/crates/relay/src/auctioneer/gloas_payload.rs +++ b/crates/relay/src/auctioneer/gloas_payload.rs @@ -17,7 +17,7 @@ impl Context { slot: Slot, res_tx: oneshot::Sender>, ) { - let held = self.payloads.get(&block_hash).and_then(|entry| { + let held = self.payloads.get_for_redemption(&block_hash).and_then(|entry| { let gloas_data = entry.gloas_data(); let payload = match entry .execution_payload() From 0faf2daa196ca2a8127011f360fda637686af037 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 19 Sep 2026 03:30:36 +0100 Subject: [PATCH 27/33] Refresh a slot's synthesized duty when preferences are resubmitted --- .../relay/src/housekeeper/proposer_prefs.rs | 88 +++++++++++++++++++ crates/relay/src/housekeeper/tile.rs | 7 +- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/crates/relay/src/housekeeper/proposer_prefs.rs b/crates/relay/src/housekeeper/proposer_prefs.rs index 785216a76..1a7ad2d4b 100644 --- a/crates/relay/src/housekeeper/proposer_prefs.rs +++ b/crates/relay/src/housekeeper/proposer_prefs.rs @@ -108,6 +108,43 @@ pub fn synthesize_duty_feed( .collect() } +/// Merges freshly synthesized entries into the feed the builders poll. +/// +/// A synthesized entry is only a snapshot of the last preferences gossiped for +/// the slot, and a proposer may resubmit up to an epoch ahead -- the store keeps +/// the latest on purpose. Keeping the first snapshot instead would leave +/// builders working from a stale fee recipient and gas limit. A real +/// registration, which a proposer signed, is never displaced. +pub fn merge_duty_feed( + existing: Vec, + synthesized: Vec, +) -> Vec { + let refreshed: FxHashMap = + synthesized.into_iter().map(|entry| (entry.slot.as_u64(), entry)).collect(); + + let mut feed: Vec<_> = existing + .into_iter() + .map(|entry| { + let slot = entry.slot.as_u64(); + match refreshed.get(&slot) { + Some(fresh) if !is_registered(&entry) => fresh.clone(), + _ => entry, + } + }) + .collect(); + + let known: rustc_hash::FxHashSet = feed.iter().map(|e| e.slot.as_u64()).collect(); + feed.extend(refreshed.into_values().filter(|entry| !known.contains(&entry.slot.as_u64()))); + feed.sort_by_key(|e| e.slot.as_u64()); + feed +} + +/// Whether the proposer signed this registration itself, rather than it being +/// synthesized from gossiped preferences. +fn is_registered(entry: &BuilderGetValidatorsResponseEntry) -> bool { + entry.entry.registration.signature != helix_types::BlsSignatureBytes::default() +} + #[cfg(test)] mod tests { use alloy_primitives::{Address, B256, address}; @@ -334,6 +371,57 @@ mod tests { assert_eq!(slots, vec![101], "builders cannot build a slot that has started"); } + /// A proposer may resubmit its preferences, and the store keeps the latest on + /// purpose. Keeping the first snapshot in the feed left builders working from a + /// stale gas limit and fee recipient, which the proposer then rejects. + #[test] + fn a_resubmitted_preference_replaces_the_served_entry() { + let chain_info = ChainInfo::default(); + let (duties, store) = duties_and_store(&[(101, 1)], &chain_info); + let first = synthesize_duty_feed( + &duties, + &store, + Slot::new(101), + &ValidatorPreferences::default(), + &chain_info, + ); + assert_eq!(first[0].entry.registration.message.gas_limit, 45_000_000); + + // The proposer changes its mind about the gas limit. + let mut later = first.clone(); + later[0].entry.registration.message.gas_limit = 200_000_000; + + let merged = merge_duty_feed(first, later); + + assert_eq!(merged.len(), 1, "the slot must not be duplicated"); + assert_eq!( + merged[0].entry.registration.message.gas_limit, 200_000_000, + "the builder has to see the latest preferences, not the first", + ); + } + + /// A registration the proposer actually signed outranks anything synthesized. + #[test] + fn a_real_registration_is_not_displaced() { + let chain_info = ChainInfo::default(); + let (duties, store) = duties_and_store(&[(101, 1)], &chain_info); + let synthesized = synthesize_duty_feed( + &duties, + &store, + Slot::new(101), + &ValidatorPreferences::default(), + &chain_info, + ); + let mut registered = synthesized.clone(); + registered[0].entry.registration.signature = + helix_types::BlsSignatureBytes::from([7u8; 96]); + registered[0].entry.registration.message.gas_limit = 36_000_000; + + let merged = merge_duty_feed(registered, synthesized); + + assert_eq!(merged[0].entry.registration.message.gas_limit, 36_000_000); + } + /// The fee recipient the builder pays comes straight from these preferences, so /// preferences gossiped by anyone but the slot's proposer would redirect the /// payment. Nothing else binds the two. diff --git a/crates/relay/src/housekeeper/tile.rs b/crates/relay/src/housekeeper/tile.rs index 85e91db6a..f79238031 100644 --- a/crates/relay/src/housekeeper/tile.rs +++ b/crates/relay/src/housekeeper/tile.rs @@ -41,7 +41,7 @@ use crate::{ build_primev_builder_configs, }, proposer_prefs::{ - ProposerPreferencesStore, prefs_match_duty, synthesize_duty_feed, + ProposerPreferencesStore, merge_duty_feed, prefs_match_duty, synthesize_duty_feed, synthesize_registration, }, }, @@ -261,10 +261,7 @@ impl HousekeeperTile { return; } - let mut feed = self.local_cache.get_proposer_duties(); - let known: rustc_hash::FxHashSet = feed.iter().map(|e| e.slot.as_u64()).collect(); - feed.extend(synthesized.into_iter().filter(|e| !known.contains(&e.slot.as_u64()))); - feed.sort_by_key(|e| e.slot.as_u64()); + let feed = merge_duty_feed(self.local_cache.get_proposer_duties(), synthesized); self.local_cache.update_proposer_duties(feed); } From 051a2ea7f9ad7ce852a3ff82a55851505637d736 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 19 Sep 2026 03:37:51 +0100 Subject: [PATCH 28/33] Retry the payload reveal until our beacon node has the block --- .../proposer/submit_signed_beacon_block.rs | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs index b29a3d5a9..1d1951713 100644 --- a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs +++ b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use alloy_primitives::B256; use axum::{Extension, http::HeaderMap}; @@ -11,7 +11,7 @@ use helix_types::{ }; use hyper::StatusCode; use ssz::Decode; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use tree_hash::TreeHash; use super::{ProposerApi, get_payload::fork_name_from_header}; @@ -136,6 +136,12 @@ fn verify_proposer_signature( Ok(()) } +/// How many times to offer the payload envelope to the beacon node, and how long +/// to wait between tries. The block reaches our node over gossip a few hundred +/// milliseconds after the proposer hands it to us directly. +const PUBLISH_ATTEMPTS: u32 = 12; +const PUBLISH_RETRY_INTERVAL: Duration = Duration::from_millis(100); + impl ProposerApi { /// Accepts a Gloas `SignedBeaconBlock`. Replaces `submitBlindedBlock`/`getPayload`; per /// , @@ -197,10 +203,37 @@ impl ProposerApi { &proposer_api.chain_info, )?; - proposer_api - .multi_beacon_client - .publish_execution_payload_envelope(Arc::new(signed_envelope), ForkName::Gloas) - .await?; + // The proposer hands us its block before the network has it, so our own + // beacon node usually rejects the first reveal with an unknown block + // root. Retry until it has seen the block. + let signed_envelope = Arc::new(signed_envelope); + let mut published = Err(()); + for attempt in 0..PUBLISH_ATTEMPTS { + match proposer_api + .multi_beacon_client + .publish_execution_payload_envelope(signed_envelope.clone(), ForkName::Gloas) + .await + { + Ok(()) => { + published = Ok(()); + break; + } + Err(err) => { + warn!(%err, attempt, "could not reveal the payload yet"); + tokio::time::sleep(PUBLISH_RETRY_INTERVAL).await; + } + } + } + + if published.is_err() { + // The proposer's request was well formed and is already accepted; the + // reveal is ours to get right, so this is not their error. + error!( + slot = block.message.slot.as_u64(), + "gave up revealing the payload after {PUBLISH_ATTEMPTS} attempts", + ); + return Err(ProposerApiError::InternalServerError); + } Ok(StatusCode::ACCEPTED) } From 497672e00ab3eeb7684f7660ef401cdff7b6ac43 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 19 Sep 2026 14:17:34 +0100 Subject: [PATCH 29/33] Wrap the Gloas bid in a fork-versioned response Lodestar types `getExecutionPayloadBid` with `VersionMeta` and parses the JSON body as `{version, data}`, so a bare `SignedExecutionPayloadBid` fails with "expected key version is undefined". `getHeader` already returns `ForkVersionedResponse`; the Gloas endpoint now does the same. The SSZ path is unchanged: it already sets `Eth-Consensus-Version`. --- .../api/proposer/get_execution_payload_bid.rs | 35 +++++++++++++++++-- crates/types/src/lib.rs | 1 + 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/relay/src/api/proposer/get_execution_payload_bid.rs b/crates/relay/src/api/proposer/get_execution_payload_bid.rs index 3f6eac5dd..6a918035e 100644 --- a/crates/relay/src/api/proposer/get_execution_payload_bid.rs +++ b/crates/relay/src/api/proposer/get_execution_payload_bid.rs @@ -9,7 +9,7 @@ use helix_common::{ decoder::{Encoding, HEADER_SSZ}, utils::extract_request_id, }; -use helix_types::{ForkName, SignedBuilderRequestAuth}; +use helix_types::{ForkName, GetExecutionPayloadBidResponse, SignedBuilderRequestAuth}; use http::{HeaderValue, header::CONTENT_TYPE}; use ssz::{Decode, Encode}; use tracing::{info, warn}; @@ -80,7 +80,14 @@ impl ProposerApi { }; match Encoding::from_accept(&headers) { - Encoding::Json => Ok(axum::Json(serde_json::to_value(&signed_bid)?).into_response()), + Encoding::Json => { + let versioned = GetExecutionPayloadBidResponse { + version: ForkName::Gloas, + metadata: Default::default(), + data: signed_bid, + }; + Ok(axum::Json(serde_json::to_value(&versioned)?).into_response()) + } Encoding::Ssz => { let mut response = signed_bid.as_ssz_bytes().into_response(); let headers = response.headers_mut(); @@ -94,3 +101,27 @@ impl ProposerApi { } } } + +#[cfg(test)] +mod tests { + use helix_types::{BlsSignature, ExecutionPayloadBid, SignedExecutionPayloadBid}; + + use super::*; + + #[test] + fn json_bid_carries_the_version_key() { + let versioned = GetExecutionPayloadBidResponse { + version: ForkName::Gloas, + metadata: Default::default(), + data: SignedExecutionPayloadBid { + message: ExecutionPayloadBid::default(), + signature: BlsSignature::empty(), + }, + }; + + let json = serde_json::to_value(&versioned).unwrap(); + + assert_eq!(json["version"], "gloas"); + assert!(json["data"]["message"].is_object()); + } +} diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index bf300df15..02686a1f5 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -87,6 +87,7 @@ pub type BeaconBlockBodyFulu = lh_types::BeaconBlockBodyFulu; pub type SignedBuilderBid = crate::builder_bid::SignedBuilderBid; /// Response object of GET `/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}` pub type GetHeaderResponse = lh_eth2::ForkVersionedResponse; +pub type GetExecutionPayloadBidResponse = lh_eth2::ForkVersionedResponse; // Get payload /// Request object of POST `/eth/v1/builder/blinded_blocks` From 28034f64c16b590c1fa378653a4efc6196d8b12f Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 19 Sep 2026 14:50:53 +0100 Subject: [PATCH 30/33] Hold the Gloas reveal until the attestation deadline Helix revealed the payload the moment the proposer handed back its block, which is at the top of the slot. An equivocating proposer can build a competing block on a payload revealed that early, before any attestation weighs behind the honest block. Gloas puts the attestation deadline at 25% of the slot (ATTESTATION_DUE_BPS_GLOAS) and the payload deadline at 50%, so hold the reveal until the attestation deadline and let the proposer have its 202 straight away. Also publish the proposer's block to our own beacon nodes. Post-Gloas publishBlockV2 takes a bare SignedBeaconBlock, not SignedBlockContents. This is what makes the block root known by the time we reveal, instead of waiting for the block to come back to us over gossip. --- crates/common/src/beacon/beacon_client.rs | 39 +++++++- .../common/src/beacon/multi_beacon_client.rs | 26 +++++- crates/common/src/chain_info.rs | 61 +++++++++++++ .../proposer/submit_signed_beacon_block.rs | 90 +++++++++++-------- 4 files changed, 178 insertions(+), 38 deletions(-) diff --git a/crates/common/src/beacon/beacon_client.rs b/crates/common/src/beacon/beacon_client.rs index cd014580c..4f75f8169 100644 --- a/crates/common/src/beacon/beacon_client.rs +++ b/crates/common/src/beacon/beacon_client.rs @@ -3,8 +3,8 @@ use std::{sync::Arc, task::Poll, time::Duration}; use ::ssz::Encode; use alloy_primitives::B256; use helix_types::{ - ForkName, LhConfig, SignedExecutionPayloadEnvelopeContents, VersionedSignedProposal, - spec_from_config, + ForkName, LhConfig, SignedBeaconBlockGloas, SignedExecutionPayloadEnvelopeContents, + VersionedSignedProposal, spec_from_config, }; use http::{Request, header::CONTENT_TYPE}; use http_body_util::Full; @@ -117,6 +117,41 @@ impl BeaconClient { } } + /// Publishes a Gloas `SignedBeaconBlock` SSZ-encoded. Post-Gloas the block carries no + /// blob sidecars, so the body is the bare block rather than `SignedBlockContents`. + /// Sending it here gives our own node the block root before the reveal needs it. + pub async fn publish_gloas_block( + &self, + block: Arc, + ) -> Result { + let target = self.config.url.join("eth/v2/beacon/blocks")?; + let body_bytes = Bytes::from(block.as_ssz_bytes()); + let req = Request::builder() + .method("POST") + .uri(target.as_str()) + .header(CONSENSUS_VERSION_HEADER, ForkName::Gloas.to_string()) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Full::new(body_bytes))?; + let mut pending = self.http.send(&target, req)?.with_timeout(PUBLISH_BLOCK_TIMEOUT); + + let (status, body) = loop { + match pending.poll_bytes() { + Poll::Pending => {} + Poll::Ready(Ok(r)) => break r, + Poll::Ready(Err(e)) => return Err(e.into()), + } + tokio::task::yield_now().await; + }; + + match status { + 200 | 202 => Ok(status), + _ => { + let api_err: ApiError = serde_json::from_slice(&body)?; + Err(BeaconClientError::Api(api_err)) + } + } + } + /// Publishes a signed execution payload envelope SSZ-encoded, so a connected beacon node /// broadcasts it to the `execution_payload` gossip topic on helix's behalf. /// diff --git a/crates/common/src/beacon/multi_beacon_client.rs b/crates/common/src/beacon/multi_beacon_client.rs index aaf660178..2cccc6b48 100644 --- a/crates/common/src/beacon/multi_beacon_client.rs +++ b/crates/common/src/beacon/multi_beacon_client.rs @@ -4,7 +4,10 @@ use std::sync::{ }; use futures::future::join_all; -use helix_types::{ForkName, SignedExecutionPayloadEnvelopeContents, VersionedSignedProposal}; +use helix_types::{ + ForkName, SignedBeaconBlockGloas, SignedExecutionPayloadEnvelopeContents, + VersionedSignedProposal, +}; use crate::{ beacon::{beacon_client::BeaconClient, error::BeaconClientError, types::BroadcastValidation}, @@ -84,6 +87,27 @@ impl MultiBeaconClient { Err(last_error.unwrap_or(BeaconClientError::BeaconNodeUnavailable)) } + /// Publishes a Gloas beacon block to all beacon clients; returns on first success. + /// The proposer already gossips its own block, so a failure here is not fatal: it only + /// costs us the head start on the block root that the reveal needs. + pub async fn publish_gloas_block( + &self, + block: Arc, + ) -> Result<(), BeaconClientError> { + let futures = + self.beacon_clients.iter().map(|client| client.publish_gloas_block(block.clone())); + + let mut last_error: Option = None; + for res in join_all(futures).await { + match res { + Ok(_) => return Ok(()), + Err(err) => last_error = Some(err), + } + } + + Err(last_error.unwrap_or(BeaconClientError::BeaconNodeUnavailable)) + } + /// Publishes the signed execution payload envelope to all beacon clients; returns on first /// success. Unlike `publish_block`, fans out via plain concurrent futures, not /// `spawn_tracked!`. diff --git a/crates/common/src/chain_info.rs b/crates/common/src/chain_info.rs index e3234a5af..a2165267e 100644 --- a/crates/common/src/chain_info.rs +++ b/crates/common/src/chain_info.rs @@ -11,6 +11,10 @@ pub(crate) const MAINNET_GENESIS_VALIDATOR_ROOT: [u8; 32] = [ 243, 63, 246, 207, 90, 210, 127, 81, 27, 254, 149, ]; +/// `ATTESTATION_DUE_BPS_GLOAS` from the Gloas preset: attestations are due a quarter +/// of the way into the slot. +const ATTESTATION_DUE_BPS_GLOAS: u32 = 2500; + /// Runtime config with all chain specific information #[derive(Clone)] pub struct ChainInfo { @@ -75,6 +79,24 @@ impl ChainInfo { self.clock.now().unwrap_or(Slot::new(0)) } + /// Unix time at which `slot` starts. + pub fn slot_start(&self, slot: Slot) -> Duration { + Duration::from_secs(self.genesis_time_in_secs + slot.as_u64() * self.seconds_per_slot()) + } + + /// Gloas divides the slot in quarters: attestations are due at 25%, the payload + /// reveal at 50%. + pub fn gloas_attestation_deadline(&self) -> Duration { + self.spec.get_slot_duration() * ATTESTATION_DUE_BPS_GLOAS / 10_000 + } + + /// How long to hold a Gloas payload before revealing it. Revealing before the + /// attestation deadline lets an equivocating proposer build a competing block on + /// the payload while the honest block still has no attestations behind it. + pub fn gloas_reveal_delay(&self, slot: Slot, now: Duration) -> Duration { + (self.slot_start(slot) + self.gloas_attestation_deadline()).saturating_sub(now) + } + pub fn max_blobs_per_block(&self) -> usize { let epoch = self.current_slot().epoch(self.slots_per_epoch()); self.spec.max_blobs_per_block(epoch) as usize @@ -87,3 +109,42 @@ impl Default for ChainInfo { Self::new(spec, MAINNET_GENESIS_VALIDATOR_ROOT.into(), MAINNET_GENESIS_TIME) } } + +#[cfg(test)] +mod gloas_reveal_tests { + use super::*; + + fn chain_info() -> ChainInfo { + ChainInfo::default() + } + + #[test] + fn the_deadline_is_a_quarter_of_the_slot() { + let info = chain_info(); + assert_eq!(info.gloas_attestation_deadline(), Duration::from_secs(3)); + } + + #[test] + fn a_reveal_asked_for_at_the_slot_start_waits_for_the_deadline() { + let info = chain_info(); + let slot = Slot::new(1_000); + let delay = info.gloas_reveal_delay(slot, info.slot_start(slot)); + assert_eq!(delay, Duration::from_secs(3)); + } + + #[test] + fn a_reveal_asked_for_after_the_deadline_does_not_wait() { + let info = chain_info(); + let slot = Slot::new(1_000); + let now = info.slot_start(slot) + Duration::from_secs(5); + assert_eq!(info.gloas_reveal_delay(slot, now), Duration::ZERO); + } + + #[test] + fn the_wait_never_runs_past_the_payload_deadline() { + let info = chain_info(); + let slot = Slot::new(1_000); + let delay = info.gloas_reveal_delay(slot, info.slot_start(slot)); + assert!(delay < info.spec.get_slot_duration() / 2); + } +} diff --git a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs index 1d1951713..638a29d69 100644 --- a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs +++ b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs @@ -2,7 +2,9 @@ use std::{sync::Arc, time::Duration}; use alloy_primitives::B256; use axum::{Extension, http::HeaderMap}; -use helix_common::{chain_info::ChainInfo, decoder::Encoding, utils::extract_request_id}; +use helix_common::{ + chain_info::ChainInfo, decoder::Encoding, spawn_tracked, utils::extract_request_id, +}; use helix_types::{ BeaconBlockRef, BlobsBundle, BlsKeypair, BlsPublicKey, BlsPublicKeyBytes, Domain, EthSpec, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequestsGloas, ForkName, @@ -136,12 +138,42 @@ fn verify_proposer_signature( Ok(()) } -/// How many times to offer the payload envelope to the beacon node, and how long -/// to wait between tries. The block reaches our node over gossip a few hundred -/// milliseconds after the proposer hands it to us directly. const PUBLISH_ATTEMPTS: u32 = 12; const PUBLISH_RETRY_INTERVAL: Duration = Duration::from_millis(100); +fn unix_now() -> Duration { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the clock is after the unix epoch") +} + +/// Offers the envelope to the beacon nodes until one takes it. The block reaches a +/// node over gossip a few hundred milliseconds after the proposer hands it to us. +async fn reveal_envelope( + proposer_api: &ProposerApi, + signed_envelope: Arc, + slot: helix_types::Slot, +) { + for attempt in 0..PUBLISH_ATTEMPTS { + match proposer_api + .multi_beacon_client + .publish_execution_payload_envelope(signed_envelope.clone(), ForkName::Gloas) + .await + { + Ok(()) => { + info!(slot = slot.as_u64(), attempt, "revealed the payload"); + return; + } + Err(err) => { + warn!(%err, attempt, "could not reveal the payload yet"); + tokio::time::sleep(PUBLISH_RETRY_INTERVAL).await; + } + } + } + + error!(slot = slot.as_u64(), "gave up revealing the payload after {PUBLISH_ATTEMPTS} attempts"); +} + impl ProposerApi { /// Accepts a Gloas `SignedBeaconBlock`. Replaces `submitBlindedBlock`/`getPayload`; per /// , @@ -196,45 +228,33 @@ impl ProposerApi { } }; - let signed_envelope = construct_signed_envelope( + let signed_envelope = Arc::new(construct_signed_envelope( &block, held, &proposer_api.gloas_builder_identity, &proposer_api.chain_info, - )?; + )?); - // The proposer hands us its block before the network has it, so our own - // beacon node usually rejects the first reveal with an unknown block - // root. Retry until it has seen the block. - let signed_envelope = Arc::new(signed_envelope); - let mut published = Err(()); - for attempt in 0..PUBLISH_ATTEMPTS { - match proposer_api - .multi_beacon_client - .publish_execution_payload_envelope(signed_envelope.clone(), ForkName::Gloas) - .await - { - Ok(()) => { - published = Ok(()); - break; - } - Err(err) => { - warn!(%err, attempt, "could not reveal the payload yet"); - tokio::time::sleep(PUBLISH_RETRY_INTERVAL).await; - } - } - } + let slot = block.message.slot; + let block = Arc::new(block); - if published.is_err() { - // The proposer's request was well formed and is already accepted; the - // reveal is ours to get right, so this is not their error. - error!( - slot = block.message.slot.as_u64(), - "gave up revealing the payload after {PUBLISH_ATTEMPTS} attempts", - ); - return Err(ProposerApiError::InternalServerError); + // The proposer gossips its own block, but handing it to our node directly is + // what makes the block root known by the time we reveal. + if let Err(err) = proposer_api.multi_beacon_client.publish_gloas_block(block.clone()).await + { + warn!(%err, "could not publish the proposer's block"); } + // Revealing before the attestation deadline hands an equivocating proposer a + // payload it can build a competing block on. Wait it out, then reveal, and let + // the proposer have its acknowledgement now rather than seconds from now. + let delay = proposer_api.chain_info.gloas_reveal_delay(slot, unix_now()); + info!(slot = slot.as_u64(), delay_ms = delay.as_millis() as u64, "holding the payload"); + spawn_tracked!(async move { + tokio::time::sleep(delay).await; + reveal_envelope(&proposer_api, signed_envelope, slot).await; + }); + Ok(StatusCode::ACCEPTED) } } From 844e0b34d291ebd039d7f6b29c5990f7ec722b83 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 19 Sep 2026 15:41:41 +0100 Subject: [PATCH 31/33] Withhold the payload when the proposer equivocates Two ways a proposer could redeem two blocks against one bid, neither of which helix noticed. First, it could send helix two different signed blocks. `get_for_redemption` is a lookup, not a take, so the second one built and revealed a second envelope. Remember the block root committed to per slot and refuse a different one. Because the reveal is now held until the attestation deadline, an equivocation seen before then aborts the pending reveal, so the payload is withheld rather than merely logged. Registration happens only after the block is known to redeem our bid: this endpoint does not verify the proposer's signature, so an unvalidated block must not be able to withhold a legitimate reveal. Second, it could gossip a competing block helix never sees. Publish the envelope with broadcast_validation=consensus_and_equivocation so the beacon node refuses to broadcast a payload for a block it has seen a competing version of. The default is `gossip`, which skips that check. --- crates/common/src/beacon/beacon_client.rs | 24 ++- crates/relay/src/api/proposer/error.rs | 6 +- crates/relay/src/api/proposer/mod.rs | 12 +- crates/relay/src/api/proposer/reveal_guard.rs | 162 ++++++++++++++++++ .../proposer/submit_signed_beacon_block.rs | 29 +++- 5 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 crates/relay/src/api/proposer/reveal_guard.rs diff --git a/crates/common/src/beacon/beacon_client.rs b/crates/common/src/beacon/beacon_client.rs index 4f75f8169..17dce6f97 100644 --- a/crates/common/src/beacon/beacon_client.rs +++ b/crates/common/src/beacon/beacon_client.rs @@ -160,7 +160,13 @@ impl BeaconClient { envelope: Arc, fork: ForkName, ) -> Result { - let target = self.config.url.join("eth/v1/beacon/execution_payload_envelopes")?; + let mut target = self.config.url.join("eth/v1/beacon/execution_payload_envelopes")?; + // Fail closed on an equivocating proposer: the node refuses to broadcast a + // payload for a block it has seen a competing version of. + target.query_pairs_mut().append_pair( + "broadcast_validation", + &BroadcastValidation::ConsensusAndEquivocation.to_string(), + ); let body_bytes = Bytes::from(envelope.as_ssz_bytes()); let req = Request::builder() .method("POST") @@ -257,6 +263,22 @@ mod tests { assert_eq!(result.unwrap(), 200); } + #[tokio::test] + async fn publish_execution_payload_envelope_asks_for_equivocation_validation() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/eth/v1/beacon/execution_payload_envelopes") + .query_param("broadcast_validation", "consensus_and_equivocation"); + then.status(200); + }); + + let client = test_client(Url::parse(&server.url("/")).unwrap()); + client.publish_execution_payload_envelope(empty_envelope(), ForkName::Gloas).await.unwrap(); + + mock.assert(); + } + #[tokio::test] async fn publish_execution_payload_envelope_202_is_ok() { let server = MockServer::start(); diff --git a/crates/relay/src/api/proposer/error.rs b/crates/relay/src/api/proposer/error.rs index 230322ffb..a823ca9b9 100644 --- a/crates/relay/src/api/proposer/error.rs +++ b/crates/relay/src/api/proposer/error.rs @@ -156,6 +156,9 @@ pub enum ProposerApiError { "bid builder_index {bid} does not match this relay's configured builder_index {configured}" )] BuilderIndexMismatch { bid: u64, configured: u64 }, + + #[error("proposer equivocated at slot {slot}: already committed to block {first:?}")] + ProposerEquivocated { slot: u64, first: B256 }, } impl ProposerApiError { @@ -222,7 +225,8 @@ impl IntoResponse for ProposerApiError { ProposerApiError::MissingTimingHeaders | ProposerApiError::NoHeldPayloadForBlock(_) | ProposerApiError::HeldPayloadBlockHashMismatch { .. } | - ProposerApiError::BuilderIndexMismatch { .. } => StatusCode::BAD_REQUEST, + ProposerApiError::BuilderIndexMismatch { .. } | + ProposerApiError::ProposerEquivocated { .. } => StatusCode::BAD_REQUEST, // All authentication failures, kept indistinguishable by status ProposerApiError::InvalidApiKey | diff --git a/crates/relay/src/api/proposer/mod.rs b/crates/relay/src/api/proposer/mod.rs index 636896e3d..1b58c81a3 100644 --- a/crates/relay/src/api/proposer/mod.rs +++ b/crates/relay/src/api/proposer/mod.rs @@ -5,10 +5,11 @@ pub(crate) mod get_payload; mod header_stream; mod ip_tracker; mod register; +mod reveal_guard; mod submit_builder_preferences; mod submit_signed_beacon_block; -use std::sync::{Arc, atomic::Ordering}; +use std::sync::{Arc, Mutex, atomic::Ordering}; use axum::{Extension, response::IntoResponse}; pub use error::*; @@ -23,7 +24,11 @@ use hyper::StatusCode; pub use submit_signed_beacon_block::{GloasBuilderIdentity, HeldGloasPayload}; use crate::{ - api::{Api, proposer::ip_tracker::IpTracker, router::Terminating}, + api::{ + Api, + proposer::{ip_tracker::IpTracker, reveal_guard::RevealGuard}, + router::Terminating, + }, auctioneer::AuctioneerHandle, gossip::GrpcGossiperClientManager, registration::RegWorkerHandle, @@ -48,6 +53,8 @@ pub struct ProposerApi { pub operator_api: Option>, pub ip_tracker: IpTracker, pub gloas_builder_identity: Arc, + /// Blocks helix has already committed to redeem, per slot. + pub reveal_guard: Arc>, } impl ProposerApi { @@ -88,6 +95,7 @@ impl ProposerApi { operator_api, ip_tracker: IpTracker::default(), gloas_builder_identity, + reveal_guard: Default::default(), } } } diff --git a/crates/relay/src/api/proposer/reveal_guard.rs b/crates/relay/src/api/proposer/reveal_guard.rs new file mode 100644 index 000000000..c0fe9e82c --- /dev/null +++ b/crates/relay/src/api/proposer/reveal_guard.rs @@ -0,0 +1,162 @@ +use std::collections::BTreeMap; + +use alloy_primitives::B256; +use helix_types::Slot; +use tokio::task::AbortHandle; + +/// Slots kept after the current one, so a late duplicate for the previous slot is +/// still recognised. +const RETAINED_SLOTS: u64 = 2; + +/// What [`RevealGuard::register`] decided about a block. +#[derive(Debug)] +pub enum Registered { + /// The first block for this slot, or a repeat of the same one. + Proceed, + /// A second, different block for a slot we already hold one for. + Equivocation { first: B256 }, +} + +/// Remembers the block root helix committed to for each recent slot, so a proposer +/// cannot redeem two different blocks against the same bid. A pending reveal is +/// abortable: the payload is held until the attestation deadline, so an equivocation +/// seen before then withholds it rather than merely recording the fact. +#[derive(Default)] +pub struct RevealGuard { + seen: BTreeMap)>, +} + +impl RevealGuard { + pub fn register(&mut self, slot: Slot, block_root: B256) -> Registered { + self.prune(slot); + match self.seen.get(&slot) { + Some((first, _)) if *first != block_root => Registered::Equivocation { first: *first }, + Some(_) => Registered::Proceed, + None => { + self.seen.insert(slot, (block_root, None)); + Registered::Proceed + } + } + } + + /// Attaches the task that will reveal `slot`'s payload, so it can be withheld. + pub fn attach(&mut self, slot: Slot, reveal: AbortHandle) { + if let Some(entry) = self.seen.get_mut(&slot) { + entry.1 = Some(reveal); + } + } + + /// Withholds `slot`'s payload if its reveal has not run yet. Returns whether a + /// pending reveal was stopped. + pub fn withhold(&mut self, slot: Slot) -> bool { + match self.seen.get_mut(&slot).and_then(|entry| entry.1.take()) { + Some(reveal) if !reveal.is_finished() => { + reveal.abort(); + true + } + _ => false, + } + } + + fn prune(&mut self, slot: Slot) { + let cutoff = slot.as_u64().saturating_sub(RETAINED_SLOTS); + self.seen.retain(|kept, _| kept.as_u64() >= cutoff); + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, + }; + + use super::*; + + fn guard() -> RevealGuard { + RevealGuard::default() + } + + #[test] + fn the_first_block_for_a_slot_proceeds() { + let mut guard = guard(); + assert!(matches!(guard.register(Slot::new(10), B256::repeat_byte(1)), Registered::Proceed)); + } + + #[test] + fn the_same_block_sent_twice_is_not_an_equivocation() { + let mut guard = guard(); + let root = B256::repeat_byte(1); + guard.register(Slot::new(10), root); + assert!(matches!(guard.register(Slot::new(10), root), Registered::Proceed)); + } + + #[test] + fn a_different_block_for_the_same_slot_is_an_equivocation() { + let mut guard = guard(); + let first = B256::repeat_byte(1); + guard.register(Slot::new(10), first); + + match guard.register(Slot::new(10), B256::repeat_byte(2)) { + Registered::Equivocation { first: reported } => assert_eq!(reported, first), + other => panic!("expected an equivocation, got {other:?}"), + } + } + + #[test] + fn different_slots_do_not_collide() { + let mut guard = guard(); + guard.register(Slot::new(10), B256::repeat_byte(1)); + assert!(matches!(guard.register(Slot::new(11), B256::repeat_byte(2)), Registered::Proceed)); + } + + /// Without pruning the map grows without bound; a slot far enough back is + /// forgotten, so its root can no longer be compared. + #[test] + fn slots_well_behind_the_head_are_forgotten() { + let mut guard = guard(); + guard.register(Slot::new(10), B256::repeat_byte(1)); + guard.register(Slot::new(20), B256::repeat_byte(2)); + assert!(matches!(guard.register(Slot::new(10), B256::repeat_byte(3)), Registered::Proceed)); + } + + #[tokio::test] + async fn an_equivocation_withholds_a_pending_reveal() { + let mut guard = guard(); + let slot = Slot::new(10); + guard.register(slot, B256::repeat_byte(1)); + + let revealed = Arc::new(AtomicBool::new(false)); + let flag = revealed.clone(); + let task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + flag.store(true, Ordering::SeqCst); + }); + guard.attach(slot, task.abort_handle()); + + assert!(matches!( + guard.register(slot, B256::repeat_byte(2)), + Registered::Equivocation { .. } + )); + assert!(guard.withhold(slot), "the pending reveal must be stopped"); + + tokio::time::sleep(Duration::from_millis(400)).await; + assert!(!revealed.load(Ordering::SeqCst), "the payload must never be revealed"); + } + + #[tokio::test] + async fn a_reveal_that_already_ran_cannot_be_withheld() { + let mut guard = guard(); + let slot = Slot::new(10); + guard.register(slot, B256::repeat_byte(1)); + + let task = tokio::spawn(async {}); + guard.attach(slot, task.abort_handle()); + let _ = task.await; + + assert!(!guard.withhold(slot)); + } +} diff --git a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs index 638a29d69..98626859d 100644 --- a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs +++ b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs @@ -16,7 +16,7 @@ use ssz::Decode; use tracing::{error, info, warn}; use tree_hash::TreeHash; -use super::{ProposerApi, get_payload::fork_name_from_header}; +use super::{ProposerApi, get_payload::fork_name_from_header, reveal_guard::Registered}; use crate::api::{Api, proposer::error::ProposerApiError}; /// A payload a builder has already handed helix for a proposer's committed bid. @@ -235,7 +235,30 @@ impl ProposerApi { &proposer_api.chain_info, )?); + // Only now is the block known to redeem our bid: this endpoint does not verify + // the proposer's signature, so an unvalidated block must never be able to + // withhold a legitimate reveal. A second, different block that still redeems + // the same bid is the proposer equivocating. let slot = block.message.slot; + let block_root = block.message.tree_hash_root(); + let registered = proposer_api + .reveal_guard + .lock() + .expect("reveal guard mutex") + .register(slot, block_root); + if let Registered::Equivocation { first } = registered { + let withheld = + proposer_api.reveal_guard.lock().expect("reveal guard mutex").withhold(slot); + error!( + slot = slot.as_u64(), + ?first, + second = ?block_root, + withheld, + "the proposer equivocated", + ); + return Err(ProposerApiError::ProposerEquivocated { slot: slot.as_u64(), first }); + } + let block = Arc::new(block); // The proposer gossips its own block, but handing it to our node directly is @@ -250,10 +273,12 @@ impl ProposerApi { // the proposer have its acknowledgement now rather than seconds from now. let delay = proposer_api.chain_info.gloas_reveal_delay(slot, unix_now()); info!(slot = slot.as_u64(), delay_ms = delay.as_millis() as u64, "holding the payload"); - spawn_tracked!(async move { + let guard = proposer_api.reveal_guard.clone(); + let reveal = spawn_tracked!(async move { tokio::time::sleep(delay).await; reveal_envelope(&proposer_api, signed_envelope, slot).await; }); + guard.lock().expect("reveal guard mutex").attach(slot, reveal.abort_handle()); Ok(StatusCode::ACCEPTED) } From 962184ced31f7734988da663abcf4f94fc6f77d2 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 19 Sep 2026 15:49:56 +0100 Subject: [PATCH 32/33] Verify the proposer signed the block redeeming its bid submitSignedBeaconBlock authenticated nothing: no signature check, and no auth middleware on the route. The bid's block hash becomes public as soon as the proposer gossips its block, so anyone could redeem a bid on the proposer's behalf. That was survivable while a forged block only produced an envelope the beacon node would reject. With the equivocation guard it is not: a forged block with a different root reads as an equivocation and aborts the real reveal, so any observer could make helix withhold a legitimate payload. Carry the proposer helix bid to alongside the held payload and check the block's signature under DOMAIN_BEACON_PROPOSER before anything else acts on it. A gossiped payload carries no bid trace and so no proposer, and is refused rather than redeemed unverified. --- crates/relay/src/api/proposer/error.rs | 10 +- .../proposer/submit_signed_beacon_block.rs | 194 +++++++++++++----- crates/relay/src/auctioneer/gloas_payload.rs | 1 + crates/relay/src/auctioneer/types.rs | 9 + crates/types/src/lib.rs | 2 +- 5 files changed, 158 insertions(+), 58 deletions(-) diff --git a/crates/relay/src/api/proposer/error.rs b/crates/relay/src/api/proposer/error.rs index a823ca9b9..a83aff7e6 100644 --- a/crates/relay/src/api/proposer/error.rs +++ b/crates/relay/src/api/proposer/error.rs @@ -159,6 +159,12 @@ pub enum ProposerApiError { #[error("proposer equivocated at slot {slot}: already committed to block {first:?}")] ProposerEquivocated { slot: u64, first: B256 }, + + #[error("the block is not signed by the proposer this bid was served to")] + InvalidProposerSignature, + + #[error("the payload held for block {0:?} has no known proposer")] + UnknownBidProposer(B256), } impl ProposerApiError { @@ -226,7 +232,9 @@ impl IntoResponse for ProposerApiError { ProposerApiError::NoHeldPayloadForBlock(_) | ProposerApiError::HeldPayloadBlockHashMismatch { .. } | ProposerApiError::BuilderIndexMismatch { .. } | - ProposerApiError::ProposerEquivocated { .. } => StatusCode::BAD_REQUEST, + ProposerApiError::ProposerEquivocated { .. } | + ProposerApiError::InvalidProposerSignature | + ProposerApiError::UnknownBidProposer(_) => StatusCode::BAD_REQUEST, // All authentication failures, kept indistinguishable by status ProposerApiError::InvalidApiKey | diff --git a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs index 98626859d..374419f1b 100644 --- a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs +++ b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs @@ -6,9 +6,9 @@ use helix_common::{ chain_info::ChainInfo, decoder::Encoding, spawn_tracked, utils::extract_request_id, }; use helix_types::{ - BeaconBlockRef, BlobsBundle, BlsKeypair, BlsPublicKey, BlsPublicKeyBytes, Domain, EthSpec, + BlobsBundle, BlsKeypair, BlsPublicKey, BlsPublicKeyBytes, Domain, EthSpec, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequestsGloas, ForkName, - MainnetEthSpec, SigError, SignedBeaconBlockGloas, SignedExecutionPayloadEnvelope, + MainnetEthSpec, SignedBeaconBlockGloas, SignedExecutionPayloadEnvelope, SignedExecutionPayloadEnvelopeContents, SignedRoot, }; use hyper::StatusCode; @@ -25,6 +25,9 @@ pub struct HeldGloasPayload { pub payload: ExecutionPayloadGloas, pub execution_requests: ExecutionRequestsGloas, pub blobs_bundle: Arc, + /// The proposer this bid was served to, whose signature the redeeming block + /// must carry. + pub proposer_pubkey: Option, } /// Helix's own on-chain Gloas builder identity: `builder_index` plus signing key. @@ -73,6 +76,31 @@ impl GloasBuilderIdentity { } } +/// Whether the proposer helix bid to signed this block. The redeeming block is not +/// otherwise authenticated, and the bid's block hash is public once the block is +/// gossiped, so without this anyone could redeem a bid on the proposer's behalf. +fn proposer_signed_block( + block: &SignedBeaconBlockGloas, + pubkey: &BlsPublicKeyBytes, + chain_info: &ChainInfo, +) -> bool { + let epoch = block.message.slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::BeaconProposer, + &fork, + chain_info.genesis_validators_root, + ); + let Ok(pubkey) = BlsPublicKey::deserialize(pubkey.as_ref()) else { + return false; + }; + let signing_root = + helix_types::SigningData { object_root: block.message.tree_hash_root(), domain } + .tree_hash_root(); + block.signature.verify(&pubkey, signing_root) +} + /// Constructs and signs the `SignedExecutionPayloadEnvelope` fulfilling `block`'s committed bid. /// `held` is the payload the auctioneer has stored for the bid's committed block hash, if any. pub(super) fn construct_signed_envelope( @@ -93,6 +121,13 @@ pub(super) fn construct_signed_envelope( let held = held.ok_or(ProposerApiError::NoHeldPayloadForBlock(bid_block_hash))?; + // Fail closed: a payload whose proposer we cannot name cannot be redeemed. + let proposer_pubkey = + held.proposer_pubkey.ok_or(ProposerApiError::UnknownBidProposer(bid_block_hash))?; + if !proposer_signed_block(block, &proposer_pubkey, chain_info) { + return Err(ProposerApiError::InvalidProposerSignature); + } + let held_block_hash: B256 = held.payload.block_hash.0; if held_block_hash != bid_block_hash { return Err(ProposerApiError::HeldPayloadBlockHashMismatch { @@ -116,28 +151,6 @@ pub(super) fn construct_signed_envelope( }) } -fn verify_proposer_signature( - block: &SignedBeaconBlockGloas, - proposer_pubkey: &BlsPublicKeyBytes, - chain_info: &ChainInfo, -) -> Result<(), SigError> { - let pubkey = BlsPublicKey::deserialize(proposer_pubkey.as_slice()) - .map_err(|_| SigError::InvalidBlsPubkeyBytes)?; - let epoch = block.message.slot.epoch(MainnetEthSpec::slots_per_epoch()); - let fork = chain_info.spec.fork_at_epoch(epoch); - let domain = chain_info.spec.get_domain( - epoch, - Domain::BeaconProposer, - &fork, - chain_info.genesis_validators_root, - ); - if !block.signature.verify(&pubkey, BeaconBlockRef::Gloas(&block.message).signing_root(domain)) - { - return Err(SigError::InvalidBlsSignature); - } - Ok(()) -} - const PUBLISH_ATTEMPTS: u32 = 12; const PUBLISH_RETRY_INTERVAL: Duration = Duration::from_millis(100); @@ -196,22 +209,6 @@ impl ProposerApi { info!(slot = block.message.slot.as_u64(), "accepted submitSignedBeaconBlock request"); - let (_, slot_duty) = proposer_api.curr_slot_info.slot_info(); - let Some(slot_duty) = slot_duty else { - return Err(ProposerApiError::ProposerNotRegistered); - }; - if slot_duty.slot != block.message.slot { - return Err(ProposerApiError::InvalidBlindedBlockSlot { - internal_slot: slot_duty.slot, - blinded_block_slot: block.message.slot, - }); - } - verify_proposer_signature( - &block, - &slot_duty.entry.registration.message.pubkey, - &proposer_api.chain_info, - )?; - let bid_block_hash: B256 = block.message.body.signed_execution_payload_bid.message.block_hash.0; let Ok(rx) = proposer_api @@ -287,32 +284,63 @@ impl ProposerApi { #[cfg(test)] mod construct_signed_envelope_tests { use helix_common::utils::install_default_crypto_provider; - use helix_types::{BeaconBlockGloas, BlsSignature, EmptyBlock, ExecutionBlockHash}; + use helix_types::{BeaconBlockGloas, EmptyBlock, ExecutionBlockHash, SigningData}; use super::*; - fn held_payload(block_hash: B256) -> HeldGloasPayload { + fn held_payload(block_hash: B256, proposer: &BlsKeypair) -> HeldGloasPayload { let mut payload = ExecutionPayloadGloas::default(); payload.block_hash = ExecutionBlockHash(block_hash); HeldGloasPayload { payload, execution_requests: ExecutionRequestsGloas::default(), blobs_bundle: Default::default(), + proposer_pubkey: Some(proposer.pk.compress().serialize().into()), } } - fn test_block( - block_hash: B256, - builder_index: u64, - parent_root: B256, + pub(super) fn proposer() -> BlsKeypair { + install_default_crypto_provider(); + BlsKeypair::random() + } + + /// Signs `message` the way the proposer's validator client would. + pub(super) fn sign_block( + message: BeaconBlockGloas, + proposer: &BlsKeypair, ) -> SignedBeaconBlockGloas { + let chain_info = ChainInfo::default(); + let epoch = message.slot.epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::BeaconProposer, + &fork, + chain_info.genesis_validators_root, + ); + let signing_root = + SigningData { object_root: message.tree_hash_root(), domain }.tree_hash_root(); + let signature = proposer.sk.sign(signing_root); + SignedBeaconBlockGloas { message, signature } + } + + fn block_message(block_hash: B256, builder_index: u64, parent_root: B256) -> BeaconBlockGloas { let chain_info = ChainInfo::default(); let mut message = BeaconBlockGloas::empty(&chain_info.spec); message.parent_root = parent_root; message.body.signed_execution_payload_bid.message.block_hash = ExecutionBlockHash(block_hash); message.body.signed_execution_payload_bid.message.builder_index = builder_index; - SignedBeaconBlockGloas { message, signature: BlsSignature::empty() } + message + } + + fn test_block( + block_hash: B256, + builder_index: u64, + parent_root: B256, + proposer: &BlsKeypair, + ) -> SignedBeaconBlockGloas { + sign_block(block_message(block_hash, builder_index, parent_root), proposer) } fn identity(builder_index: u64) -> GloasBuilderIdentity { @@ -325,8 +353,9 @@ mod construct_signed_envelope_tests { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x11); let parent_root = B256::repeat_byte(0x22); - let block = test_block(block_hash, 7, parent_root); - let held = Some(held_payload(block_hash)); + let proposer = proposer(); + let block = test_block(block_hash, 7, parent_root, &proposer); + let held = Some(held_payload(block_hash, &proposer)); let identity = identity(7); let signed_envelope = @@ -351,8 +380,9 @@ mod construct_signed_envelope_tests { fn signature_verifies_against_the_configured_identity() { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x33); - let block = test_block(block_hash, 3, B256::ZERO); - let held = Some(held_payload(block_hash)); + let proposer = proposer(); + let block = test_block(block_hash, 3, B256::ZERO, &proposer); + let held = Some(held_payload(block_hash, &proposer)); let identity = identity(3); let signed_envelope = @@ -376,7 +406,8 @@ mod construct_signed_envelope_tests { fn no_held_payload_is_an_error_not_a_panic() { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x44); - let block = test_block(block_hash, 1, B256::ZERO); + let proposer = proposer(); + let block = test_block(block_hash, 1, B256::ZERO, &proposer); let identity = identity(1); let result = construct_signed_envelope(&block, None, &identity, &chain_info); @@ -391,8 +422,9 @@ mod construct_signed_envelope_tests { let chain_info = ChainInfo::default(); let bid_block_hash = B256::repeat_byte(0x55); let wrong_held_hash = B256::repeat_byte(0x66); - let block = test_block(bid_block_hash, 1, B256::ZERO); - let held = Some(held_payload(wrong_held_hash)); + let proposer = proposer(); + let block = test_block(bid_block_hash, 1, B256::ZERO, &proposer); + let held = Some(held_payload(wrong_held_hash, &proposer)); let identity = identity(1); let result = construct_signed_envelope(&block, held, &identity, &chain_info); @@ -404,12 +436,62 @@ mod construct_signed_envelope_tests { )); } + /// The bid's block hash is public once the proposer gossips its block, so without + /// this check anyone could redeem the bid and, with the equivocation guard, make + /// helix withhold a legitimate reveal. + #[test] + fn a_block_signed_by_anyone_else_is_rejected() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x33); + let attacker = proposer(); + let proposer = proposer(); + // The attacker replays the real bid commitment under its own signature. + let block = test_block(block_hash, 4, B256::ZERO, &attacker); + let held = Some(held_payload(block_hash, &proposer)); + + let result = construct_signed_envelope(&block, held, &identity(4), &chain_info); + + assert!(matches!(result, Err(ProposerApiError::InvalidProposerSignature))); + } + + #[test] + fn an_unsigned_block_is_rejected() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x44); + let proposer = proposer(); + let block = SignedBeaconBlockGloas { + message: block_message(block_hash, 4, B256::ZERO), + signature: helix_types::BlsSignature::empty(), + }; + let held = Some(held_payload(block_hash, &proposer)); + + let result = construct_signed_envelope(&block, held, &identity(4), &chain_info); + + assert!(matches!(result, Err(ProposerApiError::InvalidProposerSignature))); + } + + /// Fail closed: a payload whose proposer helix cannot name must not be redeemable. + #[test] + fn a_payload_with_no_known_proposer_is_not_redeemable() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x55); + let proposer = proposer(); + let block = test_block(block_hash, 4, B256::ZERO, &proposer); + let mut held = held_payload(block_hash, &proposer); + held.proposer_pubkey = None; + + let result = construct_signed_envelope(&block, Some(held), &identity(4), &chain_info); + + assert!(matches!(result, Err(ProposerApiError::UnknownBidProposer(_)))); + } + #[test] fn bid_builder_index_not_matching_configured_identity_is_rejected() { let chain_info = ChainInfo::default(); let block_hash = B256::repeat_byte(0x77); - let block = test_block(block_hash, 9, B256::ZERO); - let held = Some(held_payload(block_hash)); + let proposer = proposer(); + let block = test_block(block_hash, 9, B256::ZERO, &proposer); + let held = Some(held_payload(block_hash, &proposer)); let identity = identity(1); let result = construct_signed_envelope(&block, held, &identity, &chain_info); diff --git a/crates/relay/src/auctioneer/gloas_payload.rs b/crates/relay/src/auctioneer/gloas_payload.rs index b5d306169..a3d571b6b 100644 --- a/crates/relay/src/auctioneer/gloas_payload.rs +++ b/crates/relay/src/auctioneer/gloas_payload.rs @@ -38,6 +38,7 @@ impl Context { payload, execution_requests, blobs_bundle: entry.payload_and_blobs().blobs_bundle.clone(), + proposer_pubkey: entry.proposer_pubkey().copied(), }) }); diff --git a/crates/relay/src/auctioneer/types.rs b/crates/relay/src/auctioneer/types.rs index 2c723a1db..31f4cc1b0 100644 --- a/crates/relay/src/auctioneer/types.rs +++ b/crates/relay/src/auctioneer/types.rs @@ -351,6 +351,15 @@ impl PayloadEntry { } } + /// The proposer this payload was bid to. Gossiped payloads carry no bid trace, + /// so their proposer is unknown. + pub fn proposer_pubkey(&self) -> Option<&BlsPublicKeyBytes> { + match &self { + Self::Submission(s) => Some(s.signed_bid_submission.proposer_public_key()), + Self::Gossip(_) => None, + } + } + pub fn bid_data_ref(&self) -> PayloadBidDataRef<'_> { match &self { Self::Submission(s) => PayloadBidDataRef { diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 02686a1f5..a7baeeff9 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -33,7 +33,7 @@ pub use hydration::*; pub use lh_kzg::{KzgCommitment, KzgProof}; pub use lh_types::{ Config as LhConfig, EmptyBlock, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, - ForkVersionDecode, MainnetEthSpec, SignedRoot, + ForkVersionDecode, MainnetEthSpec, SignedRoot, SigningData, }; pub use operator::*; pub use request_auth::*; From 0e47b773305696680b4c4210bd58fd942c147327 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 19 Sep 2026 16:42:54 +0100 Subject: [PATCH 33/33] Log the request auth on both Gloas proposer endpoints `BuilderRequestAuth.data` is opaque and nothing reads it, so we do not know what clients put there. Log its length and a bounded prefix, on both getExecutionPayloadBid and submitBuilderPreferences, along with the slot the auth is signed for. The field runs to 4096 bytes, so the rendering is truncated and says so while still reporting the real length. --- .../api/proposer/get_execution_payload_bid.rs | 4 ++ crates/relay/src/api/proposer/mod.rs | 55 +++++++++++++++++++ .../proposer/submit_builder_preferences.rs | 5 +- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/relay/src/api/proposer/get_execution_payload_bid.rs b/crates/relay/src/api/proposer/get_execution_payload_bid.rs index 6a918035e..db3ff479d 100644 --- a/crates/relay/src/api/proposer/get_execution_payload_bid.rs +++ b/crates/relay/src/api/proposer/get_execution_payload_bid.rs @@ -59,8 +59,12 @@ impl ProposerApi { .verify_signature(¶ms.proposer_pubkey, proposer_api.chain_info.request_auth_domain) .map_err(|_| ProposerApiError::InvalidRequestAuthSignature)?; + let (auth_slot, auth_data_len, auth_data) = super::auth_summary(&signed_request_auth); info!( slot = params.slot, + auth_slot, + auth_data_len, + %auth_data, parent_hash = ?params.parent_hash, parent_root = ?params.parent_root, proposer_pubkey = ?params.proposer_pubkey, diff --git a/crates/relay/src/api/proposer/mod.rs b/crates/relay/src/api/proposer/mod.rs index 1b58c81a3..ba6a28991 100644 --- a/crates/relay/src/api/proposer/mod.rs +++ b/crates/relay/src/api/proposer/mod.rs @@ -111,4 +111,59 @@ pub async fn status( } } +/// How much of a request auth's opaque `data` to log. Nothing reads the field yet, +/// so this is here to find out what clients actually put in it. +const LOGGED_AUTH_DATA_BYTES: usize = 64; + +/// Renders a request auth for logging: its slot, the length of `data`, and as much of +/// `data` as [`LOGGED_AUTH_DATA_BYTES`] allows. +pub(crate) fn auth_summary(auth: &helix_types::SignedBuilderRequestAuth) -> (u64, usize, String) { + let data = &auth.message.data; + let shown = data.len().min(LOGGED_AUTH_DATA_BYTES); + let mut rendered = format!("{}", alloy_primitives::Bytes::copy_from_slice(&data[..shown])); + if shown < data.len() { + rendered.push('\u{2026}'); + } + (auth.message.slot, data.len(), rendered) +} + const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; + +#[cfg(test)] +mod auth_summary_tests { + use helix_types::{ + BlsSignatureBytes, BuilderRequestAuth, RequestAuthData, SignedBuilderRequestAuth, + }; + + use super::*; + + fn auth(data: Vec) -> SignedBuilderRequestAuth { + SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: RequestAuthData(data.into()), slot: 42 }, + signature: BlsSignatureBytes::default(), + } + } + + #[test] + fn empty_data_is_reported_as_empty() { + let (slot, len, rendered) = auth_summary(&auth(vec![])); + assert_eq!((slot, len), (42, 0)); + assert_eq!(rendered, "0x"); + } + + #[test] + fn short_data_is_rendered_whole() { + let (_, len, rendered) = auth_summary(&auth(vec![0xde, 0xad, 0xbe, 0xef])); + assert_eq!(len, 4); + assert_eq!(rendered, "0xdeadbeef"); + } + + /// `data` runs to 4096 bytes; a log line must not carry all of it. + #[test] + fn long_data_is_truncated_but_its_length_is_kept() { + let (_, len, rendered) = auth_summary(&auth(vec![0xab; 1000])); + assert_eq!(len, 1000, "the real length is still reported"); + assert!(rendered.ends_with('\u{2026}'), "and the rendering says it was cut"); + assert_eq!(rendered.chars().count(), 2 + LOGGED_AUTH_DATA_BYTES * 2 + 1); + } +} diff --git a/crates/relay/src/api/proposer/submit_builder_preferences.rs b/crates/relay/src/api/proposer/submit_builder_preferences.rs index 6ae951fd6..fcfb38d05 100644 --- a/crates/relay/src/api/proposer/submit_builder_preferences.rs +++ b/crates/relay/src/api/proposer/submit_builder_preferences.rs @@ -37,9 +37,12 @@ impl ProposerApi { .verify_signature(¶ms.proposer_pubkey, proposer_api.chain_info.request_auth_domain) .map_err(|_| ProposerApiError::InvalidRequestAuthSignature)?; + let (auth_slot, auth_data_len, auth_data) = super::auth_summary(&request.auth); info!( proposer_pubkey = ?params.proposer_pubkey, - slot = request.auth.message.slot, + slot = auth_slot, + auth_data_len, + %auth_data, max_execution_payment = request.preferences.max_execution_payment, "validated submitBuilderPreferences request" );