Skip to content

Commit 6359b33

Browse files
committed
Convert produceBlockV4 to POST and round-trip Eth-Builder-Url (Gloas builder API 4/5)
Fourth PR of the Gloas builder API stack (beacon-APIs #630): - convert `/eth/v4/validator/blocks/{slot}` to POST with an optional `BuilderConfig` body (min_bid, builder_boost_factor, direct builders) - add `POST /eth/v1/validator/builder_preferences` for forwarding signed builder preferences - set `Eth-Builder-Url` on produceBlockV4 responses when a direct-builder bid wins, accept it on `POST /eth/v2/beacon/blocks`, and forward the signed block to that builder The validator client still uses the legacy GET methods at this point; it migrates in the final PR of this stack. Change-Id: I0ad30b8f36ad9b588ea1a0398220f92c9597bb95
1 parent 80ae5be commit 6359b33

9 files changed

Lines changed: 577 additions & 41 deletions

File tree

beacon_node/http_api/src/lib.rs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ use eth2::types::{
6868
self as api_types, BroadcastValidation, EndpointVersion, ForkChoice, ForkChoiceExtraData,
6969
ForkChoiceNode, LightClientUpdatesQuery, PublishBlockRequest, ValidatorId,
7070
};
71-
use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER};
71+
use eth2::{
72+
BUILDER_URL_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER,
73+
};
7274
use health_metrics::observe::Observe;
7375
use lighthouse_network::Enr;
7476
use lighthouse_network::NetworkGlobals;
@@ -106,7 +108,7 @@ use types::{
106108
};
107109
use validator::execution_payload_envelopes::get_validator_execution_payload_envelopes;
108110
use version::{
109-
ResponseIncludesVersion, V1, V2, add_consensus_version_header, add_ssz_content_type_header,
111+
ResponseIncludesVersion, V1, V2, V4, add_consensus_version_header, add_ssz_content_type_header,
110112
execution_optimistic_finalized_beacon_response, inconsistent_fork_rejection,
111113
unsupported_version_rejection,
112114
};
@@ -384,6 +386,7 @@ pub async fn serve<T: BeaconChainTypes>(
384386

385387
let eth_v1 = single_version(any_version.clone(), V1);
386388
let eth_v2 = single_version(any_version.clone(), V2);
389+
let eth_v4 = single_version(any_version.clone(), V4);
387390

388391
// Create a `warp` filter that provides access to the network globals.
389392
let inner_network_globals = ctx.network_globals.clone();
@@ -819,6 +822,9 @@ pub async fn serve<T: BeaconChainTypes>(
819822
*/
820823
let consensus_version_header_filter =
821824
warp::header::header::<ForkName>(CONSENSUS_VERSION_HEADER).boxed();
825+
// The winning builder's URL echoed by the VC on a Gloas block publish (beacon-APIs #630), so the
826+
// node forwards the block to that builder. Optional: absent for self-build / p2p-won blocks.
827+
let builder_url_header_filter = warp::header::optional::<String>(BUILDER_URL_HEADER).boxed();
822828

823829
let optional_consensus_version_header_filter =
824830
warp::header::optional::<ForkName>(CONSENSUS_VERSION_HEADER).boxed();
@@ -855,6 +861,8 @@ pub async fn serve<T: BeaconChainTypes>(
855861
&network_tx,
856862
BroadcastValidation::default(),
857863
duplicate_block_status_code,
864+
// Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas).
865+
None,
858866
)
859867
.await
860868
})
@@ -892,6 +900,8 @@ pub async fn serve<T: BeaconChainTypes>(
892900
&network_tx,
893901
BroadcastValidation::default(),
894902
duplicate_block_status_code,
903+
// Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas).
904+
None,
895905
)
896906
.await
897907
})
@@ -909,13 +919,15 @@ pub async fn serve<T: BeaconChainTypes>(
909919
.and(task_spawner_filter.clone())
910920
.and(chain_filter.clone())
911921
.and(network_tx_filter.clone())
922+
.and(builder_url_header_filter.clone())
912923
.then(
913924
move |validation_level: api_types::BroadcastValidationQuery,
914925
value: serde_json::Value,
915926
consensus_version: ForkName,
916927
task_spawner: TaskSpawner<T::EthSpec>,
917928
chain: Arc<BeaconChain<T>>,
918-
network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>| {
929+
network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>,
930+
builder_url: Option<String>| {
919931
task_spawner.spawn_async_with_rejection(Priority::P0, async move {
920932
let request = PublishBlockRequest::<T::EthSpec>::context_deserialize(
921933
&value,
@@ -932,6 +944,7 @@ pub async fn serve<T: BeaconChainTypes>(
932944
&network_tx,
933945
validation_level.broadcast_validation,
934946
duplicate_block_status_code,
947+
builder_url,
935948
)
936949
.await
937950
})
@@ -949,13 +962,15 @@ pub async fn serve<T: BeaconChainTypes>(
949962
.and(task_spawner_filter.clone())
950963
.and(chain_filter.clone())
951964
.and(network_tx_filter.clone())
965+
.and(builder_url_header_filter.clone())
952966
.then(
953967
move |validation_level: api_types::BroadcastValidationQuery,
954968
block_bytes: Bytes,
955969
consensus_version: ForkName,
956970
task_spawner: TaskSpawner<T::EthSpec>,
957971
chain: Arc<BeaconChain<T>>,
958-
network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>| {
972+
network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>,
973+
builder_url: Option<String>| {
959974
task_spawner.spawn_async_with_rejection(Priority::P0, async move {
960975
let block_contents = PublishBlockRequest::<T::EthSpec>::from_ssz_bytes(
961976
&block_bytes,
@@ -971,6 +986,7 @@ pub async fn serve<T: BeaconChainTypes>(
971986
&network_tx,
972987
validation_level.broadcast_validation,
973988
duplicate_block_status_code,
989+
builder_url,
974990
)
975991
.await
976992
})
@@ -2570,6 +2586,14 @@ pub async fn serve<T: BeaconChainTypes>(
25702586
task_spawner_filter.clone(),
25712587
);
25722588

2589+
// POST v4/validator/blocks/{slot}
2590+
let post_validator_blocks_v4 = post_validator_blocks_v4(
2591+
eth_v4.clone(),
2592+
chain_filter.clone(),
2593+
not_while_syncing_filter.clone(),
2594+
task_spawner_filter.clone(),
2595+
);
2596+
25732597
// GET validator/blinded_blocks/{slot}
25742598
let get_validator_blinded_blocks = get_validator_blinded_blocks(
25752599
eth_v1.clone(),
@@ -2683,6 +2707,12 @@ pub async fn serve<T: BeaconChainTypes>(
26832707
chain_filter.clone(),
26842708
task_spawner_filter.clone(),
26852709
);
2710+
// POST validator/builder_preferences
2711+
let post_validator_builder_preferences = post_validator_builder_preferences(
2712+
eth_v1.clone(),
2713+
chain_filter.clone(),
2714+
task_spawner_filter.clone(),
2715+
);
26862716
// POST validator/sync_committee_subscriptions
26872717
let post_validator_sync_committee_subscriptions = post_validator_sync_committee_subscriptions(
26882718
eth_v1.clone(),
@@ -3496,6 +3526,8 @@ pub async fn serve<T: BeaconChainTypes>(
34963526
.uor(post_validator_sync_committee_subscriptions)
34973527
.uor(post_validator_prepare_beacon_proposer)
34983528
.uor(post_validator_register_validator)
3529+
.uor(post_validator_builder_preferences)
3530+
.uor(post_validator_blocks_v4)
34993531
.uor(post_validator_liveness_epoch)
35003532
.uor(post_lighthouse_liveness)
35013533
.uor(post_lighthouse_database_reconstruct)

beacon_node/http_api/src/produce_block.rs

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
use crate::{
22
build_block_contents,
33
version::{
4-
ResponseIncludesVersion, add_consensus_block_value_header, add_consensus_version_header,
5-
add_execution_payload_blinded_header, add_execution_payload_included_header,
6-
add_execution_payload_value_header, add_ssz_content_type_header, beacon_response,
7-
inconsistent_fork_rejection,
4+
ResponseIncludesVersion, add_builder_url_header, add_consensus_block_value_header,
5+
add_consensus_version_header, add_execution_payload_blinded_header,
6+
add_execution_payload_included_header, add_execution_payload_value_header,
7+
add_ssz_content_type_header, beacon_response, inconsistent_fork_rejection,
88
},
99
};
1010
use beacon_chain::graffiti_calculator::GraffitiSettings;
@@ -17,9 +17,10 @@ use eth2::{
1717
beacon_response::ForkVersionedResponse,
1818
types::{BlockAndEnvelope, ProduceBlockV4Metadata},
1919
};
20+
use sensitive_url::SensitiveUrl;
2021
use ssz::Encode;
2122
use std::sync::Arc;
22-
use tracing::instrument;
23+
use tracing::{debug, instrument};
2324
use types::{execution::BlockProductionVersion, *};
2425
use warp::{
2526
http::response::Builder,
@@ -58,13 +59,30 @@ pub async fn produce_block_v4<T: BeaconChainTypes>(
5859
chain: Arc<BeaconChain<T>>,
5960
slot: Slot,
6061
query: api_types::ValidatorBlocksQuery,
62+
builder_config: api_types::BuilderConfig,
6163
) -> Result<Response, warp::Rejection> {
64+
// `produceBlockV4` is the Gloas block-production endpoint.
65+
let fork_name = chain.spec.fork_name_at_slot::<T::EthSpec>(slot);
66+
if !fork_name.gloas_enabled() {
67+
return Err(warp_utils::reject::custom_bad_request(
68+
"produceBlockV4 is only valid for Gloas and later".to_string(),
69+
));
70+
}
71+
6272
let include_payload = query.include_payload.ok_or_else(|| {
6373
warp_utils::reject::custom_bad_request(
6474
"include_payload query parameter is required".to_string(),
6575
)
6676
})?;
6777

78+
// The resolved builder config is threaded into block production, where it drives direct-builder
79+
// bid requests and the gossip/direct bid policy (see `produce_block_on_state_gloas`).
80+
debug!(
81+
%slot,
82+
builders = builder_config.builders.len(),
83+
"Received produceBlockV4 request"
84+
);
85+
6886
let randao_reveal = query.randao_reveal.decompress().map_err(|e| {
6987
warp_utils::reject::custom_bad_request(format!(
7088
"randao reveal is not a valid BLS signature: {:?}",
@@ -73,14 +91,9 @@ pub async fn produce_block_v4<T: BeaconChainTypes>(
7391
})?;
7492

7593
let randao_verification = get_randao_verification(&query, randao_reveal.is_infinity())?;
76-
// The GET route carries only a boost factor; direct builders arrive with the `BuilderConfig`
77-
// body once this route is converted to POST (later in this PR stack). Until then the winning
78-
// bid's builder URL is unused (`Eth-Builder-Url` also lands with the POST conversion).
79-
let builder_config = api_types::BuilderConfig {
80-
builder_boost_factor: query.builder_boost_factor.unwrap_or(DEFAULT_BOOST_FACTOR),
81-
..api_types::BuilderConfig::empty()
82-
};
8394

95+
// Gloas takes its bid boost policy from `builder_config` (global for gossip, per-builder for
96+
// direct), so the V3-style `builder_boost_factor` query param is not used on this path.
8497
let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy);
8598

8699
let (
@@ -89,7 +102,7 @@ pub async fn produce_block_v4<T: BeaconChainTypes>(
89102
consensus_block_value,
90103
execution_payload_value,
91104
payload_contents,
92-
_builder_url,
105+
builder_url,
93106
) = chain
94107
.produce_block_with_verification_gloas(
95108
randao_reveal,
@@ -110,6 +123,7 @@ pub async fn produce_block_v4<T: BeaconChainTypes>(
110123
consensus_block_value,
111124
execution_payload_value,
112125
payload_contents,
126+
builder_url,
113127
accept_header,
114128
&chain.spec,
115129
)
@@ -164,9 +178,13 @@ pub fn build_response_v4<T: BeaconChainTypes>(
164178
consensus_block_value: u64,
165179
execution_payload_value: Uint256,
166180
payload_contents: Option<PayloadEnvelopeContents<T::EthSpec>>,
181+
builder_url: Option<SensitiveUrl>,
167182
accept_header: Option<api_types::Accept>,
168183
spec: &ChainSpec,
169184
) -> Result<Response, warp::Rejection> {
185+
// Stringify the winning builder's URL only here, at the `Eth-Builder-Url` header boundary; it is
186+
// kept as a redacted `SensitiveUrl` everywhere upstream.
187+
let builder_url = builder_url.map(|url| url.expose_full().to_string());
170188
let fork_name = block
171189
.to_ref()
172190
.fork_name(spec)
@@ -180,14 +198,15 @@ pub fn build_response_v4<T: BeaconChainTypes>(
180198
consensus_block_value: consensus_block_value_wei,
181199
execution_payload_value,
182200
execution_payload_included,
183-
builder_url: None,
201+
builder_url: builder_url.clone(),
184202
};
185203

186204
let add_v4_headers = |res: Response| {
187205
let res = add_consensus_version_header(res, fork_name);
188206
let res = add_consensus_block_value_header(res, consensus_block_value_wei);
189207
let res = add_execution_payload_value_header(res, execution_payload_value);
190-
add_execution_payload_included_header(res, execution_payload_included)
208+
let res = add_execution_payload_included_header(res, execution_payload_included);
209+
add_builder_url_header(res, builder_url.as_deref())
191210
};
192211

193212
// When the payload is included, bundle the block with the execution payload envelope, blobs and

beacon_node/http_api/src/publish_blocks.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use logging::crit;
1919
use network::NetworkMessage;
2020
use rand::prelude::SliceRandom;
2121
use reqwest::StatusCode;
22+
use sensitive_url::SensitiveUrl;
2223
use slot_clock::SlotClock;
2324
use std::marker::PhantomData;
2425
use std::sync::Arc;
@@ -73,6 +74,62 @@ impl<T: BeaconChainTypes> ProvenancedBlock<T, Arc<SignedBeaconBlock<T::EthSpec>>
7374
}
7475
}
7576

77+
/// If a direct builder won this block's payload bid, forward the signed block to that builder via
78+
/// `submitSignedBeaconBlock` so it reveals the execution payload envelope.
79+
///
80+
/// The builder's URL is the `Eth-Builder-Url` request header the VC echoed on publish (beacon-APIs
81+
/// #630), so this works even on a beacon node that did not produce the block. `None` (self-built or
82+
/// p2p-won), no configured builders, or a malformed URL are all no-ops.
83+
///
84+
/// Fire-and-forget: the submission runs in a detached task; a failure is logged at high severity
85+
/// (the validator has already signed the commitment) but never blocks the publish response. Runs
86+
/// only once per block since it hangs off the single p2p-publish point.
87+
fn forward_signed_block_to_winning_builder<T: BeaconChainTypes>(
88+
chain: &Arc<BeaconChain<T>>,
89+
block: Arc<SignedBeaconBlock<T::EthSpec>>,
90+
builder_url: Option<&str>,
91+
) {
92+
// The VC echoes the winning builder's URL in the `Eth-Builder-Url` request header (beacon-APIs
93+
// #630); absent for a self-built block or a p2p-won bid, in which case there's nothing to forward.
94+
let Some(builder_url) = builder_url else {
95+
return;
96+
};
97+
let Some(builders) = chain.builders.as_ref() else {
98+
return;
99+
};
100+
let url = match SensitiveUrl::parse(builder_url) {
101+
Ok(url) => url,
102+
Err(e) => {
103+
warn!(error = ?e, "Ignoring malformed Eth-Builder-Url header");
104+
return;
105+
}
106+
};
107+
108+
let builders = builders.clone();
109+
let slot = block.slot();
110+
let block_root = block.canonical_root();
111+
112+
chain.task_executor.spawn(
113+
async move {
114+
match builders.forward_signed_block(&url, &block).await {
115+
Ok(()) => info!(
116+
%slot,
117+
%block_root,
118+
"Forwarded signed block to winning builder"
119+
),
120+
Err(e) => error!(
121+
%slot,
122+
%block_root,
123+
builder_url = ?url,
124+
error = ?e,
125+
"Failed to forward signed block to winning builder"
126+
),
127+
}
128+
},
129+
"forward_signed_block_to_builder",
130+
);
131+
}
132+
76133
/// Handles a request from the HTTP API for full blocks.
77134
#[allow(clippy::too_many_arguments)]
78135
#[instrument(
@@ -88,6 +145,9 @@ pub async fn publish_block<T: BeaconChainTypes, B: IntoGossipVerifiedBlock<T>>(
88145
network_tx: &UnboundedSender<NetworkMessage<T::EthSpec>>,
89146
validation_level: BroadcastValidation,
90147
duplicate_status_code: StatusCode,
148+
// The `Eth-Builder-Url` request header (beacon-APIs #630): when a direct builder won the block's
149+
// payload bid, its URL, so the block is forwarded there for envelope reveal.
150+
builder_url: Option<String>,
91151
) -> Result<Response, Rejection> {
92152
let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default();
93153
let block_publishing_delay_for_testing = chain.config.block_publishing_delay;
@@ -141,6 +201,14 @@ pub async fn publish_block<T: BeaconChainTypes, B: IntoGossipVerifiedBlock<T>>(
141201
BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish))
142202
})?;
143203

204+
// If a direct builder won this block's payload bid, forward the signed block to it so it
205+
// reveals the execution payload envelope.
206+
forward_signed_block_to_winning_builder(
207+
&publish_chain,
208+
block.clone(),
209+
builder_url.as_deref(),
210+
);
211+
144212
Ok(())
145213
};
146214

@@ -570,6 +638,8 @@ pub async fn publish_blinded_block<T: BeaconChainTypes>(
570638
network_tx,
571639
validation_level,
572640
duplicate_status_code,
641+
// Blinded (mev-boost) publish predates the Gloas builder-URL round-trip.
642+
None,
573643
)
574644
.await
575645
} else {

0 commit comments

Comments
 (0)