From 885b2f8845f6f056db81934866f8fd217fda6e90 Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:24:11 -0500 Subject: [PATCH] Add builder API types, request-auth domain, and eth2 client methods (Gloas builder API 1/5) First PR of the Gloas builder API stack (builder-specs #165 / beacon-APIs #630): - consensus/types: DOMAIN_REQUEST_AUTH application domain and `get_request_auth_domain()` - common/builder_types: new crate with the shared Builder API wire types - common/eth2: POST produceBlockV4 methods, builder-preferences methods, `Eth-Builder-Url` header plumbing, and `ProduceBlockV4Metadata::builder_url` All changes are additive: the legacy GET v4 block-production methods are kept alongside the new POST variants until the validator client migrates later in this stack, then removed in the final PR. Change-Id: I1df19d460b74b0e7387426e79a2e8edc764da837 --- Cargo.lock | 22 ++ Cargo.toml | 2 + beacon_node/http_api/src/produce_block.rs | 1 + .../tests/broadcast_validation_tests.rs | 54 ++- beacon_node/http_api/tests/tests.rs | 29 +- common/builder_types/Cargo.toml | 42 +++ common/builder_types/src/builder_config.rs | 78 +++++ common/builder_types/src/builder_entry.rs | 130 +++++++ .../src/builder_preference_entry.rs | 99 ++++++ .../builder_types/src/builder_preferences.rs | 20 ++ .../src/builder_preferences_request.rs | 35 ++ common/builder_types/src/builder_url.rs | 178 ++++++++++ common/builder_types/src/lib.rs | 56 +++ common/builder_types/src/request_auth.rs | 39 +++ .../builder_types/src/signed_request_auth.rs | 22 ++ common/eth2/Cargo.toml | 2 + common/eth2/src/lib.rs | 328 +++++++++++++++++- common/eth2/src/types.rs | 18 +- .../types/src/core/application_domain.rs | 7 + consensus/types/src/core/chain_spec.rs | 26 ++ .../validator_services/src/block_service.rs | 2 +- 21 files changed, 1144 insertions(+), 46 deletions(-) create mode 100644 common/builder_types/Cargo.toml create mode 100644 common/builder_types/src/builder_config.rs create mode 100644 common/builder_types/src/builder_entry.rs create mode 100644 common/builder_types/src/builder_preference_entry.rs create mode 100644 common/builder_types/src/builder_preferences.rs create mode 100644 common/builder_types/src/builder_preferences_request.rs create mode 100644 common/builder_types/src/builder_url.rs create mode 100644 common/builder_types/src/lib.rs create mode 100644 common/builder_types/src/request_auth.rs create mode 100644 common/builder_types/src/signed_request_auth.rs diff --git a/Cargo.lock b/Cargo.lock index af25c5e3520..480f4f9b49d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1707,6 +1707,27 @@ dependencies = [ "types", ] +[[package]] +name = "builder_types" +version = "0.1.0" +dependencies = [ + "arbitrary", + "bls", + "builder_types", + "context_deserialize", + "ethereum_serde_utils", + "ethereum_ssz", + "ethereum_ssz_derive", + "sensitive_url", + "serde", + "serde_json", + "ssz_types", + "tree_hash", + "tree_hash_derive", + "typenum", + "types", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -3217,6 +3238,7 @@ version = "0.1.0" dependencies = [ "arbitrary", "bls", + "builder_types", "context_deserialize", "educe", "eip_3076", diff --git a/Cargo.toml b/Cargo.toml index 65cdb23c272..6e0867a692d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "boot_node", "common/account_utils", "common/axum_utils", + "common/builder_types", "common/clap_utils", "common/deposit_contract", "common/directory", @@ -118,6 +119,7 @@ beacon_processor = { path = "beacon_node/beacon_processor" } bincode = "1" bitvec = "1" bls = { path = "crypto/bls" } +builder_types = { path = "common/builder_types" } byteorder = "1" bytes = "1.11.1" cargo_metadata = "0.19" diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index f84a998923e..8894bf9ce5a 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -172,6 +172,7 @@ pub fn build_response_v4( consensus_block_value: consensus_block_value_wei, execution_payload_value, execution_payload_included, + builder_url: None, }; let add_v4_headers = |res: Response| { diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index 6d80344e943..4d2be52a0d5 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -76,7 +76,11 @@ pub async fn gossip_invalid() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -140,7 +144,11 @@ pub async fn gossip_partial_pass() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert_eq!(response.unwrap().status(), StatusCode::ACCEPTED); } @@ -180,6 +188,7 @@ pub async fn gossip_full_pass() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), blobs), validation_level, + None, ) .await; @@ -228,7 +237,7 @@ pub async fn gossip_full_pass_ssz() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&block_contents, validation_level) + .post_beacon_blocks_v2_ssz(&block_contents, validation_level, None) .await; assert!(response.is_ok()); @@ -277,7 +286,11 @@ pub async fn consensus_invalid() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -339,7 +352,11 @@ pub async fn consensus_gossip() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -463,6 +480,7 @@ pub async fn consensus_full_pass() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), blobs), validation_level, + None, ) .await; @@ -514,7 +532,11 @@ pub async fn equivocation_invalid() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -587,7 +609,8 @@ pub async fn equivocation_consensus_early_equivocation() { .client .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_a.clone(), blobs_a), - validation_level + validation_level, + None ) .await .is_ok() @@ -605,6 +628,7 @@ pub async fn equivocation_consensus_early_equivocation() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_b.clone(), blobs_b), validation_level, + None, ) .await; assert!(response.is_err()); @@ -656,7 +680,11 @@ pub async fn equivocation_gossip() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -786,6 +814,7 @@ pub async fn equivocation_full_pass() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), blobs), validation_level, + None, ) .await; @@ -1631,6 +1660,7 @@ pub async fn block_seen_on_gossip_without_blobs_or_columns() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), Some(blobs)), validation_level, + None, ) .await; @@ -1716,6 +1746,7 @@ pub async fn block_seen_on_gossip_with_columns() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), Some(blobs)), validation_level, + None, ) .await; @@ -1787,6 +1818,7 @@ pub async fn columns_seen_on_gossip_without_block() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), Some((kzg_proofs, blobs))), validation_level, + None, ) .await; @@ -1861,6 +1893,7 @@ async fn columns_seen_on_gossip_without_block_and_no_http_columns() { Some((Default::default(), Default::default())), ), validation_level, + None, ) .await; @@ -1931,6 +1964,7 @@ async fn slashable_columns_seen_on_gossip_cause_failure() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_a.clone(), Some((kzg_proofs_a, blobs_a))), validation_level, + None, ) .await; @@ -2001,7 +2035,7 @@ pub async fn duplicate_block_status_code() { let block_request = PublishBlockRequest::new(block.clone(), Some((kzg_proofs, blobs))); let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&block_request, validation_level) + .post_beacon_blocks_v2_ssz(&block_request, validation_level, None) .await; // This should result in the block being fully imported. @@ -2016,7 +2050,7 @@ pub async fn duplicate_block_status_code() { // Post again. let duplicate_response: Result = tester .client - .post_beacon_blocks_v2_ssz(&block_request, validation_level) + .post_beacon_blocks_v2_ssz(&block_request, validation_level, None) .await; let err = duplicate_response.unwrap_err(); assert_eq!(err.status().unwrap(), duplicate_block_status_code); diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index ae54f110140..38bcd05a77c 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -1987,7 +1987,7 @@ impl ApiTester { let next_block = &self.next_block; self.client - .post_beacon_blocks_v2_ssz(next_block, None) + .post_beacon_blocks_v2_ssz(next_block, None, None) .await .unwrap(); @@ -2085,7 +2085,7 @@ impl ApiTester { .await .unwrap(), self.client - .post_beacon_blocks_v2_ssz(&block_contents, None) + .post_beacon_blocks_v2_ssz(&block_contents, None, None) .await .unwrap(), self.client @@ -4544,7 +4544,7 @@ impl ApiTester { block_contents.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); self.client - .post_beacon_blocks_v2_ssz(&signed_block_contents, None) + .post_beacon_blocks_v2_ssz(&signed_block_contents, None, None) .await .unwrap(); @@ -4666,7 +4666,7 @@ impl ApiTester { block_contents.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); self.client - .post_beacon_blocks_v2_ssz(&signed_block_contents, None) + .post_beacon_blocks_v2_ssz(&signed_block_contents, None, None) .await .unwrap(); @@ -4976,14 +4976,13 @@ impl ApiTester { let mut url = self .client - .get_validator_blocks_v4_path( + .post_validator_blocks_v4_path( slot, &randao_reveal, None, SkipRandaoVerification::No, false, None, - None, ) .await .unwrap(); @@ -5272,7 +5271,7 @@ impl ApiTester { let signed_block_request = PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); self.client - .post_beacon_blocks_v2_ssz(&signed_block_request, None) + .post_beacon_blocks_v2_ssz(&signed_block_request, None, None) .await .unwrap(); assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); @@ -5347,7 +5346,7 @@ impl ApiTester { PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); if ssz { self.client - .post_beacon_blocks_v2_ssz(&signed_block_request, None) + .post_beacon_blocks_v2_ssz(&signed_block_request, None, None) .await .unwrap(); } else { @@ -9217,23 +9216,21 @@ impl ApiTester { self } - async fn get_validator_blocks_v4_path_graffiti_policy(self) -> Self { + async fn post_validator_blocks_v4_path_graffiti_policy(self) -> Self { let slot = self.chain.slot().unwrap(); let epoch = self.chain.epoch().unwrap(); let (_, randao_reveal) = self.get_test_randao(slot, epoch).await; let graffiti = Some(Graffiti::from([0; GRAFFITI_BYTES_LEN])); - let builder_boost_factor = None; // When GraffitiPolicy is None let no_graffiti_policy_path = self .client - .get_validator_blocks_v4_path( + .post_validator_blocks_v4_path( slot, &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, false, - builder_boost_factor, None, ) .await @@ -9242,13 +9239,12 @@ impl ApiTester { // Default case where GraffitiPolicy is AppendClientVersions let default_path = self .client - .get_validator_blocks_v4_path( + .post_validator_blocks_v4_path( slot, &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, false, - builder_boost_factor, Some(GraffitiPolicy::AppendClientVersions), ) .await @@ -9267,13 +9263,12 @@ impl ApiTester { let preserve_path = self .client - .get_validator_blocks_v4_path( + .post_validator_blocks_v4_path( slot, &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, false, - builder_boost_factor, Some(GraffitiPolicy::PreserveUserGraffiti), ) .await @@ -10884,7 +10879,7 @@ async fn get_validator_blocks_http_api_path() { .await .get_validator_blocks_v3_path_graffiti_policy() .await - .get_validator_blocks_v4_path_graffiti_policy() + .post_validator_blocks_v4_path_graffiti_policy() .await; } diff --git a/common/builder_types/Cargo.toml b/common/builder_types/Cargo.toml new file mode 100644 index 00000000000..ee7c5476e5e --- /dev/null +++ b/common/builder_types/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "builder_types" +version = "0.1.0" +authors = ["Sigma Prime "] +edition = { workspace = true } + +# The `arbitrary`-enabled self dependency below is only used to turn the feature on for the +# SSZ/tree-hash test macros, so `cargo-udeps` can't see it being used. +[package.metadata.cargo-udeps.ignore] +development = ["builder_types"] + +[features] +default = [] +arbitrary = [ + "dep:arbitrary", + "types/arbitrary", + "bls/arbitrary", + "ethereum_ssz/arbitrary", + "ssz_types/arbitrary", +] + +[dependencies] +arbitrary = { workspace = true, features = ["derive"], optional = true } +bls = { workspace = true } +context_deserialize = { workspace = true } +ethereum_serde_utils = { workspace = true } +ethereum_ssz = { workspace = true } +ethereum_ssz_derive = { workspace = true } +sensitive_url = { workspace = true } +serde = { workspace = true } +ssz_types = { workspace = true } +tree_hash = { workspace = true } +tree_hash_derive = { workspace = true } +typenum = { workspace = true } +types = { workspace = true } + +[dev-dependencies] +# Self-dependency with the `arbitrary` feature enabled so the SSZ/tree-hash test macros (which build +# instances via `types::test_utils::test_arbitrary_instance`) work in unit tests. Mirrors the pattern +# in `consensus/types`. +builder_types = { path = ".", features = ["arbitrary"] } +serde_json = { workspace = true } diff --git a/common/builder_types/src/builder_config.rs b/common/builder_types/src/builder_config.rs new file mode 100644 index 00000000000..b0b0933571a --- /dev/null +++ b/common/builder_types/src/builder_config.rs @@ -0,0 +1,78 @@ +use crate::{BuilderEntry, MaxBuilderEntries}; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use tree_hash_derive::TreeHash; + +/// The builder config the validator client sends on a block-production request, per +/// [beacon-APIs #630](https://github.com/ethereum/beacon-APIs/pull/630). +/// +/// `builders` are the direct bid requests. Every field of every entry carries a concrete value on +/// the wire: any defaulting the validator client offers in its own configuration (such as +/// per-builder overrides inheriting file-level defaults, or auth data defaulting to the builder's +/// URL) is applied client-side before this type is built, and the entry's request auth is already +/// signed. In particular, the top-level `min_bid` and `builder_boost_factor` are *not* defaults +/// for the entries: they govern only a bid that matches no entry — in practice, a bid received +/// over p2p. +/// +/// SSZ container (field order per the spec — SSZ and tree-hash depend on it): +/// ```text +/// class BuilderConfig(Container): +/// min_bid: Gwei +/// builder_boost_factor: uint64 +/// builders: List[BuilderEntry, MAX_BUILDER_ENTRIES] +/// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +pub struct BuilderConfig { + /// Minimum total payment (Gwei) accepted from a bid that matches no entry (a p2p bid). + #[serde(with = "serde_utils::quoted_u64")] + pub min_bid: u64, + /// Percentage multiplier applied to a bid that matches no entry (a p2p bid). + #[serde(with = "serde_utils::quoted_u64")] + pub builder_boost_factor: u64, + /// The builders to request bids from directly. Empty means only p2p bids are considered. + pub builders: VariableList, +} + +impl BuilderConfig { + /// An empty config: no direct builders, with the documented compatibility defaults for the + /// p2p bid policy (`min_bid = 0`, `builder_boost_factor = 100`). + /// + /// Sent when the validator has no builder support configured, so a Gloas proposal still falls + /// back to local and p2p payloads. + pub fn empty() -> Self { + Self { + min_bid: 0, + builder_boost_factor: 100, + builders: VariableList::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderConfig); + + #[test] + fn json_shape() { + let config = BuilderConfig { + min_bid: 5, + builder_boost_factor: 100, + builders: VariableList::default(), + }; + let json = serde_json::to_value(&config).unwrap(); + let obj = json.as_object().unwrap(); + // `builders` is a JSON array; the Gwei/uint64 fields are quoted strings. + assert!(obj["builders"].is_array()); + assert_eq!(obj["min_bid"], "5"); + assert_eq!(obj["builder_boost_factor"], "100"); + + assert_eq!( + serde_json::from_value::(json).unwrap(), + config + ); + } +} diff --git a/common/builder_types/src/builder_entry.rs b/common/builder_types/src/builder_entry.rs new file mode 100644 index 00000000000..275da974cf1 --- /dev/null +++ b/common/builder_types/src/builder_entry.rs @@ -0,0 +1,130 @@ +use crate::{BuilderUrl, SignedRequestAuth}; +use bls::PublicKeyBytes; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::{VariableList, typenum}; +use tree_hash_derive::TreeHash; + +/// `MAX_BUILDER_PUBKEYS` (beacon-APIs #630) as a typenum, bounding a [`BuilderEntry`]'s +/// `builder_pubkeys` list. +pub type MaxBuilderPubkeys = typenum::U64; + +/// The builder pubkeys a [`BuilderEntry`] accepts bids from. Empty accepts any builder. +pub type BuilderPubkeys = VariableList; + +/// A per-builder bid request the validator client supplies on a block-production request, per +/// [beacon-APIs #630](https://github.com/ethereum/beacon-APIs/pull/630). +/// +/// Each entry is a direct bid request: the beacon node calls `getExecutionPayloadBid` at `url`, +/// authenticated by `auth`. One request is made per entry, so several entries MAY share a `url` +/// with different `auth`. `min_bid`/`builder_boost_factor`/`max_execution_payment` are this +/// builder's per-request selection policy; p2p bids are governed by the global values on the +/// enclosing config, not here. +/// +/// `builder_pubkeys` filters the response: an empty list accepts any builder, and a bid not signed +/// by one of a non-empty list MUST NOT be accepted. +/// +/// Field order matches the SSZ `BuilderEntry` container: +/// ```text +/// class BuilderEntry(Container): +/// url: ByteList[MAX_BUILDER_URL_SIZE] +/// auth: SignedRequestAuth +/// builder_pubkeys: List[BLSPubkey, MAX_BUILDER_PUBKEYS] +/// max_execution_payment: Gwei +/// min_bid: Gwei +/// builder_boost_factor: uint64 +/// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +pub struct BuilderEntry { + /// Where this entry's bid request is sent. Required and non-empty: beacon-APIs #630 treats a + /// zero-length url as invalid. p2p bid policy is carried by the top-level `BuilderConfig`. + pub url: BuilderUrl, + /// Authenticates this entry's bid request. + pub auth: SignedRequestAuth, + /// The builder pubkeys this entry accepts bids from. Empty accepts any builder; otherwise a + /// bid not signed by one of them MUST NOT be accepted. + pub builder_pubkeys: BuilderPubkeys, + /// Maximum trusted execution-layer payment (Gwei) accepted from this builder. + #[serde(with = "serde_utils::quoted_u64")] + pub max_execution_payment: u64, + /// Minimum total payment (Gwei) for a bid from this builder to be accepted. + #[serde(with = "serde_utils::quoted_u64")] + pub min_bid: u64, + /// Percentage multiplier applied to this builder's bid when comparing against the local payload. + #[serde(with = "serde_utils::quoted_u64")] + pub builder_boost_factor: u64, +} + +impl BuilderEntry { + /// Enforce the wire-validity rules from beacon-APIs #630: in either encoding, a zero-length + /// `url` and a zero-length `auth.message.data` are invalid. A body containing such an entry is + /// an invalid request (400), unlike per-entry failures (unreachable builder, rejected bid), + /// which are isolated and never fail the request. + pub fn validate(&self) -> Result<(), &'static str> { + if self.url.as_bytes().is_empty() { + return Err("zero-length builder url"); + } + if self.auth.message.data.is_empty() { + return Err("zero-length auth data"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderEntry); + + fn test_auth() -> SignedRequestAuth { + SignedRequestAuth { + message: crate::RequestAuth { + data: crate::RequestAuthData::new(b"http://builder.example.com".to_vec()).unwrap(), + slot: types::Slot::new(0), + }, + signature: bls::Signature::empty(), + } + } + + fn entry() -> BuilderEntry { + BuilderEntry { + url: "http://builder.example.com".parse().unwrap(), + auth: test_auth(), + builder_pubkeys: BuilderPubkeys::default(), + max_execution_payment: 1, + min_bid: 2, + builder_boost_factor: 100, + } + } + + #[test] + fn json_requires_builder_pubkeys() { + // `builder_pubkeys` is a required field (beacon-APIs #630): an empty list is serialized as + // `[]`, never omitted, and a body missing the field is rejected. + let entry = entry(); + let json = serde_json::to_value(&entry).unwrap(); + assert_eq!(json.get("builder_pubkeys"), Some(&serde_json::json!([]))); + + let mut without_field = json.clone(); + without_field + .as_object_mut() + .unwrap() + .remove("builder_pubkeys"); + assert!(serde_json::from_value::(without_field).is_err()); + + assert_eq!(serde_json::from_value::(json).unwrap(), entry); + } + + #[test] + fn json_round_trips_builder_pubkeys() { + let mut entry = entry(); + entry.builder_pubkeys = + BuilderPubkeys::new(vec![PublicKeyBytes::deserialize(&[1u8; 48]).unwrap()]).unwrap(); + let json = serde_json::to_value(&entry).unwrap(); + assert_eq!(json["builder_pubkeys"].as_array().unwrap().len(), 1); + + assert_eq!(serde_json::from_value::(json).unwrap(), entry); + } +} diff --git a/common/builder_types/src/builder_preference_entry.rs b/common/builder_types/src/builder_preference_entry.rs new file mode 100644 index 00000000000..6dd01b7e63c --- /dev/null +++ b/common/builder_types/src/builder_preference_entry.rs @@ -0,0 +1,99 @@ +use crate::{BuilderEntry, BuilderUrl, SignedRequestAuth}; +use bls::PublicKeyBytes; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::typenum; +use tree_hash_derive::TreeHash; + +/// `MAX_BUILDER_ENTRIES * (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH` (with mainnet +/// `SLOTS_PER_EPOCH`), the bound on the entry list of a single `submitBuilderPreferences` +/// beacon-API submission, per beacon-APIs #630. A fixed wire constant, not preset-derived. +pub type MaxSubmittedBuilderPreferences = typenum::U4096; + +/// [`MaxSubmittedBuilderPreferences`] as a `usize` (derived, so the two cannot drift), for runtime +/// bounds checks. +pub const MAX_SUBMITTED_BUILDER_PREFERENCES: usize = + ::USIZE; + +/// The bounded entry list of a single `submitBuilderPreferences` beacon-API submission +/// (SSZ `List[BuilderPreferencesEntry, 4096]`, per beacon-APIs #630). +pub type SubmittedBuilderPreferences = + ssz_types::VariableList; + +/// A per-builder preference a validator asks the beacon node to submit ahead of the bid request, +/// one entry per `submitBuilderPreferences` builder-API call the beacon node will make. +/// +/// This is the beacon-API (validator -> beacon node) type from beacon-APIs #630. Each entry names +/// its `proposer_pubkey`, so one flat request can carry preferences for several proposers. Unlike +/// the block-production `BuilderEntry`, it carries only what a builder is allowed to see: the routing +/// `url`, the forwarded `auth`, and the `max_execution_payment` cap. The proposer's private +/// bid-filtering knobs (`min_bid`, `builder_boost_factor`) are never sent to a builder. +/// +/// SSZ container (field order per the spec — SSZ and tree-hash depend on it): +/// ```text +/// class BuilderPreferenceEntry(Container): +/// proposer_pubkey: BLSPubkey +/// url: ByteList[MAX_BUILDER_URL_SIZE] +/// auth: SignedRequestAuth +/// max_execution_payment: Gwei +/// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +pub struct BuilderPreferenceEntry { + /// The proposer these preferences belong to. + pub proposer_pubkey: PublicKeyBytes, + /// The URL the beacon node submits these preferences to. Unsigned routing metadata. + pub url: BuilderUrl, + /// Authenticates the submission to the builder; forwarded byte-for-byte unchanged. + pub auth: SignedRequestAuth, + /// Maximum trusted execution-layer payment (Gwei) the proposer will accept from this builder. + #[serde(with = "serde_utils::quoted_u64")] + pub max_execution_payment: u64, +} + +impl BuilderPreferenceEntry { + /// Enforce the wire-validity rules from beacon-APIs #630: in either encoding, a zero-length + /// `url` and a zero-length `auth.message.data` are invalid, making the containing body an + /// invalid request (400). + pub fn validate(&self) -> Result<(), &'static str> { + if self.url.as_bytes().is_empty() { + return Err("zero-length builder url"); + } + if self.auth.message.data.is_empty() { + return Err("zero-length auth data"); + } + Ok(()) + } + + pub fn new( + proposer_pubkey: PublicKeyBytes, + url: BuilderUrl, + auth: SignedRequestAuth, + max_execution_payment: u64, + ) -> Self { + Self { + proposer_pubkey, + url, + auth, + max_execution_payment, + } + } + + /// Narrow a proposer's block-production [`BuilderEntry`] to the beacon-API preference entry, + /// dropping the builder-only fields (`min_bid`, `builder_boost_factor`, `builder_pubkeys`). + pub fn from_builder_entry(proposer_pubkey: PublicKeyBytes, entry: BuilderEntry) -> Self { + Self { + proposer_pubkey, + url: entry.url, + auth: entry.auth, + max_execution_payment: entry.max_execution_payment, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderPreferenceEntry); +} diff --git a/common/builder_types/src/builder_preferences.rs b/common/builder_types/src/builder_preferences.rs new file mode 100644 index 00000000000..e90e5037a56 --- /dev/null +++ b/common/builder_types/src/builder_preferences.rs @@ -0,0 +1,20 @@ +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; +use types::ForkName; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct BuilderPreferences { + #[serde(with = "serde_utils::quoted_u64")] + pub max_execution_payment: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderPreferences); +} diff --git a/common/builder_types/src/builder_preferences_request.rs b/common/builder_types/src/builder_preferences_request.rs new file mode 100644 index 00000000000..2defa12a3fd --- /dev/null +++ b/common/builder_types/src/builder_preferences_request.rs @@ -0,0 +1,35 @@ +use crate::{BuilderPreferences, SignedRequestAuth}; +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; +use types::ForkName; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct BuilderPreferencesRequest { + preferences: BuilderPreferences, + auth: SignedRequestAuth, +} + +impl BuilderPreferencesRequest { + pub fn new(preferences: BuilderPreferences, auth: SignedRequestAuth) -> Self { + Self { preferences, auth } + } + + pub fn preferences(&self) -> &BuilderPreferences { + &self.preferences + } + + pub fn auth(&self) -> &SignedRequestAuth { + &self.auth + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderPreferencesRequest); +} diff --git a/common/builder_types/src/builder_url.rs b/common/builder_types/src/builder_url.rs new file mode 100644 index 00000000000..e72cb910576 --- /dev/null +++ b/common/builder_types/src/builder_url.rs @@ -0,0 +1,178 @@ +use crate::RequestAuthData; +use sensitive_url::SensitiveUrl; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use std::fmt; +use std::str::FromStr; +use tree_hash::{PackedEncoding, TreeHash}; + +/// Maximum length (in bytes) of a builder URL on the wire (`MAX_BUILDER_URL_SIZE`), per +/// beacon-APIs #630. +pub type MaxBuilderUrlSize = typenum::U2048; + +/// Maximum number of builder entries a validator may supply on a single request, per +/// beacon-APIs #630. Used as the SSZ `List` bound on `BuilderConfig.builders`. +pub type MaxBuilderEntries = typenum::U64; + +/// [`MaxBuilderEntries`] as a `usize` (derived, so the two cannot drift), for runtime bounds checks. +pub const MAX_BUILDER_ENTRIES: usize = ::USIZE; + +// `to_default_auth_data` is infallible only while every possible URL fits within the auth `data` +// bound; enforce that at compile time so growing `MaxBuilderUrlSize` past `MaxDataSize` cannot +// silently turn the default into (wire-invalid) zero-length auth data. +const _: () = assert!( + ::USIZE + <= ::USIZE +); + +/// A builder URL as it travels on the beacon-API wire. +/// +/// Held as the UTF-8 bytes of the URL so it can serialize two ways, matching the `ByteList` / +/// `string` duality in beacon-APIs #630: an SSZ `ByteList[MAX_BUILDER_URL_SIZE]` (a bare byte list, +/// via the transparent struct behaviour) and a plain string in JSON. +/// +/// This is unsigned routing metadata. On the validator side the URL is held as a `SensitiveUrl` +/// (for redaction/ergonomics) and converted into a `BuilderUrl` only when building a request. +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Encode, Decode)] +#[ssz(struct_behaviour = "transparent")] +pub struct BuilderUrl { + bytes: VariableList, +} + +/// An error constructing or converting a [`BuilderUrl`]. +#[derive(Debug)] +pub enum BuilderUrlError { + /// The URL exceeds `MaxBuilderUrlSize` bytes. + TooLong, + /// The bytes are not a valid URL (invalid UTF-8 or unparseable). + InvalidUrl, +} + +impl BuilderUrl { + /// The URL's raw UTF-8 bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// The URL as a string slice, if it is valid UTF-8 (it always is when constructed through the + /// public API). + pub fn as_str(&self) -> Result<&str, std::str::Utf8Error> { + std::str::from_utf8(&self.bytes) + } + + /// Parse this URL into a [`SensitiveUrl`], for making requests or redacted logging. + /// + /// Fails if the bytes are not valid UTF-8 or do not parse as a URL. `BuilderUrl` itself is just + /// opaque bytes on the wire, so this is the conversion point where URL validity is checked. + pub fn to_sensitive_url(&self) -> Result { + let url = self.as_str().map_err(|_| BuilderUrlError::InvalidUrl)?; + SensitiveUrl::parse(url).map_err(|_| BuilderUrlError::InvalidUrl) + } + + /// The default opaque auth `data` to sign for this builder when no custom auth data is provided. + /// + /// Infallible: a `BuilderUrl` is at most `MaxBuilderUrlSize` (2048) bytes, well within + /// `MaxDataSize` (4096), so building the default from the URL cannot overflow. + pub fn to_default_auth_data(&self) -> RequestAuthData { + RequestAuthData::new(self.as_bytes().to_vec()).unwrap_or_default() + } +} + +impl TryFrom<&SensitiveUrl> for BuilderUrl { + type Error = BuilderUrlError; + + fn try_from(url: &SensitiveUrl) -> Result { + // Error rather than silently truncating to an (invalid) empty url if the URL string somehow + // exceeds `MaxBuilderUrlSize`. + let bytes = VariableList::new(url.expose_full().as_str().as_bytes().to_vec()) + .map_err(|_| BuilderUrlError::TooLong)?; + Ok(Self { bytes }) + } +} + +impl FromStr for BuilderUrl { + type Err = BuilderUrlError; + + fn from_str(s: &str) -> Result { + let bytes = + VariableList::new(s.as_bytes().to_vec()).map_err(|_| BuilderUrlError::TooLong)?; + Ok(Self { bytes }) + } +} + +impl fmt::Display for BuilderUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&String::from_utf8_lossy(&self.bytes)) + } +} + +impl Serialize for BuilderUrl { + fn serialize(&self, serializer: S) -> Result { + let s = std::str::from_utf8(&self.bytes).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(s) + } +} + +impl<'de> Deserialize<'de> for BuilderUrl { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + BuilderUrl::from_str(&s).map_err(|e| de::Error::custom(format!("{e:?}"))) + } +} + +impl TreeHash for BuilderUrl { + fn tree_hash_type() -> tree_hash::TreeHashType { + as TreeHash>::tree_hash_type() + } + + fn tree_hash_packed_encoding(&self) -> PackedEncoding { + self.bytes.tree_hash_packed_encoding() + } + + fn tree_hash_packing_factor() -> usize { + as TreeHash>::tree_hash_packing_factor() + } + + fn tree_hash_root(&self) -> tree_hash::Hash256 { + self.bytes.tree_hash_root() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderUrl); + + #[test] + fn json_is_a_string() { + let url = BuilderUrl::from_str("https://builder.example.com").unwrap(); + let json = serde_json::to_string(&url).unwrap(); + assert_eq!(json, "\"https://builder.example.com\""); + + let decoded: BuilderUrl = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, url); + } + + #[test] + fn default_auth_data_cannot_fail_even_at_max_url_size() { + use ssz_types::typenum::Unsigned; + + // The `MaxBuilderUrlSize <= MaxDataSize` invariant is asserted at compile time at module + // level; exercise the largest possible URL to confirm the default is the URL bytes and + // never the empty fallback. + let scheme = "https://"; + let url_string = format!( + "{scheme}{}", + "a".repeat(MaxBuilderUrlSize::USIZE - scheme.len()) + ); + let url = BuilderUrl::from_str(&url_string).unwrap(); + assert_eq!(url.as_bytes().len(), MaxBuilderUrlSize::USIZE); + + let data = url.to_default_auth_data(); + assert!(!data.is_empty(), "default auth data fell back to empty"); + assert_eq!(&*data, url.as_bytes()); + } +} diff --git a/common/builder_types/src/lib.rs b/common/builder_types/src/lib.rs new file mode 100644 index 00000000000..1bd1edd9271 --- /dev/null +++ b/common/builder_types/src/lib.rs @@ -0,0 +1,56 @@ +//! Types for the Gloas builder flow that are defined by the Builder API and beacon-APIs specs +//! (builder-specs, beacon-APIs) rather than the consensus-specs. +//! +//! These are wire/request types — they never participate in the state transition — so they live +//! above `consensus/types` rather than in it. Consensus-spec builder containers (`Builder`, +//! `BuilderPendingPayment`, `SignedExecutionPayloadBid`, `ProposerPreferences`, ...) remain in +//! `types`. + +/// Local equivalent of the `ssz_and_tree_hash_tests!` macro in `consensus/types`, which cannot be +/// reused here because it is `#![cfg(test)]`-gated to that crate. Builds an arbitrary instance via +/// `types::test_utils::test_arbitrary_instance` (available with the `arbitrary` feature) and checks +/// SSZ round-trips and tree hashing does not panic. +#[cfg(test)] +#[macro_use] +mod test_macros { + macro_rules! ssz_and_tree_hash_tests { + ($type:ty) => { + #[test] + fn ssz_round_trip() { + let original: $type = types::test_utils::test_arbitrary_instance(); + let bytes = ssz::ssz_encode(&original); + let decoded = <$type as ssz::Decode>::from_ssz_bytes(&bytes).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn tree_hash_root_does_not_panic() { + let original: $type = types::test_utils::test_arbitrary_instance(); + let _ = tree_hash::TreeHash::tree_hash_root(&original); + } + }; + } +} + +mod builder_config; +mod builder_entry; +mod builder_preference_entry; +mod builder_preferences; +mod builder_preferences_request; +mod builder_url; +mod request_auth; +mod signed_request_auth; + +pub use builder_config::BuilderConfig; +pub use builder_entry::{BuilderEntry, BuilderPubkeys, MaxBuilderPubkeys}; +pub use builder_preference_entry::{ + BuilderPreferenceEntry, MAX_SUBMITTED_BUILDER_PREFERENCES, MaxSubmittedBuilderPreferences, + SubmittedBuilderPreferences, +}; +pub use builder_preferences::BuilderPreferences; +pub use builder_preferences_request::BuilderPreferencesRequest; +pub use builder_url::{ + BuilderUrl, BuilderUrlError, MAX_BUILDER_ENTRIES, MaxBuilderEntries, MaxBuilderUrlSize, +}; +pub use request_auth::{MaxDataSize, RequestAuth, RequestAuthData}; +pub use signed_request_auth::SignedRequestAuth; diff --git a/common/builder_types/src/request_auth.rs b/common/builder_types/src/request_auth.rs new file mode 100644 index 00000000000..81977ae612d --- /dev/null +++ b/common/builder_types/src/request_auth.rs @@ -0,0 +1,39 @@ +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use tree_hash_derive::TreeHash; +use types::{ForkName, SignedRoot, Slot}; + +// I would like to avoid defining this on the EthSpec if we can get away with it. +// Since it's outside the consensus-spec and is generically named.. +pub type MaxDataSize = typenum::U4096; + +pub type RequestAuthData = VariableList; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct RequestAuth { + /// Opaque authentication data unique to the builder, agreed upon out of band. The meaning of + /// the up to `MaxDataSize` (4096) bytes is left to the proposer and builder; the builder checks + /// the exact bytes when it verifies. When no value has been agreed out of band, implementations + /// SHOULD default to the UTF-8 bytes of the builder's own advertised URL, exactly as advertised, + /// so proposers with no prior relationship can construct an identical `data` deterministically. + /// A zero-length `data` is invalid. + /// + /// Serialized as a `0x`-prefixed hex string (builder-specs #165 `format: hex`). + #[serde(with = "ssz_types::serde_utils::hex_var_list")] + pub data: RequestAuthData, + /// The proposal slot this request is authorized for. + pub slot: Slot, +} + +impl SignedRoot for RequestAuth {} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(RequestAuth); +} diff --git a/common/builder_types/src/signed_request_auth.rs b/common/builder_types/src/signed_request_auth.rs new file mode 100644 index 00000000000..8c820c92888 --- /dev/null +++ b/common/builder_types/src/signed_request_auth.rs @@ -0,0 +1,22 @@ +use crate::RequestAuth; +use bls::Signature; +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; +use types::ForkName; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct SignedRequestAuth { + pub message: RequestAuth, + pub signature: Signature, +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(SignedRequestAuth); +} diff --git a/common/eth2/Cargo.toml b/common/eth2/Cargo.toml index 700ef9b4c27..db4a764c6d7 100644 --- a/common/eth2/Cargo.toml +++ b/common/eth2/Cargo.toml @@ -12,6 +12,7 @@ network = ["libp2p-identity", "enr", "multiaddr"] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } context_deserialize = { workspace = true } educe = { workspace = true } eip_3076 = { workspace = true, optional = true } @@ -40,5 +41,6 @@ zeroize = { workspace = true, optional = true } [dev-dependencies] arbitrary = { workspace = true } +builder_types = { workspace = true, features = ["arbitrary"] } tokio = { workspace = true } types = { workspace = true, features = ["arbitrary"] } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index a48374a8538..a02b948a909 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -62,6 +62,7 @@ pub const EXECUTION_PAYLOAD_VALUE_HEADER: &str = "Eth-Execution-Payload-Value"; pub const EXECUTION_PAYLOAD_INCLUDED_HEADER: &str = "Eth-Execution-Payload-Included"; pub const CONSENSUS_BLOCK_VALUE_HEADER: &str = "Eth-Consensus-Block-Value"; pub const BLOB_DATA_INCLUDED_HEADER: &str = "Eth-Blob-Data-Included"; +pub const BUILDER_URL_HEADER: &str = "Eth-Builder-Url"; pub const CONTENT_TYPE_HEADER: &str = "Content-Type"; pub const SSZ_CONTENT_TYPE_HEADER: &str = "application/octet-stream"; @@ -367,6 +368,46 @@ impl BeaconNodeHttpClient { } } + /// Perform a HTTP POST request, using an `accept` header for the response and exposing the + /// response headers to `parser`. Returns `None` on a 404 error. + /// + /// `fork_name` is sent as the `Eth-Consensus-Version` request header (required on every + /// endpoint this helper serves, per beacon-APIs #630). `build_body` attaches the request body + /// (and its content-type) to the request builder, so the caller controls whether the body is + /// sent as JSON or SSZ. + pub async fn post_response_with_response_headers( + &self, + url: U, + accept_header: Accept, + fork_name: ForkName, + timeout: Duration, + build_body: impl FnOnce(RequestBuilder) -> RequestBuilder, + parser: impl FnOnce(Response, HeaderMap) -> F, + ) -> Result, Error> + where + F: Future>, + { + let request = build_body( + self.client + .post(url) + .timeout(timeout) + .accept(accept_header) + .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()), + ); + let response = request.send().await?; + + let opt_response = ok_or_error(response).await.optional()?; + + match opt_response { + Some(resp) => { + let response_headers = resp.headers().clone(); + let parsed_response = parser(resp, response_headers).await?; + Ok(Some(parsed_response)) + } + None => Ok(None), + } + } + /// Perform a HTTP POST request. async fn post(&self, url: U, body: &T) -> Result<(), Error> { self.post_generic(url, body, None).await?; @@ -517,6 +558,7 @@ impl BeaconNodeHttpClient { timeout: Option, fork: ForkName, blob_data_included: Option, + builder_url: Option<&str>, ) -> Result { let mut builder = self .client @@ -527,6 +569,11 @@ impl BeaconNodeHttpClient { if let Some(blob_data_included) = blob_data_included { builder = builder.header(BLOB_DATA_INCLUDED_HEADER, blob_data_included.to_string()); } + // Echo the winning builder's URL (beacon-APIs #630) so the beacon node forwards the block to + // that builder; only set on a block published after a direct-builder bid won. + if let Some(builder_url) = builder_url { + builder = builder.header(BUILDER_URL_HEADER, builder_url); + } let response = builder.body(body).send().await?; success_or_error(response).await } @@ -556,7 +603,7 @@ impl BeaconNodeHttpClient { timeout: Option, fork: ForkName, ) -> Result { - self.post_generic_with_envelope_headers_and_ssz_body(url, body, timeout, fork, None) + self.post_generic_with_envelope_headers_and_ssz_body(url, body, timeout, fork, None, None) .await } @@ -1424,17 +1471,23 @@ impl BeaconNodeHttpClient { } /// `POST v2/beacon/blocks` + /// `builder_url` echoes the `Eth-Builder-Url` from `produceBlockV4` (beacon-APIs #630) so the + /// beacon node forwards the block to the builder that won selection; `None` for a self-built or + /// p2p-won block. pub async fn post_beacon_blocks_v2_ssz( &self, block_contents: &PublishBlockRequest, validation_level: Option, + builder_url: Option<&str>, ) -> Result { let response = self - .post_generic_with_consensus_version_and_ssz_body( + .post_generic_with_envelope_headers_and_ssz_body( self.post_beacon_blocks_v2_path(validation_level)?, block_contents.as_ssz_bytes(), Some(self.timeouts.proposal), block_contents.signed_block().message().body().fork_name(), + None, + builder_url, ) .await?; @@ -2176,6 +2229,54 @@ impl BeaconNodeHttpClient { Ok(()) } + /// `POST validator/builder_preferences` + /// + /// Ask the beacon node to submit builder preferences ahead of the bid request (beacon-APIs #630). + /// The body is a flat list of entries, each naming its own `proposer_pubkey`, so one request may + /// cover several proposers. `fork_name` is sent as the required `Eth-Consensus-Version` header. + pub async fn post_validator_builder_preferences( + &self, + entries: &SubmittedBuilderPreferences, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("builder_preferences"); + + self.post_with_timeout_and_consensus_header( + path, + &entries, + self.timeouts.default, + fork_name, + ) + .await?; + + Ok(()) + } + + /// `POST validator/builder_preferences` (SSZ) + pub async fn post_validator_builder_preferences_ssz( + &self, + entries: &SubmittedBuilderPreferences, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("builder_preferences"); + + let ssz_body = entries.as_ssz_bytes(); + self.post_generic_with_consensus_version_and_ssz_body(path, ssz_body, None, fork_name) + .await?; + + Ok(()) + } + /// `GET config/fork_schedule` pub async fn get_config_fork_schedule(&self) -> Result>, Error> { let mut path = self.eth_path(V1)?; @@ -2714,16 +2815,15 @@ impl BeaconNodeHttpClient { opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) } - /// returns `GET v4/validator/blocks/{slot}` URL path + /// returns the `POST v4/validator/blocks/{slot}` URL path #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_path( + pub async fn post_validator_blocks_v4_path( &self, slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, include_payload: bool, - builder_booster_factor: Option, graffiti_policy: Option, ) -> Result { let mut path = self.eth_path(V4)?; @@ -2750,11 +2850,6 @@ impl BeaconNodeHttpClient { path.query_pairs_mut() .append_pair("include_payload", &include_payload.to_string()); - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - // Only append the HTTP URL request if the graffiti_policy is PreserveUserGraffiti // If AppendClientVersions (default), then we do not modify the HTTP URL request // so that the default case is compliant to the spec @@ -2766,6 +2861,197 @@ impl BeaconNodeHttpClient { Ok(path) } + /// `POST v4/validator/blocks/{slot}` + #[allow(clippy::too_many_arguments)] + pub async fn post_validator_blocks_v4( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + include_payload: bool, + builder_config: &BuilderConfig, + graffiti_policy: Option, + fork_name: ForkName, + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + self.post_validator_blocks_v4_modular( + slot, + randao_reveal, + graffiti, + SkipRandaoVerification::No, + include_payload, + builder_config, + graffiti_policy, + fork_name, + ) + .await + } + + /// `POST v4/validator/blocks/{slot}` + /// + /// `fork_name` is sent as the required `Eth-Consensus-Version` request header (beacon-APIs + /// #630): the active consensus version the request belongs to. + /// + /// Returns either a bare block or the full [`BlockAndEnvelope`] (block + execution payload + /// envelope + blobs + KZG proofs) depending on the `Eth-Execution-Payload-Included` response + /// header. Note that a builder bid yields a bare block even when `include_payload=true`. + #[allow(clippy::too_many_arguments)] + pub async fn post_validator_blocks_v4_modular( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + include_payload: bool, + builder_config: &BuilderConfig, + graffiti_policy: Option, + fork_name: ForkName, + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + let path = self + .post_validator_blocks_v4_path( + slot, + randao_reveal, + graffiti, + skip_randao_verification, + include_payload, + graffiti_policy, + ) + .await?; + + let opt_result = self + .post_response_with_response_headers( + path, + Accept::Json, + fork_name, + self.timeouts.get_validator_block, + |request| request.json(builder_config), + |response, headers| async move { + let metadata = ProduceBlockV4Metadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + let block_response = if metadata.execution_payload_included { + ProduceBlockV4Response::BlockAndEnvelope( + response + .json::, + ProduceBlockV4Metadata, + >>() + .await? + .data, + ) + } else { + ProduceBlockV4Response::BlockOnly( + response + .json::, + ProduceBlockV4Metadata, + >>() + .await? + .data, + ) + }; + Ok((block_response, metadata)) + }, + ) + .await?; + + opt_result.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + + /// `POST v4/validator/blocks/{slot}` in ssz format + #[allow(clippy::too_many_arguments)] + pub async fn post_validator_blocks_v4_ssz( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + include_payload: bool, + builder_config: &BuilderConfig, + graffiti_policy: Option, + fork_name: ForkName, + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + self.post_validator_blocks_v4_modular_ssz::( + slot, + randao_reveal, + graffiti, + SkipRandaoVerification::No, + include_payload, + builder_config, + graffiti_policy, + fork_name, + ) + .await + } + + /// `POST v4/validator/blocks/{slot}` in ssz format + /// + /// See [`Self::post_validator_blocks_v4_modular`] for the response semantics. + #[allow(clippy::too_many_arguments)] + pub async fn post_validator_blocks_v4_modular_ssz( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + include_payload: bool, + builder_config: &BuilderConfig, + graffiti_policy: Option, + fork_name: ForkName, + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + let path = self + .post_validator_blocks_v4_path( + slot, + randao_reveal, + graffiti, + skip_randao_verification, + include_payload, + graffiti_policy, + ) + .await?; + + let opt_response = self + .post_response_with_response_headers( + path, + Accept::Ssz, + fork_name, + self.timeouts.get_validator_block, + |request| { + request + .header("Content-Type", "application/octet-stream") + .body(builder_config.as_ssz_bytes()) + }, + |response, headers| async move { + let metadata = ProduceBlockV4Metadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + let response_bytes = response.bytes().await?; + let block_response = if metadata.execution_payload_included { + ProduceBlockV4Response::BlockAndEnvelope( + BlockAndEnvelope::from_ssz_bytes_for_fork( + &response_bytes, + metadata.consensus_version, + ) + .map_err(Error::InvalidSsz)?, + ) + } else { + ProduceBlockV4Response::BlockOnly( + BeaconBlock::from_ssz_bytes_for_fork( + &response_bytes, + metadata.consensus_version, + ) + .map_err(Error::InvalidSsz)?, + ) + }; + + Ok((block_response, metadata)) + }, + ) + .await?; + + opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + + // The legacy `GET v4/validator/blocks/{slot}` client methods below are kept alongside the new + // POST variants until the validator client migrates to POST (later in this PR stack), at which + // point they are removed. + /// `GET v4/validator/blocks/{slot}` pub async fn get_validator_blocks_v4( &self, @@ -2804,18 +3090,22 @@ impl BeaconNodeHttpClient { builder_booster_factor: Option, graffiti_policy: Option, ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let path = self - .get_validator_blocks_v4_path( + let mut path = self + .post_validator_blocks_v4_path( slot, randao_reveal, graffiti, skip_randao_verification, include_payload, - builder_booster_factor, graffiti_policy, ) .await?; + if let Some(builder_booster_factor) = builder_booster_factor { + path.query_pairs_mut() + .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); + } + let opt_result = self .get_response_with_response_headers( path, @@ -2889,18 +3179,22 @@ impl BeaconNodeHttpClient { builder_booster_factor: Option, graffiti_policy: Option, ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let path = self - .get_validator_blocks_v4_path( + let mut path = self + .post_validator_blocks_v4_path( slot, randao_reveal, graffiti, skip_randao_verification, include_payload, - builder_booster_factor, graffiti_policy, ) .await?; + if let Some(builder_booster_factor) = builder_booster_factor { + path.query_pairs_mut() + .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); + } + let opt_response = self .get_response_with_response_headers( path, @@ -3040,6 +3334,7 @@ impl BeaconNodeHttpClient { Some(self.timeouts.proposal), fork_name, Some(false), + None, ) .await?; @@ -3087,6 +3382,7 @@ impl BeaconNodeHttpClient { Some(self.timeouts.proposal), fork_name, Some(true), + None, ) .await?; diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 086fc0ce63f..08dc2e00c5f 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1,11 +1,13 @@ //! This module exposes a superset of the `types` crate. It adds additional types that are only //! required for the HTTP API. +pub use builder_types::*; pub use types::*; use crate::{ - CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, EXECUTION_PAYLOAD_BLINDED_HEADER, - EXECUTION_PAYLOAD_INCLUDED_HEADER, EXECUTION_PAYLOAD_VALUE_HEADER, Error as ServerError, + BUILDER_URL_HEADER, CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, + EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, + EXECUTION_PAYLOAD_VALUE_HEADER, Error as ServerError, }; use bls::{PublicKeyBytes, SecretKey, Signature, SignatureBytes}; use context_deserialize::{ContextDeserialize, context_deserialize}; @@ -1959,6 +1961,11 @@ pub struct ProduceBlockV4Metadata { #[serde(with = "serde_utils::u256_dec")] pub execution_payload_value: Uint256, pub execution_payload_included: bool, + /// The URL of the winning builder when the payload bid came through the builder-API channel + /// (the `Eth-Builder-Url` response header). `None` for a self-built block or a p2p bid. Carried + /// only in the header, never the JSON body, so it's skipped by serde. + #[serde(skip_serializing, skip_deserializing, default)] + pub builder_url: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Encode)] @@ -2244,12 +2251,19 @@ impl TryFrom<&HeaderMap> for ProduceBlockV4Metadata { s.parse::() .map_err(|e| format!("invalid {EXECUTION_PAYLOAD_INCLUDED_HEADER}: {e:?}")) })?; + // Optional; an empty or absent value means the block was self-built or a p2p bid won. + let builder_url = headers + .get(BUILDER_URL_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + .filter(|s| !s.is_empty()); Ok(ProduceBlockV4Metadata { consensus_version, consensus_block_value, execution_payload_value, execution_payload_included, + builder_url, }) } } diff --git a/consensus/types/src/core/application_domain.rs b/consensus/types/src/core/application_domain.rs index ff55a910341..0e0f2788705 100644 --- a/consensus/types/src/core/application_domain.rs +++ b/consensus/types/src/core/application_domain.rs @@ -2,16 +2,23 @@ /// Little endian hex: 0x00000001, Binary: 1000000000000000000000000 pub const APPLICATION_DOMAIN_BUILDER: u32 = 16777216; +/// `DOMAIN_REQUEST_AUTH` from builder-specs #165, for Gloas builder-API request authentication. +/// Little endian hex: 0x0B000001 (i.e. `APPLICATION_DOMAIN_BUILDER` with a `0x0B` first byte). +pub const APPLICATION_DOMAIN_REQUEST_AUTH: u32 = 16777227; + #[derive(Debug, PartialEq, Clone, Copy)] pub enum ApplicationDomain { /// NOTE: This domain is only used for out-of-protocol block building, DO NOT use it for Gloas/ePBS. Builder, + /// Authenticates a Gloas builder-API request (`SignedRequestAuth`), per builder-specs #165. + RequestAuth, } impl ApplicationDomain { pub fn get_domain_constant(&self) -> u32 { match self { ApplicationDomain::Builder => APPLICATION_DOMAIN_BUILDER, + ApplicationDomain::RequestAuth => APPLICATION_DOMAIN_REQUEST_AUTH, } } } diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index ddbf73e73af..6aed6a9d502 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -615,6 +615,19 @@ impl ChainSpec { ) } + /// The signing domain for a Gloas builder-API `SignedRequestAuth`. + /// + /// Per builder-specs #165 this is `compute_domain(DOMAIN_REQUEST_AUTH)`: the genesis fork version + /// and a zero genesis-validators-root, matching `get_builder_application_domain`'s out-of-protocol + /// computation but with the `DOMAIN_REQUEST_AUTH` (0x0B000001) domain type. + pub fn get_request_auth_domain(&self) -> Hash256 { + self.compute_domain( + Domain::ApplicationMask(ApplicationDomain::RequestAuth), + self.genesis_fork_version, + Hash256::zero(), + ) + } + /// Return the 32-byte fork data root for the `current_version` and `genesis_validators_root`. /// /// This is used primarily in signature domains to avoid collisions across forks/chains. @@ -3268,6 +3281,19 @@ mod tests { ); } + #[test] + fn test_request_auth_domain() { + let spec = ChainSpec::mainnet(); + let domain = spec.get_request_auth_domain(); + // DOMAIN_REQUEST_AUTH = 0x0B000001 (builder-specs #165), little-endian in the first 4 bytes. + assert_eq!(&domain.as_slice()[0..4], &[0x0B, 0x00, 0x00, 0x01]); + // Same out-of-protocol computation as the builder application domain (genesis fork version, + // zero root), so only the domain-type prefix differs. + let builder = spec.get_builder_application_domain(); + assert_eq!(&domain.as_slice()[4..], &builder.as_slice()[4..]); + assert_ne!(&domain.as_slice()[0..4], &builder.as_slice()[0..4]); + } + fn apply_bit_mask(domain_bytes: [u8; 4], spec: &ChainSpec) -> u32 { let mut domain = [0; 4]; let mask_bytes = int_to_bytes4(spec.domain_application_mask); diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index a26b557adf2..7531543a187 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -750,7 +750,7 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_POST], ); beacon_node - .post_beacon_blocks_v2_ssz(signed_block, None) + .post_beacon_blocks_v2_ssz(signed_block, None, None) .await .map(|_| ()) .or_else(|e| {