From 17f596b154a1eebed0823253ad14decd6b02b791 Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 24 Aug 2026 17:55:14 +0100 Subject: [PATCH 1/7] Point Gloas spec comments at the merged builder-specs pages --- crates/common/src/api/mod.rs | 5 +++-- .../src/api/proposer/get_execution_payload_bid.rs | 11 ++++------- .../src/api/proposer/submit_builder_preferences.rs | 11 ++++------- crates/types/src/request_auth.rs | 5 +++-- 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/crates/common/src/api/mod.rs b/crates/common/src/api/mod.rs index b948321ba..e28eeda30 100644 --- a/crates/common/src/api/mod.rs +++ b/crates/common/src/api/mod.rs @@ -31,8 +31,9 @@ pub const PATH_GET_HEADER: &str = "/header/{slot}/{parent_hash}/{pubkey}"; pub const PATH_HEADER_STREAM: &str = "/header_stream/{slot}/{parent_hash}/{pubkey}"; pub const PATH_GET_PAYLOAD: &str = "/blinded_blocks"; -// Gloas (ePBS) builder-API additions, per https://github.com/ethereum/builder-specs/pull/165. -// Not yet wired to the auctioneer -- see docs/gloas-support-plan.md. +// Gloas (ePBS) builder-API additions, per +// https://github.com/ethereum/builder-specs/blob/main/specs/gloas/builder.md. +// TODO(gloas): not yet wired to the auctioneer; see gattaca-com/helix#489. pub const PATH_GET_EXECUTION_PAYLOAD_BID: &str = "/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}"; pub const PATH_SUBMIT_BUILDER_PREFERENCES: &str = "/builder_preferences/{proposer_pubkey}"; diff --git a/crates/relay/src/api/proposer/get_execution_payload_bid.rs b/crates/relay/src/api/proposer/get_execution_payload_bid.rs index e0ca5fb9f..9be1c2767 100644 --- a/crates/relay/src/api/proposer/get_execution_payload_bid.rs +++ b/crates/relay/src/api/proposer/get_execution_payload_bid.rs @@ -19,11 +19,8 @@ use crate::api::{Api, proposer::error::ProposerApiError}; impl ProposerApi { /// Serves a `SignedExecutionPayloadBid` for the given slot/parent_hash/parent_root to a - /// Gloas (ePBS) proposer. - /// - /// Not yet wired to the auctioneer: this only validates the request per - /// and always answers with the - /// spec's "no bid available" response. + /// Gloas (ePBS) proposer, per + /// . #[tracing::instrument(skip_all, err(level = tracing::Level::TRACE), fields(id =% extract_request_id(&headers), slot = params.slot))] pub async fn get_execution_payload_bid( Extension(proposer_api): Extension>>, @@ -67,8 +64,8 @@ impl ProposerApi { ); // TODO(gloas): fetch/build the SignedExecutionPayloadBid from the auctioneer, honoring - // any stored max_execution_payment preference. Not wired in yet -- always reports "no - // bid available", which is a valid response per spec. + // any stored max_execution_payment preference. Until then, "no bid available" is a + // valid response per spec. Ok(StatusCode::NO_CONTENT) } } diff --git a/crates/relay/src/api/proposer/submit_builder_preferences.rs b/crates/relay/src/api/proposer/submit_builder_preferences.rs index 57b6f4171..f164932d9 100644 --- a/crates/relay/src/api/proposer/submit_builder_preferences.rs +++ b/crates/relay/src/api/proposer/submit_builder_preferences.rs @@ -13,11 +13,8 @@ use super::{ProposerApi, get_payload::fork_name_from_header}; use crate::api::{Api, proposer::error::ProposerApiError}; impl ProposerApi { - /// Accepts a proposer's Gloas (ePBS) `BuilderPreferencesRequest`. - /// - /// Not yet wired in: this only validates the request per - /// ; preferences are not yet stored - /// or enforced when serving bids. + /// Accepts a proposer's Gloas (ePBS) `BuilderPreferencesRequest`, per + /// . #[tracing::instrument(skip_all, err(level = tracing::Level::TRACE), fields(id =% extract_request_id(&headers)))] pub async fn submit_builder_preferences( Extension(proposer_api): Extension>>, @@ -44,11 +41,11 @@ impl ProposerApi { proposer_pubkey = ?params.proposer_pubkey, slot = request.auth.message.slot, max_execution_payment = request.preferences.max_execution_payment, - "validated submitBuilderPreferences request (not yet persisted -- storage not wired in)" + "validated submitBuilderPreferences request (not yet persisted)" ); // TODO(gloas): reject stale slots, store preferences per proposer per slot, and honor - // max_execution_payment when serving bids. Not wired in yet. + // max_execution_payment when serving bids. Ok(StatusCode::ACCEPTED) } } diff --git a/crates/types/src/request_auth.rs b/crates/types/src/request_auth.rs index f903d0f44..820e5ee17 100644 --- a/crates/types/src/request_auth.rs +++ b/crates/types/src/request_auth.rs @@ -2,8 +2,9 @@ //! `getExecutionPayloadBid` and `submitBuilderPreferences` requests, and to carry a //! proposer's per-builder payment preferences. //! -//! See . Not yet part of any merged -//! spec, so field shapes may still change. +//! See . +//! TODO(gloas): the merged spec renamed these to `BuilderRequestAuth`/`SignedBuilderRequestAuth`; +//! rename here to match. use alloy_primitives::B256; use lh_types::SignedRoot; From ff2e104fb8fae5f7a1e9cbbc41cb81c28459a2e3 Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 24 Aug 2026 18:21:15 +0100 Subject: [PATCH 2/7] Rename RequestAuth/SignedRequestAuth to match the merged builder-specs types --- crates/common/src/chain_info.rs | 4 +-- crates/relay/src/api/proposer/error.rs | 6 ++--- .../api/proposer/get_execution_payload_bid.rs | 11 ++++---- crates/types/src/request_auth.rs | 26 +++++++++---------- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/crates/common/src/chain_info.rs b/crates/common/src/chain_info.rs index eef457f99..e3234a5af 100644 --- a/crates/common/src/chain_info.rs +++ b/crates/common/src/chain_info.rs @@ -20,8 +20,8 @@ pub struct ChainInfo { pub clock: SlotClock, pub genesis_time_in_secs: u64, pub builder_domain: B256, - /// Domain for verifying Gloas builder-API `SignedRequestAuth` signatures. Not a consensus - /// domain; see `ChainSpec::get_request_auth_domain`. + /// Domain for verifying Gloas builder-API `SignedBuilderRequestAuth` signatures. Not a + /// consensus domain; see `ChainSpec::get_request_auth_domain`. pub request_auth_domain: B256, } diff --git a/crates/relay/src/api/proposer/error.rs b/crates/relay/src/api/proposer/error.rs index 4b831d1e4..4064c867e 100644 --- a/crates/relay/src/api/proposer/error.rs +++ b/crates/relay/src/api/proposer/error.rs @@ -125,12 +125,12 @@ pub enum ProposerApiError { SszDecodeError(DecodeError), // Gloas (ePBS) builder-API additions, per - // https://github.com/ethereum/builder-specs/pull/165. Not yet wired to the auctioneer. - #[error("invalid SignedRequestAuth: signature verification failed")] + // https://github.com/ethereum/builder-specs/blob/main/specs/gloas/builder.md. + #[error("invalid SignedBuilderRequestAuth: signature verification failed")] InvalidRequestAuthSignature, #[error( - "invalid SignedRequestAuth: auth.message.slot ({auth_slot}) does not match the request slot ({request_slot})" + "invalid SignedBuilderRequestAuth: auth.message.slot ({auth_slot}) does not match the request slot ({request_slot})" )] RequestAuthSlotMismatch { auth_slot: u64, request_slot: u64 }, diff --git a/crates/relay/src/api/proposer/get_execution_payload_bid.rs b/crates/relay/src/api/proposer/get_execution_payload_bid.rs index 9be1c2767..d10bc4548 100644 --- a/crates/relay/src/api/proposer/get_execution_payload_bid.rs +++ b/crates/relay/src/api/proposer/get_execution_payload_bid.rs @@ -9,7 +9,7 @@ use helix_common::{ decoder::Encoding, utils::extract_request_id, }; -use helix_types::{ForkName, SignedRequestAuth}; +use helix_types::{ForkName, SignedBuilderRequestAuth}; use hyper::StatusCode; use ssz::Decode; use tracing::info; @@ -39,10 +39,11 @@ impl ProposerApi { return Err(ProposerApiError::MissingTimingHeaders); } - let signed_request_auth: SignedRequestAuth = match Encoding::from_content_type(&headers) { - Encoding::Json => serde_json::from_slice(&body)?, - Encoding::Ssz => SignedRequestAuth::from_ssz_bytes(&body)?, - }; + let signed_request_auth: SignedBuilderRequestAuth = + match Encoding::from_content_type(&headers) { + Encoding::Json => serde_json::from_slice(&body)?, + Encoding::Ssz => SignedBuilderRequestAuth::from_ssz_bytes(&body)?, + }; if signed_request_auth.message.slot != params.slot { return Err(ProposerApiError::RequestAuthSlotMismatch { diff --git a/crates/types/src/request_auth.rs b/crates/types/src/request_auth.rs index 820e5ee17..046924e4f 100644 --- a/crates/types/src/request_auth.rs +++ b/crates/types/src/request_auth.rs @@ -3,8 +3,6 @@ //! proposer's per-builder payment preferences. //! //! See . -//! TODO(gloas): the merged spec renamed these to `BuilderRequestAuth`/`SignedBuilderRequestAuth`; -//! rename here to match. use alloy_primitives::B256; use lh_types::SignedRoot; @@ -24,7 +22,7 @@ crate::ssz_bytes_wrapper! { /// Authenticates a `getExecutionPayloadBid` or `submitBuilderPreferences` request. Signed /// under `DOMAIN_REQUEST_AUTH`, distinct from the in-protocol `DOMAIN_BEACON_BUILDER`. #[derive(PartialEq, Debug, Serialize, Deserialize, Clone, Encode, Decode, TreeHash)] -pub struct RequestAuth { +pub struct BuilderRequestAuth { /// Opaque authentication data agreed with the builder out of band. pub data: RequestAuthData, /// The proposal slot this request is authorized for. @@ -32,17 +30,17 @@ pub struct RequestAuth { pub slot: u64, } -impl SignedRoot for RequestAuth {} +impl SignedRoot for BuilderRequestAuth {} #[derive(PartialEq, Debug, Serialize, Deserialize, Clone, Encode, Decode)] -pub struct SignedRequestAuth { - pub message: RequestAuth, +pub struct SignedBuilderRequestAuth { + pub message: BuilderRequestAuth, pub signature: BlsSignatureBytes, } -impl SignedRequestAuth { +impl SignedBuilderRequestAuth { /// `pubkey` is resolved from the `proposer_pubkey` path parameter, not carried inside - /// `RequestAuth` itself. `domain` is `ChainInfo::request_auth_domain`. + /// `BuilderRequestAuth` itself. `domain` is `ChainInfo::request_auth_domain`. pub fn verify_signature( &self, pubkey: &BlsPublicKeyBytes, @@ -73,7 +71,7 @@ pub struct BuilderPreferences { #[derive(PartialEq, Debug, Serialize, Deserialize, Clone, Encode, Decode)] pub struct BuilderPreferencesRequest { pub preferences: BuilderPreferences, - pub auth: SignedRequestAuth, + pub auth: SignedBuilderRequestAuth, } #[cfg(test)] @@ -83,8 +81,8 @@ mod tests { use super::*; use crate::BlsKeypair; - fn sample_request_auth() -> RequestAuth { - RequestAuth { data: RequestAuthData(vec![1, 2, 3, 4].into()), slot: 123 } + fn sample_request_auth() -> BuilderRequestAuth { + BuilderRequestAuth { data: RequestAuthData(vec![1, 2, 3, 4].into()), slot: 123 } } #[test] @@ -98,14 +96,14 @@ mod tests { fn request_auth_ssz_round_trip() { let auth = sample_request_auth(); let bytes = auth.as_ssz_bytes(); - assert_eq!(auth, RequestAuth::from_ssz_bytes(&bytes).unwrap()); + assert_eq!(auth, BuilderRequestAuth::from_ssz_bytes(&bytes).unwrap()); } #[test] fn builder_preferences_request_json_round_trip() { let request = BuilderPreferencesRequest { preferences: BuilderPreferences { max_execution_payment: 42 }, - auth: SignedRequestAuth { + auth: SignedBuilderRequestAuth { message: sample_request_auth(), signature: BlsSignatureBytes::default(), }, @@ -122,7 +120,7 @@ mod tests { let root = message.signing_root(domain); let signature = keypair.sk.sign(root); - let signed = SignedRequestAuth { message, signature: signature.serialize().into() }; + let signed = SignedBuilderRequestAuth { message, signature: signature.serialize().into() }; let pubkey: BlsPublicKeyBytes = keypair.pk.serialize().into(); signed.verify_signature(&pubkey, domain).expect("valid signature should verify"); From 204619d39d50baaa6cb3cb8cb68bcb6a6c113724 Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 24 Aug 2026 19:21:31 +0100 Subject: [PATCH 3/7] Construct and sign the Gloas SignedExecutionPayloadEnvelope, and broadcast it to beacon nodes --- crates/common/src/beacon/beacon_client.rs | 125 ++++++++- .../common/src/beacon/multi_beacon_client.rs | 88 +++++- crates/relay/src/api/proposer/error.rs | 19 +- .../proposer/submit_signed_beacon_block.rs | 252 +++++++++++++++++- crates/types/src/lib.rs | 11 +- 5 files changed, 478 insertions(+), 17 deletions(-) diff --git a/crates/common/src/beacon/beacon_client.rs b/crates/common/src/beacon/beacon_client.rs index 50086b525..3a6371d71 100644 --- a/crates/common/src/beacon/beacon_client.rs +++ b/crates/common/src/beacon/beacon_client.rs @@ -2,7 +2,9 @@ use std::{sync::Arc, task::Poll, time::Duration}; use ::ssz::Encode; use alloy_primitives::B256; -use helix_types::{ForkName, LhConfig, VersionedSignedProposal, spec_from_config}; +use helix_types::{ + ForkName, LhConfig, SignedExecutionPayloadEnvelope, VersionedSignedProposal, spec_from_config, +}; use http::{Request, header::CONTENT_TYPE}; use http_body_util::Full; use hyper::body::Bytes; @@ -20,6 +22,8 @@ use crate::{ }; const CONSENSUS_VERSION_HEADER: &str = "eth-consensus-version"; +// Always "false": helix always has blobs cached from the builder's own submission. +const BLOB_DATA_INCLUDED_HEADER: &str = "eth-blob-data-included"; const PUBLISH_BLOCK_TIMEOUT: Duration = Duration::from_secs(4); const GET_TIMEOUT: Duration = Duration::from_secs(5); @@ -112,6 +116,48 @@ impl BeaconClient { } } + /// Publishes a signed execution payload envelope SSZ-encoded, so a connected beacon node + /// broadcasts it to the `execution_payload` gossip topic on helix's behalf. + /// + pub async fn publish_execution_payload_envelope( + &self, + envelope: Arc, + fork: ForkName, + ) -> Result { + let target = self.config.url.join("eth/v1/beacon/execution_payload_envelopes")?; + let body_bytes = Bytes::from(envelope.as_ssz_bytes()); + let req = Request::builder() + .method("POST") + .uri(target.as_str()) + .header(CONSENSUS_VERSION_HEADER, fork.to_string()) + .header(BLOB_DATA_INCLUDED_HEADER, "false") + .header(CONTENT_TYPE, "application/octet-stream") + .body(Full::new(body_bytes))?; + let mut pending = self.http.send(&target, req)?.with_timeout(PUBLISH_BLOCK_TIMEOUT); + + let (status, body) = loop { + match pending.poll_bytes() { + Poll::Pending => {} + Poll::Ready(Ok(r)) => break r, + Poll::Ready(Err(e)) => return Err(e.into()), + } + tokio::task::yield_now().await; + }; + + match status { + 200 => Ok(200), + 202 => { + let body_str = String::from_utf8_lossy(&body); + warn!("Envelope broadcast but not integrated: {body_str}"); + Ok(202) + } + _ => { + let api_err: ApiError = serde_json::from_slice(&body)?; + Err(BeaconClientError::Api(api_err)) + } + } + } + pub async fn get_chain_info(&self) -> Result { let spec: BeaconResponse = self.get("eth/v1/config/spec").await?; let spec = spec_from_config(spec.data); @@ -130,3 +176,80 @@ impl BeaconClient { Ok(chain_info) } } + +#[cfg(test)] +mod tests { + use helix_types::{BlsSignature, ExecutionPayloadEnvelope}; + use httpmock::{Method::POST, MockServer}; + use reqwest::Url; + + use super::*; + + fn test_client(url: Url) -> BeaconClient { + crate::utils::install_default_crypto_provider(); + BeaconClient::new(BeaconClientConfig { url }) + } + + fn empty_envelope() -> Arc { + Arc::new(SignedExecutionPayloadEnvelope { + message: ExecutionPayloadEnvelope::empty(), + signature: BlsSignature::empty(), + }) + } + + #[tokio::test] + async fn publish_execution_payload_envelope_sends_ssz_with_fork_and_blob_headers() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/eth/v1/beacon/execution_payload_envelopes") + .header("eth-consensus-version", "gloas") + .header("eth-blob-data-included", "false") + .header("content-type", "application/octet-stream"); + then.status(200); + }); + + let client = test_client(Url::parse(&server.url("/")).unwrap()); + let result = + client.publish_execution_payload_envelope(empty_envelope(), ForkName::Gloas).await; + + mock.assert(); + assert_eq!(result.unwrap(), 200); + } + + #[tokio::test] + async fn publish_execution_payload_envelope_202_is_ok() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/eth/v1/beacon/execution_payload_envelopes"); + then.status(202).body("envelope failed integration but was broadcast"); + }); + + let client = test_client(Url::parse(&server.url("/")).unwrap()); + let result = + client.publish_execution_payload_envelope(empty_envelope(), ForkName::Gloas).await; + + assert_eq!(result.unwrap(), 202); + } + + #[tokio::test] + async fn publish_execution_payload_envelope_error_response_parses_api_error() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/eth/v1/beacon/execution_payload_envelopes"); + then.status(400).json_body(serde_json::json!({ + "code": 400, + "message": "Invalid signed execution payload envelope" + })); + }); + + let client = test_client(Url::parse(&server.url("/")).unwrap()); + let result = + client.publish_execution_payload_envelope(empty_envelope(), ForkName::Gloas).await; + + match result { + Err(BeaconClientError::Api(ApiError::ErrorMessage { code: 400, .. })) => {} + other => panic!("expected a 400 ApiError, got {other:?}"), + } + } +} diff --git a/crates/common/src/beacon/multi_beacon_client.rs b/crates/common/src/beacon/multi_beacon_client.rs index d5c3e3843..6d10ce735 100644 --- a/crates/common/src/beacon/multi_beacon_client.rs +++ b/crates/common/src/beacon/multi_beacon_client.rs @@ -4,7 +4,7 @@ use std::sync::{ }; use futures::future::join_all; -use helix_types::{ForkName, VersionedSignedProposal}; +use helix_types::{ForkName, SignedExecutionPayloadEnvelope, VersionedSignedProposal}; use crate::{ beacon::{beacon_client::BeaconClient, error::BeaconClientError, types::BroadcastValidation}, @@ -83,4 +83,90 @@ impl MultiBeaconClient { Err(last_error.unwrap_or(BeaconClientError::BeaconNodeUnavailable)) } + + /// Publishes the signed execution payload envelope to all beacon clients; returns on first + /// success. Unlike `publish_block`, fans out via plain concurrent futures, not + /// `spawn_tracked!`. + pub async fn publish_execution_payload_envelope( + &self, + envelope: Arc, + fork: ForkName, + ) -> Result<(), BeaconClientError> { + let futures = self + .beacon_clients + .iter() + .map(|client| client.publish_execution_payload_envelope(envelope.clone(), fork)); + + let mut last_error: Option = None; + for res in join_all(futures).await { + match res { + Ok(_) => return Ok(()), + Err(err) => last_error = Some(err), + } + } + + Err(last_error.unwrap_or(BeaconClientError::BeaconNodeUnavailable)) + } +} + +#[cfg(test)] +mod tests { + use helix_types::{BlsSignature, ExecutionPayloadEnvelope}; + use httpmock::{Method::POST, MockServer}; + use reqwest::Url; + + use super::*; + use crate::BeaconClientConfig; + + fn envelope() -> Arc { + Arc::new(SignedExecutionPayloadEnvelope { + message: ExecutionPayloadEnvelope::empty(), + signature: BlsSignature::empty(), + }) + } + + fn client_for(server: &MockServer) -> Arc { + let url = Url::parse(&server.url("/")).unwrap(); + Arc::new(BeaconClient::new(BeaconClientConfig { url })) + } + + #[tokio::test] + async fn publish_execution_payload_envelope_returns_ok_on_first_success() { + crate::utils::install_default_crypto_provider(); + let failing = MockServer::start(); + failing.mock(|when, then| { + when.method(POST).path("/eth/v1/beacon/execution_payload_envelopes"); + then.status(500); + }); + let succeeding = MockServer::start(); + succeeding.mock(|when, then| { + when.method(POST).path("/eth/v1/beacon/execution_payload_envelopes"); + then.status(200); + }); + + let multi = MultiBeaconClient::new(vec![client_for(&failing), client_for(&succeeding)]); + let result = multi.publish_execution_payload_envelope(envelope(), ForkName::Gloas).await; + + assert!(result.is_ok(), "expected Ok, got {result:?}"); + } + + #[tokio::test] + async fn publish_execution_payload_envelope_returns_err_when_all_clients_fail() { + crate::utils::install_default_crypto_provider(); + let a = MockServer::start(); + a.mock(|when, then| { + when.method(POST).path("/eth/v1/beacon/execution_payload_envelopes"); + then.status(500); + }); + let b = MockServer::start(); + b.mock(|when, then| { + when.method(POST).path("/eth/v1/beacon/execution_payload_envelopes"); + then.status(500); + }); + + let multi = MultiBeaconClient::new(vec![client_for(&a), client_for(&b)]); + let result = multi.publish_execution_payload_envelope(envelope(), ForkName::Gloas).await; + + assert!(result.is_err(), "expected Err, got {result:?}"); + } } diff --git a/crates/relay/src/api/proposer/error.rs b/crates/relay/src/api/proposer/error.rs index 4064c867e..e29bbb27e 100644 --- a/crates/relay/src/api/proposer/error.rs +++ b/crates/relay/src/api/proposer/error.rs @@ -1,3 +1,4 @@ +use alloy_primitives::B256; use axum::{ self, response::{IntoResponse, Response}, @@ -136,6 +137,19 @@ pub enum ProposerApiError { #[error("invalid request: Date-Milliseconds and X-Timeout-Ms headers are required")] MissingTimingHeaders, + + #[error("no held execution payload for bid block hash {0:?}")] + NoHeldPayloadForBlock(B256), + + #[error( + "held payload block hash {held:?} does not match the bid's committed block hash {bid:?}" + )] + HeldPayloadBlockHashMismatch { held: B256, bid: B256 }, + + #[error( + "bid builder_index {bid} does not match this relay's configured builder_index {configured}" + )] + BuilderIndexMismatch { bid: u64, configured: u64 }, } impl From for ProposerApiError { @@ -181,7 +195,10 @@ impl IntoResponse for ProposerApiError { ProposerApiError::GetPayloadAlreadyReceived | ProposerApiError::RequestForPastSlot { .. } | ProposerApiError::RequestAuthSlotMismatch { .. } | - ProposerApiError::MissingTimingHeaders => StatusCode::BAD_REQUEST, + ProposerApiError::MissingTimingHeaders | + ProposerApiError::NoHeldPayloadForBlock(_) | + ProposerApiError::HeldPayloadBlockHashMismatch { .. } | + ProposerApiError::BuilderIndexMismatch { .. } => StatusCode::BAD_REQUEST, // All authentication failures, kept indistinguishable by status ProposerApiError::InvalidApiKey | diff --git a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs index f6b3b79a7..80d27b282 100644 --- a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs +++ b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs @@ -1,24 +1,112 @@ use std::sync::Arc; +use alloy_primitives::B256; use axum::{Extension, http::HeaderMap}; -use helix_common::{decoder::Encoding, utils::extract_request_id}; -use helix_types::{ForkName, SignedBeaconBlock, SignedBeaconBlockGloas}; +use helix_common::{chain_info::ChainInfo, decoder::Encoding, utils::extract_request_id}; +use helix_types::{ + BlsKeypair, Domain, EthSpec, ExecutionPayloadEnvelope, ExecutionPayloadGloas, + ExecutionRequestsGloas, ForkName, MainnetEthSpec, SignedBeaconBlock, SignedBeaconBlockGloas, + SignedExecutionPayloadEnvelope, SignedRoot, +}; use hyper::StatusCode; use ssz::Decode; use tracing::info; +use tree_hash::TreeHash; use super::{ProposerApi, get_payload::fork_name_from_header}; use crate::api::{Api, proposer::error::ProposerApiError}; +/// A payload a builder has already handed helix for a proposer's committed bid. +// TODO(gloas): wire into ProposerApi's shared state and call from the handler below. +#[allow(dead_code)] +pub struct HeldGloasPayload { + pub payload: ExecutionPayloadGloas, + pub execution_requests: ExecutionRequestsGloas, +} + +/// Looks up and consumes the payload held for a bid's committed block hash. Must not return +/// the same payload twice. +// TODO(gloas): implement against the auctioneer; see gattaca-com/helix#489 step 3. +#[allow(dead_code)] +pub trait GloasPayloadStore: Send + Sync { + fn take_held_payload(&self, block_hash: B256) -> Option; +} + +/// Helix's own on-chain Gloas builder identity: `builder_index` plus signing key. +// TODO(gloas): support external builder-signed bids/envelopes; see gattaca-com/helix#489 step 5. +#[allow(dead_code)] +pub struct GloasBuilderIdentity { + pub builder_index: u64, + pub keypair: BlsKeypair, +} + +impl GloasBuilderIdentity { + /// Signs under `DOMAIN_BEACON_BUILDER`, not `ChainInfo::builder_domain`, per + /// . + #[allow(dead_code)] + pub fn sign_envelope( + &self, + message: ExecutionPayloadEnvelope, + chain_info: &ChainInfo, + ) -> SignedExecutionPayloadEnvelope { + let epoch = message.slot().epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + let domain = chain_info.spec.get_domain( + epoch, + Domain::BeaconBuilder, + &fork, + chain_info.genesis_validators_root, + ); + let signature = self.keypair.sk.sign(message.signing_root(domain)); + SignedExecutionPayloadEnvelope { message, signature } + } +} + +/// Constructs and signs the `SignedExecutionPayloadEnvelope` fulfilling `block`'s committed bid. +#[allow(dead_code)] +pub(super) fn construct_signed_envelope( + block: &SignedBeaconBlockGloas, + store: &dyn GloasPayloadStore, + identity: &GloasBuilderIdentity, + chain_info: &ChainInfo, +) -> Result { + let bid = &block.message.body.signed_execution_payload_bid.message; + let bid_block_hash: B256 = bid.block_hash.0; + + if bid.builder_index != identity.builder_index { + return Err(ProposerApiError::BuilderIndexMismatch { + bid: bid.builder_index, + configured: identity.builder_index, + }); + } + + let held = store + .take_held_payload(bid_block_hash) + .ok_or(ProposerApiError::NoHeldPayloadForBlock(bid_block_hash))?; + + let held_block_hash: B256 = held.payload.block_hash.0; + if held_block_hash != bid_block_hash { + return Err(ProposerApiError::HeldPayloadBlockHashMismatch { + held: held_block_hash, + bid: bid_block_hash, + }); + } + + let envelope = ExecutionPayloadEnvelope { + payload: held.payload, + execution_requests: held.execution_requests, + builder_index: bid.builder_index, + beacon_block_root: block.message.tree_hash_root(), + parent_beacon_block_root: block.message.parent_root, + }; + + Ok(identity.sign_envelope(envelope, chain_info)) +} + impl ProposerApi { - /// Accepts a Gloas (ePBS) `SignedBeaconBlock`, replacing `submitBlindedBlock`/`getPayload`: - /// post-Gloas there is no blinded-block variant, and the payload is no longer returned - /// synchronously -- the builder reveals it later via a `SignedExecutionPayloadEnvelope` - /// broadcast to the PTC over gossip. - /// - /// Not yet wired in: this only decodes and accepts the block per - /// . No validation against a held - /// bid, and no envelope construction/broadcast, happens yet. + /// Accepts a Gloas `SignedBeaconBlock`. Replaces `submitBlindedBlock`/`getPayload`; per + /// , + /// Gloas has no blinded-block variant. #[tracing::instrument(skip_all, err(level = tracing::Level::TRACE), fields(id =% extract_request_id(&headers)))] pub async fn submit_signed_beacon_block( Extension(_proposer_api): Extension>>, @@ -46,8 +134,148 @@ impl ProposerApi { "accepted submitSignedBeaconBlock request (not yet wired to the auctioneer)" ); - // TODO(gloas): validate against a held SignedExecutionPayloadBid, then construct and - // broadcast the SignedExecutionPayloadEnvelope to the PTC. Not wired in yet. + // TODO(gloas): call construct_signed_envelope and broadcast via MultiBeaconClient. Ok(StatusCode::ACCEPTED) } } + +#[cfg(test)] +mod construct_signed_envelope_tests { + use std::sync::Mutex; + + use helix_common::utils::install_default_crypto_provider; + use helix_types::{BeaconBlockGloas, BlsSignature, EmptyBlock, ExecutionBlockHash}; + + use super::*; + + struct StubStore(Mutex>); + + impl StubStore { + fn holding(payload: HeldGloasPayload) -> Self { + Self(Mutex::new(Some(payload))) + } + + fn empty() -> Self { + Self(Mutex::new(None)) + } + } + + impl GloasPayloadStore for StubStore { + fn take_held_payload(&self, _block_hash: B256) -> Option { + self.0.lock().unwrap().take() + } + } + + fn held_payload(block_hash: B256) -> HeldGloasPayload { + let mut payload = ExecutionPayloadGloas::default(); + payload.block_hash = ExecutionBlockHash(block_hash); + HeldGloasPayload { payload, execution_requests: ExecutionRequestsGloas::default() } + } + + fn test_block( + block_hash: B256, + builder_index: u64, + parent_root: B256, + ) -> SignedBeaconBlockGloas { + let chain_info = ChainInfo::default(); + let mut message = BeaconBlockGloas::empty(&chain_info.spec); + message.parent_root = parent_root; + message.body.signed_execution_payload_bid.message.block_hash = + ExecutionBlockHash(block_hash); + message.body.signed_execution_payload_bid.message.builder_index = builder_index; + SignedBeaconBlockGloas { message, signature: BlsSignature::empty() } + } + + fn identity(builder_index: u64) -> GloasBuilderIdentity { + install_default_crypto_provider(); + GloasBuilderIdentity { builder_index, keypair: BlsKeypair::random() } + } + + #[test] + fn constructs_and_signs_envelope_matching_the_block_and_held_payload() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let block = test_block(block_hash, 7, parent_root); + let store = StubStore::holding(held_payload(block_hash)); + let identity = identity(7); + + let signed_envelope = + construct_signed_envelope(&block, &store, &identity, &chain_info).unwrap(); + + assert_eq!(signed_envelope.message.builder_index, 7); + assert_eq!(signed_envelope.message.beacon_block_root, block.message.tree_hash_root()); + assert_eq!(signed_envelope.message.parent_beacon_block_root, parent_root); + assert_eq!(signed_envelope.message.payload.block_hash.0, block_hash); + } + + #[test] + fn signature_verifies_against_the_configured_identity() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x33); + let block = test_block(block_hash, 3, B256::ZERO); + let store = StubStore::holding(held_payload(block_hash)); + let identity = identity(3); + + let signed_envelope = + construct_signed_envelope(&block, &store, &identity, &chain_info).unwrap(); + + let epoch = signed_envelope.message.slot().epoch(MainnetEthSpec::slots_per_epoch()); + let fork = chain_info.spec.fork_at_epoch(epoch); + assert!(signed_envelope.verify_signature( + &identity.keypair.pk, + &fork, + chain_info.genesis_validators_root, + &chain_info.spec, + )); + } + + #[test] + fn no_held_payload_is_an_error_not_a_panic() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x44); + let block = test_block(block_hash, 1, B256::ZERO); + let store = StubStore::empty(); + let identity = identity(1); + + let result = construct_signed_envelope(&block, &store, &identity, &chain_info); + + assert!( + matches!(result, Err(ProposerApiError::NoHeldPayloadForBlock(hash)) if hash == block_hash) + ); + } + + #[test] + fn held_payload_block_hash_mismatch_is_rejected() { + let chain_info = ChainInfo::default(); + let bid_block_hash = B256::repeat_byte(0x55); + let wrong_held_hash = B256::repeat_byte(0x66); + let block = test_block(bid_block_hash, 1, B256::ZERO); + let store = StubStore::holding(held_payload(wrong_held_hash)); + let identity = identity(1); + + let result = construct_signed_envelope(&block, &store, &identity, &chain_info); + + assert!(matches!( + result, + Err(ProposerApiError::HeldPayloadBlockHashMismatch { held, bid }) + if held == wrong_held_hash && bid == bid_block_hash + )); + } + + #[test] + fn bid_builder_index_not_matching_configured_identity_is_rejected() { + let chain_info = ChainInfo::default(); + let block_hash = B256::repeat_byte(0x77); + let block = test_block(block_hash, 9, B256::ZERO); + let store = StubStore::holding(held_payload(block_hash)); + let identity = identity(1); + + let result = construct_signed_envelope(&block, &store, &identity, &chain_info); + + assert!(matches!( + result, + Err(ProposerApiError::BuilderIndexMismatch { bid: 9, configured: 1 }) + )); + } +} diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index caa1cf849..78840c762 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -32,8 +32,8 @@ pub use helix_tcp_types::{Compression, MergeType}; pub use hydration::*; pub use lh_kzg::{KzgCommitment, KzgProof}; pub use lh_types::{ - Config as LhConfig, EthSpec, ExecPayload, ForkName, ForkVersionDecode, MainnetEthSpec, - SignedRoot, + Config as LhConfig, EmptyBlock, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, + ForkVersionDecode, MainnetEthSpec, SignedRoot, }; pub use operator::*; pub use request_auth::*; @@ -66,6 +66,13 @@ pub type SignedBeaconBlock = lh_types::SignedBeaconBlock; pub type SignedBeaconBlockFulu = lh_types::SignedBeaconBlockFulu; pub type SignedBeaconBlockGloas = lh_types::SignedBeaconBlockGloas; +// Gloas (ePBS) builder-API additions. +pub type BeaconBlockGloas = lh_types::BeaconBlockGloas; +pub type ExecutionPayloadGloas = lh_types::ExecutionPayloadGloas; +pub type ExecutionRequestsGloas = lh_types::ExecutionRequestsGloas; +pub type ExecutionPayloadEnvelope = lh_types::ExecutionPayloadEnvelope; +pub type SignedExecutionPayloadEnvelope = lh_types::SignedExecutionPayloadEnvelope; + // Beacon block pub type BeaconBlockFulu = lh_types::BeaconBlockFulu; pub type BeaconBlockBodyFulu = lh_types::BeaconBlockBodyFulu; From 70da680185d90644662be664b48d5302d56bc8ed Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 24 Aug 2026 20:06:47 +0100 Subject: [PATCH 4/7] Wire submitSignedBeaconBlock to construct, sign, and broadcast the envelope --- crates/common/src/config.rs | 5 ++ crates/relay/src/api/proposer/mod.rs | 10 ++++ .../proposer/submit_signed_beacon_block.rs | 51 ++++++++++--------- crates/relay/src/api/service.rs | 8 ++- 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 7318db37f..d6d9c7e34 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -77,6 +77,10 @@ pub struct RelayConfig { pub enable_flux_profiler: bool, #[serde(default)] pub operator_config: Option, + /// This relay's on-chain Gloas (ePBS) builder_index. Placeholder until helix has a real + /// on-chain builder registration; signs under the relay's own key in the meantime. + #[serde(default)] + pub gloas_builder_index: u64, } #[derive(Serialize, Deserialize, Clone)] @@ -131,6 +135,7 @@ impl RelayConfig { clickhouse: None, enable_flux_profiler: false, operator_config: None, + gloas_builder_index: 0, } } } diff --git a/crates/relay/src/api/proposer/mod.rs b/crates/relay/src/api/proposer/mod.rs index 9607f4dcd..7fee5c60e 100644 --- a/crates/relay/src/api/proposer/mod.rs +++ b/crates/relay/src/api/proposer/mod.rs @@ -19,6 +19,7 @@ use helix_common::{ use helix_database::handle::DbHandle; use helix_operator::OperatorPubSub; use hyper::StatusCode; +pub use submit_signed_beacon_block::{GloasBuilderIdentity, GloasPayloadStore, NoHeldPayloads}; use crate::{ api::{Api, router::Terminating}, @@ -44,6 +45,8 @@ pub struct ProposerApi { pub auctioneer_handle: AuctioneerHandle, pub reg_handle: RegWorkerHandle, pub operator_api: Option>, + pub gloas_builder_identity: Arc, + pub gloas_payload_store: Arc, } impl ProposerApi { @@ -62,7 +65,12 @@ impl ProposerApi { reg_handle: RegWorkerHandle, alert_manager: Arc, operator_api: Option>, + gloas_payload_store: Arc, ) -> Self { + let gloas_builder_identity = Arc::new(GloasBuilderIdentity { + builder_index: relay_config.gloas_builder_index, + keypair: signing_context.keypair.clone(), + }); Self { local_cache, db, @@ -78,6 +86,8 @@ impl ProposerApi { auctioneer_handle, reg_handle, operator_api, + gloas_builder_identity, + gloas_payload_store, } } } diff --git a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs index 80d27b282..5bada9a2a 100644 --- a/crates/relay/src/api/proposer/submit_signed_beacon_block.rs +++ b/crates/relay/src/api/proposer/submit_signed_beacon_block.rs @@ -5,7 +5,7 @@ use axum::{Extension, http::HeaderMap}; use helix_common::{chain_info::ChainInfo, decoder::Encoding, utils::extract_request_id}; use helix_types::{ BlsKeypair, Domain, EthSpec, ExecutionPayloadEnvelope, ExecutionPayloadGloas, - ExecutionRequestsGloas, ForkName, MainnetEthSpec, SignedBeaconBlock, SignedBeaconBlockGloas, + ExecutionRequestsGloas, ForkName, MainnetEthSpec, SignedBeaconBlockGloas, SignedExecutionPayloadEnvelope, SignedRoot, }; use hyper::StatusCode; @@ -17,8 +17,6 @@ use super::{ProposerApi, get_payload::fork_name_from_header}; use crate::api::{Api, proposer::error::ProposerApiError}; /// A payload a builder has already handed helix for a proposer's committed bid. -// TODO(gloas): wire into ProposerApi's shared state and call from the handler below. -#[allow(dead_code)] pub struct HeldGloasPayload { pub payload: ExecutionPayloadGloas, pub execution_requests: ExecutionRequestsGloas, @@ -26,15 +24,22 @@ pub struct HeldGloasPayload { /// Looks up and consumes the payload held for a bid's committed block hash. Must not return /// the same payload twice. -// TODO(gloas): implement against the auctioneer; see gattaca-com/helix#489 step 3. -#[allow(dead_code)] pub trait GloasPayloadStore: Send + Sync { fn take_held_payload(&self, block_hash: B256) -> Option; } +/// Placeholder `GloasPayloadStore`: nothing has held a payload yet. +// TODO(gloas): implement against the auctioneer; see gattaca-com/helix#489 step 3. +pub struct NoHeldPayloads; + +impl GloasPayloadStore for NoHeldPayloads { + fn take_held_payload(&self, _block_hash: B256) -> Option { + None + } +} + /// Helix's own on-chain Gloas builder identity: `builder_index` plus signing key. // TODO(gloas): support external builder-signed bids/envelopes; see gattaca-com/helix#489 step 5. -#[allow(dead_code)] pub struct GloasBuilderIdentity { pub builder_index: u64, pub keypair: BlsKeypair, @@ -43,7 +48,6 @@ pub struct GloasBuilderIdentity { impl GloasBuilderIdentity { /// Signs under `DOMAIN_BEACON_BUILDER`, not `ChainInfo::builder_domain`, per /// . - #[allow(dead_code)] pub fn sign_envelope( &self, message: ExecutionPayloadEnvelope, @@ -63,7 +67,6 @@ impl GloasBuilderIdentity { } /// Constructs and signs the `SignedExecutionPayloadEnvelope` fulfilling `block`'s committed bid. -#[allow(dead_code)] pub(super) fn construct_signed_envelope( block: &SignedBeaconBlockGloas, store: &dyn GloasPayloadStore, @@ -109,7 +112,7 @@ impl ProposerApi { /// Gloas has no blinded-block variant. #[tracing::instrument(skip_all, err(level = tracing::Level::TRACE), fields(id =% extract_request_id(&headers)))] pub async fn submit_signed_beacon_block( - Extension(_proposer_api): Extension>>, + Extension(proposer_api): Extension>>, headers: HeaderMap, body: bytes::Bytes, ) -> Result { @@ -118,23 +121,25 @@ impl ProposerApi { return Err(ProposerApiError::InvalidFork); } - let signed_block: SignedBeaconBlock = match Encoding::from_content_type(&headers) { - Encoding::Json => { - let block: SignedBeaconBlockGloas = serde_json::from_slice(&body)?; - block.into() - } - Encoding::Ssz => { - let block = SignedBeaconBlockGloas::from_ssz_bytes(&body)?; - block.into() - } + let block: SignedBeaconBlockGloas = match Encoding::from_content_type(&headers) { + Encoding::Json => serde_json::from_slice(&body)?, + Encoding::Ssz => SignedBeaconBlockGloas::from_ssz_bytes(&body)?, }; - info!( - slot = signed_block.slot().as_u64(), - "accepted submitSignedBeaconBlock request (not yet wired to the auctioneer)" - ); + info!(slot = block.message.slot.as_u64(), "accepted submitSignedBeaconBlock request"); + + let signed_envelope = construct_signed_envelope( + &block, + proposer_api.gloas_payload_store.as_ref(), + &proposer_api.gloas_builder_identity, + &proposer_api.chain_info, + )?; + + proposer_api + .multi_beacon_client + .publish_execution_payload_envelope(Arc::new(signed_envelope), ForkName::Gloas) + .await?; - // TODO(gloas): call construct_signed_envelope and broadcast via MultiBeaconClient. Ok(StatusCode::ACCEPTED) } } diff --git a/crates/relay/src/api/service.rs b/crates/relay/src/api/service.rs index 752fea64a..f5c333a40 100644 --- a/crates/relay/src/api/service.rs +++ b/crates/relay/src/api/service.rs @@ -26,8 +26,11 @@ use tracing::{error, info}; use crate::{ AuctioneerHandle, DbHandle, PostgresDatabaseService, RegWorkerHandle, api::{ - Api, FutureBidSubmissionResult, builder::api::BuilderApi, - extract::raw_web_socket::RawWebSocket, proposer::ProposerApi, router::build_router, + Api, FutureBidSubmissionResult, + builder::api::BuilderApi, + extract::raw_web_socket::RawWebSocket, + proposer::{NoHeldPayloads, ProposerApi}, + router::build_router, }, gossip::{GossipedMessage, GrpcGossiperClientManager, process_gossip_messages}, network::api::RelayNetworkApi, @@ -151,6 +154,7 @@ pub async fn run_api_service( registrations_handle, alert_manager, operator_api, + Arc::new(NoHeldPayloads), )); tokio::spawn(process_gossip_messages( From 5e7384a6195274b0bae5be97437c92c560925bb5 Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 25 Aug 2026 11:11:55 +0100 Subject: [PATCH 5/7] Wire getExecutionPayloadBid to the auctioneer, checking parent_hash/parent_root --- .../api/proposer/get_execution_payload_bid.rs | 48 ++++-- .../auctioneer/get_execution_payload_bid.rs | 145 ++++++++++++++++++ crates/relay/src/auctioneer/handle.rs | 24 ++- crates/relay/src/auctioneer/mod.rs | 40 +++++ crates/relay/src/auctioneer/types.rs | 14 +- crates/types/src/lib.rs | 2 + 6 files changed, 256 insertions(+), 17 deletions(-) create mode 100644 crates/relay/src/auctioneer/get_execution_payload_bid.rs diff --git a/crates/relay/src/api/proposer/get_execution_payload_bid.rs b/crates/relay/src/api/proposer/get_execution_payload_bid.rs index d10bc4548..3f6eac5dd 100644 --- a/crates/relay/src/api/proposer/get_execution_payload_bid.rs +++ b/crates/relay/src/api/proposer/get_execution_payload_bid.rs @@ -1,21 +1,24 @@ use std::sync::Arc; -use axum::{Extension, extract::Path, http::HeaderMap}; +use axum::{Extension, extract::Path, http::HeaderMap, response::IntoResponse}; use helix_common::{ api::{ HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, proposer_api::GetExecutionPayloadBidParams, }, api_provider::header_u64, - decoder::Encoding, + decoder::{Encoding, HEADER_SSZ}, utils::extract_request_id, }; use helix_types::{ForkName, SignedBuilderRequestAuth}; -use hyper::StatusCode; -use ssz::Decode; -use tracing::info; +use http::{HeaderValue, header::CONTENT_TYPE}; +use ssz::{Decode, Encode}; +use tracing::{info, warn}; use super::{ProposerApi, get_payload::fork_name_from_header}; -use crate::api::{Api, proposer::error::ProposerApiError}; +use crate::api::{ + Api, + proposer::{CONSENSUS_VERSION_HEADER, error::ProposerApiError}, +}; impl ProposerApi { /// Serves a `SignedExecutionPayloadBid` for the given slot/parent_hash/parent_root to a @@ -27,7 +30,7 @@ impl ProposerApi { headers: HeaderMap, Path(params): Path, body: bytes::Bytes, - ) -> Result { + ) -> Result { let fork = fork_name_from_header(&headers).ok().flatten(); if fork != Some(ForkName::Gloas) { return Err(ProposerApiError::InvalidFork); @@ -61,12 +64,33 @@ impl ProposerApi { parent_hash = ?params.parent_hash, parent_root = ?params.parent_root, proposer_pubkey = ?params.proposer_pubkey, - "validated getExecutionPayloadBid request (not yet wired to the auctioneer)" + "validated getExecutionPayloadBid request" ); - // TODO(gloas): fetch/build the SignedExecutionPayloadBid from the auctioneer, honoring - // any stored max_execution_payment preference. Until then, "no bid available" is a - // valid response per spec. - Ok(StatusCode::NO_CONTENT) + let Ok(rx) = proposer_api.auctioneer_handle.get_execution_payload_bid(params) else { + return Err(ProposerApiError::InternalServerError); + }; + + let signed_bid = match rx.await { + Ok(res) => res?, + Err(err) => { + warn!(%err, "failed to get execution payload bid from auctioneer"); + return Err(ProposerApiError::InternalServerError); + } + }; + + match Encoding::from_accept(&headers) { + Encoding::Json => Ok(axum::Json(serde_json::to_value(&signed_bid)?).into_response()), + Encoding::Ssz => { + let mut response = signed_bid.as_ssz_bytes().into_response(); + let headers = response.headers_mut(); + headers.insert(CONTENT_TYPE, HeaderValue::from_str(HEADER_SSZ).unwrap()); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&ForkName::Gloas.to_string()).unwrap(), + ); + Ok(response) + } + } } } diff --git a/crates/relay/src/auctioneer/get_execution_payload_bid.rs b/crates/relay/src/auctioneer/get_execution_payload_bid.rs new file mode 100644 index 000000000..883db63aa --- /dev/null +++ b/crates/relay/src/auctioneer/get_execution_payload_bid.rs @@ -0,0 +1,145 @@ +use helix_common::api::proposer_api::GetExecutionPayloadBidParams; +use tokio::sync::oneshot; +use tracing::warn; + +use crate::{ + api::proposer::ProposerApiError, + auctioneer::{ + bid_adjustor::BidAdjustor, + context::Context, + types::{GetExecutionPayloadBidResult, SlotData}, + }, +}; + +impl Context { + pub(super) fn handle_get_execution_payload_bid( + &self, + params: GetExecutionPayloadBidParams, + slot_data: &SlotData, + res_tx: oneshot::Sender, + ) { + let _ = res_tx.send(get_execution_payload_bid(¶ms, slot_data)); + } +} + +/// Checks `params.parent_hash`/`params.parent_root` against currently-live payload attributes, +/// then reports "no bid available" -- serving a real Gloas bid needs step 5's builder->relay +/// submission wire format, not landed yet. +pub(super) fn get_execution_payload_bid( + params: &GetExecutionPayloadBidParams, + slot_data: &SlotData, +) -> GetExecutionPayloadBidResult { + let Some(attrs) = slot_data.payload_attributes_map.get(¶ms.parent_hash) else { + warn!( + req =% params.parent_hash, + have =? slot_data.payload_attributes_map.keys(), + "get execution payload bid for unknown parent hash" + ); + return Err(ProposerApiError::NoBidPrepared); + }; + + if attrs.parent_beacon_block_root != Some(params.parent_root) { + warn!( + req =% params.parent_root, + have =? attrs.parent_beacon_block_root, + "get execution payload bid for mismatched parent root" + ); + return Err(ProposerApiError::NoBidPrepared); + } + + Err(ProposerApiError::NoBidPrepared) +} + +#[cfg(test)] +mod tests { + use alloy_primitives::B256; + use helix_common::PayloadAttributesUpdate; + use helix_types::ForkName; + use rustc_hash::FxHashMap; + + use super::*; + + fn slot_data(payload_attributes_map: FxHashMap) -> SlotData { + SlotData { + bid_slot: Default::default(), + registration_data: Default::default(), + current_fork: ForkName::Gloas, + payload_attributes_map, + il: Default::default(), + } + } + + fn attrs_update( + parent_hash: B256, + parent_beacon_block_root: Option, + ) -> PayloadAttributesUpdate { + let mut update = PayloadAttributesUpdate { + slot: Default::default(), + parent_hash, + withdrawals_root: Default::default(), + payload_attributes: Default::default(), + }; + update.payload_attributes.parent_beacon_block_root = parent_beacon_block_root; + update + } + + fn params(parent_hash: B256, parent_root: B256) -> GetExecutionPayloadBidParams { + GetExecutionPayloadBidParams { + slot: 1, + parent_hash, + parent_root, + proposer_pubkey: Default::default(), + } + } + + #[test] + fn unknown_parent_hash_is_no_bid() { + let parent_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let data = slot_data(FxHashMap::default()); + + let result = get_execution_payload_bid(¶ms(parent_hash, parent_root), &data); + + assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); + } + + #[test] + fn parent_root_mismatch_is_no_bid() { + let parent_hash = B256::repeat_byte(0x11); + let live_root = B256::repeat_byte(0x22); + let requested_root = B256::repeat_byte(0x33); + let mut map = FxHashMap::default(); + map.insert(parent_hash, attrs_update(parent_hash, Some(live_root))); + let data = slot_data(map); + + let result = get_execution_payload_bid(¶ms(parent_hash, requested_root), &data); + + assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); + } + + #[test] + fn missing_parent_beacon_block_root_is_no_bid() { + let parent_hash = B256::repeat_byte(0x11); + let requested_root = B256::repeat_byte(0x33); + let mut map = FxHashMap::default(); + map.insert(parent_hash, attrs_update(parent_hash, None)); + let data = slot_data(map); + + let result = get_execution_payload_bid(¶ms(parent_hash, requested_root), &data); + + assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); + } + + #[test] + fn matching_parent_still_reports_no_bid_until_step_5() { + let parent_hash = B256::repeat_byte(0x11); + let parent_root = B256::repeat_byte(0x22); + let mut map = FxHashMap::default(); + map.insert(parent_hash, attrs_update(parent_hash, Some(parent_root))); + let data = slot_data(map); + + let result = get_execution_payload_bid(¶ms(parent_hash, parent_root), &data); + + assert!(matches!(result, Err(ProposerApiError::NoBidPrepared))); + } +} diff --git a/crates/relay/src/auctioneer/handle.rs b/crates/relay/src/auctioneer/handle.rs index be2135c68..048840e12 100644 --- a/crates/relay/src/auctioneer/handle.rs +++ b/crates/relay/src/auctioneer/handle.rs @@ -2,7 +2,11 @@ use std::sync::Arc; use dashmap::DashMap; use futures::{FutureExt, future::Shared}; -use helix_common::{GetPayloadTrace, api::proposer_api::GetHeaderParams, chain_info::ChainInfo}; +use helix_common::{ + GetPayloadTrace, + api::proposer_api::{GetExecutionPayloadBidParams, GetHeaderParams}, + chain_info::ChainInfo, +}; use helix_types::{ BlsPublicKey, BlsPublicKeyBytes, ExecPayload, GetPayloadResponse, SigError, SignedBlindedBeaconBlock, @@ -12,7 +16,7 @@ use tracing::trace; use crate::{ api::proposer::{ProposerApiError, get_payload::ProposerApiVersion}, - auctioneer::types::{Event, GetHeaderResult, GetPayloadResult}, + auctioneer::types::{Event, GetExecutionPayloadBidResult, GetHeaderResult, GetPayloadResult}, gossip::BroadcastPayloadParams, }; @@ -65,6 +69,22 @@ impl AuctioneerHandle { Ok(rx) } + pub fn get_execution_payload_bid( + &self, + params: GetExecutionPayloadBidParams, + ) -> Result, ChannelFull> { + let (tx, rx) = oneshot::channel(); + trace!("sending to auctioneer"); + self.auctioneer + .try_send(Event::GetExecutionPayloadBid { + params, + res_tx: tx, + span: tracing::Span::current(), + }) + .map_err(|_| ChannelFull)?; + Ok(rx) + } + pub fn get_payload( &self, chain_info: &ChainInfo, diff --git a/crates/relay/src/auctioneer/mod.rs b/crates/relay/src/auctioneer/mod.rs index 921aa26f9..8dcb4d082 100644 --- a/crates/relay/src/auctioneer/mod.rs +++ b/crates/relay/src/auctioneer/mod.rs @@ -2,6 +2,7 @@ mod bid_adjustor; mod bid_sorter; mod block_merger; mod context; +mod get_execution_payload_bid; mod get_header; mod get_payload; mod handle; @@ -441,6 +442,24 @@ impl State { drop(_guard); } + // get_execution_payload_bid (Gloas) + (State::Sorting(slot_data), Event::GetExecutionPayloadBid { params, res_tx, span }) => { + let _guard = span.enter(); + trace!("received in auctioneer"); + + if slot_data.bid_slot != params.slot { + let _ = res_tx.send(Err(ProposerApiError::RequestWrongSlot { + request_slot: params.slot, + bid_slot: slot_data.bid_slot.into(), + })); + } else { + ctx.handle_get_execution_payload_bid(params, slot_data, res_tx) + } + + trace!("finished processing"); + drop(_guard); + } + // get_payload ( State::Sorting(slot_data), @@ -525,6 +544,11 @@ impl State { let _ = res_tx.send(Err(ProposerApiError::DeliveringPayload)); } + // late get_execution_payload_bid + (State::Broadcasting { .. }, Event::GetExecutionPayloadBid { res_tx, .. }) => { + let _ = res_tx.send(Err(ProposerApiError::DeliveringPayload)); + } + // duplicate get_payload, proposer equivocating? ( State::Broadcasting { slot_data: slot_ctx, block_hash }, @@ -597,6 +621,22 @@ impl State { } } + // get_execution_payload_bid not sorting + ( + State::Slot { bid_slot, .. }, + Event::GetExecutionPayloadBid { res_tx, params, .. }, + ) => { + if params.slot == bid_slot.as_u64() { + // either not registered or waiting for full data from housekepper + let _ = res_tx.send(Err(ProposerApiError::NoBidPrepared)); + } else { + let _ = res_tx.send(Err(ProposerApiError::RequestWrongSlot { + request_slot: params.slot, + bid_slot: bid_slot.as_u64(), + })); + } + } + // get_payload unregistered (State::Slot { bid_slot, .. }, Event::GetPayload { res_tx, blinded, .. }) => { if bid_slot.saturating_sub(Slot::from(1u64)) == blinded.slot() { diff --git a/crates/relay/src/auctioneer/types.rs b/crates/relay/src/auctioneer/types.rs index 28e02e629..3f43a41e0 100644 --- a/crates/relay/src/auctioneer/types.rs +++ b/crates/relay/src/auctioneer/types.rs @@ -6,7 +6,7 @@ use helix_common::{ GetPayloadTrace, PayloadAttributesUpdate, SubmissionTrace, api::{ builder_api::{BuilderGetValidatorsResponseEntry, InclusionListWithMetadata}, - proposer_api::GetHeaderParams, + proposer_api::{GetExecutionPayloadBidParams, GetHeaderParams}, }, decoder::{Encoding, SubmissionDecoderParams, SubmissionType}, metrics::BID_CREATION_LATENCY, @@ -15,8 +15,8 @@ use helix_tcp_types::{BidSubmissionFlags, BidSubmissionHeader}; use helix_types::{ BidAdjustmentData, BlockMergingData, BlsPublicKeyBytes, BuilderBid, Compression, ExecutionPayload, ForkName, GetPayloadResponse, MergeType, PayloadAndBlobs, PayloadBidData, - PayloadBidDataRef, SignedBidSubmission, SignedBlindedBeaconBlock, Slot, Submission, - SubmissionVersion, VersionedSignedProposal, mock_public_key_bytes, + PayloadBidDataRef, SignedBidSubmission, SignedBlindedBeaconBlock, SignedExecutionPayloadBid, + Slot, Submission, SubmissionVersion, VersionedSignedProposal, mock_public_key_bytes, }; use http::{ HeaderMap, HeaderValue, @@ -51,6 +51,7 @@ pub enum SubmissionRef { pub type GetHeaderResult = Result; pub type GetPayloadResult = Result; +pub type GetExecutionPayloadBidResult = Result; #[derive(Debug, Clone, Copy)] #[repr(C)] @@ -434,6 +435,12 @@ pub enum Event { span: tracing::Span, is_mev_boost: bool, }, + /// Gloas (ePBS) analogue of `GetHeader`. + GetExecutionPayloadBid { + params: GetExecutionPayloadBidParams, + res_tx: oneshot::Sender, + span: tracing::Span, + }, // Receive multiple of these potentially, assume some light validation GetPayload { block_hash: B256, @@ -459,6 +466,7 @@ impl Event { Event::SlotData { .. } => "SlotData", Event::Submission { .. } => "Submission", Event::GetHeader { .. } => "GetHeader", + Event::GetExecutionPayloadBid { .. } => "GetExecutionPayloadBid", Event::GetPayload { .. } => "GetPayload", Event::GossipPayload(_) => "GossipPayload", Event::SimResult(_) => "SimResult", diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 78840c762..78a37a036 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -72,6 +72,8 @@ pub type ExecutionPayloadGloas = lh_types::ExecutionPayloadGloas pub type ExecutionRequestsGloas = lh_types::ExecutionRequestsGloas; pub type ExecutionPayloadEnvelope = lh_types::ExecutionPayloadEnvelope; pub type SignedExecutionPayloadEnvelope = lh_types::SignedExecutionPayloadEnvelope; +pub type ExecutionPayloadBid = lh_types::ExecutionPayloadBid; +pub type SignedExecutionPayloadBid = lh_types::SignedExecutionPayloadBid; // Beacon block pub type BeaconBlockFulu = lh_types::BeaconBlockFulu; From bcad6349c84f98d57a58242e262afddaedece09a Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 25 Aug 2026 11:33:02 +0100 Subject: [PATCH 6/7] Wire submitBuilderPreferences to per-proposer per-slot storage on the auctioneer --- .../proposer/submit_builder_preferences.rs | 24 ++++- .../src/auctioneer/builder_preferences.rs | 94 +++++++++++++++++++ crates/relay/src/auctioneer/context.rs | 4 + crates/relay/src/auctioneer/handle.rs | 24 ++++- crates/relay/src/auctioneer/mod.rs | 23 +++++ crates/relay/src/auctioneer/types.rs | 10 ++ 6 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 crates/relay/src/auctioneer/builder_preferences.rs diff --git a/crates/relay/src/api/proposer/submit_builder_preferences.rs b/crates/relay/src/api/proposer/submit_builder_preferences.rs index f164932d9..6ae951fd6 100644 --- a/crates/relay/src/api/proposer/submit_builder_preferences.rs +++ b/crates/relay/src/api/proposer/submit_builder_preferences.rs @@ -7,7 +7,7 @@ use helix_common::{ use helix_types::{BuilderPreferencesRequest, ForkName}; use hyper::StatusCode; use ssz::Decode; -use tracing::info; +use tracing::{info, warn}; use super::{ProposerApi, get_payload::fork_name_from_header}; use crate::api::{Api, proposer::error::ProposerApiError}; @@ -41,11 +41,25 @@ impl ProposerApi { proposer_pubkey = ?params.proposer_pubkey, slot = request.auth.message.slot, max_execution_payment = request.preferences.max_execution_payment, - "validated submitBuilderPreferences request (not yet persisted)" + "validated submitBuilderPreferences request" ); - // TODO(gloas): reject stale slots, store preferences per proposer per slot, and honor - // max_execution_payment when serving bids. - Ok(StatusCode::ACCEPTED) + let Ok(rx) = proposer_api.auctioneer_handle.submit_builder_preferences( + params.proposer_pubkey, + request.auth.message.slot, + request.preferences.max_execution_payment, + ) else { + return Err(ProposerApiError::InternalServerError); + }; + + // TODO(gloas): honor max_execution_payment when getExecutionPayloadBid serves a real + // bid; not consumed anywhere yet. + match rx.await { + Ok(res) => res.map(|()| StatusCode::ACCEPTED), + Err(err) => { + warn!(%err, "failed to store builder preferences"); + Err(ProposerApiError::InternalServerError) + } + } } } diff --git a/crates/relay/src/auctioneer/builder_preferences.rs b/crates/relay/src/auctioneer/builder_preferences.rs new file mode 100644 index 000000000..0926fc651 --- /dev/null +++ b/crates/relay/src/auctioneer/builder_preferences.rs @@ -0,0 +1,94 @@ +use helix_types::BlsPublicKeyBytes; +use rustc_hash::FxHashMap; + +/// Per-proposer-per-slot `max_execution_payment` preferences, submitted via +/// `submitBuilderPreferences` up to an epoch ahead of the slot they apply to. Lives on `Context` +/// (not `SlotContext`), since entries must survive across slot transitions until their own slot +/// arrives or passes. +#[derive(Default)] +pub struct BuilderPreferencesStore { + by_proposer_slot: FxHashMap<(BlsPublicKeyBytes, u64), u64>, +} + +impl BuilderPreferencesStore { + pub fn store( + &mut self, + proposer_pubkey: BlsPublicKeyBytes, + slot: u64, + max_execution_payment: u64, + ) { + self.by_proposer_slot.insert((proposer_pubkey, slot), max_execution_payment); + } + + pub fn max_execution_payment( + &self, + proposer_pubkey: &BlsPublicKeyBytes, + slot: u64, + ) -> Option { + self.by_proposer_slot.get(&(*proposer_pubkey, slot)).copied() + } + + /// Drops entries for slots that have already passed, so a proposer's own resubmissions + /// (or one that never proposes) don't grow this unboundedly. + pub fn on_new_slot(&mut self, bid_slot: u64) { + self.by_proposer_slot.retain(|(_, slot), _| *slot >= bid_slot); + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self.by_proposer_slot.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pubkey(byte: u8) -> BlsPublicKeyBytes { + BlsPublicKeyBytes::repeat_byte(byte) + } + + #[test] + fn stores_and_looks_up_per_proposer_per_slot() { + let mut store = BuilderPreferencesStore::default(); + let alice = pubkey(1); + let bob = pubkey(2); + + store.store(alice, 100, 500); + store.store(bob, 100, 900); + store.store(alice, 101, 700); + + assert_eq!(store.max_execution_payment(&alice, 100), Some(500)); + assert_eq!(store.max_execution_payment(&bob, 100), Some(900)); + assert_eq!(store.max_execution_payment(&alice, 101), Some(700)); + assert_eq!(store.max_execution_payment(&alice, 102), None); + } + + #[test] + fn resubmission_overwrites_the_previous_value() { + let mut store = BuilderPreferencesStore::default(); + let alice = pubkey(1); + + store.store(alice, 100, 500); + store.store(alice, 100, 600); + + assert_eq!(store.max_execution_payment(&alice, 100), Some(600)); + } + + #[test] + fn on_new_slot_prunes_entries_for_slots_already_passed() { + let mut store = BuilderPreferencesStore::default(); + let alice = pubkey(1); + + store.store(alice, 100, 500); + store.store(alice, 101, 600); + store.store(alice, 102, 700); + + store.on_new_slot(101); + + assert_eq!(store.max_execution_payment(&alice, 100), None); + assert_eq!(store.max_execution_payment(&alice, 101), Some(600)); + assert_eq!(store.max_execution_payment(&alice, 102), Some(700)); + assert_eq!(store.len(), 2); + } +} diff --git a/crates/relay/src/auctioneer/context.rs b/crates/relay/src/auctioneer/context.rs index 361b83649..07386d0db 100644 --- a/crates/relay/src/auctioneer/context.rs +++ b/crates/relay/src/auctioneer/context.rs @@ -36,6 +36,7 @@ use crate::{ bid_adjustor::BidAdjustor, bid_sorter::BidSorter, block_merger::BlockMerger, + builder_preferences::BuilderPreferencesStore, types::{PayloadEntry, PendingPayload, SubmissionRef}, }, simulator::{SimRequest, tile::ValidationResult}, @@ -75,6 +76,7 @@ pub struct Context { pub failsafe_triggered: Arc, pub alert_manager: Arc, pub operator_api: Option>, + pub builder_preferences: BuilderPreferencesStore, } const EXPECTED_PAYLOADS_PER_SLOT: usize = 5000; @@ -143,6 +145,7 @@ impl Context { failsafe_triggered, alert_manager, operator_api, + builder_preferences: BuilderPreferencesStore::default(), } } @@ -254,6 +257,7 @@ impl Context { self.block_merger.on_new_slot(bid_slot.as_u64()); self.bid_adjustor.on_new_slot(bid_slot.as_u64()); + self.builder_preferences.on_new_slot(bid_slot.as_u64()); self.auctioneer_handle.clear_inflight_payloads(); self.decoded.clear(); diff --git a/crates/relay/src/auctioneer/handle.rs b/crates/relay/src/auctioneer/handle.rs index 048840e12..8db373629 100644 --- a/crates/relay/src/auctioneer/handle.rs +++ b/crates/relay/src/auctioneer/handle.rs @@ -16,7 +16,10 @@ use tracing::trace; use crate::{ api::proposer::{ProposerApiError, get_payload::ProposerApiVersion}, - auctioneer::types::{Event, GetExecutionPayloadBidResult, GetHeaderResult, GetPayloadResult}, + auctioneer::types::{ + Event, GetExecutionPayloadBidResult, GetHeaderResult, GetPayloadResult, + SubmitBuilderPreferencesResult, + }, gossip::BroadcastPayloadParams, }; @@ -85,6 +88,25 @@ impl AuctioneerHandle { Ok(rx) } + pub fn submit_builder_preferences( + &self, + proposer_pubkey: BlsPublicKeyBytes, + slot: u64, + max_execution_payment: u64, + ) -> Result, ChannelFull> { + let (tx, rx) = oneshot::channel(); + trace!("sending to auctioneer"); + self.auctioneer + .try_send(Event::SubmitBuilderPreferences { + proposer_pubkey, + slot, + max_execution_payment, + res_tx: tx, + }) + .map_err(|_| ChannelFull)?; + Ok(rx) + } + pub fn get_payload( &self, chain_info: &ChainInfo, diff --git a/crates/relay/src/auctioneer/mod.rs b/crates/relay/src/auctioneer/mod.rs index 8dcb4d082..b9ed9aeb1 100644 --- a/crates/relay/src/auctioneer/mod.rs +++ b/crates/relay/src/auctioneer/mod.rs @@ -1,6 +1,7 @@ mod bid_adjustor; mod bid_sorter; mod block_merger; +mod builder_preferences; mod context; mod get_execution_payload_bid; mod get_header; @@ -664,6 +665,28 @@ impl State { ) => { ctx.handle_builder_demotion(slot, builder_pubkey, block_hash, reason, false); } + + // submit_builder_preferences (Gloas), valid regardless of state + ( + State::Slot { .. } | State::Sorting(_) | State::Broadcasting { .. }, + Event::SubmitBuilderPreferences { + proposer_pubkey, + slot, + max_execution_payment, + res_tx, + }, + ) => { + let bid_slot = self.bid_slot(); + if slot < bid_slot { + let _ = res_tx.send(Err(ProposerApiError::RequestForPastSlot { + request_slot: slot.into(), + head_slot: bid_slot.into(), + })); + } else { + ctx.builder_preferences.store(proposer_pubkey, slot, max_execution_payment); + let _ = res_tx.send(Ok(())); + } + } } } diff --git a/crates/relay/src/auctioneer/types.rs b/crates/relay/src/auctioneer/types.rs index 3f43a41e0..9b9069053 100644 --- a/crates/relay/src/auctioneer/types.rs +++ b/crates/relay/src/auctioneer/types.rs @@ -52,6 +52,7 @@ pub enum SubmissionRef { pub type GetHeaderResult = Result; pub type GetPayloadResult = Result; pub type GetExecutionPayloadBidResult = Result; +pub type SubmitBuilderPreferencesResult = Result<(), ProposerApiError>; #[derive(Debug, Clone, Copy)] #[repr(C)] @@ -441,6 +442,14 @@ pub enum Event { res_tx: oneshot::Sender, span: tracing::Span, }, + /// Gloas (ePBS) `submitBuilderPreferences`: a proposer's per-builder `max_execution_payment` + /// for a future slot, valid regardless of the current auctioneer state. + SubmitBuilderPreferences { + proposer_pubkey: BlsPublicKeyBytes, + slot: u64, + max_execution_payment: u64, + res_tx: oneshot::Sender, + }, // Receive multiple of these potentially, assume some light validation GetPayload { block_hash: B256, @@ -467,6 +476,7 @@ impl Event { Event::Submission { .. } => "Submission", Event::GetHeader { .. } => "GetHeader", Event::GetExecutionPayloadBid { .. } => "GetExecutionPayloadBid", + Event::SubmitBuilderPreferences { .. } => "SubmitBuilderPreferences", Event::GetPayload { .. } => "GetPayload", Event::GossipPayload(_) => "GossipPayload", Event::SimResult(_) => "SimResult", From 494798cc9bb58437cd3d07063fc5980b97ed36e9 Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 25 Aug 2026 14:43:40 +0100 Subject: [PATCH 7/7] Unblock Gloas submissions through the existing bid-submission wire shape helix's ExecutionPayload/SignedBidSubmission is its own bounded-list builder<->relay wire shape, shared unchanged across Bellatrix-Fulu; it is not a mirror of the real per-fork consensus SSZ types. Route the Gloas fork through the same decode path Fulu already uses instead of erroring, and add conversion functions producing the real, progressive-list Gloas consensus types (ExecutionPayloadGloas, ExecutionRequestsGloas) for use at the outbound bid/envelope boundary. block_access_list, builder_deposits, and builder_exits are left empty (TODO(gloas): EIP-7928/EIP-8282, no producer path yet). --- crates/types/src/execution_payload.rs | 88 +++++++++++++++++++++++++-- crates/types/src/fields.rs | 54 +++++++++++++++- crates/types/src/hydration.rs | 47 ++++++++++++-- 3 files changed, 177 insertions(+), 12 deletions(-) diff --git a/crates/types/src/execution_payload.rs b/crates/types/src/execution_payload.rs index eff1535e6..fe2968593 100644 --- a/crates/types/src/execution_payload.rs +++ b/crates/types/src/execution_payload.rs @@ -8,7 +8,7 @@ use tree_hash_derive::TreeHash; use crate::{ BlockValidationError, SszError, TestRandom, convert_bloom_to_lighthouse, - convert_transactions_to_lighthouse, + convert_transactions_to_lighthouse, convert_transactions_to_progressive, fields::{Bloom, ExtraData, Transactions, Withdrawals}, }; @@ -155,10 +155,42 @@ impl ExecutionPayload { excess_blob_gas: self.excess_blob_gas, }) } + + /// Converts to the real, progressive-list Gloas execution payload shape used on-chain. + /// `block_access_list` is left empty -- TODO(gloas): populate once EIP-7928 block-access-list + /// tracking exists. + pub fn to_lighthouse_gloas_payload( + &self, + slot: lh_types::Slot, + ) -> Result, SszError> { + Ok(lh_types::ExecutionPayloadGloas { + parent_hash: self.parent_hash.into(), + fee_recipient: self.fee_recipient, + state_root: self.state_root, + receipts_root: self.receipts_root, + logs_bloom: convert_bloom_to_lighthouse(&self.logs_bloom), + prev_randao: self.prev_randao, + block_number: self.block_number, + gas_limit: self.gas_limit, + gas_used: self.gas_used, + timestamp: self.timestamp, + extra_data: self.extra_data.to_ssz_type()?, + base_fee_per_gas: self.base_fee_per_gas, + block_hash: self.block_hash.into(), + transactions: convert_transactions_to_progressive(&self.transactions), + withdrawals: self.withdrawals.iter().cloned().collect(), + blob_gas_used: self.blob_gas_used, + excess_blob_gas: self.excess_blob_gas, + block_access_list: Default::default(), + slot_number: slot, + }) + } } impl ForkVersionDecode for ExecutionPayload { - /// SSZ decode with explicit fork variant. + /// SSZ decode with explicit fork variant. Gloas uses the same bounded-list wire shape as + /// Fulu here -- this is helix's own builder<->relay representation, not the real, + /// progressive-list consensus `ExecutionPayloadGloas`; see `to_lighthouse_gloas_payload`. fn from_ssz_bytes_by_fork(bytes: &[u8], fork_name: ForkName) -> Result { let builder_bid = match fork_name { ForkName::Altair | @@ -167,13 +199,12 @@ impl ForkVersionDecode for ExecutionPayload { ForkName::Capella | ForkName::Deneb | ForkName::Electra | - ForkName::Gloas | ForkName::Heze => { return Err(ssz::DecodeError::BytesInvalid(format!( "unsupported fork for ExecutionPayloadHeader: {fork_name}", ))); } - ForkName::Fulu => ExecutionPayload::from_ssz_bytes(bytes)?, + ForkName::Fulu | ForkName::Gloas => ExecutionPayload::from_ssz_bytes(bytes)?, }; Ok(builder_bid) } @@ -328,6 +359,55 @@ mod tests { assert_eq!(our_payload.tree_hash_root(), lh_json_str.tree_hash_root()); } + #[test] + fn test_execution_payload_decodes_under_gloas_fork() { + let our_payload = ExecutionPayload::test_random(); + let ssz_bytes = our_payload.as_ssz_bytes(); + + let decoded = ExecutionPayload::from_ssz_bytes_by_fork(&ssz_bytes, ForkName::Gloas) + .expect("Gloas should decode via the same shape as Fulu"); + + assert_eq!(our_payload.tree_hash_root(), decoded.tree_hash_root()); + } + + #[test] + fn to_lighthouse_gloas_payload_preserves_fields() { + let our_payload = ExecutionPayload::test_random(); + let slot = lh_types::Slot::new(42); + + let gloas = our_payload.to_lighthouse_gloas_payload(slot).unwrap(); + + assert_eq!(gloas.parent_hash.0, our_payload.parent_hash); + assert_eq!(gloas.block_hash.0, our_payload.block_hash); + assert_eq!(gloas.fee_recipient, our_payload.fee_recipient); + assert_eq!(gloas.state_root, our_payload.state_root); + assert_eq!(gloas.receipts_root, our_payload.receipts_root); + assert_eq!(gloas.prev_randao, our_payload.prev_randao); + assert_eq!(gloas.block_number, our_payload.block_number); + assert_eq!(gloas.gas_limit, our_payload.gas_limit); + assert_eq!(gloas.gas_used, our_payload.gas_used); + assert_eq!(gloas.timestamp, our_payload.timestamp); + assert_eq!(gloas.base_fee_per_gas, our_payload.base_fee_per_gas); + assert_eq!(gloas.blob_gas_used, our_payload.blob_gas_used); + assert_eq!(gloas.excess_blob_gas, our_payload.excess_blob_gas); + assert_eq!(gloas.slot_number, slot); + assert!(gloas.block_access_list.is_empty()); + + assert_eq!(gloas.transactions.len(), our_payload.transactions.len()); + for (converted, original) in + gloas.transactions.as_slice().iter().zip(our_payload.transactions.iter()) + { + assert_eq!(converted.as_slice(), original.as_ref()); + } + + assert_eq!(gloas.withdrawals.len(), our_payload.withdrawals.len()); + for (converted, original) in + gloas.withdrawals.as_slice().iter().zip(our_payload.withdrawals.iter()) + { + assert_eq!(converted, original); + } + } + #[test] fn test_execution_payload_header() { test_execution_payload_header_variant(ForkName::Fulu); diff --git a/crates/types/src/fields.rs b/crates/types/src/fields.rs index 2c7fa64bc..7c1486568 100644 --- a/crates/types/src/fields.rs +++ b/crates/types/src/fields.rs @@ -1,9 +1,11 @@ +use std::marker::PhantomData; + use alloy_primitives::FixedBytes; use lh_types::{EthSpec, MainnetEthSpec}; use rand::Rng; -use ssz_types::{FixedVector, VariableList}; +use ssz_types::{FixedVector, ProgressiveVariableList, VariableList}; -use crate::{SszError, TestRandom, ssz_bytes_wrapper}; +use crate::{ExecutionRequestsGloas, SszError, TestRandom, ssz_bytes_wrapper}; pub type Withdrawal = lh_types::Withdrawal; pub type Withdrawals = lh_types::Withdrawals; @@ -32,6 +34,29 @@ pub fn convert_transactions_to_lighthouse( VariableList::new(new) } +/// Real, progressive-list Gloas transactions shape, per EIP-7688. +pub fn convert_transactions_to_progressive( + txs: &Transactions, +) -> lh_types::ProgressiveTransactions { + ProgressiveVariableList::new( + txs.iter().map(|tx| ProgressiveVariableList::new(tx.as_ref().to_vec())).collect(), + ) +} + +/// Converts helix's Electra-shaped builder-submission execution requests into the real, +/// progressive-list Gloas shape. `builder_deposits`/`builder_exits` are left empty -- +/// TODO(gloas): populate once EIP-8282 builder deposit/exit submission exists. +pub fn execution_requests_to_gloas(requests: &ExecutionRequests) -> ExecutionRequestsGloas { + ExecutionRequestsGloas { + deposits: requests.deposits.iter().cloned().collect(), + withdrawals: requests.withdrawals.iter().cloned().collect(), + consolidations: requests.consolidations.iter().cloned().collect(), + builder_deposits: Default::default(), + builder_exits: Default::default(), + _phantom: PhantomData, + } +} + const LOGS_BLOOM_SIZE: usize = 256; pub type Bloom = FixedBytes; // FixedVector; @@ -124,4 +149,29 @@ mod tests { let lh_tree_hash = lh_transaction.tree_hash_root(); assert_eq!(our_tree_hash, lh_tree_hash, "Tree hash root should match lighthouse"); } + + #[test] + fn convert_transactions_to_progressive_preserves_bytes() { + let txs = Transactions::random_for_test(&mut rand::rng()); + + let progressive = convert_transactions_to_progressive(&txs); + + assert_eq!(progressive.len(), txs.len()); + for (converted, original) in progressive.as_slice().iter().zip(txs.iter()) { + assert_eq!(converted.as_slice(), original.as_ref()); + } + } + + #[test] + fn execution_requests_to_gloas_preserves_lists_and_defaults_builder_requests() { + let requests = ExecutionRequests::random_for_test(&mut rand::rng()); + + let gloas = execution_requests_to_gloas(&requests); + + assert!(gloas.deposits.iter().eq(requests.deposits.iter())); + assert!(gloas.withdrawals.iter().eq(requests.withdrawals.iter())); + assert!(gloas.consolidations.iter().eq(requests.consolidations.iter())); + assert!(gloas.builder_deposits.is_empty()); + assert!(gloas.builder_exits.is_empty()); + } } diff --git a/crates/types/src/hydration.rs b/crates/types/src/hydration.rs index 7c2540817..aa4540c45 100644 --- a/crates/types/src/hydration.rs +++ b/crates/types/src/hydration.rs @@ -26,6 +26,8 @@ pub enum DehydratedBidSubmission { } impl ForkVersionDecode for DehydratedBidSubmission { + /// Gloas uses the same bounded-list wire shape as Fulu here -- this is helix's own + /// builder<->relay representation, not the real Gloas consensus shape. fn from_ssz_bytes_by_fork(bytes: &[u8], fork: ForkName) -> Result { match fork { ForkName::Base | @@ -34,9 +36,8 @@ impl ForkVersionDecode for DehydratedBidSubmission { ForkName::Capella | ForkName::Deneb | ForkName::Electra | - ForkName::Gloas | ForkName::Heze => Err(DecodeError::NoMatchingVariant), - ForkName::Fulu => DehydratedBidSubmissionFulu::from_ssz_bytes(bytes) + ForkName::Fulu | ForkName::Gloas => DehydratedBidSubmissionFulu::from_ssz_bytes(bytes) .map(DehydratedBidSubmission::Fulu), } } @@ -212,10 +213,11 @@ impl ForkVersionDecode for DehydratedBidSubmissionFuluWithAdjustments { ForkName::Bellatrix | ForkName::Capella | ForkName::Deneb | - ForkName::Gloas | ForkName::Heze | ForkName::Electra => Err(DecodeError::NoMatchingVariant), - ForkName::Fulu => DehydratedBidSubmissionFuluWithAdjustments::from_ssz_bytes(bytes), + ForkName::Fulu | ForkName::Gloas => { + DehydratedBidSubmissionFuluWithAdjustments::from_ssz_bytes(bytes) + } } } } @@ -255,10 +257,11 @@ impl ForkVersionDecode for DehydratedBidSubmissionFuluWithMergingData { ForkName::Bellatrix | ForkName::Capella | ForkName::Deneb | - ForkName::Gloas | ForkName::Heze | ForkName::Electra => Err(DecodeError::NoMatchingVariant), - ForkName::Fulu => DehydratedBidSubmissionFuluWithMergingData::from_ssz_bytes(bytes), + ForkName::Fulu | ForkName::Gloas => { + DehydratedBidSubmissionFuluWithMergingData::from_ssz_bytes(bytes) + } } } } @@ -645,4 +648,36 @@ mod tests { assert_eq!(split_merging_data, expected_merging_data); assert!(matches!(dehydrated, DehydratedBidSubmission::Fulu(_))); } + + #[test] + fn dehydrated_with_merging_data_decodes_under_gloas_fork() { + let submission = + DehydratedBidSubmissionFuluWithMergingData::random_for_test(&mut rand::rng()); + let bytes = submission.as_ssz_bytes(); + + let decoded = DehydratedBidSubmissionFuluWithMergingData::from_ssz_bytes_by_fork( + &bytes, + ForkName::Gloas, + ) + .expect("Gloas should decode via the same shape as Fulu"); + + assert_eq!(decoded.message, submission.message); + assert_eq!(decoded.merging_data, submission.merging_data); + } + + #[test] + fn dehydrated_bid_submission_decodes_under_gloas_fork() { + let (dehydrated, _) = + DehydratedBidSubmissionFuluWithMergingData::random_for_test(&mut rand::rng()).split(); + let inner_bytes = match &dehydrated { + DehydratedBidSubmission::Fulu(inner) => inner.as_ssz_bytes(), + }; + + let decoded = + DehydratedBidSubmission::from_ssz_bytes_by_fork(&inner_bytes, ForkName::Gloas) + .expect("Gloas should decode via the same shape as Fulu"); + + assert!(matches!(decoded, DehydratedBidSubmission::Fulu(_))); + assert_eq!(decoded.bid_trace(), dehydrated.bid_trace()); + } }