From 27e2f7d62d109abaa9a71f966f57906055cb542a Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:10:31 +0200 Subject: [PATCH 01/10] Gloas select payload bid using boost factor When producing a Gloas block, select between the locally-built bid and the highest gossip-verified builder bid for the same parent, using `builder_boost_factor` (mirroring the pre-Gloas builder/local race in `execution_layer`): - `boosted_bid = (cached_bid.value / 100) * boost_factor` (raw on `None`) - if `local_value_wei >= boosted_bid_wei` -> keep local - if EL signaled `should_override_builder` -> keep local - else -> use the cached builder bid and drop local payload data (the builder reveals the envelope) `cached_bid.value` is in gwei (u64); `payload_value` is in wei (Uint256); the comparison is performed in wei. `should_override_builder` is now plumbed from `GetPayloadResponseGloas` through `BlockProposalContentsGloas` into the new `LocalBuildResult` so the selection step can honor the EL hint. Selection logic is factored into a pure `select_payload_bid_pure` function with unit tests covering empty cache, no local build, override hook, boost=0, neutral boost, local-higher, tie, and boost amplification. --- .../src/block_production/gloas.rs | 301 +++++++++++++++++- beacon_node/beacon_chain/src/test_utils.rs | 1 + .../beacon_chain/tests/prepare_payload.rs | 1 + beacon_node/execution_layer/src/lib.rs | 2 + 4 files changed, 295 insertions(+), 10 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 79ea78ce4a5..141e0872714 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -78,6 +78,16 @@ pub struct ExecutionPayloadData { pub blobs_and_proofs: (types::BlobsList, types::KzgProofs), } +/// The result of a local payload build, used to decide whether to include a builder bid +/// from the gossip cache or fall back to self-build. +pub struct LocalBuildResult { + pub payload_data: ExecutionPayloadData, + /// EL block value (in wei) of the locally-built payload. + pub payload_value: types::Uint256, + /// `true` if the EL signaled `engine_getPayload`'s `shouldOverrideBuilder` flag. + pub should_override_builder: bool, +} + impl BeaconChain { pub async fn produce_block_with_verification_gloas( self: &Arc, @@ -85,7 +95,7 @@ impl BeaconChain { slot: Slot, graffiti_settings: GraffitiSettings, verification: ProduceBlockVerification, - _builder_boost_factor: Option, + builder_boost_factor: Option, ) -> Result, BlockProductionError> { metrics::inc_counter(&metrics::BLOCK_PRODUCTION_REQUESTS); let _complete_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_TIMES); @@ -121,11 +131,11 @@ impl BeaconChain { randao_reveal, graffiti_settings, verification, + builder_boost_factor, ) .await } - // TODO(gloas) need to implement builder boost factor logic #[instrument(level = "debug", skip_all)] #[allow(clippy::too_many_arguments)] pub async fn produce_block_on_state_gloas( @@ -138,6 +148,7 @@ impl BeaconChain { randao_reveal: Signature, graffiti_settings: GraffitiSettings, verification: ProduceBlockVerification, + builder_boost_factor: Option, ) -> Result, BlockProductionError> { // Extract the parent's execution requests from the envelope (if parent was full). let parent_execution_requests = if parent_payload_status == PayloadStatus::Full { @@ -179,10 +190,10 @@ impl BeaconChain { // Part 2/3 (async) // - // Produce the execution payload bid. - // TODO(gloas) this is strictly for building local bids - // We'll need to build out trustless/trusted bid paths. - let (execution_payload_bid, state, payload_data) = self + // Produce a local execution payload bid, then select between it and any cached + // gossip-verified builder bid using `builder_boost_factor`. + // TODO(gloas) build out trustless/trusted bid paths. + let (local_signed_bid, state, local_build) = self .clone() .produce_execution_payload_bid( state, @@ -194,6 +205,9 @@ impl BeaconChain { ) .await?; + let (execution_payload_bid, payload_data) = + self.select_payload_bid(local_signed_bid, local_build, builder_boost_factor); + // Part 3/3 (blocking) // // Complete the block with the execution payload bid. @@ -701,7 +715,7 @@ impl BeaconChain { ( SignedExecutionPayloadBid, BeaconState, - Option>, + Option>, ), BlockProductionError, > { @@ -773,10 +787,11 @@ impl BeaconChain { let BlockProposalContentsGloas { payload, - payload_value: _, + payload_value, execution_requests, blob_kzg_commitments, blobs_and_proofs, + should_override_builder, } = block_proposal_contents; // TODO(gloas) since we are defaulting to local building, execution payment is 0 @@ -813,11 +828,119 @@ impl BeaconChain { signature: Signature::infinity().map_err(BlockProductionError::BlsError)?, }, state, - // Local building always returns payload data. + // Local building always returns local-build context. // Trustless building would return None here. - Some(payload_data), + Some(LocalBuildResult { + payload_data, + payload_value, + should_override_builder, + }), )) } + + /// Look up the highest gossip-verified bid for the `(slot, parent_block_hash, + /// parent_block_root)` of the local bid, then choose the winner. + fn select_payload_bid( + &self, + local_signed_bid: SignedExecutionPayloadBid, + local_build: Option>, + builder_boost_factor: Option, + ) -> ( + SignedExecutionPayloadBid, + Option>, + ) { + let cached_bid = if local_build.is_some() { + self.gossip_verified_payload_bid_cache.get_highest_bid( + local_signed_bid.message.slot, + local_signed_bid.message.parent_block_hash, + local_signed_bid.message.parent_block_root, + ) + } else { + None + }; + select_payload_bid_pure( + local_signed_bid, + local_build, + cached_bid, + builder_boost_factor, + ) + } +} + +/// Pure selection logic, factored out for unit testing. +/// +/// Selection rule (mirrors the pre-Gloas builder/local race in `execution_layer`): +/// - `boosted_bid = (cached_bid.value / 100) * builder_boost_factor` (raw value when `None`) +/// - if `local_value_wei >= boosted_bid_wei` → keep local +/// - if the EL signaled `should_override_builder` → keep local +/// - otherwise → use the cached builder bid and drop local payload data +/// (the builder is responsible for revealing the envelope). +/// +/// `cached_bid.value` is in gwei (`u64`); `payload_value` is in wei (`Uint256`); compared in wei. +pub(crate) fn select_payload_bid_pure( + local_signed_bid: SignedExecutionPayloadBid, + local_build: Option>, + cached_bid: Option>>, + builder_boost_factor: Option, +) -> ( + SignedExecutionPayloadBid, + Option>, +) { + let Some(LocalBuildResult { + payload_data, + payload_value, + should_override_builder, + }) = local_build + else { + // Trustless / pre-built path — nothing to select against. + return (local_signed_bid, None); + }; + + let Some(cached_bid) = cached_bid else { + return (local_signed_bid, Some(payload_data)); + }; + + let slot = local_signed_bid.message.slot; + + if should_override_builder { + debug!( + %slot, + cached_bid_value = cached_bid.message.value, + "Using local payload because EL signaled shouldOverrideBuilder" + ); + return (local_signed_bid, Some(payload_data)); + } + + // Convert bid value (gwei) to wei for comparison with `payload_value` (wei). + let bid_value_wei = types::Uint256::from(cached_bid.message.value) + .saturating_mul(types::Uint256::from(1_000_000_000u64)); + let boosted_bid_wei = match builder_boost_factor { + Some(factor) => { + (bid_value_wei / types::Uint256::from(100)).saturating_mul(types::Uint256::from(factor)) + } + None => bid_value_wei, + }; + + if payload_value >= boosted_bid_wei { + debug!( + %slot, + %payload_value, + cached_bid_value_gwei = cached_bid.message.value, + ?builder_boost_factor, + "Local payload is more profitable than cached builder bid" + ); + (local_signed_bid, Some(payload_data)) + } else { + debug!( + %slot, + %payload_value, + cached_bid_value_gwei = cached_bid.message.value, + cached_bid_builder_index = cached_bid.message.builder_index, + ?builder_boost_factor, + "Including cached builder bid" + ); + ((*cached_bid).clone(), None) + } } /// Gets an execution payload for inclusion in a block. @@ -1150,4 +1273,162 @@ mod tests { assert_eq!(exits.len(), 2); } + + // ---- select_payload_bid_pure ---- + + fn local_signed_bid(builder_index: BuilderIndex) -> SignedExecutionPayloadBid { + SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + builder_index, + slot: Slot::new(7), + parent_block_hash: ExecutionBlockHash::repeat_byte(0xaa), + parent_block_root: Hash256::repeat_byte(0xbb), + ..Default::default() + }, + signature: Signature::empty(), + } + } + + fn cached_bid(value_gwei: u64) -> Arc> { + Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + builder_index: 999, + slot: Slot::new(7), + parent_block_hash: ExecutionBlockHash::repeat_byte(0xaa), + parent_block_root: Hash256::repeat_byte(0xbb), + value: value_gwei, + ..Default::default() + }, + signature: Signature::empty(), + }) + } + + fn local_build( + payload_value_wei: types::Uint256, + should_override_builder: bool, + ) -> LocalBuildResult { + LocalBuildResult { + payload_data: ExecutionPayloadData { + payload: types::ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequests::default(), + builder_index: BUILDER_INDEX_SELF_BUILD, + slot: Slot::new(7), + blobs_and_proofs: (VariableList::empty(), VariableList::empty()), + }, + payload_value: payload_value_wei, + should_override_builder, + } + } + + fn one_gwei_in_wei() -> types::Uint256 { + types::Uint256::from(1_000_000_000u64) + } + + #[test] + fn select_no_local_build_returns_input_bid_and_no_data() { + // Trustless / pre-built path: no LocalBuildResult means we keep the supplied bid + // and emit no payload data, regardless of cache content. + let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); + let cache = Some(cached_bid(1_000)); + let (out, data) = select_payload_bid_pure::(local.clone(), None, cache, None); + assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); + assert!(data.is_none()); + } + + #[test] + fn select_empty_cache_keeps_local() { + let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); + let build = local_build(types::Uint256::ZERO, false); + let (out, data) = + select_payload_bid_pure::(local, Some(build), None, Some(u64::MAX)); + assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); + assert!(data.is_some()); + } + + #[test] + fn select_should_override_builder_keeps_local() { + // Even with a fat cached bid and infinite boost, EL override wins. + let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); + let build = local_build(types::Uint256::ZERO, true); + let cache = Some(cached_bid(u64::MAX)); + let (out, data) = + select_payload_bid_pure::(local, Some(build), cache, Some(u64::MAX)); + assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); + assert!(data.is_some()); + } + + #[test] + fn select_boost_zero_keeps_local() { + // boost=0 deflates the bid to 0 → local always wins. + let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); + let build = local_build(types::Uint256::ZERO, false); + let cache = Some(cached_bid(u64::MAX)); + let (out, data) = select_payload_bid_pure::(local, Some(build), cache, Some(0)); + assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); + assert!(data.is_some()); + } + + #[test] + fn select_neutral_boost_picks_higher() { + // Cached bid = 5 gwei = 5e9 wei, local payload = 1e9 wei. None means no boost. + let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); + let build = local_build(one_gwei_in_wei(), false); + let cache = Some(cached_bid(5)); + let (out, data) = select_payload_bid_pure::(local, Some(build), cache, None); + assert_eq!(out.message.builder_index, 999); + assert!(data.is_none()); + } + + #[test] + fn select_local_strictly_higher_keeps_local() { + // Local payload value > raw bid value → keep local (no boost). + let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); + let build = local_build( + one_gwei_in_wei().saturating_mul(types::Uint256::from(10u64)), + false, + ); + let cache = Some(cached_bid(5)); + let (out, data) = select_payload_bid_pure::(local, Some(build), cache, None); + assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); + assert!(data.is_some()); + } + + #[test] + fn select_tie_keeps_local() { + // local_value == boosted_bid → `>=` keeps local. + let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); + let build = local_build( + one_gwei_in_wei().saturating_mul(types::Uint256::from(5u64)), + false, + ); + let cache = Some(cached_bid(5)); + let (out, data) = select_payload_bid_pure::(local, Some(build), cache, None); + assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); + assert!(data.is_some()); + } + + #[test] + fn select_boost_factor_amplifies_bid() { + // Local 5 gwei, cached bid 3 gwei. Raw compare → local wins. + // With boost=200 → bid effectively 6 gwei → bid wins. + let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); + let cache = Some(cached_bid(3)); + let build = local_build( + one_gwei_in_wei().saturating_mul(types::Uint256::from(5u64)), + false, + ); + let (out_raw, data_raw) = + select_payload_bid_pure::(local.clone(), Some(build), cache.clone(), None); + assert_eq!(out_raw.message.builder_index, BUILDER_INDEX_SELF_BUILD); + assert!(data_raw.is_some()); + + let build = local_build( + one_gwei_in_wei().saturating_mul(types::Uint256::from(5u64)), + false, + ); + let (out_boosted, data_boosted) = + select_payload_bid_pure::(local, Some(build), cache, Some(200)); + assert_eq!(out_boosted.message.builder_index, 999); + assert!(data_boosted.is_none()); + } } diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index f67b5015c5c..f61a7abbe69 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1186,6 +1186,7 @@ where randao_reveal, graffiti_settings, ProduceBlockVerification::VerifyRandao, + None, ) .await .unwrap(); diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 1d23990b802..37b0bf3067d 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -637,6 +637,7 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { randao_reveal, graffiti_settings, ProduceBlockVerification::VerifyRandao, + None, ) .await .unwrap(); diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 4146543fd56..b2dabb7c018 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -205,6 +205,7 @@ pub struct BlockProposalContentsGloas { pub blob_kzg_commitments: KzgCommitments, pub blobs_and_proofs: (BlobsList, KzgProofs), pub execution_requests: ExecutionRequests, + pub should_override_builder: bool, } impl From> for BlockProposalContentsGloas { @@ -215,6 +216,7 @@ impl From> for BlockProposalContentsGloas blob_kzg_commitments: response.blobs_bundle.commitments, blobs_and_proofs: (response.blobs_bundle.blobs, response.blobs_bundle.proofs), execution_requests: response.requests, + should_override_builder: response.should_override_builder, } } } From 23dc2cfd5c9ee6a0574d818b09d299f5e7f8b149 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:35:11 +0200 Subject: [PATCH 02/10] Tighten bid-selection tests, drop dead trustless branch Two readability fixes after self-review: - `select_payload_bid_pure` now takes `LocalBuildResult` directly instead of `Option`. The `None` branch was scaffolding for a hypothetical "skip the EL build entirely" path, but in practice the local build is always needed as the fallback for empty-cache slots, so it never becomes None. `produce_execution_payload_bid` returns `LocalBuildResult` directly to match. - Tests collapsed onto a single positional `pick(local_payload_gwei, should_override, cached_gwei, boost) -> (BuilderIndex, bool)` helper, with arg semantics documented on the helper's doc comment so the call sites stay one-liners that read as truth-table rows. --- .../src/block_production/gloas.rs | 187 ++++++------------ 1 file changed, 62 insertions(+), 125 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 141e0872714..78e3a25c44f 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -715,7 +715,7 @@ impl BeaconChain { ( SignedExecutionPayloadBid, BeaconState, - Option>, + LocalBuildResult, ), BlockProductionError, > { @@ -820,21 +820,17 @@ impl BeaconChain { blobs_and_proofs, }; - // TODO(gloas) this is only local building - // we'll need to implement builder signature for the trustless path Ok(( SignedExecutionPayloadBid { message: bid, signature: Signature::infinity().map_err(BlockProductionError::BlsError)?, }, state, - // Local building always returns local-build context. - // Trustless building would return None here. - Some(LocalBuildResult { + LocalBuildResult { payload_data, payload_value, should_override_builder, - }), + }, )) } @@ -843,21 +839,17 @@ impl BeaconChain { fn select_payload_bid( &self, local_signed_bid: SignedExecutionPayloadBid, - local_build: Option>, + local_build: LocalBuildResult, builder_boost_factor: Option, ) -> ( SignedExecutionPayloadBid, Option>, ) { - let cached_bid = if local_build.is_some() { - self.gossip_verified_payload_bid_cache.get_highest_bid( - local_signed_bid.message.slot, - local_signed_bid.message.parent_block_hash, - local_signed_bid.message.parent_block_root, - ) - } else { - None - }; + let cached_bid = self.gossip_verified_payload_bid_cache.get_highest_bid( + local_signed_bid.message.slot, + local_signed_bid.message.parent_block_hash, + local_signed_bid.message.parent_block_root, + ); select_payload_bid_pure( local_signed_bid, local_build, @@ -867,7 +859,7 @@ impl BeaconChain { } } -/// Pure selection logic, factored out for unit testing. +/// Pure local-vs-cached selection logic, factored out for unit testing. /// /// Selection rule (mirrors the pre-Gloas builder/local race in `execution_layer`): /// - `boosted_bid = (cached_bid.value / 100) * builder_boost_factor` (raw value when `None`) @@ -879,22 +871,18 @@ impl BeaconChain { /// `cached_bid.value` is in gwei (`u64`); `payload_value` is in wei (`Uint256`); compared in wei. pub(crate) fn select_payload_bid_pure( local_signed_bid: SignedExecutionPayloadBid, - local_build: Option>, + local_build: LocalBuildResult, cached_bid: Option>>, builder_boost_factor: Option, ) -> ( SignedExecutionPayloadBid, Option>, ) { - let Some(LocalBuildResult { + let LocalBuildResult { payload_data, payload_value, should_override_builder, - }) = local_build - else { - // Trustless / pre-built path — nothing to select against. - return (local_signed_bid, None); - }; + } = local_build; let Some(cached_bid) = cached_bid else { return (local_signed_bid, Some(payload_data)); @@ -1276,13 +1264,16 @@ mod tests { // ---- select_payload_bid_pure ---- - fn local_signed_bid(builder_index: BuilderIndex) -> SignedExecutionPayloadBid { + const REMOTE_BUILDER: BuilderIndex = 999; + + fn gwei(n: u64) -> types::Uint256 { + types::Uint256::from(n).saturating_mul(types::Uint256::from(1_000_000_000u64)) + } + + fn local_bid() -> SignedExecutionPayloadBid { SignedExecutionPayloadBid { message: ExecutionPayloadBid { - builder_index, - slot: Slot::new(7), - parent_block_hash: ExecutionBlockHash::repeat_byte(0xaa), - parent_block_root: Hash256::repeat_byte(0xbb), + builder_index: BUILDER_INDEX_SELF_BUILD, ..Default::default() }, signature: Signature::empty(), @@ -1292,10 +1283,7 @@ mod tests { fn cached_bid(value_gwei: u64) -> Arc> { Arc::new(SignedExecutionPayloadBid { message: ExecutionPayloadBid { - builder_index: 999, - slot: Slot::new(7), - parent_block_hash: ExecutionBlockHash::repeat_byte(0xaa), - parent_block_root: Hash256::repeat_byte(0xbb), + builder_index: REMOTE_BUILDER, value: value_gwei, ..Default::default() }, @@ -1303,132 +1291,81 @@ mod tests { }) } - fn local_build( - payload_value_wei: types::Uint256, - should_override_builder: bool, - ) -> LocalBuildResult { + fn local_build(payload_gwei: u64, should_override_builder: bool) -> LocalBuildResult { LocalBuildResult { payload_data: ExecutionPayloadData { payload: types::ExecutionPayloadGloas::default(), execution_requests: ExecutionRequests::default(), builder_index: BUILDER_INDEX_SELF_BUILD, - slot: Slot::new(7), + slot: Slot::new(0), blobs_and_proofs: (VariableList::empty(), VariableList::empty()), }, - payload_value: payload_value_wei, + payload_value: gwei(payload_gwei), should_override_builder, } } - fn one_gwei_in_wei() -> types::Uint256 { - types::Uint256::from(1_000_000_000u64) - } + const LOCAL: BuilderIndex = BUILDER_INDEX_SELF_BUILD; + const REMOTE: BuilderIndex = REMOTE_BUILDER; - #[test] - fn select_no_local_build_returns_input_bid_and_no_data() { - // Trustless / pre-built path: no LocalBuildResult means we keep the supplied bid - // and emit no payload data, regardless of cache content. - let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); - let cache = Some(cached_bid(1_000)); - let (out, data) = select_payload_bid_pure::(local.clone(), None, cache, None); - assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); - assert!(data.is_none()); + /// Run `select_payload_bid_pure` and return `(winning_builder_index, has_payload_data)`. + /// + /// Args (positional, mirror `select_payload_bid_pure`): + /// - `local_payload_gwei`: local payload value, in gwei. + /// - `should_override`: EL's `shouldOverrideBuilder` flag. + /// - `cached_gwei`: `Some(g)` ⇒ seed the cache with a bid of `g` gwei. + /// - `boost`: `None` = neutral, `Some(0)` = always local, `Some(>100)` = boost bid. + fn pick( + local_payload_gwei: u64, + should_override: bool, + cached_gwei: Option, + boost: Option, + ) -> (BuilderIndex, bool) { + let build = local_build(local_payload_gwei, should_override); + let cache = cached_gwei.map(cached_bid); + let (out, data) = select_payload_bid_pure::(local_bid(), build, cache, boost); + (out.message.builder_index, data.is_some()) } #[test] fn select_empty_cache_keeps_local() { - let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); - let build = local_build(types::Uint256::ZERO, false); - let (out, data) = - select_payload_bid_pure::(local, Some(build), None, Some(u64::MAX)); - assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); - assert!(data.is_some()); + assert_eq!(pick(0, false, None, Some(u64::MAX)), (LOCAL, true)); } #[test] - fn select_should_override_builder_keeps_local() { - // Even with a fat cached bid and infinite boost, EL override wins. - let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); - let build = local_build(types::Uint256::ZERO, true); - let cache = Some(cached_bid(u64::MAX)); - let (out, data) = - select_payload_bid_pure::(local, Some(build), cache, Some(u64::MAX)); - assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); - assert!(data.is_some()); + fn select_el_override_beats_any_cached_bid() { + // `shouldOverrideBuilder` short-circuits regardless of cache or boost. + assert_eq!(pick(0, true, Some(u64::MAX), Some(u64::MAX)), (LOCAL, true)); } #[test] - fn select_boost_zero_keeps_local() { - // boost=0 deflates the bid to 0 → local always wins. - let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); - let build = local_build(types::Uint256::ZERO, false); - let cache = Some(cached_bid(u64::MAX)); - let (out, data) = select_payload_bid_pure::(local, Some(build), cache, Some(0)); - assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); - assert!(data.is_some()); + fn select_boost_zero_always_keeps_local() { + // boost=0 deflates the bid to 0 ⇒ local always wins. + assert_eq!(pick(0, false, Some(u64::MAX), Some(0)), (LOCAL, true)); } #[test] - fn select_neutral_boost_picks_higher() { - // Cached bid = 5 gwei = 5e9 wei, local payload = 1e9 wei. None means no boost. - let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); - let build = local_build(one_gwei_in_wei(), false); - let cache = Some(cached_bid(5)); - let (out, data) = select_payload_bid_pure::(local, Some(build), cache, None); - assert_eq!(out.message.builder_index, 999); - assert!(data.is_none()); + fn select_neutral_boost_picks_higher_bid() { + // 5 gwei bid > 1 gwei local, neutral compare ⇒ bid. + assert_eq!(pick(1, false, Some(5), None), (REMOTE, false)); } #[test] fn select_local_strictly_higher_keeps_local() { - // Local payload value > raw bid value → keep local (no boost). - let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); - let build = local_build( - one_gwei_in_wei().saturating_mul(types::Uint256::from(10u64)), - false, - ); - let cache = Some(cached_bid(5)); - let (out, data) = select_payload_bid_pure::(local, Some(build), cache, None); - assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); - assert!(data.is_some()); + assert_eq!(pick(10, false, Some(5), None), (LOCAL, true)); } #[test] - fn select_tie_keeps_local() { - // local_value == boosted_bid → `>=` keeps local. - let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); - let build = local_build( - one_gwei_in_wei().saturating_mul(types::Uint256::from(5u64)), - false, - ); - let cache = Some(cached_bid(5)); - let (out, data) = select_payload_bid_pure::(local, Some(build), cache, None); - assert_eq!(out.message.builder_index, BUILDER_INDEX_SELF_BUILD); - assert!(data.is_some()); + fn select_tie_goes_to_local() { + // `>=` ⇒ local wins ties. + assert_eq!(pick(5, false, Some(5), None), (LOCAL, true)); } #[test] fn select_boost_factor_amplifies_bid() { - // Local 5 gwei, cached bid 3 gwei. Raw compare → local wins. - // With boost=200 → bid effectively 6 gwei → bid wins. - let local = local_signed_bid(BUILDER_INDEX_SELF_BUILD); - let cache = Some(cached_bid(3)); - let build = local_build( - one_gwei_in_wei().saturating_mul(types::Uint256::from(5u64)), - false, - ); - let (out_raw, data_raw) = - select_payload_bid_pure::(local.clone(), Some(build), cache.clone(), None); - assert_eq!(out_raw.message.builder_index, BUILDER_INDEX_SELF_BUILD); - assert!(data_raw.is_some()); - - let build = local_build( - one_gwei_in_wei().saturating_mul(types::Uint256::from(5u64)), - false, - ); - let (out_boosted, data_boosted) = - select_payload_bid_pure::(local, Some(build), cache, Some(200)); - assert_eq!(out_boosted.message.builder_index, 999); - assert!(data_boosted.is_none()); + // 5 gwei local vs 3 gwei bid: raw ⇒ local. + assert_eq!(pick(5, false, Some(3), None), (LOCAL, true)); + // boost=200 ⇒ bid scaled to 6 gwei ⇒ bid wins. + assert_eq!(pick(5, false, Some(3), Some(200)), (REMOTE, false)); } } From 37963ec8a589a2b5033a96882262a25acf7fe51d Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:11:11 +0200 Subject: [PATCH 03/10] Add Gloas variant to BuilderBid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the explicit `unsupported fork for ExecutionPayloadHeader` error when decoding a `BuilderBid` for Gloas. The Gloas variant reuses the Fulu wire format (`ExecutionPayloadHeaderFulu` as the header) so existing relays can serve Gloas-fork lighthouses without a wire-protocol change — the bid is mapped onto a Gloas self-build wrapper at the producer (builder_index = u64::MAX, infinity sig). The auto-generated `map_ref_into(ExecutionPayloadHeaderRef)` macros are replaced with explicit match arms because `ExecutionPayloadHeader` has no Gloas variant; the Gloas builder bid maps to the Fulu header ref. --- beacon_node/execution_layer/src/lib.rs | 10 +++++ consensus/types/src/builder/builder_bid.rs | 49 ++++++++++++++++------ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index b2dabb7c018..c3018057eb5 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -125,6 +125,16 @@ impl TryFrom> for ProvenancedPayload BlockProposalContents::PayloadAndBlobs { + payload: ExecutionPayloadHeader::Fulu(builder_bid.header).into(), + block_value: builder_bid.value, + kzg_commitments: builder_bid.blob_kzg_commitments, + blobs_and_proofs: None, + requests: Some(builder_bid.execution_requests), + }, }; Ok(ProvenancedPayload::Builder( BlockProposalContentsType::Blinded(block_proposal_contents), diff --git a/consensus/types/src/builder/builder_bid.rs b/consensus/types/src/builder/builder_bid.rs index e706b01283f..6c2539d64e4 100644 --- a/consensus/types/src/builder/builder_bid.rs +++ b/consensus/types/src/builder/builder_bid.rs @@ -21,7 +21,7 @@ use crate::{ }; #[superstruct( - variants(Bellatrix, Capella, Deneb, Electra, Fulu), + variants(Bellatrix, Capella, Deneb, Electra, Fulu, Gloas), variant_attributes( derive( PartialEq, @@ -35,9 +35,7 @@ use crate::{ TestRandom ), serde(bound = "E: EthSpec", deny_unknown_fields) - ), - map_ref_into(ExecutionPayloadHeaderRef), - map_ref_mut_into(ExecutionPayloadHeaderRefMut) + ) )] #[derive(PartialEq, Debug, Encode, Serialize, Deserialize, TreeHash, Clone)] #[serde(bound = "E: EthSpec", deny_unknown_fields, untagged)] @@ -54,9 +52,14 @@ pub struct BuilderBid { pub header: ExecutionPayloadHeaderElectra, #[superstruct(only(Fulu), partial_getter(rename = "header_fulu"))] pub header: ExecutionPayloadHeaderFulu, - #[superstruct(only(Deneb, Electra, Fulu))] + // Gloas reuses the Fulu header shape on the wire so existing relays don't + // need a protocol change. Lighthouse maps this to a Gloas self-build bid + // (`builder_index = u64::MAX`, infinity sig) at the producer. + #[superstruct(only(Gloas), partial_getter(rename = "header_gloas"))] + pub header: ExecutionPayloadHeaderFulu, + #[superstruct(only(Deneb, Electra, Fulu, Gloas))] pub blob_kzg_commitments: KzgCommitments, - #[superstruct(only(Electra, Fulu))] + #[superstruct(only(Electra, Fulu, Gloas))] pub execution_requests: ExecutionRequests, #[serde(with = "serde_utils::quoted_u256")] pub value: Uint256, @@ -71,17 +74,34 @@ impl BuilderBid { impl<'a, E: EthSpec> BuilderBidRef<'a, E> { pub fn header(&self) -> ExecutionPayloadHeaderRef<'a, E> { - map_builder_bid_ref_into_execution_payload_header_ref!(&'a _, self, |bid, cons| cons( - &bid.header - )) + match self { + BuilderBidRef::Bellatrix(bid) => ExecutionPayloadHeaderRef::Bellatrix(&bid.header), + BuilderBidRef::Capella(bid) => ExecutionPayloadHeaderRef::Capella(&bid.header), + BuilderBidRef::Deneb(bid) => ExecutionPayloadHeaderRef::Deneb(&bid.header), + BuilderBidRef::Electra(bid) => ExecutionPayloadHeaderRef::Electra(&bid.header), + BuilderBidRef::Fulu(bid) => ExecutionPayloadHeaderRef::Fulu(&bid.header), + // Gloas wire format reuses the Fulu shape — return it as a Fulu ref. + BuilderBidRef::Gloas(bid) => ExecutionPayloadHeaderRef::Fulu(&bid.header), + } } } impl<'a, E: EthSpec> BuilderBidRefMut<'a, E> { pub fn header_mut(self) -> ExecutionPayloadHeaderRefMut<'a, E> { - map_builder_bid_ref_mut_into_execution_payload_header_ref_mut!(&'a _, self, |bid, cons| { - cons(&mut bid.header) - }) + match self { + BuilderBidRefMut::Bellatrix(bid) => { + ExecutionPayloadHeaderRefMut::Bellatrix(&mut bid.header) + } + BuilderBidRefMut::Capella(bid) => { + ExecutionPayloadHeaderRefMut::Capella(&mut bid.header) + } + BuilderBidRefMut::Deneb(bid) => ExecutionPayloadHeaderRefMut::Deneb(&mut bid.header), + BuilderBidRefMut::Electra(bid) => { + ExecutionPayloadHeaderRefMut::Electra(&mut bid.header) + } + BuilderBidRefMut::Fulu(bid) => ExecutionPayloadHeaderRefMut::Fulu(&mut bid.header), + BuilderBidRefMut::Gloas(bid) => ExecutionPayloadHeaderRefMut::Fulu(&mut bid.header), + } } } @@ -89,7 +109,7 @@ impl ForkVersionDecode for BuilderBid { /// SSZ decode with explicit fork variant. fn from_ssz_bytes_by_fork(bytes: &[u8], fork_name: ForkName) -> Result { let builder_bid = match fork_name { - ForkName::Altair | ForkName::Base | ForkName::Gloas => { + ForkName::Altair | ForkName::Base => { return Err(ssz::DecodeError::BytesInvalid(format!( "unsupported fork for ExecutionPayloadHeader: {fork_name}", ))); @@ -101,6 +121,9 @@ impl ForkVersionDecode for BuilderBid { ForkName::Deneb => BuilderBid::Deneb(BuilderBidDeneb::from_ssz_bytes(bytes)?), ForkName::Electra => BuilderBid::Electra(BuilderBidElectra::from_ssz_bytes(bytes)?), ForkName::Fulu => BuilderBid::Fulu(BuilderBidFulu::from_ssz_bytes(bytes)?), + // Gloas wire format reuses the Fulu shape; lighthouse maps the + // decoded bid onto a Gloas self-build wrapper at the producer. + ForkName::Gloas => BuilderBid::Gloas(BuilderBidGloas::from_ssz_bytes(bytes)?), }; Ok(builder_bid) } From 8726a7b22c4172399d4649775353a0ab05ee4ac8 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:23:31 +0200 Subject: [PATCH 04/10] Wire mev-boost as Gloas self-build candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a builder is configured, the Gloas producer now races local EL + relay using the existing pre-Gloas `get_payload` selection (with `builder_boost_factor`, `should_override_builder`, and the `builder-fallback-*` chain-health gates), and wraps whichever wins as a Gloas self-build bid (`builder_index = u64::MAX`, infinity sig, value 0). That self-build candidate is then compared against the highest p2p gossip-cache bid via `select_payload_bid_pure`, again using `builder_boost_factor`. The same operator knob applies at both layers. For the relay-won case `payload_data` is `None` at production time — the proposer must unblind via the existing mev-boost endpoint before envelope publication (relay never reveals up front). Adds: - `GloasPayloadSource` enum in `execution_layer` - `get_payload_gloas_with_builder` (delegates to pre-Gloas `get_payload`) `LocalBuildResult.payload_data` is now `Option<...>` so the relay-blinded case can flow through the existing select path unchanged. --- .../src/block_production/gloas.rs | 132 ++++++++++++------ beacon_node/execution_layer/src/lib.rs | 84 ++++++++++- 2 files changed, 174 insertions(+), 42 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 78e3a25c44f..f5a4f034a10 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use bls::{PublicKeyBytes, Signature}; use execution_layer::{ - BlockProposalContentsGloas, BuilderParams, PayloadAttributes, PayloadParameters, + BlockProposalContentsGloas, BuilderParams, GloasPayloadSource, PayloadAttributes, + PayloadParameters, }; use fork_choice::PayloadStatus; use operation_pool::CompactAttestationRef; @@ -48,7 +49,7 @@ pub const EXECUTION_PAYMENT_TRUSTLESS_BUILD: u64 = 0; type ConsensusBlockValue = u64; type BlockProductionResult = (BeaconBlock, BeaconState, ConsensusBlockValue); -pub type PreparePayloadResult = Result, BlockProductionError>; +pub type PreparePayloadResult = Result, BlockProductionError>; pub type PreparePayloadHandle = JoinHandle>>; pub struct PartialBeaconBlock { @@ -78,13 +79,17 @@ pub struct ExecutionPayloadData { pub blobs_and_proofs: (types::BlobsList, types::KzgProofs), } -/// The result of a local payload build, used to decide whether to include a builder bid -/// from the gossip cache or fall back to self-build. +/// The result of a self-build payload acquisition, used to decide whether to include +/// a builder bid from the gossip cache or fall back to self-build. +/// +/// `payload_data` is `None` when the self-build wraps a blinded relay header — the +/// proposer will unblind via mev-boost before envelope publication. pub struct LocalBuildResult { - pub payload_data: ExecutionPayloadData, - /// EL block value (in wei) of the locally-built payload. + pub payload_data: Option>, + /// Self-build candidate value (in wei). pub payload_value: types::Uint256, - /// `true` if the EL signaled `engine_getPayload`'s `shouldOverrideBuilder` flag. + /// `true` if the local EL signaled `shouldOverrideBuilder` (only meaningful for + /// the local-EL self-build path). pub should_override_builder: bool, } @@ -202,6 +207,7 @@ impl BeaconChain { produce_at_slot, BID_VALUE_SELF_BUILD, BUILDER_INDEX_SELF_BUILD, + builder_boost_factor, ) .await?; @@ -702,6 +708,7 @@ impl BeaconChain { /// For local building, payload data is always returned (`Some`). /// For trustless building, the builder provides the envelope separately, so `None` is returned. #[allow(clippy::type_complexity)] + #[allow(clippy::too_many_arguments)] #[instrument(level = "debug", skip_all)] pub async fn produce_execution_payload_bid( self: Arc, @@ -711,6 +718,7 @@ impl BeaconChain { produce_at_slot: Slot, bid_value: u64, builder_index: BuilderIndex, + builder_boost_factor: Option, ) -> Result< ( SignedExecutionPayloadBid, @@ -768,8 +776,6 @@ impl BeaconChain { }; // TODO(gloas) this should be BlockProductionVersion::V4 - // V3 is okay for now as long as we're not connected to a builder - // TODO(gloas) add builder boost factor let prepare_payload_handle = get_execution_payload_gloas( self.clone(), &state, @@ -778,46 +784,83 @@ impl BeaconChain { parent_envelope, proposer_index, builder_params, + builder_boost_factor, )?; - let block_proposal_contents = prepare_payload_handle + let payload_source = prepare_payload_handle .await .map_err(BlockProductionError::TokioJoin)? .ok_or(BlockProductionError::ShuttingDown)??; - let BlockProposalContentsGloas { - payload, - payload_value, - execution_requests, + // Build a Gloas self-build bid from either the local-EL full payload or + // the relay's blinded header. `bid_value` is 0 for self-build regardless; + // the value used for the boost-factor compare against the p2p bid is the + // EL block-value (local) or relay bid value (builder), in wei. + let ( + bid_fields, blob_kzg_commitments, - blobs_and_proofs, + exec_requests_root, + payload_value, + payload_data, should_override_builder, - } = block_proposal_contents; + ) = match payload_source { + GloasPayloadSource::Local(BlockProposalContentsGloas { + payload, + payload_value, + execution_requests, + blob_kzg_commitments, + blobs_and_proofs, + should_override_builder, + }) => { + let block_hash = payload.block_hash; + let prev_randao = payload.prev_randao; + let gas_limit = payload.gas_limit; + let exec_requests_root = execution_requests.tree_hash_root(); + let payload_data = ExecutionPayloadData { + payload, + execution_requests, + builder_index, + slot: produce_at_slot, + blobs_and_proofs, + }; + ( + (block_hash, prev_randao, gas_limit), + blob_kzg_commitments, + exec_requests_root, + payload_value, + Some(payload_data), + should_override_builder, + ) + } + GloasPayloadSource::Builder { + header, + blob_kzg_commitments, + execution_requests, + value_wei, + } => ( + (header.block_hash, header.prev_randao, header.gas_limit), + blob_kzg_commitments, + execution_requests.tree_hash_root(), + value_wei, + None, + false, + ), + }; + let (block_hash, prev_randao, gas_limit) = bid_fields; - // TODO(gloas) since we are defaulting to local building, execution payment is 0 - // execution payment should only be set to > 0 for trusted building. let bid = ExecutionPayloadBid:: { parent_block_hash, parent_block_root: parent_root, - block_hash: payload.block_hash, - prev_randao: payload.prev_randao, + block_hash, + prev_randao, fee_recipient: Address::ZERO, - gas_limit: payload.gas_limit, + gas_limit, builder_index, slot: produce_at_slot, value: bid_value, execution_payment: EXECUTION_PAYMENT_TRUSTLESS_BUILD, blob_kzg_commitments, - execution_requests_root: execution_requests.tree_hash_root(), - }; - - // Store payload data for envelope construction after block is created - let payload_data = ExecutionPayloadData { - payload, - execution_requests, - builder_index, - slot: produce_at_slot, - blobs_and_proofs, + execution_requests_root: exec_requests_root, }; Ok(( @@ -885,7 +928,7 @@ pub(crate) fn select_payload_bid_pure( } = local_build; let Some(cached_bid) = cached_bid else { - return (local_signed_bid, Some(payload_data)); + return (local_signed_bid, payload_data); }; let slot = local_signed_bid.message.slot; @@ -896,7 +939,7 @@ pub(crate) fn select_payload_bid_pure( cached_bid_value = cached_bid.message.value, "Using local payload because EL signaled shouldOverrideBuilder" ); - return (local_signed_bid, Some(payload_data)); + return (local_signed_bid, payload_data); } // Convert bid value (gwei) to wei for comparison with `payload_value` (wei). @@ -917,7 +960,7 @@ pub(crate) fn select_payload_bid_pure( ?builder_boost_factor, "Local payload is more profitable than cached builder bid" ); - (local_signed_bid, Some(payload_data)) + (local_signed_bid, payload_data) } else { debug!( %slot, @@ -937,6 +980,7 @@ pub(crate) fn select_payload_bid_pure( /// /// Will return an error when using a pre-Gloas `state`. Ensure to only run this function /// after the Gloas fork. +#[allow(clippy::too_many_arguments)] fn get_execution_payload_gloas( chain: Arc>, state: &BeaconState, @@ -945,6 +989,7 @@ fn get_execution_payload_gloas( parent_envelope: Option>>, proposer_index: u64, builder_params: BuilderParams, + builder_boost_factor: Option, ) -> Result, BlockProductionError> { // Compute all required values from the `state` now to avoid needing to pass it into a spawned // task. @@ -998,6 +1043,7 @@ fn get_execution_payload_gloas( builder_params, withdrawals, parent_beacon_block_root, + builder_boost_factor, ) .await } @@ -1026,7 +1072,8 @@ async fn prepare_execution_payload( builder_params: BuilderParams, withdrawals: Vec, parent_beacon_block_root: Hash256, -) -> Result, BlockProductionError> + builder_boost_factor: Option, +) -> Result, BlockProductionError> where T: BeaconChainTypes, { @@ -1080,12 +1127,17 @@ where current_fork: fork, }; - let block_contents = execution_layer - .get_payload_gloas(payload_parameters) + let payload_source = execution_layer + .get_payload_gloas_with_builder( + payload_parameters, + builder_params, + builder_boost_factor, + spec, + ) .await .map_err(BlockProductionError::GetPayloadFailed)?; - Ok(block_contents) + Ok(payload_source) } /// Drop voluntary exits whose target validators will be exited by the parent envelope's @@ -1293,13 +1345,13 @@ mod tests { fn local_build(payload_gwei: u64, should_override_builder: bool) -> LocalBuildResult { LocalBuildResult { - payload_data: ExecutionPayloadData { + payload_data: Some(ExecutionPayloadData { payload: types::ExecutionPayloadGloas::default(), execution_requests: ExecutionRequests::default(), builder_index: BUILDER_INDEX_SELF_BUILD, slot: Slot::new(0), blobs_and_proofs: (VariableList::empty(), VariableList::empty()), - }, + }), payload_value: gwei(payload_gwei), should_override_builder, } diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index c3018057eb5..094b6e7ffd6 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -53,8 +53,8 @@ use types::{ }; use types::{ BeaconStateError, BlindedPayload, ChainSpec, Epoch, ExecPayload, ExecutionPayloadBellatrix, - ExecutionPayloadCapella, ExecutionPayloadElectra, ExecutionPayloadFulu, FullPayload, - ProposerPreparationData, Slot, + ExecutionPayloadCapella, ExecutionPayloadElectra, ExecutionPayloadFulu, + ExecutionPayloadHeaderFulu, FullPayload, ProposerPreparationData, Slot, }; mod block_hash; @@ -218,6 +218,24 @@ pub struct BlockProposalContentsGloas { pub should_override_builder: bool, } +/// Output of a Gloas self-build payload fetch, after racing local EL against any +/// configured mev-boost relay using the existing pre-Gloas selection rules. +/// Both variants are wrapped as a self-build bid (`builder_index = u64::MAX`, +/// infinity sig) at the producer. +pub enum GloasPayloadSource { + /// Full payload from local EL. + Local(BlockProposalContentsGloas), + /// Blinded relay header (Fulu wire shape — Gloas bid fields derive from + /// it). Full payload + blobs unblind via the existing mev-boost endpoint + /// before envelope publication. + Builder { + header: ExecutionPayloadHeaderFulu, + blob_kzg_commitments: KzgCommitments, + execution_requests: ExecutionRequests, + value_wei: Uint256, + }, +} + impl From> for BlockProposalContentsGloas { fn from(response: GetPayloadResponseGloas) -> Self { Self { @@ -913,6 +931,68 @@ impl ExecutionLayer { /// /// The result will be returned from the first node that returns successfully. No more nodes /// will be contacted. + /// Gloas self-build payload fetch with mev-boost race. + /// + /// When a builder is configured, reuses the pre-Gloas `get_payload` race + /// (with `builder_boost_factor` selection rules) and adapts the result to + /// a Gloas-shaped source. The relay's `BuilderBid::Gloas` wire format + /// reuses Fulu (`ExecutionPayloadHeaderFulu`); the producer extracts the + /// bid fields and wraps as a Gloas self-build bid. + #[instrument(level = "debug", skip_all)] + pub async fn get_payload_gloas_with_builder( + &self, + payload_parameters: PayloadParameters<'_>, + builder_params: BuilderParams, + builder_boost_factor: Option, + spec: &ChainSpec, + ) -> Result, Error> { + if self.builder().is_none() { + return self + .get_payload_gloas(payload_parameters) + .await + .map(GloasPayloadSource::Local); + } + match self + .get_payload( + payload_parameters, + builder_params, + spec, + builder_boost_factor, + BlockProductionVersion::V3, + ) + .await? + { + BlockProposalContentsType::Full(_) => self + .get_payload_gloas(payload_parameters) + .await + .map(GloasPayloadSource::Local), + BlockProposalContentsType::Blinded(BlockProposalContents::PayloadAndBlobs { + payload, + block_value, + kzg_commitments, + requests, + .. + }) => { + let ExecutionPayloadHeader::Fulu(header) = payload.to_execution_payload_header() + else { + return Err(Error::Unexpected( + "Gloas relay returned non-Fulu-shaped header".into(), + )); + }; + Ok(GloasPayloadSource::Builder { + header, + blob_kzg_commitments: kzg_commitments, + execution_requests: requests + .ok_or_else(|| Error::Unexpected("Gloas relay missing requests".into()))?, + value_wei: block_value, + }) + } + BlockProposalContentsType::Blinded(BlockProposalContents::Payload { .. }) => Err( + Error::Unexpected("blinded Gloas payload missing blob commitments".into()), + ), + } + } + #[instrument(level = "debug", skip_all)] pub async fn get_payload_gloas( &self, From d431d01ead215efa675439ef135e1e263cac392f Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:04:03 +0200 Subject: [PATCH 05/10] Switch Gloas mev-boost to native SignedExecutionPayloadBid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the earlier "BuilderBid::Gloas reuses Fulu wire shape" hack with the protocol-honest design: Gloas-aware relays return a `SignedExecutionPayloadBid` directly (the native commitment type). - Reverts the `BuilderBid::Gloas` variant (and the subsequent macro expansion fallout). `BuilderBid` correctly stops at Fulu again — that envelope shape is retired in Gloas. - Adds a new builder-client endpoint: `GET /eth/v1/builder/payload_bid/{slot}/{parent_hash}/{pubkey}` returning `ForkVersionedResponse>`. - Adds `ForkVersionDecode for SignedExecutionPayloadBid` (Gloas-only). - `get_payload_gloas_with_builder` no longer routes through pre-Gloas `get_payload`; instead races the new Gloas relay endpoint against local EL using `builder_boost_factor`. The local EL's `should_override_builder` flag still forces local. - `GloasPayloadSource::Builder` now carries `SignedExecutionPayloadBid` directly. Producer extracts bid fields from `signed_bid.message` — no Fulu→Gloas translation. --- .../src/block_production/gloas.rs | 28 ++-- beacon_node/builder_client/src/lib.rs | 51 ++++++- beacon_node/execution_layer/src/lib.rs | 127 +++++++++--------- consensus/types/src/builder/builder_bid.rs | 49 ++----- .../execution/signed_execution_payload_bid.rs | 14 +- 5 files changed, 153 insertions(+), 116 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index f5a4f034a10..287c35d4a12 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -832,19 +832,21 @@ impl BeaconChain { should_override_builder, ) } - GloasPayloadSource::Builder { - header, - blob_kzg_commitments, - execution_requests, - value_wei, - } => ( - (header.block_hash, header.prev_randao, header.gas_limit), - blob_kzg_commitments, - execution_requests.tree_hash_root(), - value_wei, - None, - false, - ), + GloasPayloadSource::Builder(signed_bid) => { + let m = &signed_bid.message; + // Convert the relay-bid value (gwei, u64) to wei for downstream + // comparison against the cached p2p bid. + let value_wei = types::Uint256::from(m.value) + .saturating_mul(types::Uint256::from(1_000_000_000u64)); + ( + (m.block_hash, m.prev_randao, m.gas_limit), + m.blob_kzg_commitments.clone(), + m.execution_requests_root, + value_wei, + None, + false, + ) + } }; let (block_hash, prev_randao, gas_limit) = bid_fields; diff --git a/beacon_node/builder_client/src/lib.rs b/beacon_node/builder_client/src/lib.rs index 7dc0cbfc6d0..5b559bc6d42 100644 --- a/beacon_node/builder_client/src/lib.rs +++ b/beacon_node/builder_client/src/lib.rs @@ -5,7 +5,7 @@ use eth2::types::beacon_response::EmptyMetadata; use eth2::types::builder::SignedBuilderBid; use eth2::types::{ ContentType, EthSpec, ExecutionBlockHash, ForkName, ForkVersionDecode, ForkVersionedResponse, - SignedValidatorRegistrationData, Slot, + SignedExecutionPayloadBid, SignedValidatorRegistrationData, Slot, }; use eth2::types::{FullPayloadContents, SignedBlindedBeaconBlock}; use eth2::{ @@ -521,6 +521,55 @@ impl BuilderHttpClient { } } + /// `GET /eth/v1/builder/payload_bid/{slot}/{parent_hash}/{pubkey}` + /// + /// Gloas-specific endpoint. The relay returns a `SignedExecutionPayloadBid` + /// directly (the Gloas-native commitment type) instead of a `BuilderBid` + /// envelope around an `ExecutionPayloadHeader`. + pub async fn get_builder_payload_bid_gloas( + &self, + slot: Slot, + parent_hash: ExecutionBlockHash, + pubkey: &PublicKeyBytes, + ) -> Result>>, Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("payload_bid") + .push(slot.to_string().as_str()) + .push(format!("{parent_hash:?}").as_str()) + .push(pubkey.as_hex_string().as_str()); + + let mut headers = HeaderMap::new(); + if self.disable_ssz { + headers.insert( + ACCEPT, + HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + } else { + headers.insert( + ACCEPT, + HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + } + + let resp = self + .get_with_header(path, self.timeouts.get_header, headers) + .await; + + if matches!(resp, Err(Error::StatusCode(StatusCode::NO_CONTENT))) { + Ok(None) + } else { + resp.map(Some) + } + } + /// `GET /eth/v1/builder/status` pub async fn get_builder_status(&self) -> Result<(), Error> { let mut path = self.server.expose_full().clone(); diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 094b6e7ffd6..78b7dcab81a 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -53,8 +53,8 @@ use types::{ }; use types::{ BeaconStateError, BlindedPayload, ChainSpec, Epoch, ExecPayload, ExecutionPayloadBellatrix, - ExecutionPayloadCapella, ExecutionPayloadElectra, ExecutionPayloadFulu, - ExecutionPayloadHeaderFulu, FullPayload, ProposerPreparationData, Slot, + ExecutionPayloadCapella, ExecutionPayloadElectra, ExecutionPayloadFulu, FullPayload, + ProposerPreparationData, SignedExecutionPayloadBid, Slot, }; mod block_hash; @@ -125,16 +125,6 @@ impl TryFrom> for ProvenancedPayload BlockProposalContents::PayloadAndBlobs { - payload: ExecutionPayloadHeader::Fulu(builder_bid.header).into(), - block_value: builder_bid.value, - kzg_commitments: builder_bid.blob_kzg_commitments, - blobs_and_proofs: None, - requests: Some(builder_bid.execution_requests), - }, }; Ok(ProvenancedPayload::Builder( BlockProposalContentsType::Blinded(block_proposal_contents), @@ -219,21 +209,19 @@ pub struct BlockProposalContentsGloas { } /// Output of a Gloas self-build payload fetch, after racing local EL against any -/// configured mev-boost relay using the existing pre-Gloas selection rules. +/// configured mev-boost relay using `builder_boost_factor`. +/// /// Both variants are wrapped as a self-build bid (`builder_index = u64::MAX`, -/// infinity sig) at the producer. +/// infinity sig, `value = 0`) at the producer; the only difference is whether +/// the proposer already holds the full payload (`Local`) or commits to the +/// relay's bid hash and unblinds the payload via mev-boost later (`Builder`). pub enum GloasPayloadSource { /// Full payload from local EL. Local(BlockProposalContentsGloas), - /// Blinded relay header (Fulu wire shape — Gloas bid fields derive from - /// it). Full payload + blobs unblind via the existing mev-boost endpoint + /// Relay bid (Gloas-native `SignedExecutionPayloadBid`). The proposer + /// must unblind the full payload via the existing mev-boost endpoint /// before envelope publication. - Builder { - header: ExecutionPayloadHeaderFulu, - blob_kzg_commitments: KzgCommitments, - execution_requests: ExecutionRequests, - value_wei: Uint256, - }, + Builder(SignedExecutionPayloadBid), } impl From> for BlockProposalContentsGloas { @@ -933,63 +921,72 @@ impl ExecutionLayer { /// will be contacted. /// Gloas self-build payload fetch with mev-boost race. /// - /// When a builder is configured, reuses the pre-Gloas `get_payload` race - /// (with `builder_boost_factor` selection rules) and adapts the result to - /// a Gloas-shaped source. The relay's `BuilderBid::Gloas` wire format - /// reuses Fulu (`ExecutionPayloadHeaderFulu`); the producer extracts the - /// bid fields and wraps as a Gloas self-build bid. + /// When a builder is configured, fetches a Gloas-native + /// `SignedExecutionPayloadBid` from the relay in parallel with a local-EL + /// `engine_getPayload` call, then races the two using `builder_boost_factor` + /// (same `(value / 100) * factor` rule as pre-Gloas). The local EL's + /// `shouldOverrideBuilder` flag also forces local. + /// + /// Whichever wins is wrapped as a Gloas self-build bid at the producer. + /// Bid value is in gwei (u64), local payload value is in wei (Uint256); + /// compared in wei. #[instrument(level = "debug", skip_all)] pub async fn get_payload_gloas_with_builder( &self, payload_parameters: PayloadParameters<'_>, builder_params: BuilderParams, builder_boost_factor: Option, - spec: &ChainSpec, + _spec: &ChainSpec, ) -> Result, Error> { - if self.builder().is_none() { + let Some(builder) = self.builder() else { return self .get_payload_gloas(payload_parameters) .await .map(GloasPayloadSource::Local); - } - match self - .get_payload( - payload_parameters, - builder_params, - spec, - builder_boost_factor, - BlockProductionVersion::V3, - ) - .await? - { - BlockProposalContentsType::Full(_) => self + }; + + if builder_params.chain_health != ChainHealth::Healthy { + return self .get_payload_gloas(payload_parameters) .await - .map(GloasPayloadSource::Local), - BlockProposalContentsType::Blinded(BlockProposalContents::PayloadAndBlobs { - payload, - block_value, - kzg_commitments, - requests, - .. - }) => { - let ExecutionPayloadHeader::Fulu(header) = payload.to_execution_payload_header() - else { - return Err(Error::Unexpected( - "Gloas relay returned non-Fulu-shaped header".into(), - )); - }; - Ok(GloasPayloadSource::Builder { - header, - blob_kzg_commitments: kzg_commitments, - execution_requests: requests - .ok_or_else(|| Error::Unexpected("Gloas relay missing requests".into()))?, - value_wei: block_value, - }) + .map(GloasPayloadSource::Local); + } + + let slot = builder_params.slot; + let pubkey = &builder_params.pubkey; + let parent_hash = payload_parameters.parent_hash; + + let (relay_result, local_result) = tokio::join!( + builder.get_builder_payload_bid_gloas::(slot, parent_hash, pubkey), + self.get_payload_gloas(payload_parameters), + ); + + let local = local_result?; + + let Ok(Some(relay)) = relay_result else { + // Relay error or no bid ⇒ use local. + return Ok(GloasPayloadSource::Local(local)); + }; + + // EL hint: ignore the relay this slot. + if local.should_override_builder { + return Ok(GloasPayloadSource::Local(local)); + } + + let signed_bid = relay.data; + let bid_value_wei = + Uint256::from(signed_bid.message.value).saturating_mul(Uint256::from(1_000_000_000u64)); + let boosted_bid_wei = match builder_boost_factor { + Some(factor) => { + (bid_value_wei / Uint256::from(100)).saturating_mul(Uint256::from(factor)) } - BlockProposalContentsType::Blinded(BlockProposalContents::Payload { .. }) => Err( - Error::Unexpected("blinded Gloas payload missing blob commitments".into()), - ), + None => bid_value_wei, + }; + + if local.payload_value >= boosted_bid_wei { + Ok(GloasPayloadSource::Local(local)) + } else { + Ok(GloasPayloadSource::Builder(signed_bid)) } } diff --git a/consensus/types/src/builder/builder_bid.rs b/consensus/types/src/builder/builder_bid.rs index 6c2539d64e4..e706b01283f 100644 --- a/consensus/types/src/builder/builder_bid.rs +++ b/consensus/types/src/builder/builder_bid.rs @@ -21,7 +21,7 @@ use crate::{ }; #[superstruct( - variants(Bellatrix, Capella, Deneb, Electra, Fulu, Gloas), + variants(Bellatrix, Capella, Deneb, Electra, Fulu), variant_attributes( derive( PartialEq, @@ -35,7 +35,9 @@ use crate::{ TestRandom ), serde(bound = "E: EthSpec", deny_unknown_fields) - ) + ), + map_ref_into(ExecutionPayloadHeaderRef), + map_ref_mut_into(ExecutionPayloadHeaderRefMut) )] #[derive(PartialEq, Debug, Encode, Serialize, Deserialize, TreeHash, Clone)] #[serde(bound = "E: EthSpec", deny_unknown_fields, untagged)] @@ -52,14 +54,9 @@ pub struct BuilderBid { pub header: ExecutionPayloadHeaderElectra, #[superstruct(only(Fulu), partial_getter(rename = "header_fulu"))] pub header: ExecutionPayloadHeaderFulu, - // Gloas reuses the Fulu header shape on the wire so existing relays don't - // need a protocol change. Lighthouse maps this to a Gloas self-build bid - // (`builder_index = u64::MAX`, infinity sig) at the producer. - #[superstruct(only(Gloas), partial_getter(rename = "header_gloas"))] - pub header: ExecutionPayloadHeaderFulu, - #[superstruct(only(Deneb, Electra, Fulu, Gloas))] + #[superstruct(only(Deneb, Electra, Fulu))] pub blob_kzg_commitments: KzgCommitments, - #[superstruct(only(Electra, Fulu, Gloas))] + #[superstruct(only(Electra, Fulu))] pub execution_requests: ExecutionRequests, #[serde(with = "serde_utils::quoted_u256")] pub value: Uint256, @@ -74,34 +71,17 @@ impl BuilderBid { impl<'a, E: EthSpec> BuilderBidRef<'a, E> { pub fn header(&self) -> ExecutionPayloadHeaderRef<'a, E> { - match self { - BuilderBidRef::Bellatrix(bid) => ExecutionPayloadHeaderRef::Bellatrix(&bid.header), - BuilderBidRef::Capella(bid) => ExecutionPayloadHeaderRef::Capella(&bid.header), - BuilderBidRef::Deneb(bid) => ExecutionPayloadHeaderRef::Deneb(&bid.header), - BuilderBidRef::Electra(bid) => ExecutionPayloadHeaderRef::Electra(&bid.header), - BuilderBidRef::Fulu(bid) => ExecutionPayloadHeaderRef::Fulu(&bid.header), - // Gloas wire format reuses the Fulu shape — return it as a Fulu ref. - BuilderBidRef::Gloas(bid) => ExecutionPayloadHeaderRef::Fulu(&bid.header), - } + map_builder_bid_ref_into_execution_payload_header_ref!(&'a _, self, |bid, cons| cons( + &bid.header + )) } } impl<'a, E: EthSpec> BuilderBidRefMut<'a, E> { pub fn header_mut(self) -> ExecutionPayloadHeaderRefMut<'a, E> { - match self { - BuilderBidRefMut::Bellatrix(bid) => { - ExecutionPayloadHeaderRefMut::Bellatrix(&mut bid.header) - } - BuilderBidRefMut::Capella(bid) => { - ExecutionPayloadHeaderRefMut::Capella(&mut bid.header) - } - BuilderBidRefMut::Deneb(bid) => ExecutionPayloadHeaderRefMut::Deneb(&mut bid.header), - BuilderBidRefMut::Electra(bid) => { - ExecutionPayloadHeaderRefMut::Electra(&mut bid.header) - } - BuilderBidRefMut::Fulu(bid) => ExecutionPayloadHeaderRefMut::Fulu(&mut bid.header), - BuilderBidRefMut::Gloas(bid) => ExecutionPayloadHeaderRefMut::Fulu(&mut bid.header), - } + map_builder_bid_ref_mut_into_execution_payload_header_ref_mut!(&'a _, self, |bid, cons| { + cons(&mut bid.header) + }) } } @@ -109,7 +89,7 @@ impl ForkVersionDecode for BuilderBid { /// SSZ decode with explicit fork variant. fn from_ssz_bytes_by_fork(bytes: &[u8], fork_name: ForkName) -> Result { let builder_bid = match fork_name { - ForkName::Altair | ForkName::Base => { + ForkName::Altair | ForkName::Base | ForkName::Gloas => { return Err(ssz::DecodeError::BytesInvalid(format!( "unsupported fork for ExecutionPayloadHeader: {fork_name}", ))); @@ -121,9 +101,6 @@ impl ForkVersionDecode for BuilderBid { ForkName::Deneb => BuilderBid::Deneb(BuilderBidDeneb::from_ssz_bytes(bytes)?), ForkName::Electra => BuilderBid::Electra(BuilderBidElectra::from_ssz_bytes(bytes)?), ForkName::Fulu => BuilderBid::Fulu(BuilderBidFulu::from_ssz_bytes(bytes)?), - // Gloas wire format reuses the Fulu shape; lighthouse maps the - // decoded bid onto a Gloas self-build wrapper at the producer. - ForkName::Gloas => BuilderBid::Gloas(BuilderBidGloas::from_ssz_bytes(bytes)?), }; Ok(builder_bid) } diff --git a/consensus/types/src/execution/signed_execution_payload_bid.rs b/consensus/types/src/execution/signed_execution_payload_bid.rs index 48da4453329..75190fec1e7 100644 --- a/consensus/types/src/execution/signed_execution_payload_bid.rs +++ b/consensus/types/src/execution/signed_execution_payload_bid.rs @@ -1,10 +1,11 @@ use crate::execution::ExecutionPayloadBid; use crate::test_utils::TestRandom; -use crate::{EthSpec, ForkName}; +use crate::{EthSpec, ForkName, ForkVersionDecode}; use bls::Signature; use context_deserialize::context_deserialize; use educe::Educe; use serde::{Deserialize, Serialize}; +use ssz::Decode as _; use ssz_derive::{Decode, Encode}; use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; @@ -33,6 +34,17 @@ impl SignedExecutionPayloadBid { } } +impl ForkVersionDecode for SignedExecutionPayloadBid { + fn from_ssz_bytes_by_fork(bytes: &[u8], fork_name: ForkName) -> Result { + if !fork_name.gloas_enabled() { + return Err(ssz::DecodeError::BytesInvalid(format!( + "SignedExecutionPayloadBid is only defined for Gloas+; got {fork_name}", + ))); + } + Self::from_ssz_bytes(bytes) + } +} + #[cfg(test)] mod tests { use super::*; From c2fd964db0f5612df7c3dbe287f967bf43d125f7 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 29 Apr 2026 11:43:45 +1000 Subject: [PATCH 06/10] Update proposer boost calculation --- beacon_node/beacon_chain/src/beacon_chain.rs | 46 ++++++++++++++++++- .../tests/payload_invalidation.rs | 1 + consensus/fork_choice/src/fork_choice.rs | 17 ++++--- consensus/fork_choice/tests/tests.rs | 2 + testing/ef_tests/src/cases/fork_choice.rs | 10 +--- 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 9da64888c2c..552cd1e3d5e 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4175,7 +4175,14 @@ impl BeaconChain { }; // Read the cached head prior to taking the fork choice lock to avoid potential deadlocks. - let old_head_slot = self.canonical_head.cached_head().head_slot(); + let cached_head = self.canonical_head.cached_head(); + let old_head_slot = cached_head.head_slot(); + + // Compute the expected proposer for `current_slot` on the canonical chain. This is used by + // `on_block` to gate proposer boost on the block's proposer matching the canonical proposer + // (per spec `update_proposer_boost_root` added in v1.7.0-alpha.5). + let canonical_head_proposer_index = + self.canonical_head_proposer_index(current_slot, &cached_head)?; // Take an upgradable read lock on fork choice so we can check if this block has already // been imported. We don't want to repeat work importing a block that is already imported. @@ -4208,6 +4215,7 @@ impl BeaconChain { block_delay, &state, payload_verification_status, + canonical_head_proposer_index, &self.spec, ) .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))?; @@ -4950,6 +4958,42 @@ impl BeaconChain { })) } + /// Compute the expected beacon proposer for `slot` on the canonical chain extending `cached_head`. + /// + /// Uses the beacon proposer cache to avoid recomputing the shuffling on every block import. + /// + /// This is used by `update_proposer_boost_root` to gate proposer boost on the block's proposer + /// matching the canonical proposer, per consensus-specs v1.7.0-alpha.5. + /// + /// This function should never error unless there is some corruption of the head state. If a + /// state advance is needed, it will be handled by the proposer cache. + pub fn canonical_head_proposer_index( + &self, + slot: Slot, + cached_head: &CachedHead, + ) -> Result { + let proposal_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); + let head_block_root = cached_head.head_block_root(); + let head_state = &cached_head.snapshot.beacon_state; + + let shuffling_decision_root = head_state.proposer_shuffling_decision_root_at_epoch( + proposal_epoch, + head_block_root, + &self.spec, + )?; + + self.with_proposer_cache::<_, Error>( + shuffling_decision_root, + proposal_epoch, + |proposers| { + proposers + .get_slot::(slot) + .map(|p| p.index as u64) + }, + || Ok((cached_head.head_state_root(), head_state.clone())), + ) + } + pub fn get_expected_withdrawals( &self, forkchoice_update_params: &ForkchoiceUpdateParameters, diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index 38d4f4c47e1..be85fc2245b 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1093,6 +1093,7 @@ async fn invalid_parent() { Duration::from_secs(0), &state, PayloadVerificationStatus::Optimistic, + block.message().proposer_index(), &rig.harness.chain.spec, ), Err(ForkChoiceError::ProtoArrayStringError(message)) diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index a9e62dbe946..477d1fa3b4e 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -756,6 +756,7 @@ where block_delay: Duration, state: &BeaconState, payload_verification_status: PayloadVerificationStatus, + canonical_head_proposer_index: u64, spec: &ChainSpec, ) -> Result<(), Error> { let _timer = metrics::start_timer(&metrics::FORK_CHOICE_ON_BLOCK_TIMES); @@ -820,16 +821,18 @@ where let attestation_threshold = spec.get_attestation_due::(block.slot()); - // Add proposer score boost if the block is timely. - // TODO(gloas): the spec's `update_proposer_boost_root` additionally checks that - // `block.proposer_index == get_beacon_proposer_index(head_state)` — i.e. that - // the block's proposer matches the expected proposer on the canonical chain. - // This requires calling `get_head` and advancing the head state to the current - // slot, which is expensive. Implement once we have a cached proposer index. + // Add proposer score boost if the block is the first timely block for this slot and its + // proposer matches the expected proposer on the canonical chain (per spec + // `update_proposer_boost_root`, introduced in v1.7.0-alpha.5). let is_before_attesting_interval = block_delay < attestation_threshold; let is_first_block = self.fc_store.proposer_boost_root().is_zero(); - if current_slot == block.slot() && is_before_attesting_interval && is_first_block { + let is_canonical_proposer = block.proposer_index() == canonical_head_proposer_index; + if current_slot == block.slot() + && is_before_attesting_interval + && is_first_block + && is_canonical_proposer + { self.fc_store.set_proposer_boost_root(block_root); } diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index d6f937c0ca9..353893026bf 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -316,6 +316,7 @@ impl ForkChoiceTest { Duration::from_secs(0), &state, PayloadVerificationStatus::Verified, + block.message().proposer_index(), &self.harness.chain.spec, ) .unwrap(); @@ -359,6 +360,7 @@ impl ForkChoiceTest { Duration::from_secs(0), &state, PayloadVerificationStatus::Verified, + block.message().proposer_index(), &self.harness.chain.spec, ) .expect_err("on_block did not return an error"); diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 2af205ee471..8b0b74d2564 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -335,15 +335,6 @@ impl Case for ForkChoiceTest { } fn result(&self, _case_index: usize, fork_name: ForkName) -> Result<(), Error> { - // TODO(gloas): We have not implemented this change to fork choice/proposer boost yet. - // https://github.com/sigp/lighthouse/issues/8689 - if self.description == "voting_source_beyond_two_epoch" - || self.description == "justified_update_not_realized_finality" - || self.description == "justified_update_always_if_better" - { - return Err(Error::SkippedKnownFailure); - } - let tester = Tester::new(self, testing_spec::(fork_name))?; for step in &self.steps { @@ -791,6 +782,7 @@ impl Tester { block_delay, &state, PayloadVerificationStatus::Irrelevant, + block.message().proposer_index(), &self.harness.chain.spec, ); From 6841dc5ec59c72a59ce3e41a57cf8fab20eb7ad8 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 29 Apr 2026 12:42:25 +1000 Subject: [PATCH 07/10] Fix HTTP API tests --- beacon_node/http_api/tests/tests.rs | 36 ++++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index b8326f4495c..56835da4598 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -3450,17 +3450,20 @@ impl ApiTester { .unwrap() .unwrap_or(self.chain.head_beacon_block_root()); - // Presently, the beacon chain harness never runs the code that primes the proposer - // cache. If this changes in the future then we'll need some smarter logic here, but - // this is succinct and effective for the time being. - assert!( - self.chain - .beacon_proposer_cache - .lock() - .get_epoch::(dependent_root, epoch) - .is_none(), - "the proposer cache should miss initially" - ); + // Block import primes the proposer cache for each epoch it runs through (to gate + // proposer boost), so epochs `<= current_epoch` are already cached. The only epoch + // for which we can observe the endpoint's own caching behaviour is + // `current_epoch + 1`, which no block import has touched yet. + if epoch == current_epoch + 1 { + assert!( + self.chain + .beacon_proposer_cache + .lock() + .get_epoch::(dependent_root, epoch) + .is_none(), + "the proposer cache should miss initially for the next epoch" + ); + } let result = self .client @@ -3468,8 +3471,9 @@ impl ApiTester { .await .unwrap(); - // Check that current-epoch requests prime the proposer cache, whilst non-current - // requests don't. + // A current-epoch request should leave the cache primed (block import already did so, + // but this is still a useful end-to-end check). A request for `current_epoch + 1` + // should not prime the cache. if epoch == current_epoch { assert!( self.chain @@ -3477,16 +3481,16 @@ impl ApiTester { .lock() .get_epoch::(dependent_root, epoch) .is_some(), - "a current-epoch request should prime the proposer cache" + "the proposer cache should be primed for the current epoch" ); - } else { + } else if epoch == current_epoch + 1 { assert!( self.chain .beacon_proposer_cache .lock() .get_epoch::(dependent_root, epoch) .is_none(), - "a non-current-epoch request should not prime the proposer cache" + "a request for the next epoch should not prime the proposer cache" ); } From 56386ab7f61640b86b17fa6577faf5c98a533234 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:32:33 +0200 Subject: [PATCH 08/10] Use existing /eth/v1/builder/header URL for Gloas relay bid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new builder-client method `get_builder_header_gloas` calls the same URL as the pre-Gloas `get_builder_header` — relays dispatch on the slot's fork (returning `SignedBuilderBid` for pre-Gloas slots, `SignedExecutionPayloadBid` for Gloas slots). The two Rust methods exist because the response types differ and Rust can't dispatch on return type, but the wire URL is identical (no new spec route invented). `BuilderBid` itself stops at Fulu — adding a Gloas variant would force 60+ pre-Gloas call sites to handle `Option
` since Gloas has no `ExecutionPayloadHeader`. The minimal-diff path keeps `BuilderBid` untouched and adds a parallel client method. --- beacon_node/builder_client/src/lib.rs | 13 +++++++------ beacon_node/execution_layer/src/lib.rs | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/beacon_node/builder_client/src/lib.rs b/beacon_node/builder_client/src/lib.rs index 5b559bc6d42..9ea5ad51a69 100644 --- a/beacon_node/builder_client/src/lib.rs +++ b/beacon_node/builder_client/src/lib.rs @@ -521,12 +521,13 @@ impl BuilderHttpClient { } } - /// `GET /eth/v1/builder/payload_bid/{slot}/{parent_hash}/{pubkey}` + /// `GET /eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}` for Gloas slots. /// - /// Gloas-specific endpoint. The relay returns a `SignedExecutionPayloadBid` - /// directly (the Gloas-native commitment type) instead of a `BuilderBid` - /// envelope around an `ExecutionPayloadHeader`. - pub async fn get_builder_payload_bid_gloas( + /// Same URL as the pre-Gloas `get_builder_header`; the relay dispatches on + /// the slot's fork and returns a `SignedExecutionPayloadBid` (Gloas-native + /// commitment) instead of a `SignedBuilderBid` (pre-Gloas envelope around + /// an `ExecutionPayloadHeader`). + pub async fn get_builder_header_gloas( &self, slot: Slot, parent_hash: ExecutionBlockHash, @@ -539,7 +540,7 @@ impl BuilderHttpClient { .push("eth") .push("v1") .push("builder") - .push("payload_bid") + .push("header") .push(slot.to_string().as_str()) .push(format!("{parent_hash:?}").as_str()) .push(pubkey.as_hex_string().as_str()); diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 78b7dcab81a..331f4a98ef3 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -957,7 +957,7 @@ impl ExecutionLayer { let parent_hash = payload_parameters.parent_hash; let (relay_result, local_result) = tokio::join!( - builder.get_builder_payload_bid_gloas::(slot, parent_hash, pubkey), + builder.get_builder_header_gloas::(slot, parent_hash, pubkey), self.get_payload_gloas(payload_parameters), ); From f3e31104427ee168ac0c6709ae2e4b6576197c41 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:57:03 +0200 Subject: [PATCH 09/10] Revert mev-boost-for-Gloas wiring; defer to follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the relay-side plumbing for Gloas (the get_payload_gloas_with_builder race, the SignedExecutionPayloadBid ForkVersionDecode impl, the get_builder_header_gloas method, the GloasPayloadSource enum) until the relay wire format for Gloas is settled. The constraint trilemma (existing macro / existing get_builder_header / no new code) couldn't be resolved without either: - adding a Gloas variant to BuilderBid (breaks the map_builder_bid_ref_into_execution_payload_header_ref macro since ExecutionPayloadHeader has no Gloas variant), - adding a Gloas variant to ExecutionPayloadHeader (massive blast radius across state_root/receipts_root/etc. accessors), or - duplicating HTTP boilerplate. Rather than ship a design that gets rewritten once the spec lands, this PR ships only the gossip-cache selection (Path C — the proposer picks a SignedExecutionPayloadBid from the gossip cache via select_payload_bid_pure using builder_boost_factor). Path B (mev-boost-as-self-build) is a follow-up that will revisit the wire-format question. --- .../src/block_production/gloas.rs | 134 ++++++------------ beacon_node/builder_client/src/lib.rs | 52 +------ beacon_node/execution_layer/src/lib.rs | 89 +----------- .../execution/signed_execution_payload_bid.rs | 14 +- 4 files changed, 43 insertions(+), 246 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 287c35d4a12..78e3a25c44f 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -4,8 +4,7 @@ use std::sync::Arc; use bls::{PublicKeyBytes, Signature}; use execution_layer::{ - BlockProposalContentsGloas, BuilderParams, GloasPayloadSource, PayloadAttributes, - PayloadParameters, + BlockProposalContentsGloas, BuilderParams, PayloadAttributes, PayloadParameters, }; use fork_choice::PayloadStatus; use operation_pool::CompactAttestationRef; @@ -49,7 +48,7 @@ pub const EXECUTION_PAYMENT_TRUSTLESS_BUILD: u64 = 0; type ConsensusBlockValue = u64; type BlockProductionResult = (BeaconBlock, BeaconState, ConsensusBlockValue); -pub type PreparePayloadResult = Result, BlockProductionError>; +pub type PreparePayloadResult = Result, BlockProductionError>; pub type PreparePayloadHandle = JoinHandle>>; pub struct PartialBeaconBlock { @@ -79,17 +78,13 @@ pub struct ExecutionPayloadData { pub blobs_and_proofs: (types::BlobsList, types::KzgProofs), } -/// The result of a self-build payload acquisition, used to decide whether to include -/// a builder bid from the gossip cache or fall back to self-build. -/// -/// `payload_data` is `None` when the self-build wraps a blinded relay header — the -/// proposer will unblind via mev-boost before envelope publication. +/// The result of a local payload build, used to decide whether to include a builder bid +/// from the gossip cache or fall back to self-build. pub struct LocalBuildResult { - pub payload_data: Option>, - /// Self-build candidate value (in wei). + pub payload_data: ExecutionPayloadData, + /// EL block value (in wei) of the locally-built payload. pub payload_value: types::Uint256, - /// `true` if the local EL signaled `shouldOverrideBuilder` (only meaningful for - /// the local-EL self-build path). + /// `true` if the EL signaled `engine_getPayload`'s `shouldOverrideBuilder` flag. pub should_override_builder: bool, } @@ -207,7 +202,6 @@ impl BeaconChain { produce_at_slot, BID_VALUE_SELF_BUILD, BUILDER_INDEX_SELF_BUILD, - builder_boost_factor, ) .await?; @@ -708,7 +702,6 @@ impl BeaconChain { /// For local building, payload data is always returned (`Some`). /// For trustless building, the builder provides the envelope separately, so `None` is returned. #[allow(clippy::type_complexity)] - #[allow(clippy::too_many_arguments)] #[instrument(level = "debug", skip_all)] pub async fn produce_execution_payload_bid( self: Arc, @@ -718,7 +711,6 @@ impl BeaconChain { produce_at_slot: Slot, bid_value: u64, builder_index: BuilderIndex, - builder_boost_factor: Option, ) -> Result< ( SignedExecutionPayloadBid, @@ -776,6 +768,8 @@ impl BeaconChain { }; // TODO(gloas) this should be BlockProductionVersion::V4 + // V3 is okay for now as long as we're not connected to a builder + // TODO(gloas) add builder boost factor let prepare_payload_handle = get_execution_payload_gloas( self.clone(), &state, @@ -784,85 +778,46 @@ impl BeaconChain { parent_envelope, proposer_index, builder_params, - builder_boost_factor, )?; - let payload_source = prepare_payload_handle + let block_proposal_contents = prepare_payload_handle .await .map_err(BlockProductionError::TokioJoin)? .ok_or(BlockProductionError::ShuttingDown)??; - // Build a Gloas self-build bid from either the local-EL full payload or - // the relay's blinded header. `bid_value` is 0 for self-build regardless; - // the value used for the boost-factor compare against the p2p bid is the - // EL block-value (local) or relay bid value (builder), in wei. - let ( - bid_fields, - blob_kzg_commitments, - exec_requests_root, + let BlockProposalContentsGloas { + payload, payload_value, - payload_data, + execution_requests, + blob_kzg_commitments, + blobs_and_proofs, should_override_builder, - ) = match payload_source { - GloasPayloadSource::Local(BlockProposalContentsGloas { - payload, - payload_value, - execution_requests, - blob_kzg_commitments, - blobs_and_proofs, - should_override_builder, - }) => { - let block_hash = payload.block_hash; - let prev_randao = payload.prev_randao; - let gas_limit = payload.gas_limit; - let exec_requests_root = execution_requests.tree_hash_root(); - let payload_data = ExecutionPayloadData { - payload, - execution_requests, - builder_index, - slot: produce_at_slot, - blobs_and_proofs, - }; - ( - (block_hash, prev_randao, gas_limit), - blob_kzg_commitments, - exec_requests_root, - payload_value, - Some(payload_data), - should_override_builder, - ) - } - GloasPayloadSource::Builder(signed_bid) => { - let m = &signed_bid.message; - // Convert the relay-bid value (gwei, u64) to wei for downstream - // comparison against the cached p2p bid. - let value_wei = types::Uint256::from(m.value) - .saturating_mul(types::Uint256::from(1_000_000_000u64)); - ( - (m.block_hash, m.prev_randao, m.gas_limit), - m.blob_kzg_commitments.clone(), - m.execution_requests_root, - value_wei, - None, - false, - ) - } - }; - let (block_hash, prev_randao, gas_limit) = bid_fields; + } = block_proposal_contents; + // TODO(gloas) since we are defaulting to local building, execution payment is 0 + // execution payment should only be set to > 0 for trusted building. let bid = ExecutionPayloadBid:: { parent_block_hash, parent_block_root: parent_root, - block_hash, - prev_randao, + block_hash: payload.block_hash, + prev_randao: payload.prev_randao, fee_recipient: Address::ZERO, - gas_limit, + gas_limit: payload.gas_limit, builder_index, slot: produce_at_slot, value: bid_value, execution_payment: EXECUTION_PAYMENT_TRUSTLESS_BUILD, blob_kzg_commitments, - execution_requests_root: exec_requests_root, + execution_requests_root: execution_requests.tree_hash_root(), + }; + + // Store payload data for envelope construction after block is created + let payload_data = ExecutionPayloadData { + payload, + execution_requests, + builder_index, + slot: produce_at_slot, + blobs_and_proofs, }; Ok(( @@ -930,7 +885,7 @@ pub(crate) fn select_payload_bid_pure( } = local_build; let Some(cached_bid) = cached_bid else { - return (local_signed_bid, payload_data); + return (local_signed_bid, Some(payload_data)); }; let slot = local_signed_bid.message.slot; @@ -941,7 +896,7 @@ pub(crate) fn select_payload_bid_pure( cached_bid_value = cached_bid.message.value, "Using local payload because EL signaled shouldOverrideBuilder" ); - return (local_signed_bid, payload_data); + return (local_signed_bid, Some(payload_data)); } // Convert bid value (gwei) to wei for comparison with `payload_value` (wei). @@ -962,7 +917,7 @@ pub(crate) fn select_payload_bid_pure( ?builder_boost_factor, "Local payload is more profitable than cached builder bid" ); - (local_signed_bid, payload_data) + (local_signed_bid, Some(payload_data)) } else { debug!( %slot, @@ -982,7 +937,6 @@ pub(crate) fn select_payload_bid_pure( /// /// Will return an error when using a pre-Gloas `state`. Ensure to only run this function /// after the Gloas fork. -#[allow(clippy::too_many_arguments)] fn get_execution_payload_gloas( chain: Arc>, state: &BeaconState, @@ -991,7 +945,6 @@ fn get_execution_payload_gloas( parent_envelope: Option>>, proposer_index: u64, builder_params: BuilderParams, - builder_boost_factor: Option, ) -> Result, BlockProductionError> { // Compute all required values from the `state` now to avoid needing to pass it into a spawned // task. @@ -1045,7 +998,6 @@ fn get_execution_payload_gloas( builder_params, withdrawals, parent_beacon_block_root, - builder_boost_factor, ) .await } @@ -1074,8 +1026,7 @@ async fn prepare_execution_payload( builder_params: BuilderParams, withdrawals: Vec, parent_beacon_block_root: Hash256, - builder_boost_factor: Option, -) -> Result, BlockProductionError> +) -> Result, BlockProductionError> where T: BeaconChainTypes, { @@ -1129,17 +1080,12 @@ where current_fork: fork, }; - let payload_source = execution_layer - .get_payload_gloas_with_builder( - payload_parameters, - builder_params, - builder_boost_factor, - spec, - ) + let block_contents = execution_layer + .get_payload_gloas(payload_parameters) .await .map_err(BlockProductionError::GetPayloadFailed)?; - Ok(payload_source) + Ok(block_contents) } /// Drop voluntary exits whose target validators will be exited by the parent envelope's @@ -1347,13 +1293,13 @@ mod tests { fn local_build(payload_gwei: u64, should_override_builder: bool) -> LocalBuildResult { LocalBuildResult { - payload_data: Some(ExecutionPayloadData { + payload_data: ExecutionPayloadData { payload: types::ExecutionPayloadGloas::default(), execution_requests: ExecutionRequests::default(), builder_index: BUILDER_INDEX_SELF_BUILD, slot: Slot::new(0), blobs_and_proofs: (VariableList::empty(), VariableList::empty()), - }), + }, payload_value: gwei(payload_gwei), should_override_builder, } diff --git a/beacon_node/builder_client/src/lib.rs b/beacon_node/builder_client/src/lib.rs index 9ea5ad51a69..7dc0cbfc6d0 100644 --- a/beacon_node/builder_client/src/lib.rs +++ b/beacon_node/builder_client/src/lib.rs @@ -5,7 +5,7 @@ use eth2::types::beacon_response::EmptyMetadata; use eth2::types::builder::SignedBuilderBid; use eth2::types::{ ContentType, EthSpec, ExecutionBlockHash, ForkName, ForkVersionDecode, ForkVersionedResponse, - SignedExecutionPayloadBid, SignedValidatorRegistrationData, Slot, + SignedValidatorRegistrationData, Slot, }; use eth2::types::{FullPayloadContents, SignedBlindedBeaconBlock}; use eth2::{ @@ -521,56 +521,6 @@ impl BuilderHttpClient { } } - /// `GET /eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}` for Gloas slots. - /// - /// Same URL as the pre-Gloas `get_builder_header`; the relay dispatches on - /// the slot's fork and returns a `SignedExecutionPayloadBid` (Gloas-native - /// commitment) instead of a `SignedBuilderBid` (pre-Gloas envelope around - /// an `ExecutionPayloadHeader`). - pub async fn get_builder_header_gloas( - &self, - slot: Slot, - parent_hash: ExecutionBlockHash, - pubkey: &PublicKeyBytes, - ) -> Result>>, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("header") - .push(slot.to_string().as_str()) - .push(format!("{parent_hash:?}").as_str()) - .push(pubkey.as_hex_string().as_str()); - - let mut headers = HeaderMap::new(); - if self.disable_ssz { - headers.insert( - ACCEPT, - HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - } else { - headers.insert( - ACCEPT, - HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - } - - let resp = self - .get_with_header(path, self.timeouts.get_header, headers) - .await; - - if matches!(resp, Err(Error::StatusCode(StatusCode::NO_CONTENT))) { - Ok(None) - } else { - resp.map(Some) - } - } - /// `GET /eth/v1/builder/status` pub async fn get_builder_status(&self) -> Result<(), Error> { let mut path = self.server.expose_full().clone(); diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 331f4a98ef3..b2dabb7c018 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -54,7 +54,7 @@ use types::{ use types::{ BeaconStateError, BlindedPayload, ChainSpec, Epoch, ExecPayload, ExecutionPayloadBellatrix, ExecutionPayloadCapella, ExecutionPayloadElectra, ExecutionPayloadFulu, FullPayload, - ProposerPreparationData, SignedExecutionPayloadBid, Slot, + ProposerPreparationData, Slot, }; mod block_hash; @@ -208,22 +208,6 @@ pub struct BlockProposalContentsGloas { pub should_override_builder: bool, } -/// Output of a Gloas self-build payload fetch, after racing local EL against any -/// configured mev-boost relay using `builder_boost_factor`. -/// -/// Both variants are wrapped as a self-build bid (`builder_index = u64::MAX`, -/// infinity sig, `value = 0`) at the producer; the only difference is whether -/// the proposer already holds the full payload (`Local`) or commits to the -/// relay's bid hash and unblinds the payload via mev-boost later (`Builder`). -pub enum GloasPayloadSource { - /// Full payload from local EL. - Local(BlockProposalContentsGloas), - /// Relay bid (Gloas-native `SignedExecutionPayloadBid`). The proposer - /// must unblind the full payload via the existing mev-boost endpoint - /// before envelope publication. - Builder(SignedExecutionPayloadBid), -} - impl From> for BlockProposalContentsGloas { fn from(response: GetPayloadResponseGloas) -> Self { Self { @@ -919,77 +903,6 @@ impl ExecutionLayer { /// /// The result will be returned from the first node that returns successfully. No more nodes /// will be contacted. - /// Gloas self-build payload fetch with mev-boost race. - /// - /// When a builder is configured, fetches a Gloas-native - /// `SignedExecutionPayloadBid` from the relay in parallel with a local-EL - /// `engine_getPayload` call, then races the two using `builder_boost_factor` - /// (same `(value / 100) * factor` rule as pre-Gloas). The local EL's - /// `shouldOverrideBuilder` flag also forces local. - /// - /// Whichever wins is wrapped as a Gloas self-build bid at the producer. - /// Bid value is in gwei (u64), local payload value is in wei (Uint256); - /// compared in wei. - #[instrument(level = "debug", skip_all)] - pub async fn get_payload_gloas_with_builder( - &self, - payload_parameters: PayloadParameters<'_>, - builder_params: BuilderParams, - builder_boost_factor: Option, - _spec: &ChainSpec, - ) -> Result, Error> { - let Some(builder) = self.builder() else { - return self - .get_payload_gloas(payload_parameters) - .await - .map(GloasPayloadSource::Local); - }; - - if builder_params.chain_health != ChainHealth::Healthy { - return self - .get_payload_gloas(payload_parameters) - .await - .map(GloasPayloadSource::Local); - } - - let slot = builder_params.slot; - let pubkey = &builder_params.pubkey; - let parent_hash = payload_parameters.parent_hash; - - let (relay_result, local_result) = tokio::join!( - builder.get_builder_header_gloas::(slot, parent_hash, pubkey), - self.get_payload_gloas(payload_parameters), - ); - - let local = local_result?; - - let Ok(Some(relay)) = relay_result else { - // Relay error or no bid ⇒ use local. - return Ok(GloasPayloadSource::Local(local)); - }; - - // EL hint: ignore the relay this slot. - if local.should_override_builder { - return Ok(GloasPayloadSource::Local(local)); - } - - let signed_bid = relay.data; - let bid_value_wei = - Uint256::from(signed_bid.message.value).saturating_mul(Uint256::from(1_000_000_000u64)); - let boosted_bid_wei = match builder_boost_factor { - Some(factor) => { - (bid_value_wei / Uint256::from(100)).saturating_mul(Uint256::from(factor)) - } - None => bid_value_wei, - }; - - if local.payload_value >= boosted_bid_wei { - Ok(GloasPayloadSource::Local(local)) - } else { - Ok(GloasPayloadSource::Builder(signed_bid)) - } - } - #[instrument(level = "debug", skip_all)] pub async fn get_payload_gloas( &self, diff --git a/consensus/types/src/execution/signed_execution_payload_bid.rs b/consensus/types/src/execution/signed_execution_payload_bid.rs index 75190fec1e7..48da4453329 100644 --- a/consensus/types/src/execution/signed_execution_payload_bid.rs +++ b/consensus/types/src/execution/signed_execution_payload_bid.rs @@ -1,11 +1,10 @@ use crate::execution::ExecutionPayloadBid; use crate::test_utils::TestRandom; -use crate::{EthSpec, ForkName, ForkVersionDecode}; +use crate::{EthSpec, ForkName}; use bls::Signature; use context_deserialize::context_deserialize; use educe::Educe; use serde::{Deserialize, Serialize}; -use ssz::Decode as _; use ssz_derive::{Decode, Encode}; use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; @@ -34,17 +33,6 @@ impl SignedExecutionPayloadBid { } } -impl ForkVersionDecode for SignedExecutionPayloadBid { - fn from_ssz_bytes_by_fork(bytes: &[u8], fork_name: ForkName) -> Result { - if !fork_name.gloas_enabled() { - return Err(ssz::DecodeError::BytesInvalid(format!( - "SignedExecutionPayloadBid is only defined for Gloas+; got {fork_name}", - ))); - } - Self::from_ssz_bytes(bytes) - } -} - #[cfg(test)] mod tests { use super::*; From a2b037d985d0364366a207926c6811df7ab25fb6 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:33:02 +0200 Subject: [PATCH 10/10] Update produce_execution_payload_bid doc, drop trustless TODO --- .../beacon_chain/src/block_production/gloas.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 78e3a25c44f..49997332415 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -691,16 +691,13 @@ impl BeaconChain { Ok((block, state, consensus_block_value)) } - // TODO(gloas) introduce `ProposerPreferences` so we can build out trustless - // bid building. Right now this only works for local building. - /// Produce an `ExecutionPayloadBid` for some `slot` upon the given `state`. + /// Produce a self-build `ExecutionPayloadBid` for some `slot` upon the given `state`. /// This function assumes we've already advanced `state`. /// - /// Returns the signed bid, the state, and optionally the payload data needed to construct - /// the `ExecutionPayloadEnvelope` after the beacon block is created. - /// - /// For local building, payload data is always returned (`Some`). - /// For trustless building, the builder provides the envelope separately, so `None` is returned. + /// Returns the signed bid, the state, and a `LocalBuildResult` carrying the payload + /// data needed to construct the `ExecutionPayloadEnvelope` after the beacon block is + /// created, plus the EL block value and `should_override_builder` flag used by the + /// caller to compare against any cached p2p builder bid. #[allow(clippy::type_complexity)] #[instrument(level = "debug", skip_all)] pub async fn produce_execution_payload_bid(