From f41b823ba3d657daaca4dbbe075cb7550b2509c2 Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 24 Aug 2026 19:21:31 +0100 Subject: [PATCH 1/2] 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 50086b52..3a6371d7 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 d5c3e384..6d10ce73 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 4064c867..e29bbb27 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 f6b3b79a..80d27b28 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 caa1cf84..78840c76 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 6c3aca0a646cbd6504938fa3f2eb71de783b209a Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 24 Aug 2026 20:06:47 +0100 Subject: [PATCH 2/2] 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 0858c35a..d301031d 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -80,6 +80,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)] @@ -135,6 +139,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 9607f4dc..7fee5c60 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 80d27b28..5bada9a2 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 752fea64..f5c333a4 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(