From 12670f6bc867ef1bbc1d6d279c3ad208177acbeb Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 11 Aug 2026 15:57:39 -0700 Subject: [PATCH 01/80] feat(pbs): ePBS builder API + strict Eth-Consensus-Version enforcement Adds the ePBS (gloas) builder-API surface and the strict header rulings, rebased onto main so it sits on top of the SSZ rewrite (#468) and the fork-from-slot fix (#487). ePBS endpoints: - getExecutionPayloadBid, submitBuilderPreferences, submitSignedBeaconBlock, with per-builder routing by SignedRequestAuth.data (no Eth-Builder-Url). - SSZ-first request/response with JSON fallback; the bid poll ladder honors the proposer's timing headers. Header discipline (builder-specs #165): - Eth-Consensus-Version required for JSON and SSZ on the request-auth endpoints; absent -> MissingVersionHeader, present-but-unsupported -> InvalidVersionHeader, both 400. The accepted set is Gloas only; any other fork name is a client bug. - A relay bid whose response fork is not Gloas on the Gloas-only endpoint is a bad relay response: dropped, never forwarded under the wrong fork. Status + observability: - zero addressed builders accepting an ePBS submission maps to 500, not 502 (neither endpoint declares 502); new PbsClientError::NoBuilderResponse. - decode/accept rejections counted in BEACON_NODE_STATUS via record_client_error; dropped relay responses in pbs_relay_invalid_response_total{reason,endpoint,relay_id}. Legacy PBS, the websocket get_header stream (#483), and the #487 fork fix are unchanged. Suite green, clippy clean. --- Cargo.lock | 167 +- Cargo.toml | 3 +- benches/pbs/src/main.rs | 3 + config.example.toml | 30 + crates/common/Cargo.toml | 3 +- crates/common/src/config/mux.rs | 3 + crates/common/src/config/pbs.rs | 23 +- crates/common/src/config/signer.rs | 3 + crates/common/src/constants.rs | 8 + crates/common/src/pbs/constants.rs | 11 + crates/common/src/pbs/error.rs | 22 +- crates/common/src/pbs/relay.rs | 116 + crates/common/src/pbs/types/mod.rs | 216 +- crates/common/src/signature.rs | 98 +- crates/common/src/utils.rs | 21 +- crates/common/src/wire.rs | 337 ++- crates/pbs/src/constants.rs | 10 + crates/pbs/src/error.rs | 78 + crates/pbs/src/metrics.rs | 12 + crates/pbs/src/routes/builder_preferences.rs | 397 ++++ .../pbs/src/routes/execution_payload_bid.rs | 1341 ++++++++++++ crates/pbs/src/routes/mod.rs | 6 + crates/pbs/src/routes/router.rs | 22 +- .../src/routes/submit_signed_beacon_block.rs | 146 ++ crates/pbs/src/service.rs | 14 +- crates/pbs/src/utils.rs | 343 ++- crates/signer/src/service.rs | 15 +- tests/Cargo.toml | 1 + tests/src/mock_relay.rs | 441 +++- tests/src/mock_ssv_node.rs | 8 +- tests/src/mock_ssv_public.rs | 8 +- tests/src/mock_validator.rs | 132 +- tests/src/signer_service.rs | 8 +- tests/src/utils.rs | 208 +- tests/tests/pbs_cfg_file_update.rs | 12 +- tests/tests/pbs_get_execution_payload_bid.rs | 1950 +++++++++++++++++ tests/tests/pbs_get_header.rs | 15 +- tests/tests/pbs_get_status.rs | 32 +- tests/tests/pbs_mux.rs | 81 +- tests/tests/pbs_mux_refresh.rs | 9 +- tests/tests/pbs_post_blinded_blocks.rs | 12 +- tests/tests/pbs_post_validators.rs | 45 +- tests/tests/pbs_submit_builder_preferences.rs | 656 ++++++ tests/tests/pbs_submit_signed_beacon_block.rs | 374 ++++ tests/tests/signer_jwt_auth.rs | 20 +- tests/tests/signer_jwt_auth_cleanup.rs | 5 +- tests/tests/signer_request_sig.rs | 14 +- tests/tests/signer_tls.rs | 5 +- 48 files changed, 7184 insertions(+), 300 deletions(-) create mode 100644 crates/pbs/src/routes/builder_preferences.rs create mode 100644 crates/pbs/src/routes/execution_payload_bid.rs create mode 100644 crates/pbs/src/routes/submit_signed_beacon_block.rs create mode 100644 tests/tests/pbs_get_execution_payload_bid.rs create mode 100644 tests/tests/pbs_submit_builder_preferences.rs create mode 100644 tests/tests/pbs_submit_signed_beacon_block.rs diff --git a/Cargo.lock b/Cargo.lock index 61dac49d7..f2b668456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,8 +245,6 @@ dependencies = [ "c-kzg", "derive_more", "either", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", "serde", "serde_with", "sha2", @@ -557,14 +555,10 @@ dependencies = [ "alloy-primitives 1.6.1", "alloy-rpc-types-engine", "derive_more", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", "serde", "serde_json", "serde_with", "thiserror 2.0.19", - "tree_hash 0.10.0", - "tree_hash_derive 0.10.0", ] [[package]] @@ -591,8 +585,6 @@ dependencies = [ "alloy-rlp", "alloy-serde", "derive_more", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", "rand 0.8.7", "serde", "strum", @@ -1649,15 +1641,15 @@ dependencies = [ "alloy-primitives 1.6.1", "arbitrary", "blst", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "ethereum_serde_utils 0.8.1", - "ethereum_ssz 0.10.4", + "ethereum_ssz", "fixed_bytes", "hex", "rand 0.9.5", "safe_arith", "serde", - "tree_hash 0.12.1", + "tree_hash", "zeroize", ] @@ -1889,8 +1881,8 @@ dependencies = [ "eth2", "eth2_keystore", "ethereum_serde_utils 0.7.0", - "ethereum_ssz 0.10.4", - "ethereum_ssz_derive 0.10.4", + "ethereum_ssz", + "ethereum_ssz_derive", "eyre", "futures", "headers-accept", @@ -1907,7 +1899,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", - "ssz_types 0.11.0", + "ssz_types", "tempfile", "thiserror 2.0.19", "tokio", @@ -1916,8 +1908,9 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "tree_hash 0.12.1", - "tree_hash_derive 0.12.1", + "tree_hash", + "tree_hash_derive", + "typenum", "types", "unicode-normalization", "url", @@ -1948,7 +1941,7 @@ dependencies = [ "cb-common", "cb-metrics", "ethereum_serde_utils 0.7.0", - "ethereum_ssz 0.10.4", + "ethereum_ssz", "eyre", "futures", "headers", @@ -1965,7 +1958,7 @@ dependencies = [ "tokio-tungstenite", "tower-http", "tracing", - "tree_hash 0.12.1", + "tree_hash", "types", "url", "uuid 1.24.0", @@ -1999,7 +1992,7 @@ dependencies = [ "tonic", "tonic-build", "tracing", - "tree_hash 0.12.1", + "tree_hash", "uuid 1.24.0", ] @@ -2013,7 +2006,7 @@ dependencies = [ "cb-pbs", "cb-signer", "eth2", - "ethereum_ssz 0.10.4", + "ethereum_ssz", "eyre", "futures", "jsonwebtoken", @@ -2021,6 +2014,7 @@ dependencies = [ "reqwest 0.13.4", "serde", "serde_json", + "ssz_types", "tempfile", "tokio", "tokio-tungstenite", @@ -2028,7 +2022,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-test", - "tree_hash 0.12.1", + "tree_hash", "types", "url", ] @@ -2238,8 +2232,8 @@ dependencies = [ "tempfile", "tokio", "tracing", - "tree_hash 0.12.1", - "tree_hash_derive 0.12.1", + "tree_hash", + "tree_hash_derive", ] [[package]] @@ -3042,8 +3036,8 @@ dependencies = [ "context_deserialize", "educe", "ethereum_serde_utils 0.8.1", - "ethereum_ssz 0.10.4", - "ethereum_ssz_derive 0.10.4", + "ethereum_ssz", + "ethereum_ssz_derive", "futures", "futures-util", "mediatype 0.19.20", @@ -3053,7 +3047,7 @@ dependencies = [ "sensitive_url", "serde", "serde_json", - "ssz_types 0.14.1", + "ssz_types", "superstruct", "types", ] @@ -3064,7 +3058,7 @@ version = "0.2.0" source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "bls", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "hex", "num-bigint", "serde", @@ -3107,17 +3101,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ethereum_hashing" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c853bd72c9e5787f8aafc3df2907c2ed03cff3150c3acd94e2e53a98ab70a8ab" -dependencies = [ - "cpufeatures 0.2.17", - "ring", - "sha2", -] - [[package]] name = "ethereum_hashing" version = "0.8.0" @@ -3155,21 +3138,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "ethereum_ssz" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dcddb2554d19cde19b099fadddde576929d7a4d0c1cd3512d1fd95cf174375c" -dependencies = [ - "alloy-primitives 1.6.1", - "ethereum_serde_utils 0.8.1", - "itertools 0.13.0", - "serde", - "serde_derive", - "smallvec", - "typenum", -] - [[package]] name = "ethereum_ssz" version = "0.10.4" @@ -3186,18 +3154,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ethereum_ssz_derive" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a657b6b3b7e153637dc6bdc6566ad9279d9ee11a15b12cfb24a2e04360637e9f" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "ethereum_ssz_derive" version = "0.10.4" @@ -4422,17 +4378,17 @@ dependencies = [ "arbitrary", "c-kzg", "educe", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "ethereum_serde_utils 0.8.1", - "ethereum_ssz 0.10.4", - "ethereum_ssz_derive 0.10.4", + "ethereum_ssz", + "ethereum_ssz_derive", "hex", "rayon", "rust_eth_kzg", "serde", "serde_json", "tracing", - "tree_hash 0.12.1", + "tree_hash", ] [[package]] @@ -4566,7 +4522,7 @@ version = "0.2.0" source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "alloy-primitives 1.6.1", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "fixed_bytes", "safe_arith", ] @@ -4615,15 +4571,15 @@ dependencies = [ "alloy-primitives 1.6.1", "context_deserialize", "educe", - "ethereum_hashing 0.8.0", - "ethereum_ssz 0.10.4", - "ethereum_ssz_derive 0.10.4", + "ethereum_hashing", + "ethereum_ssz", + "ethereum_ssz_derive", "itertools 0.13.0", "parking_lot", "rayon", "serde", "smallvec", - "tree_hash 0.12.1", + "tree_hash", "triomphe", "typenum", "vec_map", @@ -6594,22 +6550,6 @@ dependencies = [ "der", ] -[[package]] -name = "ssz_types" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b55bedc9a18ed2860a46d6beb4f4082416ee1d60be0cc364cebdcdddc7afd4" -dependencies = [ - "ethereum_serde_utils 0.8.1", - "ethereum_ssz 0.9.1", - "itertools 0.13.0", - "serde", - "serde_derive", - "smallvec", - "tree_hash 0.10.0", - "typenum", -] - [[package]] name = "ssz_types" version = "0.14.1" @@ -6619,12 +6559,12 @@ dependencies = [ "context_deserialize", "educe", "ethereum_serde_utils 0.8.1", - "ethereum_ssz 0.10.4", + "ethereum_ssz", "itertools 0.14.0", "serde", "serde_derive", "smallvec", - "tree_hash 0.12.1", + "tree_hash", "typenum", ] @@ -6710,7 +6650,7 @@ version = "0.2.0" source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "alloy-primitives 1.6.1", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "fixed_bytes", ] @@ -7412,19 +7352,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "tree_hash" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee44f4cef85f88b4dea21c0b1f58320bdf35715cf56d840969487cff00613321" -dependencies = [ - "alloy-primitives 1.6.1", - "ethereum_hashing 0.7.0", - "ethereum_ssz 0.9.1", - "smallvec", - "typenum", -] - [[package]] name = "tree_hash" version = "0.12.1" @@ -7432,24 +7359,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7fd51aa83d2eb83b04570808430808b5d24fdbf479a4d5ac5dee4a2e2dd2be4" dependencies = [ "alloy-primitives 1.6.1", - "ethereum_hashing 0.8.0", - "ethereum_ssz 0.10.4", + "ethereum_hashing", + "ethereum_ssz", "smallvec", "typenum", ] -[[package]] -name = "tree_hash_derive" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bee2ea1551f90040ab0e34b6fb7f2fa3bad8acc925837ac654f2c78a13e3089" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tree_hash_derive" version = "0.12.1" @@ -7515,10 +7430,10 @@ dependencies = [ "context_deserialize", "educe", "eth2_interop_keypairs", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "ethereum_serde_utils 0.8.1", - "ethereum_ssz 0.10.4", - "ethereum_ssz_derive 0.10.4", + "ethereum_ssz", + "ethereum_ssz_derive", "fixed_bytes", "hex", "int_to_bytes", @@ -7539,14 +7454,14 @@ dependencies = [ "serde_json", "serde_yaml", "smallvec", - "ssz_types 0.14.1", + "ssz_types", "superstruct", "swap_or_not_shuffle", "tempfile", "test_random_derive", "tracing", - "tree_hash 0.12.1", - "tree_hash_derive 0.12.1", + "tree_hash", + "tree_hash_derive", "typenum", ] diff --git a/Cargo.toml b/Cargo.toml index 7492148df..9ffd37754 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ alloy = { version = "^1.0.35", features = [ "rpc-types-beacon", "serde", "signer-local", - "ssz", ] } alloy-primitives = "^1.3.1" assert_cmd = "2.1.2" @@ -74,7 +73,7 @@ serde = { version = "1.0.202", features = ["derive"] } serde_json = "1.0.117" serde_yaml = "0.9.33" sha2 = "0.10.8" -ssz_types = "0.11" +ssz_types = "0.14.1" subtle = "2.5" tempfile = "3.20.0" thiserror = "2.0.12" diff --git a/benches/pbs/src/main.rs b/benches/pbs/src/main.rs index 4a0007652..986d2173a 100644 --- a/benches/pbs/src/main.rs +++ b/benches/pbs/src/main.rs @@ -161,7 +161,10 @@ fn get_mock_validator(bench: BenchConfig) -> RelayClient { enable_timing_games: false, target_first_request_ms: None, frequency_get_header_ms: None, + bid_poll_timeout_ms: None, validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: None, }; RelayClient::new(config).unwrap() diff --git a/config.example.toml b/config.example.toml index 4b6b2853f..7a4959c21 100644 --- a/config.example.toml +++ b/config.example.toml @@ -44,6 +44,19 @@ skip_sigverify = false # Can be specified as a float or a string for extra precision (e.g. "0.01") # OPTIONAL, DEFAULT: 0.0 min_bid_eth = 0.0 +# Maximum trusted execution payment in Gwei accepted in an ePBS bid. With the default of 0 any bid +# carrying an execution payment is rejected, so payments only flow through the on-chain trustless +# mechanism. Can be overridden per relay with `max_execution_payment_gwei` on the relay entry +# OPTIONAL, DEFAULT: 0 +max_execution_payment_gwei = 0 +# Expected fee recipient in ePBS bids. When set, bids whose fee_recipient differs are rejected +# OPTIONAL, DEFAULT: unset (no check) +# fee_recipient = "0x1234567890123456789012345678901234567890" +# Whether to verify the BLS signature of the `SignedRequestAuth` on an ePBS request against the +# proposer pubkey in the request path, rejecting a bad signature with 401. CB forwards by default +# because the downstream builder must re-verify anyway; operators terminating trust at CB set it true +# OPTIONAL, DEFAULT: false +verify_request_auth = false # How late in milliseconds in the slot is "late". This impacts the `get_header` requests, by shortening timeouts for `get_header` calls to # relays and make sure a header is returned within this deadline. If the request from the CL comes later in the slot, then fetching headers is skipped # to force local building and miniminzing the risk of missed slots. See also the timing games section below @@ -130,6 +143,20 @@ target_first_request_ms = 200 # Frequency in ms to send get_header requests # OPTIONAL frequency_get_header_ms = 300 +# How long each ePBS bid poll may take, in ms. Every poll shares the proposer's deadline, so bounding +# the early ones lands a floor of progressively better bids instead of leaving everything to the last +# instant; the final poll always holds for the remaining budget. Set this above your round trip to +# the builder, or every early poll times out +# OPTIONAL, DEFAULT: 500 +# bid_poll_timeout_ms = 500 +# Maximum trusted execution payment in Gwei accepted in an ePBS bid from this relay +# OPTIONAL, DEFAULT: the PBS-level `max_execution_payment_gwei` +# max_execution_payment_gwei = 0 +# The ePBS auth data this relay serves: a bid request routes here only when its `auth.message.data` +# equals this value. When unset, the relay is matched only by auth data carrying its URL; auth data +# matching no configured relay is rejected with 400 +# OPTIONAL, DEFAULT: unset +# expected_auth_data = "0x68747470733a2f2f6275696c6465722e6578616d706c652e636f6d" # Configuration for the PBS multiplexers, which enable different configs to be used for get header requests, depending on validator pubkey # Note that: @@ -145,6 +172,9 @@ validator_pubkeys = [ "0x80c7f782b2467c5898c5516a8b6595d75623960b4afc4f71ee07d40985d20e117ba35e7cd352a3e75fb85a8668a3b745", "0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09fe73ccd21f88eab31d6de16194d17782e", ] +# Expected fee recipient in ePBS bids for this mux's validators +# OPTIONAL, DEFAULT: the PBS-level `fee_recipient` +# fee_recipient = "0x1234567890123456789012345678901234567890" # Loader for validator pubkeys. Three types of loaders are supported: # - File: path to a file containing a list of validator pubkeys in JSON format # - URL: URL to an HTTP endpoint returning a list of validator pubkeys in JSON format diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 7350b85cc..affcba71b 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -53,10 +53,11 @@ tracing-appender.workspace = true tracing-subscriber.workspace = true tree_hash.workspace = true tree_hash_derive.workspace = true +typenum.workspace = true unicode-normalization.workspace = true url.workspace = true uuid.workspace = true reqwest-eventsource.workspace = true [dev-dependencies] - tempfile.workspace = true \ No newline at end of file + tempfile.workspace = true diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index e9072ba82..fb8390c3f 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -112,6 +112,7 @@ impl PbsMuxes { late_in_slot_time_ms: mux .late_in_slot_time_ms .unwrap_or(default_pbs.late_in_slot_time_ms), + fee_recipient: mux.fee_recipient.or(default_pbs.fee_recipient), ..default_pbs.clone() }; config.validate(chain).await?; @@ -153,6 +154,8 @@ pub struct MuxConfig { pub loader: Option, pub timeout_get_header_ms: Option, pub late_in_slot_time_ms: Option, + /// Expected fee recipient in ePBS bids for this mux's validators + pub fee_recipient: Option
, } impl MuxConfig { diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index c261db59d..94d8ad096 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -8,7 +8,7 @@ use std::{ }; use alloy::{ - primitives::{U256, utils::format_ether}, + primitives::{Address, Bytes, U256, utils::format_ether}, providers::{Provider, ProviderBuilder}, }; use docker_image::DockerImage; @@ -68,10 +68,19 @@ pub struct RelayConfig { pub target_first_request_ms: Option, /// Frequency in ms to send get_header requests pub frequency_get_header_ms: Option, + /// How long each ePBS bid poll may take, except the last which holds until + /// the proposer's deadline + pub bid_poll_timeout_ms: Option, /// Maximum number of validators to send to relays in one registration /// request #[serde(deserialize_with = "empty_string_as_none", default)] pub validator_registration_batch_size: Option, + /// Maximum trusted execution payment in Gwei accepted in an ePBS bid + pub max_execution_payment_gwei: Option, + /// ePBS auth data this relay serves: a bid request routes here only when + /// its `auth.message.data` equals this value. When unset, the relay is + /// matched only by auth data carrying its URL + pub expected_auth_data: Option, } fn empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> @@ -129,6 +138,18 @@ pub struct PbsConfig { /// Minimum bid that will be accepted from get_header #[serde(rename = "min_bid_eth", with = "as_eth_str", default = "default_u256")] pub min_bid_wei: U256, + /// Maximum trusted execution payment in Gwei accepted in an ePBS bid + #[serde(default = "default_u64::<0>")] + pub max_execution_payment_gwei: u64, + /// When enabled, the BLS signature of an ePBS request's + /// `SignedRequestAuth` is verified against the proposer pubkey. False by + /// default: CB forwards because the downstream builder must re-verify + /// anyway; operators terminating trust at CB set it true + #[serde(default = "default_bool::")] + pub verify_request_auth: bool, + /// Expected fee recipient in ePBS bids; when set, bids with a different + /// fee_recipient are rejected + pub fee_recipient: Option
, /// How late in the slot we consider to be "late" #[serde(default = "default_u64::")] pub late_in_slot_time_ms: u64, diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 0ac6ce1b9..a0bf66f3a 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -475,6 +475,9 @@ mod tests { timeout_register_validator_ms: 0, skip_sigverify: false, min_bid_wei: Uint::<256, 4>::from(0), + max_execution_payment_gwei: 0, + verify_request_auth: false, + fee_recipient: None, late_in_slot_time_ms: 0, extra_validation_enabled: false, rpc_url: None, diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs index c075ed135..952c2ccd0 100644 --- a/crates/common/src/constants.rs +++ b/crates/common/src/constants.rs @@ -1,4 +1,12 @@ pub const APPLICATION_BUILDER_DOMAIN: [u8; 4] = [0, 0, 0, 1]; +// In-protocol builder domain (consensus-specs DOMAIN_BEACON_BUILDER); not the +// legacy builder domain 0x00000001 nor the request-auth domain 0x0B000001. +pub const DOMAIN_BEACON_BUILDER: [u8; 4] = [0x0B, 0x00, 0x00, 0x00]; +// Out-of-protocol Builder API request-auth domain (builder-specs +// DOMAIN_REQUEST_AUTH), for `RequestAuth` only. +pub const DOMAIN_REQUEST_AUTH: [u8; 4] = [0x0B, 0x00, 0x00, 0x01]; +// TODO placeholders: gloas devnet fork version +pub const GLOAS_FORK_VERSION: [u8; 4] = [0x80, 0x43, 0x50, 0x48]; pub const GENESIS_VALIDATORS_ROOT: [u8; 32] = [0; 32]; pub const COMMIT_BOOST_DOMAIN: [u8; 4] = [109, 109, 111, 67]; pub const COMMIT_BOOST_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/common/src/pbs/constants.rs b/crates/common/src/pbs/constants.rs index 66ab42f06..2e2818c50 100644 --- a/crates/common/src/pbs/constants.rs +++ b/crates/common/src/pbs/constants.rs @@ -11,6 +11,11 @@ pub const REGISTER_VALIDATOR_PATH: &str = "/validators"; pub const SUBMIT_BLOCK_PATH: &str = "/blinded_blocks"; pub const RELOAD_PATH: &str = "/reload"; +pub const GET_EXECUTION_PAYLOAD_BID_PATH: &str = + "/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}"; +pub const SUBMIT_BUILDER_PREFERENCES_PATH: &str = "/builder_preferences/{proposer_pubkey}"; +pub const SUBMIT_SIGNED_BEACON_BLOCK_PATH: &str = "/beacon_blocks"; + // https://ethereum.github.io/builder-specs/#/Builder // Currently unused to enable a stateless default PBS module @@ -36,6 +41,12 @@ impl DefaultTimeout { pub const LATE_IN_SLOT_TIME_MS: u64 = 2000; +/// How long each ePBS bid poll may take before the next one supersedes it. Set +/// generously: a proposer far from its builders needs more than the poll +/// cadence to land any bid at all, and a value below the round trip would time +/// out every poll. The final poll ignores this and holds until the deadline. +pub const DEFAULT_BID_POLL_TIMEOUT_MS: u64 = 500; + // Maximum number of retries for validator registration request per relay pub const REGISTER_VALIDATOR_RETRY_LIMIT: u32 = 3; diff --git a/crates/common/src/pbs/error.rs b/crates/common/src/pbs/error.rs index 8fcd928f1..26d89c65a 100644 --- a/crates/common/src/pbs/error.rs +++ b/crates/common/src/pbs/error.rs @@ -1,4 +1,4 @@ -use alloy::primitives::{B256, U256}; +use alloy::primitives::{Address, B256, U256}; use lh_types::ForkName; use thiserror::Error; @@ -59,7 +59,7 @@ impl PbsError { } /// Extract the HTTP status code from relay-originated errors. - fn relay_status_code(&self) -> Option { + pub fn relay_status_code(&self) -> Option { match self { PbsError::RelayResponse { code, .. } => Some(*code), PbsError::ReadResponse(ResponseReadError::NonSuccess { status_code, .. }) => { @@ -93,6 +93,9 @@ pub enum ValidationError { #[error("parent hash mismatch: expected {expected} got {got}")] ParentHashMismatch { expected: B256, got: B256 }, + #[error("parent root mismatch: expected {expected} got {got}")] + ParentRootMismatch { expected: B256, got: B256 }, + #[error("block hash mismatch: expected {expected} got {got}")] BlockHashMismatch { expected: B256, got: B256 }, @@ -112,6 +115,18 @@ pub enum ValidationError { #[error("bid below minimum: min: {min} got {got}")] BidTooLow { min: U256, got: U256 }, + #[error("total payment below minimum bid (gwei): min: {min} got {got}")] + TotalPaymentTooLow { min: u64, got: u64 }, + + #[error("fee recipient mismatch: expected {expected} got {got}")] + FeeRecipientMismatch { expected: Address, got: Address }, + + #[error("trusted bid above maximum (gwei): max: {max} got {got}")] + TrustedBidTooHigh { max: u64, got: u64 }, + + #[error("empty parent root")] + EmptyParentRoot, + #[error("empty tx root")] EmptyTxRoot, @@ -124,6 +139,9 @@ pub enum ValidationError { #[error("wrong block number: parent: {parent} header: {header}")] BlockNumberMismatch { parent: u64, header: u64 }, + #[error("wrong slot number: expected: {expected} got: {got}")] + SlotNumberMismatch { expected: u64, got: u64 }, + #[error("invalid gas limit: parent: {parent} header: {header}")] GasLimit { parent: u64, header: u64 }, diff --git a/crates/common/src/pbs/relay.rs b/crates/common/src/pbs/relay.rs index 91327a952..978694266 100644 --- a/crates/common/src/pbs/relay.rs +++ b/crates/common/src/pbs/relay.rs @@ -10,6 +10,7 @@ use super::{ HEADER_VERSION_KEY, HEADER_VERSION_VALUE, constants::{ GET_HEADER_STREAM_PATH, GET_STATUS_PATH, REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, + SUBMIT_SIGNED_BEACON_BLOCK_PATH, }, error::PbsError, }; @@ -200,6 +201,39 @@ impl RelayClient { pub fn submit_block_url(&self, api_version: BuilderApiVersion) -> Result { self.builder_api_url(SUBMIT_BLOCK_PATH, api_version) } + + /// builder-API: POST /eth/v1/builder/execution_payload_bid/{slot}/ + /// {parent_hash}/{parent_root}/{proposer_pubkey} + pub fn get_execution_payload_bid_url( + &self, + slot: u64, + parent_hash: &B256, + parent_root: &B256, + validator_pubkey: &BlsPublicKey, + ) -> Result { + self.builder_api_url( + &format!( + "/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{validator_pubkey}" + ), + BuilderApiVersion::V1, + ) + } + + /// builder-API: POST /eth/v1/builder/builder_preferences/{proposer_pubkey} + pub fn submit_builder_preferences_url( + &self, + validator_pubkey: &BlsPublicKey, + ) -> Result { + self.builder_api_url( + &format!("/builder_preferences/{validator_pubkey}"), + BuilderApiVersion::V1, + ) + } + + /// builder-API: POST /eth/v1/builder/beacon_blocks + pub fn submit_signed_beacon_block_url(&self) -> Result { + self.builder_api_url(SUBMIT_SIGNED_BEACON_BLOCK_PATH, BuilderApiVersion::V1) + } } #[cfg(test)] @@ -401,4 +435,86 @@ mod tests { let config = serde_json::from_str::(relay_config).unwrap(); assert_eq!(config.get_header, GetHeaderTransport::Http); } + + #[test] + fn test_relay_url_get_execution_payload() { + let slot = 0; + let parent_hash = B256::ZERO; + let parent_root = B256::ZERO; + let validator_pubkey = bls_pubkey_from_hex_unchecked( + "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", + ); + let expected = format!( + "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz/eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{validator_pubkey}" + ); + + let relay_config = r#" + { + "url": "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz" + }"#; + + let config = serde_json::from_str::(relay_config).unwrap(); + let relay = RelayClient::new(config).unwrap(); + + assert_eq!( + relay + .get_execution_payload_bid_url(slot, &parent_hash, &parent_root, &validator_pubkey) + .unwrap() + .to_string(), + expected + ); + + let relay_config = r#" + { + "url": "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz//" + }"#; + + let config = serde_json::from_str::(relay_config).unwrap(); + let relay = RelayClient::new(config).unwrap(); + + assert_eq!( + relay + .get_execution_payload_bid_url(slot, &parent_hash, &parent_root, &validator_pubkey) + .unwrap() + .to_string(), + expected + ); + } + + #[test] + fn test_relay_url_with_get_params_get_execution_payload() { + let slot = 0; + let parent_hash = B256::ZERO; + let parent_root = B256::ZERO; + let validator_pubkey = bls_pubkey_from_hex_unchecked( + "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", + ); + // Note: HashMap iteration order is not guaranteed, so we can't predict the + // exact order of parameters Instead of hard-coding the order, we'll + // check that both parameters are present in the URL + let url_prefix = format!( + "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz/eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{validator_pubkey}?" + ); + + let mut get_params = HashMap::new(); + get_params.insert("param1".to_string(), "value1".to_string()); + get_params.insert("param2".to_string(), "value2".to_string()); + + let relay_config = r#" + { + "url": "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz" + }"#; + + let mut config = serde_json::from_str::(relay_config).unwrap(); + config.get_params = Some(get_params); + let relay = RelayClient::new(config).unwrap(); + + let url = relay + .get_execution_payload_bid_url(slot, &parent_hash, &parent_root, &validator_pubkey) + .unwrap() + .to_string(); + assert!(url.starts_with(&url_prefix)); + assert!(url.contains("param1=value1")); + assert!(url.contains("param2=value2")); + } } diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index 738221a8d..fec7c9308 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -1,10 +1,13 @@ -use alloy::primitives::{B256, U256, b256}; +use alloy::primitives::{Address, B256, U256, b256}; pub use lh_eth2::ForkVersionedResponse; pub use lh_types::ForkName; -use lh_types::{BlindedPayload, ExecPayload, MainnetEthSpec}; +use lh_types::{BlindedPayload, ExecPayload, MainnetEthSpec, Slot}; use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use tree_hash_derive::TreeHash; -use crate::types::BlsPublicKey; +use crate::types::{BlsPublicKey, BlsSignature}; pub const EMPTY_TX_ROOT_HASH: B256 = b256!("7ffe241ea60187fdb0187bfa22de35d1f9bed7ab061d9401fd47e34a54fbede1"); @@ -13,6 +16,8 @@ pub type ExecutionRequests = lh_types::ExecutionRequests; /// Request object of POST `/eth/v1/builder/blinded_blocks` pub type SignedBlindedBeaconBlock = lh_types::SignedBlindedBeaconBlock; +/// Request object of POST `/eth/v1/builder/beacon_blocks` (Gloas onwards) +pub type SignedBeaconBlock = lh_types::SignedBeaconBlock; pub type BlindedBeaconBlock<'a> = lh_types::BeaconBlockRef<'a, MainnetEthSpec, BlindedPayload>; pub type BlindedBeaconBlockElectra = @@ -62,6 +67,85 @@ pub struct GetHeaderParams { pub pubkey: BlsPublicKey, } +pub type ExecutionPayloadBid = lh_types::ExecutionPayloadBid; +pub type SignedExecutionPayloadBid = lh_types::SignedExecutionPayloadBid; + +/// Whether `block` is a Gloas block. The submit endpoint is Gloas-only per +/// spec. +pub fn is_gloas(block: &SignedBeaconBlock) -> bool { + matches!(block, lh_types::SignedBeaconBlock::Gloas(_)) +} + +/// Response object of POST +/// `/eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/ +/// {proposer_pubkey}` +pub type GetExecutionPayloadBidResponse = ForkVersionedResponse; + +pub trait GetExecutionPayloadBidInfo { + fn block_hash(&self) -> B256; + fn parent_hash(&self) -> B256; + fn parent_root(&self) -> B256; + fn value(&self) -> u64; + fn execution_payment(&self) -> u64; + fn fee_recipient(&self) -> Address; + fn builder_index(&self) -> u64; + fn slot(&self) -> u64; + fn gas_limit(&self) -> u64; +} + +impl GetExecutionPayloadBidInfo for GetExecutionPayloadBidResponse { + fn block_hash(&self) -> B256 { + self.data.message.block_hash.0 + } + + fn parent_hash(&self) -> B256 { + self.data.message.parent_block_hash.0 + } + + fn parent_root(&self) -> B256 { + self.data.message.parent_block_root + } + + fn value(&self) -> u64 { + self.data.message.value + } + + fn execution_payment(&self) -> u64 { + self.data.message.execution_payment + } + + fn fee_recipient(&self) -> Address { + self.data.message.fee_recipient + } + + fn builder_index(&self) -> u64 { + self.data.message.builder_index + } + + fn slot(&self) -> u64 { + self.data.message.slot.as_u64() + } + + fn gas_limit(&self) -> u64 { + self.data.message.gas_limit + } +} + +/// Path params of POST +/// `/eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/ +/// {proposer_pubkey}` +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GetExecutionPayloadBidParams { + /// The slot for which the block should be proposed. + pub slot: u64, + /// The hash of the execution layer block the proposer will build on. + pub parent_hash: B256, + /// The root of the beacon block the proposer will build on. + pub parent_root: B256, + /// The public key of the proposer + pub proposer_pubkey: BlsPublicKey, +} + pub trait GetHeaderInfo { fn block_hash(&self) -> B256; fn value(&self) -> &U256; @@ -107,3 +191,129 @@ impl GetPayloadInfo for SignedBlindedBeaconBlock { self.message().body().execution_payload().map(|r| r.parent_hash().0).unwrap_or_default() } } + +#[allow(non_camel_case_types)] +pub type MAX_DATA_SIZE = typenum::U4096; + +// `RequestAuth` is used to authenticate requests to a builder. This is useful +// so that other builders do not DDOS or run replay attacks on the builder. +#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone, TreeHash)] +pub struct RequestAuth { + /// Opaque authentication data agreed with the builder out of band; hex + /// string on the JSON wire + #[serde(with = "ssz_types::serde_utils::hex_var_list")] + pub data: VariableList, + pub slot: Slot, +} + +// `SignedRequestAuth` +#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] +pub struct SignedRequestAuth { + pub message: RequestAuth, + pub signature: BlsSignature, +} + +/// Per-builder preferences a proposer submits ahead of the bid request. +#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] +pub struct BuilderPreferences { + /// Maximum execution-layer payment, in Gwei, this proposer will accept from + /// this builder; quoted string on the JSON wire + #[serde(with = "serde_utils::quoted_u64")] + pub max_execution_payment: u64, +} + +/// The `submitBuilderPreferences` request body. +/// SSZ field order per builder-specs `gloas/validator.md` +#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] +pub struct BuilderPreferencesRequest { + pub auth: SignedRequestAuth, + pub preferences: BuilderPreferences, +} + +/// Path params for `POST /eth/v1/builder/builder_preferences/{proposer_pubkey}` +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SubmitBuilderPreferencesParams { + /// The public key of the proposer expressing these preferences + pub proposer_pubkey: BlsPublicKey, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `data` is an opaque hex STRING on the wire + #[test] + fn test_request_auth_data_serializes_as_hex() { + let auth = SignedRequestAuth { + message: RequestAuth { + data: VariableList::new(vec![0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]) + .unwrap(), + slot: Slot::new(100), + }, + signature: BlsSignature::empty(), + }; + let json = serde_json::to_value(&auth).unwrap(); + assert_eq!(json["message"]["data"], "0x1234567890abcdef"); + assert_eq!(json["message"]["slot"], "100"); + } + + /// Round-trip the spec's wire shape back into the struct + #[test] + fn test_request_auth_deserializes_spec_json() { + let json = r#"{ + "message": { + "data": "0x1234567890abcdef", + "slot": "100" + }, + "signature": "0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }"#; + let auth: SignedRequestAuth = serde_json::from_str(json).unwrap(); + assert_eq!(auth.message.data.to_vec(), vec![ + 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef + ]); + assert_eq!(auth.message.slot, Slot::new(100)); + } + + /// Spec vector for the SSZ layout of `BuilderPreferencesRequest`: + /// `(auth, preferences)` per specs/gloas/validator.md + #[test] + fn test_builder_preferences_request_ssz_spec_vector() { + use ssz::{Decode, Encode}; + + // The infinity signature: 0xc0 followed by 95 zero bytes + let mut infinity_sig = vec![0u8; 96]; + infinity_sig[0] = 0xc0; + + let auth = SignedRequestAuth { + message: RequestAuth { + data: VariableList::new(vec![0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]) + .unwrap(), + slot: Slot::new(1234), + }, + signature: BlsSignature::deserialize(&infinity_sig).unwrap(), + }; + let request = BuilderPreferencesRequest { + auth: auth.clone(), + preferences: BuilderPreferences { max_execution_payment: 1_000_000_000 }, + }; + + // Hand-assembled outer container: 4-byte offset to the variable-size + // `auth` (12 = 4-byte offset + 8-byte fixed `preferences`), the 8-byte + // `max_execution_payment` LE, then the `auth` bytes + let auth_bytes = auth.as_ssz_bytes(); + let mut expected = Vec::new(); + expected.extend_from_slice(&12u32.to_le_bytes()); + expected.extend_from_slice(&1_000_000_000u64.to_le_bytes()); + expected.extend_from_slice(&auth_bytes); + + assert_eq!(request.as_ssz_bytes(), expected); + + let decoded = BuilderPreferencesRequest::from_ssz_bytes(&expected).unwrap(); + assert_eq!(decoded.preferences.max_execution_payment, 1_000_000_000); + assert_eq!(decoded.auth.message.slot, Slot::new(1234)); + assert_eq!(decoded.auth.message.data.to_vec(), vec![ + 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef + ]); + assert_eq!(decoded.auth.signature.serialize().to_vec(), infinity_sig); + } +} diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index 41631e33a..946fe664d 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -3,7 +3,9 @@ use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ - constants::{COMMIT_BOOST_DOMAIN, GENESIS_VALIDATORS_ROOT}, + constants::{ + COMMIT_BOOST_DOMAIN, DOMAIN_BEACON_BUILDER, DOMAIN_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, + }, signer::{EcdsaSignature, verify_bls_signature, verify_ecdsa_signature}, types::{self, BlsPublicKey, BlsSecretKey, BlsSignature, Chain, SignatureRequestInfo}, }; @@ -35,10 +37,21 @@ pub fn compute_prop_commit_signing_root( } } -// NOTE: this currently works only for builder domain signatures and -// verifications -// ref: https://github.com/ralexstokes/ethereum-consensus/blob/cf3c404043230559660810bc0c9d6d5a8498d819/ethereum-consensus/src/builder/mod.rs#L26-L29 +// Signing domain from a chain + 4-byte domain mask (genesis fork version, zero +// root). ref: https://github.com/ralexstokes/ethereum-consensus/blob/cf3c404043230559660810bc0c9d6d5a8498d819/ethereum-consensus/src/builder/mod.rs#L26-L29 pub fn compute_domain(chain: Chain, domain_mask: &B32) -> B256 { + compute_domain_with_fork_version( + chain.genesis_fork_version(), + GENESIS_VALIDATORS_ROOT.into(), + domain_mask, + ) +} + +pub fn compute_domain_with_fork_version( + fork_version: [u8; 4], + genesis_validators_root: B256, + domain_mask: &B32, +) -> B256 { #[derive(Debug, TreeHash)] struct ForkData { fork_version: [u8; 4], @@ -48,8 +61,7 @@ pub fn compute_domain(chain: Chain, domain_mask: &B32) -> B256 { let mut domain = [0u8; 32]; domain[..4].copy_from_slice(&domain_mask.0); - let fork_version = chain.genesis_fork_version(); - let fd = ForkData { fork_version, genesis_validators_root: GENESIS_VALIDATORS_ROOT.into() }; + let fd = ForkData { fork_version, genesis_validators_root }; let fork_data_root = fd.tree_hash_root(); domain[4..].copy_from_slice(&fork_data_root[..28]); @@ -57,6 +69,51 @@ pub fn compute_domain(chain: Chain, domain_mask: &B32) -> B256 { B256::from(domain) } +/// ePBS bid signing domain, mirroring consensus-specs +/// `get_domain(state, DOMAIN_BEACON_BUILDER)`. +pub fn execution_payload_bid_domain(fork_version: [u8; 4], genesis_validators_root: B256) -> B256 { + compute_domain_with_fork_version( + fork_version, + genesis_validators_root, + &B32::from(DOMAIN_BEACON_BUILDER), + ) +} + +/// Builder API request-auth signing domain. The request WIRE type is +/// fork-versioned per builder-specs, but the signing domain is not: the spec's +/// `compute_domain(DOMAIN_REQUEST_AUTH)` takes the genesis fork version and a +/// zero root, exactly like the validator registrations it replaces. +pub fn request_auth_domain(chain: Chain) -> B256 { + compute_domain(chain, &B32::from(DOMAIN_REQUEST_AUTH)) +} + +/// Signs a `RequestAuth` message root under the request-auth domain. +pub fn sign_request_auth_root( + secret_key: &BlsSecretKey, + object_root: &B256, + chain: Chain, +) -> BlsSignature { + let signing_data = types::SigningData { + object_root: *object_root, + signing_domain: request_auth_domain(chain), + }; + sign_message(secret_key, signing_data.tree_hash_root()) +} + +/// Verifies a `SignedRequestAuth` signature under the request-auth domain. +pub fn verify_request_auth_signature( + pubkey: &BlsPublicKey, + msg: &T, + signature: &BlsSignature, + chain: Chain, +) -> bool { + let signing_data = types::SigningData { + object_root: msg.tree_hash_root(), + signing_domain: request_auth_domain(chain), + }; + verify_bls_signature(pubkey, signing_data.tree_hash_root(), signature) +} + pub fn verify_signed_message( chain: Chain, pubkey: &BlsPublicKey, @@ -95,6 +152,35 @@ pub fn sign_builder_root( sign_message(secret_key, signing_root) } +/// Signs a message root under the ePBS execution payload bid domain. +pub fn sign_execution_payload_bid_root( + secret_key: &BlsSecretKey, + object_root: &B256, + fork_version: [u8; 4], + genesis_validators_root: B256, +) -> BlsSignature { + let signing_data = types::SigningData { + object_root: *object_root, + signing_domain: execution_payload_bid_domain(fork_version, genesis_validators_root), + }; + sign_message(secret_key, signing_data.tree_hash_root()) +} + +/// Verifies an ePBS execution payload bid signature under the bid domain. +pub fn verify_execution_payload_bid_signature( + pubkey: &BlsPublicKey, + msg: &T, + signature: &BlsSignature, + fork_version: [u8; 4], + genesis_validators_root: B256, +) -> bool { + let signing_data = types::SigningData { + object_root: msg.tree_hash_root(), + signing_domain: execution_payload_bid_domain(fork_version, genesis_validators_root), + }; + verify_bls_signature(pubkey, signing_data.tree_hash_root(), signature) +} + pub fn sign_commit_boost_root( chain: Chain, secret_key: &BlsSecretKey, diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index c0b53b4c0..1632cf32d 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -27,11 +27,13 @@ use crate::{ const MILLIS_PER_SECOND: u64 = 1_000; +// Saturating: an attacker-supplied huge slot must clamp to the far future, not +// overflow-panic in debug builds and drop the connection with no response pub fn timestamp_of_slot_start_sec(slot: u64, chain: Chain) -> u64 { - chain.genesis_time_sec() + slot * chain.slot_time_sec() + chain.genesis_time_sec().saturating_add(slot.saturating_mul(chain.slot_time_sec())) } pub fn timestamp_of_slot_start_millis(slot: u64, chain: Chain) -> u64 { - timestamp_of_slot_start_sec(slot, chain) * MILLIS_PER_SECOND + timestamp_of_slot_start_sec(slot, chain).saturating_mul(MILLIS_PER_SECOND) } pub fn ms_into_slot(slot: u64, chain: Chain) -> u64 { let slot_start_ms = timestamp_of_slot_start_millis(slot, chain); @@ -461,14 +463,25 @@ mod test { use alloy::primitives::keccak256; use super::{ - create_admin_jwt, create_jwt, decode_admin_jwt, decode_jwt, random_jwt_secret, + create_admin_jwt, create_jwt, decode_admin_jwt, decode_jwt, ms_into_slot, + random_jwt_secret, timestamp_of_slot_start_millis, timestamp_of_slot_start_sec, validate_admin_jwt, validate_jwt, }; use crate::{ constants::SIGNER_JWT_EXPIRATION, - types::{Jwt, JwtAdminClaims, ModuleId}, + types::{Chain, Jwt, JwtAdminClaims, ModuleId}, }; + // An attacker-supplied huge slot must saturate to the far future, not + // overflow-panic in debug builds and drop the connection with no response + #[test] + fn test_slot_timestamp_saturates_on_huge_slot() { + assert_eq!(timestamp_of_slot_start_sec(u64::MAX, Chain::Mainnet), u64::MAX); + assert_eq!(timestamp_of_slot_start_millis(u64::MAX, Chain::Mainnet), u64::MAX); + // The far-future slot has not started, so no time has elapsed into it + assert_eq!(ms_into_slot(u64::MAX, Chain::Mainnet), 0); + } + #[test] fn test_jwt_validation_no_payload_hash() { // Check valid JWT diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index 4f4263980..79e3819da 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -6,15 +6,19 @@ use axum::http::HeaderValue; use bytes::Bytes; use futures::StreamExt; use headers_accept::Accept; -use lh_types::{BeaconBlock, ForkName, SignedBeaconBlock, map_fork_name}; +use lh_types::{ + BeaconBlock, ForkName, ForkVersionDecode, SignedBeaconBlock as LhSignedBeaconBlock, + map_fork_name, +}; use mediatype::{MediaType, ReadParams, names}; use reqwest::{ Response, header::{ACCEPT, CONTENT_TYPE, HeaderMap, ToStrError}, }; +use ssz::Decode; use thiserror::Error; -use crate::pbs::{HEADER_VERSION_VALUE, SignedBlindedBeaconBlock}; +use crate::pbs::{HEADER_VERSION_VALUE, SignedBeaconBlock, SignedBlindedBeaconBlock}; pub const APPLICATION_JSON: &str = "application/json"; pub const APPLICATION_OCTET_STREAM: &str = "application/octet-stream"; @@ -206,6 +210,20 @@ impl IntoIterator for AcceptedEncodings { /// q-value, then original order). pub fn get_accept_types( req_headers: &HeaderMap, +) -> Result { + get_accept_types_with_default(req_headers, NO_PREFERENCE_DEFAULT) +} + +/// Like `get_accept_types`, but the caller chooses the encoding used when the +/// request expresses NO format preference. This covers both the wildcard +/// (`*/*`, `application/*`) Accept ranges and the "no Accept header AND no +/// Content-Type" case. An explicit `Accept`/`Content-Type` is always obeyed — +/// only the no-preference tiebreak changes. Legacy callers use +/// [`get_accept_types`] (default JSON); SSZ-by-default endpoints pass +/// `EncodingType::Ssz`. +pub fn get_accept_types_with_default( + req_headers: &HeaderMap, + no_preference_default: EncodingType, ) -> Result { // Only two supported media types, so the ordered set is at most two // entries: primary + optional fallback. @@ -235,7 +253,7 @@ pub fn get_accept_types( continue; } - if let Some(enc) = essence_encoding(&mt.essence()) { + if let Some(enc) = essence_encoding(&mt.essence(), no_preference_default) { had_supported = true; match primary { None => primary = Some(enc), @@ -254,24 +272,24 @@ pub fn get_accept_types( return Err(AcceptedEncodingsError::UnsupportedAcceptType) } - // No Accept header (or only q=0 rejections): per the Builder API a missing - // Accept means JSON, and request/response encodings are independent — so do - // NOT inherit the request Content-Type (an SSZ request still gets JSON). - Ok(AcceptedEncodings::single(NO_PREFERENCE_DEFAULT)) + // No Accept header (or only q=0 rejections): request and response encodings + // are independent, so do NOT inherit the request Content-Type; fall back to + // this endpoint's no-preference default. + Ok(AcceptedEncodings::single(no_preference_default)) } -fn essence_encoding(mt: &MediaType) -> Option { +fn essence_encoding(mt: &MediaType, default: EncodingType) -> Option { if mt.suffix.is_some() { return None; } match () { - _ if mt.ty == names::_STAR && mt.subty == names::_STAR => Some(NO_PREFERENCE_DEFAULT), + _ if mt.ty == names::_STAR && mt.subty == names::_STAR => Some(default), _ if mt.ty == names::APPLICATION && mt.subty == names::OCTET_STREAM => { Some(EncodingType::Ssz) } _ if mt.ty == names::APPLICATION && mt.subty == names::JSON => Some(EncodingType::Json), - _ if mt.ty == names::APPLICATION && mt.subty == names::_STAR => Some(NO_PREFERENCE_DEFAULT), + _ if mt.ty == names::APPLICATION && mt.subty == names::_STAR => Some(default), _ => None, } } @@ -280,6 +298,38 @@ fn essence_encoding(mt: &MediaType) -> Option { pub static OUTBOUND_ACCEPT_SSZ_FIRST: HeaderValue = HeaderValue::from_static("application/octet-stream;q=1.0,application/json;q=0.9"); +/// Return the q-value for the index-th entry of an outbound `Accept` header. +/// The first entry gets q=1.0, each subsequent entry decreases by 0.1, and the +/// value is clamped to a minimum of 0.1 so we never emit q=0 (which per +/// RFC 7231 §5.3.1 means "not acceptable"). +fn accept_q_value_for_index(index: usize) -> f32 { + // `as i32` would silently wrap for large indices (e.g. usize::MAX → -1), + // which would invert the clamp. Saturate the cast explicitly. + let idx = i32::try_from(index).unwrap_or(i32::MAX); + let step = 10_i32.saturating_sub(idx).max(1); + step as f32 / 10.0 +} + +/// Format a single `Accept` header entry as `";q="`. +#[inline] +fn format_accept_entry(enc: EncodingType, q: f32) -> String { + format!("{};q={:.1}", enc.content_type(), q) +} + +/// Build an `Accept` header listing the given encodings in preference order: +/// the first entry gets q=1.0 and each subsequent one a q-value 0.1 lower. +/// Returns a ready-to-use `HeaderValue` — the output is always valid ASCII, so +/// infallible. +pub fn build_outbound_accept(preferred: AcceptedEncodings) -> HeaderValue { + let s = preferred + .iter() + .enumerate() + .map(|(i, enc)| format_accept_entry(enc, accept_q_value_for_index(i))) + .collect::>() + .join(","); + HeaderValue::from_str(&s).expect("build_outbound_accept produces valid header value") +} + pub fn get_content_type(req_headers: &HeaderMap) -> EncodingType { EncodingType::from_str( req_headers @@ -290,6 +340,40 @@ pub fn get_content_type(req_headers: &HeaderMap) -> EncodingType { .unwrap_or(EncodingType::Json) } +/// The strict form of [`get_consensus_version_header`], per builder-specs +/// (specs/gloas/builder.md): the header is required on every request that +/// carries a body, and the builder MUST 400 when it is absent or names a fork +/// it does not recognize. On this branch "recognized" means GLOAS ONLY +pub fn require_consensus_version_header( + req_headers: &HeaderMap, +) -> Result { + let value = req_headers + .get(CONSENSUS_VERSION_HEADER) + .ok_or(BodyDeserializeError::MissingVersionHeader)?; + let value = value + .to_str() + .map_err(|_| BodyDeserializeError::InvalidVersionHeader("".to_string()))?; + if value.is_empty() { + return Err(BodyDeserializeError::InvalidVersionHeader("".to_string())); + } + // Echoed into the 400 body, so bound attacker-controlled length + let unsupported = + || BodyDeserializeError::InvalidVersionHeader(value.chars().take(64).collect()); + // Exhaustive on purpose, no wildcard: when lighthouse adds a post-Gloas + // fork this match stops compiling, forcing an explicit decision about the + // window instead of silently 400ing the new fork's clients + match ForkName::from_str(value).map_err(|_| unsupported())? { + ForkName::Gloas => Ok(ForkName::Gloas), + ForkName::Base | + ForkName::Altair | + ForkName::Bellatrix | + ForkName::Capella | + ForkName::Deneb | + ForkName::Electra | + ForkName::Fulu => Err(unsupported()), + } +} + pub fn get_consensus_version_header(req_headers: &HeaderMap) -> Option { ForkName::from_str( req_headers @@ -343,7 +427,8 @@ impl FromStr for EncodingType { // (e.g. `application/json; charset=utf-8`). Compare essence only. let parsed = MediaType::parse(value).map_err(|e| format!("invalid content type {value}: {e}"))?; - essence_encoding(&parsed).ok_or_else(|| format!("unsupported encoding type: {value}")) + essence_encoding(&parsed, EncodingType::Json) + .ok_or_else(|| format!("unsupported encoding type: {value}")) } } @@ -384,6 +469,38 @@ pub enum BodyDeserializeError { UnsupportedMediaType, #[error("missing consensus version header")] MissingVersionHeader, + #[error("unsupported consensus version header: {0}")] + InvalidVersionHeader(String), + #[error("missing request body")] + MissingBody, +} + +/// The request body encoding to decode with, from the Content-Type, using the +/// shared `NO_PREFERENCE_DEFAULT` (JSON) when no Content-Type is present. +pub fn content_type_encoding(headers: &HeaderMap) -> Result { + content_type_encoding_with_default(headers, NO_PREFERENCE_DEFAULT) +} + +/// Like `content_type_encoding`, but the caller chooses the encoding used when +/// the request has no Content-Type header. This is the Content-Type analogue of +/// [`get_accept_types_with_default`]. Precedence: +/// - Content-Type absent → `no_preference_default` +/// - Content-Type recognized → use it +/// - Content-Type present but unrecognized → UnsupportedMediaType +/// +/// Legacy callers use [`content_type_encoding`] (default JSON); SSZ-by-default +/// endpoints pass `EncodingType::Ssz`. +pub fn content_type_encoding_with_default( + headers: &HeaderMap, + no_preference_default: EncodingType, +) -> Result { + match headers.get(CONTENT_TYPE) { + None => Ok(no_preference_default), + Some(hv) => { + let value = hv.to_str().map_err(|_| BodyDeserializeError::UnsupportedMediaType)?; + EncodingType::from_str(value).map_err(|_| BodyDeserializeError::UnsupportedMediaType) + } + } } pub fn deserialize_body( @@ -409,13 +526,13 @@ pub fn deserialize_body( // reports the wrong fork for every Fulu block. Some(version) => Ok(map_fork_name!( version, - SignedBeaconBlock, + LhSignedBeaconBlock, serde_json::from_slice(&body).map_err(BodyDeserializeError::SerdeJsonError)? )), // builder-specs doesn't require the header for JSON bodies. // A request without it still has to decode and an untagged decode would silently pick // Electra. Assume Fulu to be conservative until ePBS warrants the refactor - None => Ok(SignedBeaconBlock::Fulu( + None => Ok(LhSignedBeaconBlock::Fulu( serde_json::from_slice(&body).map_err(BodyDeserializeError::SerdeJsonError)?, )), }, @@ -429,19 +546,80 @@ pub fn deserialize_body( } } +/// Decode a fork-versioned ePBS request body (builder-specs fork-versions +/// `SignedRequestAuth` and `BuilderPreferencesRequest`) as JSON or SSZ, +/// defaulting to SSZ when no `Content-Type` is set. An empty body is rejected +/// first so a missing body reads as `MissingBody`. `Eth-Consensus-Version` is +/// required for BOTH encodings and its value must name a fork this build +/// recognizes (absent -> `MissingVersionHeader`, unrecognized -> +/// `InvalidVersionHeader`, both -> 400), per builder-specs +/// specs/gloas/builder.md. +pub fn decode_versioned_request_body( + headers: &HeaderMap, + body: &Bytes, +) -> Result +where + T: serde::de::DeserializeOwned + Decode, +{ + if body.is_empty() { + return Err(BodyDeserializeError::MissingBody); + } + // Content-Type first so an unsupported media type stays a 415 + let encoding = content_type_encoding_with_default(headers, EncodingType::Ssz)?; + require_consensus_version_header(headers)?; + match encoding { + EncodingType::Json => { + serde_json::from_slice(body.as_ref()).map_err(BodyDeserializeError::SerdeJsonError) + } + EncodingType::Ssz => { + T::from_ssz_bytes(body.as_ref()).map_err(BodyDeserializeError::SszDecodeError) + } + } +} + +/// Decode a `submitSignedBeaconBlock` request body. Like the other ePBS +/// endpoints it defaults to SSZ when no `Content-Type` is set, and +/// `Eth-Consensus-Version` is required and must name a known fork for BOTH +/// encodings (absent -> `MissingVersionHeader`, unrecognized -> +/// `InvalidVersionHeader`, both -> 400; spec PR #165). +pub fn decode_signed_beacon_block( + headers: &HeaderMap, + body: &Bytes, +) -> Result { + if body.is_empty() { + return Err(BodyDeserializeError::MissingBody); + } + // The header is required and must name a known fork on every body-carrying + // request. SSZ uses the fork to select the variant; JSON + // self-describes via `deny_unknown_fields`, so a recognized-but-mismatched + // value is ignored rather than second-guessing a decodable body. + let encoding = content_type_encoding_with_default(headers, EncodingType::Ssz)?; + let fork = require_consensus_version_header(headers)?; + match encoding { + EncodingType::Json => serde_json::from_slice::(body.as_ref()) + .map_err(BodyDeserializeError::SerdeJsonError), + EncodingType::Ssz => SignedBeaconBlock::from_ssz_bytes_by_fork(body.as_ref(), fork) + .map_err(BodyDeserializeError::SszDecodeError), + } +} + #[cfg(test)] mod test { use axum::http::{HeaderMap, HeaderName, HeaderValue}; use bytes::Bytes; - use lh_types::ForkName; + use lh_types::{ForkName, MainnetEthSpec, SignedBeaconBlockElectra, SignedBeaconBlockGloas}; use reqwest::header::{ACCEPT, CONTENT_TYPE}; + use ssz::Encode; use super::{ APPLICATION_JSON, APPLICATION_OCTET_STREAM, AcceptedEncodings, BodyDeserializeError, CONSENSUS_VERSION_HEADER, EncodingType, NO_PREFERENCE_DEFAULT, OUTBOUND_ACCEPT_SSZ_FIRST, - WILDCARD, deserialize_body, get_accept_types, get_consensus_version_header, - get_content_type, parse_response_encoding_and_fork, + WILDCARD, accept_q_value_for_index, build_outbound_accept, + content_type_encoding_with_default, decode_signed_beacon_block, deserialize_body, + format_accept_entry, get_accept_types, get_consensus_version_header, get_content_type, + parse_response_encoding_and_fork, }; + use crate::{pbs::SignedBeaconBlock, utils::TestRandomSeed}; const APPLICATION_TEXT: &str = "application/text"; @@ -909,12 +1087,6 @@ mod test { ); } - /// Format a single `Accept` header entry as `";q="`. - #[inline] - fn format_accept_entry(enc: EncodingType, q: f32) -> String { - format!("{};q={:.1}", enc.content_type(), q) - } - // Pins the wire format PBS sends to relays: SSZ preferred (q=1.0), JSON as // fallback (q=0.9). #[test] @@ -925,6 +1097,23 @@ mod test { ); } + /// `build_outbound_accept` mirrors the static SSZ-first header for the + /// same preference order, and the q-value clamp never emits q=0. + #[test] + fn test_build_outbound_accept() { + let both = + AcceptedEncodings { primary: EncodingType::Ssz, fallback: Some(EncodingType::Json) }; + assert_eq!(build_outbound_accept(both), OUTBOUND_ACCEPT_SSZ_FIRST); + assert_eq!( + build_outbound_accept(AcceptedEncodings::single(EncodingType::Json)), + "application/json;q=1.0" + ); + assert_eq!(accept_q_value_for_index(0), 1.0); + assert_eq!(accept_q_value_for_index(9), 0.1); + assert_eq!(accept_q_value_for_index(10), 0.1); + assert_eq!(accept_q_value_for_index(usize::MAX), 0.1); + } + /// Present-but-unrecognized Content-Type still bails as /// `UnsupportedMediaType`; the fallback only covers *missing* headers. #[tokio::test] @@ -1002,4 +1191,108 @@ mod test { "a headerless JSON body must not fall back to the untagged Electra match" ); } + + // ── content_type_encoding_with_default ─────────────────────────────────── + + /// With no Content-Type the caller's default wins: SSZ for the + /// SSZ-by-default endpoints, not the shared JSON default. + #[test] + fn test_content_type_encoding_with_default_absent_uses_default() { + let headers = HeaderMap::new(); + assert_eq!( + content_type_encoding_with_default(&headers, EncodingType::Ssz).unwrap(), + EncodingType::Ssz + ); + assert_eq!( + content_type_encoding_with_default(&headers, EncodingType::Json).unwrap(), + EncodingType::Json + ); + } + + /// An explicit recognized Content-Type is obeyed regardless of the default, + /// while an unrecognized one is UnsupportedMediaType. + #[test] + fn test_content_type_encoding_with_default_explicit_obeyed() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_JSON)); + assert_eq!( + content_type_encoding_with_default(&headers, EncodingType::Ssz).unwrap(), + EncodingType::Json + ); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); + assert!(matches!( + content_type_encoding_with_default(&headers, EncodingType::Ssz), + Err(BodyDeserializeError::UnsupportedMediaType) + )); + } + + // ── decode_signed_beacon_block ─────────────────────────────────────────── + + /// builder-specs requires the header on every body-carrying request: a + /// JSON body with no version header is rejected, superseding the earlier + /// best-effort policy (spec PR #165). + #[test] + fn test_decode_signed_beacon_block_json_missing_version_header_rejected() { + let block = + SignedBeaconBlock::Gloas(SignedBeaconBlockGloas::::test_random()); + let body = Bytes::from(serde_json::to_vec(&block).unwrap()); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_JSON)); + // no Eth-Consensus-Version header + + let err = decode_signed_beacon_block(&headers, &body).unwrap_err(); + assert!(matches!(err, BodyDeserializeError::MissingVersionHeader)); + + // Present but unrecognized is a DISTINCT 400, naming the bad value + headers.insert( + HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(), + HeaderValue::from_static("futurefork"), + ); + let err = decode_signed_beacon_block(&headers, &body).unwrap_err(); + assert!(matches!(err, BodyDeserializeError::InvalidVersionHeader(v) if v == "futurefork")); + } + + /// gloas is the ONLY accepted value, so a mismatch-within-the-window is + /// impossible; a `fulu` label is rejected at the wire layer like any other + /// deprecated fork. + #[test] + fn test_decode_signed_beacon_block_fulu_label_rejected() { + let block = + SignedBeaconBlock::Gloas(SignedBeaconBlockGloas::::test_random()); + let body = Bytes::from(serde_json::to_vec(&block).unwrap()); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_JSON)); + headers.insert( + HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(), + HeaderValue::from_static("fulu"), + ); + + let err = decode_signed_beacon_block(&headers, &body).unwrap_err(); + assert!(matches!(err, BodyDeserializeError::InvalidVersionHeader(v) if v == "fulu")); + } + + /// An SSZ body whose bytes are a different fork than the header claims is a + /// clean `SszDecodeError`, never a panic: Electra bytes decoded as Gloas + /// cannot succeed, and the decode must surface an error rather than + /// aborting. + #[test] + fn test_decode_signed_beacon_block_ssz_fork_bytes_mismatch() { + let electra = + SignedBeaconBlock::Electra(SignedBeaconBlockElectra::::test_random()); + let body = Bytes::from(electra.as_ssz_bytes()); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_OCTET_STREAM)); + headers.insert( + HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(), + HeaderValue::from_static("gloas"), + ); + + let err = decode_signed_beacon_block(&headers, &body).unwrap_err(); + assert!(matches!(err, BodyDeserializeError::SszDecodeError(_)), "got {err:?}"); + } } diff --git a/crates/pbs/src/constants.rs b/crates/pbs/src/constants.rs index 5976468db..837f97b58 100644 --- a/crates/pbs/src/constants.rs +++ b/crates/pbs/src/constants.rs @@ -2,6 +2,9 @@ pub const STATUS_ENDPOINT_TAG: &str = "status"; pub const REGISTER_VALIDATOR_ENDPOINT_TAG: &str = "register_validator"; pub const SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG: &str = "submit_blinded_block"; pub const GET_HEADER_ENDPOINT_TAG: &str = "get_header"; +pub const GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG: &str = "get_execution_payload_bid"; +pub const SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG: &str = "submit_builder_preferences"; +pub const SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG: &str = "submit_signed_beacon_block"; pub const RELOAD_ENDPOINT_TAG: &str = "reload"; /// For metrics recorded when a request times out @@ -19,6 +22,13 @@ pub const MAX_SIZE_SUBMIT_BLOCK_RESPONSE: usize = 20 * 1024 * 1024; /// 20 MiB, enough to process ~45000 registrations in one request pub const MAX_SIZE_REGISTER_VALIDATOR_REQUEST: usize = 20 * 1024 * 1024; +/// A Gloas `SignedBeaconBlock` carries the signed bid, not the execution +/// payload (that ships separately in the `ExecutionPayloadEnvelope`), so the +/// body is blinded-block-sized, not full-block-sized. This 20 MiB cap matches +/// the inbound blinded block limit (`MAX_SIZE_SUBMIT_BLOCK_RESPONSE`) as a +/// conservative ceiling; a real block is far smaller. +pub const MAX_SIZE_SUBMIT_SIGNED_BEACON_BLOCK: usize = MAX_SIZE_SUBMIT_BLOCK_RESPONSE; + /// 5 MiB, to account for max execution requests / commitments pub const MAX_SIZE_GET_HEADER_RESPONSE: usize = 5 * 1024 * 1024; diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index 98f8a2f16..7957d37be 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -20,6 +20,27 @@ struct ErrorResponse { pub enum PbsClientError { #[error("no response from relays")] NoResponse, + /// ePBS submission fan-out where zero addressed builders accepted. 500, + /// not 502: neither endpoint's builder-specs response set contains 502 + /// (submitBuilderPreferences declares {202, 400, 401, 415, 500} - 415 added + /// in spec PR #165; submitSignedBeaconBlock declares {202, 400, 415, + /// 500}); 502 is not in either set. Legacy routes keep `NoResponse` -> 502. + #[error("no builder accepted the submission")] + NoBuilderResponse, + #[error("auth data does not match a configured builder")] + AuthDataMismatch, + #[error("missing or invalid timing headers")] + MissingTimingHeader, + #[error("auth slot does not match the request path")] + AuthSlotMismatch, + #[error("auth slot has already passed")] + AuthSlotPassed, + #[error("the addressed builder rejected the request with {code}")] + BuilderRejected { code: u16 }, + #[error("auth signature verification failed")] + AuthSigVerify, + #[error("submitted block is not a Gloas block")] + NotGloasBlock, #[error("no payload from relays")] NoPayload, #[error("internal server error")] @@ -34,6 +55,19 @@ impl PbsClientError { pub fn status_code(&self) -> StatusCode { match self { PbsClientError::NoResponse => StatusCode::BAD_GATEWAY, + PbsClientError::NoBuilderResponse => StatusCode::INTERNAL_SERVER_ERROR, + PbsClientError::AuthDataMismatch => StatusCode::BAD_REQUEST, + PbsClientError::MissingTimingHeader => StatusCode::BAD_REQUEST, + PbsClientError::AuthSlotMismatch => StatusCode::BAD_REQUEST, + PbsClientError::AuthSlotPassed => StatusCode::BAD_REQUEST, + // A lone addressed builder's own 400/401 from the preferences + // endpoint is propagated (the sole constructor guards to those two + // codes, so the 502 fallback below is currently dead). + PbsClientError::BuilderRejected { code } => { + StatusCode::from_u16(*code).unwrap_or(StatusCode::BAD_GATEWAY) + } + PbsClientError::AuthSigVerify => StatusCode::UNAUTHORIZED, + PbsClientError::NotGloasBlock => StatusCode::BAD_REQUEST, PbsClientError::NoPayload => StatusCode::BAD_GATEWAY, PbsClientError::Internal => StatusCode::INTERNAL_SERVER_ERROR, PbsClientError::DecodeError(BodyDeserializeError::UnsupportedMediaType) => { @@ -50,6 +84,30 @@ impl IntoResponse for PbsClientError { let status = self.status_code(); let message = match &self { PbsClientError::NoResponse => "no response from relays".to_string(), + PbsClientError::NoBuilderResponse => "no builder accepted the submission".to_string(), + PbsClientError::AuthDataMismatch => { + "Invalid SignedRequestAuth: auth.message.data does not match the value agreed with this builder".to_string() + } + PbsClientError::MissingTimingHeader => { + "Invalid request: Date-Milliseconds and X-Timeout-Ms headers are required".to_string() + } + PbsClientError::AuthSlotMismatch => { + "Invalid SignedRequestAuth: auth.message.slot does not match the proposal slot in the request path".to_string() + } + PbsClientError::AuthSlotPassed => { + "Invalid SignedRequestAuth: auth.message.slot has already passed".to_string() + } + // The builder's own body is never forwarded: it is untrusted and may be + // arbitrarily large + PbsClientError::BuilderRejected { code } => { + format!("The addressed builder rejected the submission with status {code}") + } + PbsClientError::AuthSigVerify => { + "Invalid SignedRequestAuth: signature verification failed".to_string() + } + PbsClientError::NotGloasBlock => { + "Invalid signed beacon block: only Gloas blocks are supported".to_string() + } PbsClientError::NoPayload => "no payload from relays".to_string(), PbsClientError::Internal => "internal server error".to_string(), PbsClientError::DecodeError(e) => format!("error decoding request: {e}"), @@ -80,6 +138,26 @@ mod test { PbsClientError::DecodeError(BodyDeserializeError::MissingVersionHeader).status_code(), StatusCode::BAD_REQUEST, ); + // The unrecognized-fork variant must stay 400: it sits under the + // variant-specific 415 arm, and only the DecodeError(_) catch-all + // routes it today + assert_eq!( + PbsClientError::DecodeError(BodyDeserializeError::InvalidVersionHeader( + "futurefork".to_string() + )) + .status_code(), + StatusCode::BAD_REQUEST, + ); + } + + #[test] + fn auth_errors_map_to_spec_status_codes() { + assert_eq!(PbsClientError::AuthSlotMismatch.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(PbsClientError::AuthSigVerify.status_code(), StatusCode::UNAUTHORIZED); + assert_eq!( + PbsClientError::DecodeError(BodyDeserializeError::MissingBody).status_code(), + StatusCode::BAD_REQUEST, + ); } #[tokio::test] diff --git a/crates/pbs/src/metrics.rs b/crates/pbs/src/metrics.rs index 8ef049aa4..473764d05 100644 --- a/crates/pbs/src/metrics.rs +++ b/crates/pbs/src/metrics.rs @@ -71,4 +71,16 @@ lazy_static! { &["relay_id"], PBS_METRICS_REGISTRY ).unwrap(); + + /// Relay responses CB rejected during validation, by reason. Deliberately + /// NOT a synthetic entry in `RELAY_STATUS_CODE`: the relay's HTTP status + /// was already counted there (usually a 200), and overloading a status + /// label with validation semantics is how the 555/556 bucketing confusion + /// started. A dropped response is an event of its own kind. + pub static ref RELAY_INVALID_RESPONSE: IntCounterVec = register_int_counter_vec_with_registry!( + "pbs_relay_invalid_response_total", + "Relay responses rejected by CB validation, by reason", + &["reason", "endpoint", "relay_id"], + PBS_METRICS_REGISTRY + ).unwrap(); } diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs new file mode 100644 index 000000000..4d37c7d15 --- /dev/null +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -0,0 +1,397 @@ +use std::time::Duration; + +use axum::{ + body::Bytes, + extract::{Path, State}, + http::HeaderMap, + response::IntoResponse, +}; +use cb_common::{ + pbs::{ + BuilderPreferencesRequest, RelayClient, SignedRequestAuth, SubmitBuilderPreferencesParams, + error::PbsError, + }, + types::Chain, + utils::ms_into_slot, + wire::{EncodingType, decode_versioned_request_body, get_user_agent, safe_read_http_response}, +}; +use futures::{FutureExt, future::join_all}; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::Encode; +use tracing::{Instrument, debug, error, info, warn}; + +use crate::{ + PbsStateGuard, + constants::{MAX_SIZE_DEFAULT, SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG}, + error::PbsClientError, + metrics::BEACON_NODE_STATUS, + state::{BuilderApiState, PbsState}, + utils::{ + epbs_base_send_headers, expect_status, match_relays_by_auth_data, record_client_error, + send_to_relay, verify_auth_signature, + }, +}; + +/// The body is the required `BuilderPreferencesRequest`; like the bid +/// endpoint it is fork-versioned per builder-specs, and `Eth-Consensus-Version` +/// is required for JSON and SSZ alike +pub async fn handle_submit_builder_preferences( + State(state): State>, + req_headers: HeaderMap, + Path(params): Path, + body: Bytes, +) -> Result { + let request = + decode_versioned_request_body::(&req_headers, &body) + .map_err(|err| record_client_error(err, SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG))?; + tracing::Span::current().record("validator", tracing::field::debug(¶ms.proposer_pubkey)); + tracing::Span::current().record("slot", request.auth.message.slot.as_u64()); + + let state = state.read().clone(); + let ua = get_user_agent(&req_headers); + info!( + ua, + slot = %request.auth.message.slot, + max_execution_payment = request.preferences.max_execution_payment, + "new request" + ); + + match submit_builder_preferences(params, request, req_headers, state).await { + Ok(()) => { + BEACON_NODE_STATUS + .with_label_values(&["202", SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG]) + .inc(); + Ok(StatusCode::ACCEPTED.into_response()) + } + Err(err) => { + error!(%err, "submit_builder_preferences failed"); + + BEACON_NODE_STATUS + .with_label_values(&[ + err.status_code().as_str(), + SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, + ]) + .inc(); + Err(err) + } + } +} + +/// Implements https://ethereum.github.io/builder-specs/?urls.primaryName=dev#/Builder/submitBuilderPreferences +/// Ok(()) if at least one addressed builder accepted (-> 202). +pub async fn submit_builder_preferences( + params: SubmitBuilderPreferencesParams, + request: BuilderPreferencesRequest, + req_headers: HeaderMap, + state: PbsState, +) -> Result<(), PbsClientError> { + let (pbs_config, relays, maybe_mux_id) = state.mux_config_and_relays(¶ms.proposer_pubkey); + + if let Some(mux_id) = maybe_mux_id { + debug!(mux_id, relays = relays.len(), pubkey = %params.proposer_pubkey, "using mux config"); + } else { + debug!(relays = relays.len(), pubkey = %params.proposer_pubkey, "using default config"); + } + + // Validate before any outbound work so a rejected request costs nothing + validate_preferences_auth( + &request.auth, + ¶ms, + state.config.chain, + pbs_config.verify_request_auth, + )?; + + let relays = match_relays_by_auth_data(relays, request.auth.message.data.as_ref()); + if relays.is_empty() { + return Err(PbsClientError::AuthDataMismatch); + } + + let send_headers = epbs_base_send_headers(&req_headers)?; + + // Preferences are submitted an epoch ahead, so they share the registration + // timeout rather than the block-production one + let timeout_ms = pbs_config.timeout_register_validator_ms; + + // Spawned like register_validator's sends: a BN disconnect must not cancel + // in-flight writes mid-fan-out, leaving some builders with the prefs and + // others without + let mut handles = Vec::with_capacity(relays.len()); + for &relay in relays.iter() { + handles.push( + tokio::spawn( + send_one_submit_builder_preferences( + params.proposer_pubkey.clone(), + request.clone(), + relay.clone(), + send_headers.clone(), + timeout_ms, + ) + .in_current_span(), + ) + .map(|join_result| match join_result { + Ok(res) => res, + Err(err) => Err(PbsError::TokioJoinError(err)), + }), + ); + } + + let results = join_all(handles).await; + let mut accepted = 0; + let mut lone_rejection = None; + for (res, relay) in results.into_iter().zip(relays.iter()) { + let relay_id = relay.id.as_str(); + match res { + Ok(()) => accepted += 1, + Err(err) if err.is_timeout() => error!(err = "Timed Out", relay_id), + Err(err) => { + // Only a single addressed builder's verdict is unambiguous enough + // to hand back to the proposer + if relays.len() == 1 { + lone_rejection = err.relay_status_code(); + } + error!(%err, relay_id) + } + } + } + + // One accepting builder is a successful submission: the others are separate + // destinations, not replicas, and the proposer addressed each by auth data + if accepted == 0 { + // A lone builder's own 400/401 tells the proposer whether its auth data or + // its signature was rejected, which a blanket 502 would hide + return Err(match lone_rejection { + Some(code @ (400 | 401)) => PbsClientError::BuilderRejected { code }, + _ => PbsClientError::NoBuilderResponse, + }); + } + + info!(accepted, addressed = relays.len(), "builder preferences submitted"); + Ok(()) +} + +/// Validates the caller's `SignedRequestAuth`. There is no slot in the +/// request path here, so instead of matching one we reject a slot that has +/// already ended: preferences are submitted an epoch ahead, and a replayed +/// submission must not be able to roll a proposer's preferences back to a stale +/// value. The `auth.message.data` check is the demux's job +/// (`match_relays_by_auth_data`). +fn validate_preferences_auth( + auth: &SignedRequestAuth, + params: &SubmitBuilderPreferencesParams, + chain: Chain, + verify_signature: bool, +) -> Result<(), PbsClientError> { + if slot_has_passed(auth.message.slot.as_u64(), chain) { + warn!(auth_slot = %auth.message.slot, "auth slot already passed"); + return Err(PbsClientError::AuthSlotPassed); + } + + verify_auth_signature(¶ms.proposer_pubkey, auth, chain, verify_signature) +} + +/// `ms_into_slot` saturates at 0 for a future slot, so a full slot's worth of +/// elapsed time means the slot is over. +fn slot_has_passed(slot: u64, chain: Chain) -> bool { + ms_into_slot(slot, chain) >= chain.slot_time_sec() * 1000 +} + +async fn send_one_submit_builder_preferences( + proposer_pubkey: cb_common::types::BlsPublicKey, + request: BuilderPreferencesRequest, + relay: RelayClient, + headers: HeaderMap, + timeout_ms: u64, +) -> Result<(), PbsError> { + let url = relay.submit_builder_preferences_url(&proposer_pubkey)?; + + // The builder decodes what the proposer signed either way, and SSZ is the + // faster wire format on the relay hop + let req = relay + .client + .post(url) + .timeout(Duration::from_millis(timeout_ms)) + .headers(headers) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(request.as_ssz_bytes()); + let (res, request_latency) = + send_to_relay(req, &relay, SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG).await?; + let code = res.status(); + + // Cap the read like every other relay call: a builder is untrusted and must + // not be able to stream an unbounded error body into memory and the logs + safe_read_http_response(res, MAX_SIZE_DEFAULT).await?; + + // The spec makes 202 the only success: another 2xx means the builder did not + // commit to storing these preferences + expect_status(code, StatusCode::ACCEPTED)?; + + debug!(relay_id = relay.id.as_ref(), latency = ?request_latency, "preferences accepted"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use cb_common::{ + pbs::{BuilderPreferences, RequestAuth}, + types::BlsSignature, + utils::{timestamp_of_slot_start_sec, utcnow_ms, utcnow_sec}, + wire::{BodyDeserializeError, CONSENSUS_VERSION_HEADER}, + }; + + use super::*; + + fn current_slot(chain: Chain) -> u64 { + (utcnow_sec() - chain.genesis_time_sec()) / chain.slot_time_sec() + } + + /// The boundary is the slot's END, not its start: a proposer legitimately + /// submits for a slot that is still in progress. + #[test] + fn slot_has_passed_is_exclusive_of_the_current_slot() { + let chain = Chain::Hoodi; + let now = current_slot(chain); + + assert!(!slot_has_passed(now, chain), "the in-progress slot has not passed"); + assert!(!slot_has_passed(now + 1, chain), "the next slot has not passed"); + assert!(!slot_has_passed(now + 1_000, chain), "a far future slot has not passed"); + assert!(slot_has_passed(now - 1, chain), "the previous slot has passed"); + assert!(slot_has_passed(0, chain), "slot 0 has long passed"); + } + + /// A slot is over exactly one slot-duration after it began, so the check + /// must not fire a millisecond early or a millisecond late. + #[test] + fn slot_has_passed_flips_one_slot_after_the_start() { + let chain = Chain::Hoodi; + let now = current_slot(chain); + let elapsed_ms = utcnow_ms() - timestamp_of_slot_start_sec(now, chain) * 1_000; + + // Whatever point of the slot the test runs at, exactly one slot's worth + // of elapsed time separates "not passed" from "passed" + assert!(elapsed_ms < chain.slot_time_sec() * 1_000); + assert!(!slot_has_passed(now, chain)); + assert!(slot_has_passed(now - 1, chain)); + } + + #[test] + fn decode_rejects_an_empty_body() { + let err = decode_versioned_request_body::( + &HeaderMap::new(), + &Bytes::new(), + ) + .expect_err("an empty body is not a request"); + assert!(matches!(err, BodyDeserializeError::MissingBody)); + } + + /// This endpoint's no-preference default is SSZ, not the shared JSON one, + /// and `Eth-Consensus-Version` is required regardless of encoding. + #[test] + fn decode_defaults_to_ssz_without_a_content_type() { + let request = BuilderPreferencesRequest { + auth: SignedRequestAuth { + message: RequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, + signature: BlsSignature::empty(), + }, + preferences: BuilderPreferences { max_execution_payment: 7 }, + }; + let body = Bytes::from(request.as_ssz_bytes()); + + // Missing the header, the SSZ-default body is rejected, not misparsed + let err = + decode_versioned_request_body::(&HeaderMap::new(), &body) + .expect_err("ssz without the version header must be rejected"); + assert!(matches!(err, BodyDeserializeError::MissingVersionHeader)); + + let mut headers = HeaderMap::new(); + headers.insert(CONSENSUS_VERSION_HEADER, axum::http::HeaderValue::from_static("gloas")); + let decoded = decode_versioned_request_body::(&headers, &body) + .expect("ssz body decodes without a content type"); + assert_eq!(decoded.preferences.max_execution_payment, 7); + assert_eq!(decoded.auth.message.slot.as_u64(), 3); + + // gloas is the ONLY supported value: fulu is rejected + // at the wire layer like every other deprecated fork + let mut headers = HeaderMap::new(); + headers.insert(CONSENSUS_VERSION_HEADER, axum::http::HeaderValue::from_static("fulu")); + let err = decode_versioned_request_body::(&headers, &body) + .expect_err("fulu must be rejected on the gloas-only endpoints"); + assert!(matches!(err, BodyDeserializeError::InvalidVersionHeader(ref v) if v == "fulu")); + + // A DEPRECATED fork name (recognized by lighthouse, outside the gloas-only + // window) is rejected as unsupported + let mut headers = HeaderMap::new(); + headers.insert(CONSENSUS_VERSION_HEADER, axum::http::HeaderValue::from_static("electra")); + let err = decode_versioned_request_body::(&headers, &body) + .expect_err("a deprecated fork must be rejected"); + assert!(matches!(err, BodyDeserializeError::InvalidVersionHeader(ref v) if v == "electra")); + + // Case is folded before the window check (lighthouse FromStr + // lowercases), so an uppercase spelling of the one legal value passes. + // Pinned: if the spec is ever read as lowercase-only, this is the + // deliberate place to change it. + let mut headers = HeaderMap::new(); + headers.insert(CONSENSUS_VERSION_HEADER, axum::http::HeaderValue::from_static("GLOAS")); + decode_versioned_request_body::(&headers, &body) + .expect("case-insensitive gloas is accepted"); + } + + /// builder-specs marks `Eth-Consensus-Version` required on this endpoint + /// for JSON and SSZ alike (builder-specs #165): JSON without it is a 400, + /// not a best-effort decode. Since spec PR #165 the same rule covers + /// `submitSignedBeaconBlock` too — no endpoint is lenient. + #[test] + fn decode_rejects_json_without_the_version_header() { + let request = BuilderPreferencesRequest { + auth: SignedRequestAuth { + message: RequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, + signature: BlsSignature::empty(), + }, + preferences: BuilderPreferences { max_execution_payment: 7 }, + }; + let body = Bytes::from(serde_json::to_vec(&request).unwrap()); + + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); + let err = decode_versioned_request_body::(&headers, &body) + .expect_err("json without the version header must be rejected"); + assert!(matches!(err, BodyDeserializeError::MissingVersionHeader)); + + headers.insert(CONSENSUS_VERSION_HEADER, axum::http::HeaderValue::from_static("gloas")); + decode_versioned_request_body::(&headers, &body) + .expect("the same json body decodes once the header is present"); + } + + /// An unrecognized fork name is a 400 per builder-specs, and the error + /// names the value rather than claiming the header is missing. + #[test] + fn decode_rejects_an_unrecognized_fork_value() { + let request = BuilderPreferencesRequest { + auth: SignedRequestAuth { + message: RequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, + signature: BlsSignature::empty(), + }, + preferences: BuilderPreferences { max_execution_payment: 7 }, + }; + for (ct, body) in [ + ("application/json", Bytes::from(serde_json::to_vec(&request).unwrap())), + ("application/octet-stream", Bytes::from(request.as_ssz_bytes())), + ] { + let mut headers = HeaderMap::new(); + headers + .insert(axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static(ct)); + headers.insert( + CONSENSUS_VERSION_HEADER, + axum::http::HeaderValue::from_static("futurefork"), + ); + let err = decode_versioned_request_body::(&headers, &body) + .expect_err("an unrecognized fork value must be rejected"); + assert!( + matches!(err, BodyDeserializeError::InvalidVersionHeader(ref v) if v == "futurefork"), + "{ct}: wrong error: {err}" + ); + } + } +} diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs new file mode 100644 index 000000000..fe0b39539 --- /dev/null +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -0,0 +1,1341 @@ +use std::{sync::Arc, time::Duration}; + +use alloy::{ + consensus::BlockHeader, + primitives::{Address, B256, U256, utils::format_ether}, + providers::Provider, + rpc::types::Block, +}; +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, HeaderValue}, + response::IntoResponse, +}; +use cb_common::{ + constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, + pbs::{ + DEFAULT_BID_POLL_TIMEOUT_MS, ForkName, GetExecutionPayloadBidInfo, + GetExecutionPayloadBidParams, GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, + HEADER_TIMEOUT_MS, RelayClient, SignedExecutionPayloadBid, SignedRequestAuth, + error::{PbsError, ValidationError}, + }, + signature::verify_execution_payload_bid_signature, + types::{BlsPublicKey, BlsSignature, Chain}, + utils::{ms_into_slot, utcnow_ms}, + wire::{ + AcceptedEncodings, AcceptedEncodingsError, CONSENSUS_VERSION_HEADER, EncodingType, + build_outbound_accept, decode_versioned_request_body, get_accept_types_with_default, + get_user_agent, parse_response_encoding_and_fork, safe_read_http_response, + }, +}; +use futures::future::join_all; +use parking_lot::RwLock; +use reqwest::{ + StatusCode, + header::{ACCEPT, CONTENT_TYPE}, +}; +use ssz::{Decode, Encode}; +use tokio::time::sleep; +use tracing::{Instrument, debug, error, info, warn}; +use tree_hash::TreeHash; +use url::Url; + +use crate::{ + PbsStateGuard, + constants::{ + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, MAX_SIZE_GET_HEADER_RESPONSE, TIMEOUT_ERROR_CODE, + }, + error::PbsClientError, + metrics::{BEACON_NODE_STATUS, RELAY_HEADER_VALUE, RELAY_LAST_SLOT}, + state::{BuilderApiState, PbsState}, + utils::{ + check_gas_limit, epbs_base_send_headers, match_relays_by_auth_data, record_client_error, + send_to_relay, verify_auth_signature, + }, +}; + +/// The body is the required `SignedRequestAuth`; builder-specs fork-versions +/// the request wire type, and `Eth-Consensus-Version` is required for JSON and +/// SSZ alike (builder-specs #165). +pub async fn handle_get_execution_payload_bid( + State(state): State>, + req_headers: HeaderMap, + Path(params): Path, + body: Bytes, +) -> Result { + // Count decode rejections: a client broken by the strict header rule must + // show up as a 400 spike on this endpoint, not vanish from the counter + let body = Arc::new( + decode_versioned_request_body::(&req_headers, &body) + .map_err(|err| record_client_error(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG))?, + ); + tracing::Span::current().record("slot", params.slot); + tracing::Span::current().record("parent_hash", tracing::field::debug(params.parent_hash)); + tracing::Span::current().record("parent_root", tracing::field::debug(params.parent_root)); + tracing::Span::current().record("validator", tracing::field::debug(¶ms.proposer_pubkey)); + tracing::Span::current() + .record("auth data", tracing::field::debug(&body.message.data.to_vec())); + tracing::Span::current().record("auth signature", tracing::field::debug(&body.signature)); + + let state = state.read().clone(); + + let ua = get_user_agent(&req_headers); + let ms_into_slot = ms_into_slot(params.slot, state.config.chain); + + // Parse Accept before req_headers is consumed below; server tiebreak = SSZ. + // No-preference (absent Accept / wildcard) defaults to SSZ; an explicit + // Accept header is still obeyed. + let response_encoding = get_accept_types_with_default(&req_headers, EncodingType::Ssz) + .inspect_err(|err| error!(%err, "error parsing accept header")) + .map_err(|err| record_client_error(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG))? + .preferred(&[EncodingType::Ssz, EncodingType::Json]); + + info!(ua, ms_into_slot, "new request"); + + match get_execution_payload_bid(params, body, req_headers, state).await { + Ok(res) => { + if let Some(max_bid) = res { + info!(trustless_bid_eth = format_ether(max_bid.value()), execution_payment_eth = format_ether(max_bid.execution_payment()), block_hash =% max_bid.block_hash(), builder_index = max_bid.builder_index(), "received header"); + + // Eth-Consensus-Version is required on the 200 for both encodings + let consensus_version_header = HeaderValue::from_str(&max_bid.version.to_string()) + .expect("fork name is always a valid header value"); + + match response_encoding { + // Unreachable in practice: get_accept_types errors (-> 406 + // above) when the caller offers nothing we support. Counted + // here, NOT above, so a request emits exactly one label. + None => { + BEACON_NODE_STATUS + .with_label_values(&["406", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG]) + .inc(); + Err(PbsClientError::HeaderError( + AcceptedEncodingsError::UnsupportedAcceptType, + )) + } + Some(EncodingType::Ssz) => { + BEACON_NODE_STATUS + .with_label_values(&["200", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG]) + .inc(); + let mut res = max_bid.data.as_ssz_bytes().into_response(); + res.headers_mut() + .insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + res.headers_mut() + .insert(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()); + Ok(res) + } + Some(EncodingType::Json) => { + BEACON_NODE_STATUS + .with_label_values(&["200", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG]) + .inc(); + let mut res = axum::Json(max_bid).into_response(); + res.headers_mut() + .insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + Ok(res) + } + } + } else { + // spec: return 204 if request is valid but no bid available + info!("no header available for slot"); + + BEACON_NODE_STATUS + .with_label_values(&["204", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG]) + .inc(); + Ok(StatusCode::NO_CONTENT.into_response()) + } + } + Err(err) => { + error!(%err, "get_execution_payload_bid failed"); + + BEACON_NODE_STATUS + .with_label_values(&[ + err.status_code().as_str(), + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, + ]) + .inc(); + Err(err) + } + } +} + +/// Implements https://ethereum.github.io/builder-specs/?urls.primaryName=dev#/Builder/getExecutionPayloadBid +/// Some(bid) if a relay serves one (-> 200), None if none do (-> 204); errors +/// with Internal (-> 500). +pub async fn get_execution_payload_bid( + params: GetExecutionPayloadBidParams, + body: Arc, + req_headers: HeaderMap, + state: PbsState, +) -> Result, PbsClientError> { + let ms_into_slot = ms_into_slot(params.slot, state.config.chain); + let (pbs_config, relays, maybe_mux_id) = state.mux_config_and_relays(¶ms.proposer_pubkey); + + // All acceptable builders this pubkey can talk to + if let Some(mux_id) = maybe_mux_id { + debug!(mux_id, relays = relays.len(), pubkey = %params.proposer_pubkey, "using mux config"); + } else { + debug!(relays = relays.len(), pubkey = %params.proposer_pubkey, "using default config"); + } + + // Validate before any outbound work so a rejected request costs nothing + validate_request_auth(&body, ¶ms, state.config.chain, pbs_config.verify_request_auth)?; + + let parent_block = Arc::new(RwLock::new(None)); + if state.extra_validation_enabled() && + let Some(rpc_url) = pbs_config.rpc_url.clone() + { + tokio::spawn( + fetch_parent_block(rpc_url, params.parent_hash, parent_block.clone()).in_current_span(), + ); + } + + let relays = match_relays_by_auth_data(relays, body.message.data.as_ref()); + if relays.is_empty() { + return Err(PbsClientError::AuthDataMismatch); + } + + let max_timeout_ms = pbs_config + .timeout_get_header_ms + .min(pbs_config.late_in_slot_time_ms.saturating_sub(ms_into_slot)); + + if max_timeout_ms == 0 { + warn!( + ms_into_slot, + threshold = pbs_config.late_in_slot_time_ms, + "late in slot, skipping relay requests" + ); + + return Ok(None); + } + + // The proposer's deadline bounds everything below it + let budget_ms = request_budget_ms(&req_headers, utcnow_ms())?; + if budget_ms == 0 { + warn!("proposer deadline already passed, skipping relay requests"); + return Ok(None); + } + let max_timeout_ms = max_timeout_ms.min(budget_ms); + + // prepare headers, except for start time which is set in `send_one_get_header` + let mut send_headers = epbs_base_send_headers(&req_headers)?; + + // Forward the caller's Accept preference to the relay so it returns the + // format the BN wants, avoiding a decode->re-encode. No-preference defaults + // to SSZ (this endpoint is SSZ-by-default). Always offer both encodings as + // fallback so a format-limited relay still returns a bid. + let caller_accept = get_accept_types_with_default(&req_headers, EncodingType::Ssz) + .map_err(|_| PbsClientError::Internal)?; + let relay_accept = AcceptedEncodings { + primary: caller_accept.primary, + fallback: Some(match caller_accept.primary { + EncodingType::Ssz => EncodingType::Json, + EncodingType::Json => EncodingType::Ssz, + }), + }; + send_headers.insert(ACCEPT, build_outbound_accept(relay_accept)); + + let mut handles = Vec::with_capacity(relays.len()); + for &relay in relays.iter() { + handles.push( + send_timed_get_execution_payload_bid( + params.clone(), + body.clone(), + relay.clone(), + send_headers.clone(), + ms_into_slot, + max_timeout_ms, + ValidationContext { + skip_sigverify: pbs_config.skip_sigverify, + // the ePBS floor is the same min_bid policy knob, in gwei + min_bid_gwei: (pbs_config.min_bid_wei / U256::from(1_000_000_000)) + .try_into() + .unwrap_or(u64::MAX), + max_trusted_bid_gwei: relay + .config + .max_execution_payment_gwei + .unwrap_or(pbs_config.max_execution_payment_gwei), + expected_fee_recipient: pbs_config.fee_recipient, + extra_validation_enabled: state.extra_validation_enabled(), + parent_block: parent_block.clone(), + }, + ) + .in_current_span(), + ); + } + + let results = join_all(handles).await; + let mut relay_bids = Vec::with_capacity(relays.len()); + for (res, relay) in results.into_iter().zip(relays.iter()) { + let relay_id = relay.id.as_str(); + + match res { + Ok(Some(res)) => { + RELAY_LAST_SLOT.with_label_values(&[relay_id]).set(params.slot as i64); + let value_gwei = (U256::from(res.value()) / U256::from(1_000_000_000)) + .try_into() + .unwrap_or_default(); + RELAY_HEADER_VALUE.with_label_values(&[relay_id]).set(value_gwei); + + relay_bids.push((relay_id, res)) + } + Ok(_) => {} + Err(err) if err.is_timeout() => error!(err = "Timed Out", relay_id), + Err(err) => error!(%err, relay_id), + } + } + + let max_bid = select_max_bid(relay_bids); + + if let Some((winning_relay_id, ref bid)) = max_bid { + info!( + relay_id = winning_relay_id, + bid_eth = format_ether(total_payment(bid)), + trustless_bid_eth = format_ether(bid.value()), + execution_payment_eth = format_ether(bid.execution_payment()), + block_hash = %bid.block_hash(), + "auction winner" + ); + } + + Ok(max_bid.map(|(_, bid)| bid)) +} + +/// Timeout for one bid poll. Every poll shares the proposer's deadline, so an +/// early poll is bounded to land a bid in hand while there is still time to use +/// it; only the last poll holds for the full remainder. +fn poll_call_timeout_ms(timeout_left_ms: u64, poll_timeout_ms: u64, is_last: bool) -> u64 { + if is_last { timeout_left_ms } else { poll_timeout_ms.min(timeout_left_ms) } +} + +struct RungBudget { + call_timeout_ms: u64, + is_last: bool, +} + +/// One rung's slice of the shared budget, derived from the absolute deadline +/// at the moment the rung fires. Nominal bookkeeping (budget minus one cadence +/// step per rung) drifts optimistic because a rung costs cadence PLUS +/// scheduling delay; anchoring on the deadline means a rung, in particular the +/// last one, is never granted more than truly remains. +fn rung_budget( + deadline_ms: u64, + now_ms: u64, + send_freq_ms: u64, + poll_timeout_ms: u64, +) -> RungBudget { + let remaining_ms = deadline_ms.saturating_sub(now_ms); + let is_last = remaining_ms <= send_freq_ms; + RungBudget { + call_timeout_ms: poll_call_timeout_ms(remaining_ms, poll_timeout_ms, is_last), + is_last, + } +} + +/// Pre-ladder wait for `target_first_request_ms` (0 when the slot is already +/// past the target). `None` means the target sits at or beyond the proposer's +/// deadline, so every poll would go out with a 0ms timeout: the relay must be +/// skipped instead of being sent requests that cannot succeed. +fn target_first_request_delay_ms(target_ms: u64, ms_into_slot: u64, budget_ms: u64) -> Option { + let delay = target_ms.saturating_sub(ms_into_slot); + if delay >= budget_ms { None } else { Some(delay) } +} + +/// Milliseconds left to serve this request, from the proposer's required timing +/// headers. `X-Timeout-Ms` is measured from `Date-Milliseconds`, so the +/// deadline is absolute and survives transit delay. It is also clamped to +/// `now + X-Timeout-Ms` so a proposer whose clock runs ahead cannot hand out +/// more time than it meant to. Returns 0 when the deadline has already passed. +fn request_budget_ms(req_headers: &HeaderMap, now_ms: u64) -> Result { + fn header_u64(req_headers: &HeaderMap, name: &str) -> Result { + req_headers + .get(name) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .ok_or(PbsClientError::MissingTimingHeader) + } + + let sent_at_ms = header_u64(req_headers, HEADER_START_TIME_UNIX_MS)?; + let timeout_ms = header_u64(req_headers, HEADER_TIMEOUT_MS)?; + if timeout_ms == 0 { + return Err(PbsClientError::MissingTimingHeader); + } + + let until_deadline = sent_at_ms.saturating_add(timeout_ms).saturating_sub(now_ms); + Ok(until_deadline.min(timeout_ms)) +} + +/// Validates the caller's `SignedRequestAuth` against the request path. The +/// `auth.message.data` check is the demux's job (`match_relays_by_auth_data`), +/// so only the slot is checked here, plus the signature when +/// `verify_request_auth` is on. The downstream builder verifies the signature +/// regardless, which is why the crypto is opt-in. +fn validate_request_auth( + auth: &SignedRequestAuth, + params: &GetExecutionPayloadBidParams, + chain: Chain, + verify_signature: bool, +) -> Result<(), PbsClientError> { + if auth.message.slot.as_u64() != params.slot { + warn!(auth_slot = %auth.message.slot, path_slot = params.slot, "auth slot mismatch"); + return Err(PbsClientError::AuthSlotMismatch); + } + + verify_auth_signature(¶ms.proposer_pubkey, auth, chain, verify_signature) +} + +fn total_payment(bid: &impl GetExecutionPayloadBidInfo) -> u64 { + bid.value().saturating_add(bid.execution_payment()) +} + +// `L` is an opaque label (relay id for the cross-relay layer, request start +// time for the per-relay in-flight layer) carried through to the winner. +fn select_max_bid(bids: Vec<(L, I)>) -> Option<(L, I)> { + bids.into_iter().max_by_key(|(_, bid)| total_payment(bid)) +} + +/// Fetch the parent block from the RPC URL for extra validation of the header. +/// Extra validation will be skipped if: +/// - relay returns header before parent block is fetched +/// - parent block is not found, eg because of a RPC delay +async fn fetch_parent_block( + rpc_url: Url, + parent_hash: B256, + parent_block: Arc>>, +) { + let provider = alloy::providers::ProviderBuilder::new().connect_http(rpc_url).to_owned(); + + debug!(%parent_hash, "fetching parent block"); + + match provider.get_block_by_hash(parent_hash).await { + Ok(maybe_block) => { + debug!(block_found = maybe_block.is_some(), "fetched parent block"); + let mut guard = parent_block.write(); + *guard = maybe_block; + } + Err(err) => { + error!(%err, "fetch failed"); + } + } +} + +async fn send_timed_get_execution_payload_bid( + params: GetExecutionPayloadBidParams, + body: Arc, + relay: RelayClient, + headers: HeaderMap, + ms_into_slot: u64, + timeout_left_ms: u64, + validation: ValidationContext, +) -> Result, PbsError> { + let url = relay.get_execution_payload_bid_url( + params.slot, + ¶ms.parent_hash, + ¶ms.parent_root, + ¶ms.proposer_pubkey, + )?; + + // The proposer's deadline is absolute (same clock basis as + // `request_budget_ms`); every budget below is derived from it at the moment + // it is needed, so sleep and scheduling drift can never over-grant. + let deadline_ms = utcnow_ms().saturating_add(timeout_left_ms); + + if relay.config.enable_timing_games { + if let Some(target_ms) = relay.config.target_first_request_ms { + // sleep until target time in slot + + let Some(delay) = + target_first_request_delay_ms(target_ms, ms_into_slot, timeout_left_ms) + else { + warn!( + relay_id = relay.id.as_ref(), + target_ms, + ms_into_slot, + budget_ms = timeout_left_ms, + "TG: target_first_request_ms exceeds the request budget, skipping relay" + ); + return Ok(None); + }; + if delay > 0 { + debug!( + relay_id = relay.id.as_ref(), + target_ms, ms_into_slot, "TG: waiting to send first header request" + ); + sleep(Duration::from_millis(delay)).await; + } else { + debug!( + relay_id = relay.id.as_ref(), + target_ms, ms_into_slot, "TG: request already late enough in slot" + ); + } + } + + if let Some(send_freq_ms) = relay.config.frequency_get_header_ms { + let mut handles = Vec::new(); + + debug!( + relay_id = relay.id.as_ref(), + send_freq_ms, + budget_left_ms = deadline_ms.saturating_sub(utcnow_ms()), + "TG: sending multiple header requests" + ); + + // Every poll shares the proposer's deadline, so granting each one all + // the time left would let a builder hold them all until that instant + // and leave nothing in hand if it is missed. Bound the early polls so + // they land as a floor of progressively better bids; only the last + // poll holds for the full remainder. + let poll_timeout_ms = + relay.config.bid_poll_timeout_ms.unwrap_or(DEFAULT_BID_POLL_TIMEOUT_MS); + + loop { + let rung = rung_budget(deadline_ms, utcnow_ms(), send_freq_ms, poll_timeout_ms); + // Drift can consume the remainder before a trailing rung fires; + // a 0ms poll cannot succeed, so stop once something is in flight + if rung.call_timeout_ms == 0 && !handles.is_empty() { + break; + } + let params = params.clone(); + handles.push(tokio::spawn( + send_one_get_execution_payload_bid( + params, + body.clone(), + relay.clone(), + RequestContext { + timeout_ms: rung.call_timeout_ms, + url: url.clone(), + headers: headers.clone(), + }, + validation.clone(), + ) + .in_current_span(), + )); + + if rung.is_last { + break; + } + sleep(Duration::from_millis(send_freq_ms)).await; + } + + let results = join_all(handles).await; + let mut n_headers = 0; + let mut served_no_bid = false; + + let bids: Vec<_> = results + .into_iter() + .filter_map(|res| { + // ignore join error and timeouts, log other errors + res.ok().and_then(|inner_res| match inner_res { + Ok((start_time, Some(header))) => { + n_headers += 1; + Some((start_time, header)) + } + // a 204 is the relay answering "no bid", not failing + Ok((_, None)) => { + served_no_bid = true; + None + } + Err(err) if err.is_timeout() => None, + Err(err) => { + error!(relay_id = relay.id.as_ref(),%err, "TG: error sending header request"); + None + } + }) + }) + .collect(); + + // Pick the highest total payment across this relay's in-flight responses + if let Some((_, header)) = select_max_bid(bids) { + debug!(relay_id = relay.id.as_ref(), n_headers, "TG: received headers from relay"); + return Ok(Some(header)); + } else if served_no_bid { + // Answered, just with nothing to offer: same result as the single-request path + debug!(relay_id = relay.id.as_ref(), "TG: relay served no bid"); + return Ok(None); + } else { + // all requests failed + warn!(relay_id = relay.id.as_ref(), "TG: no headers received"); + + return Err(PbsError::RelayResponse { + error_msg: "no headers received".to_string(), + code: TIMEOUT_ERROR_CODE, + }); + } + } + } + + // if no timing games or no repeated send, just send one request + send_one_get_execution_payload_bid( + params, + body, + relay, + RequestContext { timeout_ms: deadline_ms.saturating_sub(utcnow_ms()), url, headers }, + validation, + ) + .await + .map(|(_, maybe_header)| maybe_header) +} + +struct RequestContext { + url: Url, + timeout_ms: u64, + headers: HeaderMap, +} + +#[derive(Clone)] +struct ValidationContext { + skip_sigverify: bool, + min_bid_gwei: u64, + max_trusted_bid_gwei: u64, + expected_fee_recipient: Option
, + extra_validation_enabled: bool, + parent_block: Arc>>, +} + +async fn send_one_get_execution_payload_bid( + params: GetExecutionPayloadBidParams, + body: Arc, + relay: RelayClient, + mut req_config: RequestContext, + validation: ValidationContext, +) -> Result<(u64, Option), PbsError> { + // the timestamp in the header is the consensus block time which is fixed, + // request send time, forwarded to the relay in HEADER_START_TIME_UNIX_MS + let start_request_time = utcnow_ms(); + req_config.headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); + + // The timeout header indicating how long a relay has to respond, so they can + // minimize timing games without losing the bid + req_config.headers.insert(HEADER_TIMEOUT_MS, HeaderValue::from(req_config.timeout_ms)); + + // This is a new endpoint, so every builder is expected to implement SSZ; we + // therefore send the request body in SSZ (the most performant encoding) + // unconditionally rather than negotiating it. The auth is forwarded + // byte-for-byte so the builder verifies what the validator signed. The + // response encoding still honors what the beacon node asked for via its + // Accept header. + let request = relay + .client + .post(req_config.url) + .timeout(Duration::from_millis(req_config.timeout_ms)) + .headers(req_config.headers) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(body.as_ssz_bytes()); + let (res, request_latency) = + send_to_relay(request, &relay, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG).await?; + let code = res.status(); + + // Parse the negotiated Content-Type (and optional fork) before the body is + // consumed. Only successful responses carry a meaningful encoding; on + // non-success we fall through to safe_read_http_response's NonSuccess error, + // so these values are never consumed. + let (content_type, fork) = if code.is_success() { + parse_response_encoding_and_fork(res.headers(), code.as_u16())? + } else { + (EncodingType::Json, None) + }; + + let response_bytes = safe_read_http_response(res, MAX_SIZE_GET_HEADER_RESPONSE).await?; + let header_size_bytes = response_bytes.len(); + if code == StatusCode::NO_CONTENT { + debug!( + relay_id = relay.id.as_ref(), + ?code, + latency = ?request_latency, + response = ?response_bytes, + "no header from relay" + ); + return Ok((start_request_time, None)); + } + + let get_header_response = match content_type { + EncodingType::Json => { + match serde_json::from_slice::(&response_bytes) { + Ok(parsed) => parsed, + Err(err) => { + return Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(&response_bytes).into_owned(), + }); + } + } + } + EncodingType::Ssz => { + // SSZ requires the fork from Eth-Consensus-Version; its absence is a + // relay protocol violation. + let fork = fork.ok_or_else(|| PbsError::RelayResponse { + error_msg: "relay did not provide consensus version header for ssz payload" + .to_string(), + code: code.as_u16(), + })?; + let data = SignedExecutionPayloadBid::from_ssz_bytes(&response_bytes).map_err(|e| { + PbsError::SSZDecode { err: format!("error decoding relay payload: {e:?}"), fork } + })?; + GetExecutionPayloadBidResponse { version: fork, data, metadata: Default::default() } + } + }; + + // The endpoint serves Gloas bids only, and `version` is stamped verbatim + // onto CB's 200 to the BN - so a relay claiming any other fork is a bad + // relay response (this relay contributes no bid), not something to forward + if get_header_response.version != ForkName::Gloas { + crate::utils::record_invalid_relay_response( + "wrong_fork", + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, + &relay.id, + ); + return Err(PbsError::RelayResponse { + error_msg: format!( + "relay served a {} bid on the gloas-only bid endpoint", + get_header_response.version + ), + code: code.as_u16(), + }); + } + + info!( + relay_id = relay.id.as_ref(), + header_size_bytes, + latency = ?request_latency, + version =? get_header_response.version, + bid_eth = format_ether(get_header_response.data.message.value + get_header_response.data.message.execution_payment), + trustless_bid_eth = format_ether(get_header_response.data.message.value), + execution_payment_eth = format_ether(get_header_response.data.message.execution_payment), + block_hash = %get_header_response.data.message.block_hash, + "received new header" + ); + + let header_info = HeaderInfo { + block_hash: get_header_response.block_hash(), + parent_hash: get_header_response.parent_hash(), + parent_root: get_header_response.parent_root(), + slot: get_header_response.slot(), + trustless_payment: get_header_response.value(), + trusted_payment: get_header_response.execution_payment(), + fee_recipient: get_header_response.fee_recipient(), + gas_limit: get_header_response.gas_limit(), + }; + + validate_header_data( + &header_info, + ¶ms, + validation.min_bid_gwei, + validation.max_trusted_bid_gwei, + validation.expected_fee_recipient, + )?; + + if !validation.skip_sigverify { + validate_signature( + relay.pubkey(), + &get_header_response.data.message, + &get_header_response.data.signature, + )?; + } + + if validation.extra_validation_enabled { + let parent_block = validation.parent_block.read(); + if let Some(parent_block) = parent_block.as_ref() { + extra_validation(parent_block, &header_info, ¶ms)?; + } else { + warn!( + relay_id = relay.id.as_ref(), + "parent block not found, skipping extra validation" + ); + } + } + + Ok((start_request_time, Some(get_header_response))) +} + +struct HeaderInfo { + block_hash: B256, + parent_hash: B256, + parent_root: B256, + slot: u64, + trustless_payment: u64, + trusted_payment: u64, + fee_recipient: Address, + gas_limit: u64, +} + +fn validate_header_data( + header_info: &HeaderInfo, + params: &GetExecutionPayloadBidParams, + min_bid_gwei: u64, + max_trusted_bid_gwei: u64, + expected_fee_recipient: Option
, +) -> Result<(), ValidationError> { + if header_info.block_hash == B256::ZERO { + return Err(ValidationError::EmptyBlockhash); + } + + if params.parent_hash != header_info.parent_hash { + return Err(ValidationError::ParentHashMismatch { + expected: params.parent_hash, + got: header_info.parent_hash, + }); + } + + if params.parent_root != header_info.parent_root { + return Err(ValidationError::ParentRootMismatch { + expected: params.parent_root, + got: header_info.parent_root, + }); + } + + if params.slot != header_info.slot { + return Err(ValidationError::SlotNumberMismatch { + expected: params.slot, + got: header_info.slot, + }); + } + + let total_payment = header_info.trustless_payment.saturating_add(header_info.trusted_payment); + if total_payment < min_bid_gwei { + return Err(ValidationError::TotalPaymentTooLow { min: min_bid_gwei, got: total_payment }); + } + + if header_info.trusted_payment > max_trusted_bid_gwei { + return Err(ValidationError::TrustedBidTooHigh { + max: max_trusted_bid_gwei, + got: header_info.trusted_payment, + }); + } + + if let Some(expected) = expected_fee_recipient && + header_info.fee_recipient != expected + { + return Err(ValidationError::FeeRecipientMismatch { + expected, + got: header_info.fee_recipient, + }); + } + + Ok(()) +} + +fn validate_signature( + expected_pubkey: &BlsPublicKey, + message: &T, + signature: &BlsSignature, +) -> Result<(), ValidationError> { + if !verify_execution_payload_bid_signature( + expected_pubkey, + &message, + signature, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ) { + return Err(ValidationError::Sigverify); + } + + Ok(()) +} + +fn extra_validation( + parent_block: &Block, + header_info: &HeaderInfo, + params: &GetExecutionPayloadBidParams, +) -> Result<(), ValidationError> { + if parent_block.hash() != params.parent_hash { + return Err(ValidationError::ParentHashMismatch { + got: parent_block.header.parent_hash, + expected: params.parent_hash, + }); + }; + + let Some(parent_root) = parent_block.header.parent_beacon_block_root() else { + tracing::error!("parent block is missing parent_beacon_block_root"); + return Err(ValidationError::EmptyParentRoot); + }; + + if parent_root != params.parent_root { + return Err(ValidationError::ParentRootMismatch { + got: parent_root, + expected: params.parent_root, + }); + } + + // TODO potentially check builder index -> pubkey mapping + + if !check_gas_limit(header_info.gas_limit, parent_block.header.gas_limit) { + return Err(ValidationError::GasLimit { + parent: parent_block.header.gas_limit, + header: header_info.gas_limit, + }); + }; + + Ok(()) +} + +#[cfg(test)] +mod tests { + + use alloy::primitives::{B256, aliases::B32}; + use cb_common::{ + constants::{DOMAIN_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, + pbs::{RequestAuth, error::ValidationError}, + signature::{ + compute_domain, compute_domain_with_fork_version, request_auth_domain, + sign_builder_message, sign_execution_payload_bid_root, sign_request_auth_root, + }, + types::{BlsSecretKey, Chain}, + utils::TestRandomSeed, + wire::BodyDeserializeError, + }; + use lh_types::Slot; + + use super::{validate_header_data, *}; + + #[test] + fn test_validate_header() { + let slot = 5; + let parent_hash = B256::from_slice(&[1; 32]); + let parent_root = B256::from_slice(&[2; 32]); + let min_bid = 500; + let max_trusted_payment = 1000; + let secret_key = BlsSecretKey::test_random(); + let pubkey = secret_key.public_key(); + + let mock_params = GetExecutionPayloadBidParams { + slot, + parent_hash: parent_hash.clone(), + parent_root: parent_root.clone(), + proposer_pubkey: pubkey, + }; + + let mut mock_header_data = HeaderInfo { + block_hash: B256::default(), + parent_hash: B256::default(), + parent_root: B256::default(), + slot: 0, + trustless_payment: min_bid - 1, + trusted_payment: 0, + fee_recipient: Address::ZERO, + gas_limit: 0, + }; + + assert_eq!( + validate_header_data( + &mock_header_data, + &mock_params, + min_bid, + max_trusted_payment, + None + ), + Err(ValidationError::EmptyBlockhash) + ); + + mock_header_data.block_hash.0[1] = 1; + + assert_eq!( + validate_header_data( + &mock_header_data, + &mock_params, + min_bid, + max_trusted_payment, + None + ), + Err(ValidationError::ParentHashMismatch { + expected: mock_params.parent_hash, + got: B256::default() + }) + ); + + mock_header_data.parent_hash = parent_hash; + + assert_eq!( + validate_header_data( + &mock_header_data, + &mock_params, + min_bid, + max_trusted_payment, + None + ), + Err(ValidationError::ParentRootMismatch { + expected: mock_params.parent_root, + got: B256::default() + }) + ); + + mock_header_data.parent_root = parent_root; + + assert_eq!( + validate_header_data( + &mock_header_data, + &mock_params, + min_bid, + max_trusted_payment, + None + ), + Err(ValidationError::SlotNumberMismatch { expected: slot, got: 0 }) + ); + + mock_header_data.slot = slot; + + assert_eq!( + validate_header_data( + &mock_header_data, + &mock_params, + min_bid, + max_trusted_payment, + None + ), + Err(ValidationError::TotalPaymentTooLow { + min: min_bid, + got: mock_header_data.trustless_payment, + }) + ); + + mock_header_data.trusted_payment = max_trusted_payment + 1; + + assert_eq!( + validate_header_data( + &mock_header_data, + &mock_params, + min_bid, + max_trusted_payment, + None + ), + Err(ValidationError::TrustedBidTooHigh { + max: max_trusted_payment, + got: mock_header_data.trusted_payment, + }) + ); + + mock_header_data.trusted_payment = max_trusted_payment; + + let expected_fee_recipient = Address::from([1; 20]); + + assert_eq!( + validate_header_data( + &mock_header_data, + &mock_params, + min_bid, + max_trusted_payment, + Some(expected_fee_recipient), + ), + Err(ValidationError::FeeRecipientMismatch { + expected: expected_fee_recipient, + got: Address::ZERO, + }) + ); + + mock_header_data.fee_recipient = expected_fee_recipient; + + validate_header_data( + &mock_header_data, + &mock_params, + min_bid, + max_trusted_payment, + Some(expected_fee_recipient), + ) + .unwrap(); + } + + #[test] + fn test_validate_signature() { + let secret_key = BlsSecretKey::test_random(); + let pubkey = secret_key.public_key(); + let wrong_signature = BlsSignature::test_random(); + + let message = B256::random(); + + // A legacy builder-domain signature must be rejected: bids use the + // gloas bid domain (DOMAIN_BEACON_BUILDER), not APPLICATION_BUILDER_DOMAIN. + let builder_domain_sig = sign_builder_message(Chain::Holesky, &secret_key, &message); + let bid_domain_sig = sign_execution_payload_bid_root( + &secret_key, + &message.tree_hash_root(), + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ); + + assert!(matches!( + validate_signature(&pubkey, &message, &wrong_signature), + Err(ValidationError::Sigverify) + )); + assert!(matches!( + validate_signature(&pubkey, &message, &builder_domain_sig), + Err(ValidationError::Sigverify) + )); + assert!(validate_signature(&pubkey, &message, &bid_domain_sig).is_ok()); + } + + fn test_auth(slot: u64, signature: BlsSignature) -> SignedRequestAuth { + SignedRequestAuth { + // `data` is the demux's input, not this validator's: it is unused here + message: RequestAuth { data: Default::default(), slot: Slot::new(slot) }, + signature, + } + } + + // An empty body is as invalid as a malformed one: the spec requires the auth + #[test] + fn test_decode_request_auth_rejects_empty_body() { + assert!(matches!( + decode_versioned_request_body::(&HeaderMap::new(), &Bytes::new()), + Err(BodyDeserializeError::MissingBody) + )); + } + + #[test] + fn test_poll_call_timeout_ms() { + // Early polls are bounded so a bid lands while there is time to use it, + // even when the deadline is far away + assert_eq!(poll_call_timeout_ms(4000, 1000, false), 1000); + // The last poll holds for everything that is left + assert_eq!(poll_call_timeout_ms(4000, 1000, true), 4000); + // Never promise more time than remains before the shared deadline + assert_eq!(poll_call_timeout_ms(600, 1000, false), 600); + // A budget shorter than the poll timeout degrades to today's behavior: + // one poll that holds until the deadline + assert_eq!(poll_call_timeout_ms(800, 1000, true), 800); + } + + #[test] + fn test_rung_budget_tracks_the_real_clock_not_nominal_cadence() { + // Deadline 1000ms from the clock basis, 300ms cadence, 700ms poll bound. + // First rung fires on time: bounded early rung, plenty of budget left + let rung = rung_budget(1000, 0, 300, 700); + assert!(!rung.is_last); + assert_eq!(rung.call_timeout_ms, 700); + + // By the second rung, drift has burned 400ms of wall clock while + // nominal bookkeeping would claim only one 300ms cadence step: the + // budget must reflect the real 600ms remaining, not the nominal 700 + let rung = rung_budget(1000, 400, 300, 700); + assert!(!rung.is_last); + assert_eq!(rung.call_timeout_ms, 600); + + // The last rung carries exactly what truly remains, never the nominal + // remainder (which would be 1000 - 2 * 300 = 400 here) + let rung = rung_budget(1000, 800, 300, 700); + assert!(rung.is_last); + assert_eq!(rung.call_timeout_ms, 200); + + // Drift past the deadline leaves nothing to grant + let rung = rung_budget(1000, 1100, 300, 700); + assert!(rung.is_last); + assert_eq!(rung.call_timeout_ms, 0); + } + + #[test] + fn test_target_first_request_delay_ms() { + // Already past the target: fire immediately + assert_eq!(target_first_request_delay_ms(200, 300, 1000), Some(0)); + // Normal case: wait out the remainder of the target + assert_eq!(target_first_request_delay_ms(500, 100, 1000), Some(400)); + // Target slightly under the budget: the reduced remainder still buys a + // real poll (1ms here, granted by the deadline math after the sleep) + assert_eq!(target_first_request_delay_ms(999, 0, 1000), Some(999)); + assert_eq!(rung_budget(1000, 999, 300, 700).call_timeout_ms, 1); + // Target consumes the whole budget: skip the relay, never a 0ms poll + assert_eq!(target_first_request_delay_ms(1000, 0, 1000), None); + assert_eq!(target_first_request_delay_ms(5000, 0, 1000), None); + assert_eq!(target_first_request_delay_ms(600, 100, 400), None); + } + + #[test] + fn test_request_budget_ms() { + let headers = |sent: u64, timeout: u64| { + let mut h = HeaderMap::new(); + h.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(sent)); + h.insert(HEADER_TIMEOUT_MS, HeaderValue::from(timeout)); + h + }; + let now = 1_000_000; + + // Transit delay eats the budget: the deadline is absolute + assert_eq!(request_budget_ms(&headers(now, 1000), now).unwrap(), 1000); + assert_eq!(request_budget_ms(&headers(now - 400, 1000), now).unwrap(), 600); + + // A deadline already in the past leaves nothing + assert_eq!(request_budget_ms(&headers(now - 5000, 1000), now).unwrap(), 0); + + // A proposer clock running ahead cannot grant more than it advertised + assert_eq!(request_budget_ms(&headers(now + 10_000, 1000), now).unwrap(), 1000); + + // Both headers are required, and a zero timeout is not a valid request + for h in [ + HeaderMap::new(), + { + let mut h = HeaderMap::new(); + h.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(now)); + h + }, + { + let mut h = HeaderMap::new(); + h.insert(HEADER_TIMEOUT_MS, HeaderValue::from(1000u64)); + h + }, + headers(now, 0), + { + let mut h = HeaderMap::new(); + h.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from_static("soon")); + h.insert(HEADER_TIMEOUT_MS, HeaderValue::from(1000u64)); + h + }, + ] { + assert!(matches!(request_budget_ms(&h, now), Err(PbsClientError::MissingTimingHeader))); + } + } + + // The auth domain is NOT fork-versioned: it must equal the spec's + // compute_domain(DOMAIN_REQUEST_AUTH), i.e. genesis fork version and a zero + // root. A sign/verify round trip cannot catch a wrong domain, so pin it. + #[test] + fn test_request_auth_domain_is_not_fork_versioned() { + for chain in [Chain::Mainnet, Chain::Hoodi, Chain::Holesky] { + assert_eq!( + request_auth_domain(chain), + compute_domain(chain, &B32::from(DOMAIN_REQUEST_AUTH)), + ); + // A fork-versioned domain would differ; that is the bug this guards + assert_ne!( + request_auth_domain(chain), + compute_domain_with_fork_version( + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + &B32::from(DOMAIN_REQUEST_AUTH), + ), + ); + } + // Chains are separated by their genesis fork version + assert_ne!(request_auth_domain(Chain::Mainnet), request_auth_domain(Chain::Hoodi)); + } + + #[test] + fn test_validate_request_auth() { + let chain = Chain::Hoodi; + let secret_key = BlsSecretKey::test_random(); + let pubkey = secret_key.public_key(); + let slot = 5; + let params = GetExecutionPayloadBidParams { + slot, + parent_hash: B256::ZERO, + parent_root: B256::ZERO, + proposer_pubkey: pubkey, + }; + + // Slot mismatch is a 400 whether or not sigverify is on + for verify in [false, true] { + assert!(matches!( + validate_request_auth( + &test_auth(slot + 1, BlsSignature::empty()), + ¶ms, + chain, + verify + ), + Err(PbsClientError::AuthSlotMismatch) + )); + } + + // With verification off a bad signature passes through to the builder + let bad = test_auth(slot, BlsSignature::test_random()); + validate_request_auth(&bad, ¶ms, chain, false).unwrap(); + assert!(matches!( + validate_request_auth(&bad, ¶ms, chain, true), + Err(PbsClientError::AuthSigVerify) + )); + + // The bid domain must not be accepted for a request auth + let message = test_auth(slot, BlsSignature::empty()).message; + let bid_domain_sig = sign_execution_payload_bid_root( + &secret_key, + &message.tree_hash_root(), + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ); + assert!(matches!( + validate_request_auth(&test_auth(slot, bid_domain_sig), ¶ms, chain, true), + Err(PbsClientError::AuthSigVerify) + )); + + // A signature made for another chain must not verify here + let other_chain_sig = + sign_request_auth_root(&secret_key, &message.tree_hash_root(), Chain::Mainnet); + assert!(matches!( + validate_request_auth(&test_auth(slot, other_chain_sig), ¶ms, chain, true), + Err(PbsClientError::AuthSigVerify) + )); + + let good_sig = sign_request_auth_root(&secret_key, &message.tree_hash_root(), chain); + validate_request_auth(&test_auth(slot, good_sig), ¶ms, chain, true).unwrap(); + } + + struct MockBid { + value: u64, + execution_payment: u64, + } + + impl GetExecutionPayloadBidInfo for MockBid { + fn block_hash(&self) -> B256 { + B256::default() + } + fn parent_hash(&self) -> B256 { + B256::default() + } + fn parent_root(&self) -> B256 { + B256::default() + } + fn value(&self) -> u64 { + self.value + } + fn execution_payment(&self) -> u64 { + self.execution_payment + } + fn fee_recipient(&self) -> Address { + Address::ZERO + } + fn builder_index(&self) -> u64 { + 0 + } + fn slot(&self) -> u64 { + 0 + } + fn gas_limit(&self) -> u64 { + 0 + } + } + + // The winning bid is the one paying the proposer the most in TOTAL: + // value + execution_payment (the builder commits to pay the sum), not + // the highest trustless value alone. + #[test] + fn test_select_max_bid_by_total_payment() { + let bids = vec![ + ("value_winner", MockBid { value: 6, execution_payment: 0 }), + ("total_winner", MockBid { value: 5, execution_payment: 10 }), + ]; + let (winner, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner, "total_winner"); + + // A saturating sum must not misrank a near-overflow bid + let bids = vec![ + ("honest", MockBid { value: 7, execution_payment: 0 }), + ("overflow", MockBid { value: u64::MAX, execution_payment: u64::MAX }), + ]; + let (winner, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner, "overflow"); + } + + // Per-relay in-flight aggregation (timing games) must pick the highest + // TOTAL payment, not the latest-started response. + #[test] + fn test_inflight_selection_prefers_max_total_not_latest() { + // Labels are request start times (utcnow_ms), as in the timing-games path. + let early = 1_000u64; + let late = 1_050u64; + let mid = 1_025u64; + // Max total is neither first nor last, and the later-started response + // pays LESS: this fails both latest-wins and first-wins. + let bids = vec![ + (late, MockBid { value: 3, execution_payment: 1 }), // total 4 + (early, MockBid { value: 10, execution_payment: 5 }), // total 15 (winner) + (mid, MockBid { value: 6, execution_payment: 2 }), // total 8 + ]; + let (winner_start, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner_start, early, "must pick highest total, not latest- or first-started"); + } +} diff --git a/crates/pbs/src/routes/mod.rs b/crates/pbs/src/routes/mod.rs index 84853d9ea..595552f58 100644 --- a/crates/pbs/src/routes/mod.rs +++ b/crates/pbs/src/routes/mod.rs @@ -1,12 +1,18 @@ +mod builder_preferences; +mod execution_payload_bid; mod get_header; mod register_validator; mod reload; mod router; mod status; mod submit_block; +mod submit_signed_beacon_block; +use builder_preferences::handle_submit_builder_preferences; +use execution_payload_bid::handle_get_execution_payload_bid; use get_header::handle_get_header; use register_validator::handle_register_validator; pub use router::create_app_router; use status::handle_get_status; use submit_block::handle_submit_block_v1; +use submit_signed_beacon_block::handle_submit_signed_beacon_block; diff --git a/crates/pbs/src/routes/router.rs b/crates/pbs/src/routes/router.rs index e98c89c14..a1091e2f0 100644 --- a/crates/pbs/src/routes/router.rs +++ b/crates/pbs/src/routes/router.rs @@ -8,19 +8,22 @@ use axum::{ }; use axum_extra::headers::{ContentType, HeaderMapExt, UserAgent}; use cb_common::pbs::{ - BUILDER_V1_API_PATH, BUILDER_V2_API_PATH, GET_HEADER_PATH, GET_STATUS_PATH, - REGISTER_VALIDATOR_PATH, RELOAD_PATH, SUBMIT_BLOCK_PATH, + BUILDER_V1_API_PATH, BUILDER_V2_API_PATH, GET_EXECUTION_PAYLOAD_BID_PATH, GET_HEADER_PATH, + GET_STATUS_PATH, REGISTER_VALIDATOR_PATH, RELOAD_PATH, SUBMIT_BLOCK_PATH, + SUBMIT_BUILDER_PREFERENCES_PATH, SUBMIT_SIGNED_BEACON_BLOCK_PATH, }; use tower_http::trace::TraceLayer; use tracing::{info, trace, warn}; use uuid::Uuid; use super::{ - handle_get_header, handle_get_status, handle_register_validator, handle_submit_block_v1, - reload::handle_reload, + handle_get_execution_payload_bid, handle_get_header, handle_get_status, + handle_register_validator, handle_submit_block_v1, handle_submit_builder_preferences, + handle_submit_signed_beacon_block, reload::handle_reload, }; use crate::{ MAX_SIZE_REGISTER_VALIDATOR_REQUEST, MAX_SIZE_SUBMIT_BLOCK_RESPONSE, + MAX_SIZE_SUBMIT_SIGNED_BEACON_BLOCK, api::BuilderApi, routes::submit_block::handle_submit_block_v2, state::{BuilderApiState, PbsStateGuard}, @@ -42,7 +45,14 @@ pub fn create_app_router>(state: PbsStateGu SUBMIT_BLOCK_PATH, post(handle_submit_block_v1::) .route_layer(DefaultBodyLimit::max(MAX_SIZE_SUBMIT_BLOCK_RESPONSE)), - ); // header is smaller than the response but err on the safe side + ) // header is smaller than the response but err on the safe side + .route(GET_EXECUTION_PAYLOAD_BID_PATH, post(handle_get_execution_payload_bid::)) + .route(SUBMIT_BUILDER_PREFERENCES_PATH, post(handle_submit_builder_preferences::)) + .route( + SUBMIT_SIGNED_BEACON_BLOCK_PATH, + post(handle_submit_signed_beacon_block::) + .route_layer(DefaultBodyLimit::max(MAX_SIZE_SUBMIT_SIGNED_BEACON_BLOCK)), + ); let v2_builder_routes = Router::new().route( SUBMIT_BLOCK_PATH, post(handle_submit_block_v2::) @@ -70,7 +80,7 @@ pub fn create_app_router>(state: PbsStateGu } #[tracing::instrument( - name = "", + name = "", skip_all, fields( method = %req.extensions().get::().map(|m| m.as_str()).unwrap_or("unknown"), diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs new file mode 100644 index 000000000..6d7fc6596 --- /dev/null +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -0,0 +1,146 @@ +use std::time::Duration; + +use axum::{body::Bytes, extract::State, http::HeaderMap, response::IntoResponse}; +use cb_common::{ + pbs::{RelayClient, SignedBeaconBlock, error::PbsError, is_gloas}, + wire::{EncodingType, decode_signed_beacon_block, get_user_agent, safe_read_http_response}, +}; +use futures::future::join_all; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::Encode; +use tracing::{Instrument, error, info}; + +use crate::{ + PbsStateGuard, + constants::{MAX_SIZE_DEFAULT, SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG}, + error::PbsClientError, + metrics::BEACON_NODE_STATUS, + state::{BuilderApiState, PbsState}, + utils::{epbs_base_send_headers, expect_status, record_client_error, send_to_relay}, +}; + +/// The body is the required `SignedBeaconBlock`. `Eth-Consensus-Version` is +/// required for JSON and SSZ alike and must name a known fork (spec PR #165); +/// the SSZ form additionally uses it to select the variant +pub async fn handle_submit_signed_beacon_block( + State(state): State>, + req_headers: HeaderMap, + body: Bytes, +) -> Result { + let block = decode_signed_beacon_block(&req_headers, &body) + .map_err(|err| record_client_error(err, SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG))?; + let slot = block.slot().as_u64(); + tracing::Span::current().record("slot", slot); + + let state = state.read().clone(); + let ua = get_user_agent(&req_headers); + info!(ua, slot, "new request"); + + match submit_signed_beacon_block(block, req_headers, state).await { + Ok(()) => { + BEACON_NODE_STATUS + .with_label_values(&["202", SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG]) + .inc(); + Ok(StatusCode::ACCEPTED.into_response()) + } + Err(err) => { + error!(%err, "submit_signed_beacon_block failed"); + + BEACON_NODE_STATUS + .with_label_values(&[ + err.status_code().as_str(), + SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, + ]) + .inc(); + Err(err) + } + } +} + +/// Broadcasts a `SignedBeaconBlock` to every configured builder. CB is +/// stateless here: it keeps no record of the auction winner, so it forwards the +/// block to all relays to improve inclusion guarantees, +/// additive to the beacon node's own p2p gossip. +/// Ok(()) means at least one builder accepted with a 202. +pub async fn submit_signed_beacon_block( + block: SignedBeaconBlock, + req_headers: HeaderMap, + state: PbsState, +) -> Result<(), PbsClientError> { + // Gloas-only endpoint per spec; earlier forks carry no execution payload bid + if !is_gloas(&block) { + return Err(PbsClientError::NotGloasBlock); + } + + // Base headers carry Eth-Consensus-Version: gloas, which the builder needs + // to decode the SSZ block + let send_headers = epbs_base_send_headers(&req_headers)?; + + let timeout_ms = state.pbs_config().timeout_get_payload_ms; + + let body = Bytes::from(block.as_ssz_bytes()); + let relays = state.all_relays(); + let mut handles = Vec::with_capacity(relays.len()); + for relay in relays.iter() { + handles.push( + send_one_submit_signed_beacon_block( + relay.clone(), + body.clone(), + send_headers.clone(), + timeout_ms, + ) + .in_current_span(), + ); + } + + let results = join_all(handles).await; + let accepted = results + .into_iter() + .zip(relays.iter()) + .filter(|(res, relay)| match res { + Ok(()) => true, + Err(err) => { + error!(relay_id = relay.id.as_ref(), %err, "builder did not accept the block"); + false + } + }) + .count(); + + // Only the winner accepts, so one 202 across the broadcast is success + if accepted == 0 { + return Err(PbsClientError::NoBuilderResponse); + } + info!(accepted, addressed = relays.len(), "signed beacon block submitted"); + Ok(()) +} + +async fn send_one_submit_signed_beacon_block( + relay: RelayClient, + body: Bytes, + headers: HeaderMap, + timeout_ms: u64, +) -> Result<(), PbsError> { + let url = relay.submit_signed_beacon_block_url()?; + + // Every builder implements SSZ for this new endpoint, so the block is + // forwarded in SSZ (the fork travels in Eth-Consensus-Version). + let req = relay + .client + .post(url) + .timeout(Duration::from_millis(timeout_ms)) + .headers(headers) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(body); + let (res, _latency) = + send_to_relay(req, &relay, SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG).await?; + let code = res.status(); + + // Cap the read: a builder is untrusted and must not stream an unbounded + // error body into memory and the logs + safe_read_http_response(res, MAX_SIZE_DEFAULT).await?; + + // 202 is the spec's only success; the builder publishes the payload envelope + expect_status(code, StatusCode::ACCEPTED)?; + + Ok(()) +} diff --git a/crates/pbs/src/service.rs b/crates/pbs/src/service.rs index ded5b7db9..d09bb0d27 100644 --- a/crates/pbs/src/service.rs +++ b/crates/pbs/src/service.rs @@ -30,7 +30,18 @@ pub struct PbsService; impl PbsService { pub async fn run>(state: PbsState) -> Result<()> { - let addr = state.config.endpoint; + let listener = TcpListener::bind(state.config.endpoint).await?; + Self::run_with_listener::(state, listener).await + } + + /// Serve from an already-bound listener. Prefer this in tests: discovering + /// a free port, dropping the listener, then rebinding leaves a window + /// where another process can take the port before the server binds it. + pub async fn run_with_listener>( + state: PbsState, + listener: TcpListener, + ) -> Result<()> { + let addr = listener.local_addr()?; info!(version = COMMIT_BOOST_VERSION, commit_hash = COMMIT_BOOST_COMMIT, ?addr, chain =? state.config.chain, "starting PBS service"); // Check if refreshing registry muxes is required @@ -44,7 +55,6 @@ impl PbsService { let config_path = state.config_path.clone(); let state: Arc>> = RwLock::new(state).into(); let app = create_app_router::(state.clone()); - let listener = TcpListener::bind(addr).await?; let task = tokio::spawn( diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 782ae79b6..f488af05f 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -1,9 +1,118 @@ +use std::time::{Duration, Instant}; + +use cb_common::{ + pbs::{ForkName, RelayClient, SignedRequestAuth, error::PbsError}, + signature::verify_request_auth_signature, + types::{BlsPublicKey, Chain}, + wire::{CONSENSUS_VERSION_HEADER, get_user_agent_with_version}, +}; +use reqwest::{ + StatusCode, + header::{HeaderMap, HeaderValue, USER_AGENT}, +}; +use tracing::warn; +use url::Url; + +use crate::{ + constants::TIMEOUT_ERROR_CODE_STR, + error::PbsClientError, + metrics::{RELAY_LATENCY, RELAY_STATUS_CODE}, +}; + +/// Sends one already-built relay request and records the per-relay metrics +/// shared by all three ePBS endpoints: a send failure bumps `RELAY_STATUS_CODE` +/// at `TIMEOUT_ERROR_CODE_STR` and returns the error; otherwise the latency is +/// observed and the response status recorded. Returns the response and its +/// latency so the caller can read/decode the body itself. `tag` is the +/// per-endpoint metric label. Callers build their own `RequestBuilder` because +/// the requests legitimately differ (bid sets a per-call timeout and timing +/// headers). +pub(crate) async fn send_to_relay( + req: reqwest::RequestBuilder, + relay: &RelayClient, + tag: &str, +) -> Result<(reqwest::Response, Duration), PbsError> { + let start_request = Instant::now(); + let res = match req.send().await { + Ok(res) => res, + Err(err) => { + RELAY_STATUS_CODE.with_label_values(&[TIMEOUT_ERROR_CODE_STR, tag, &relay.id]).inc(); + return Err(err.into()); + } + }; + + let request_latency = start_request.elapsed(); + RELAY_LATENCY.with_label_values(&[tag, &relay.id]).observe(request_latency.as_secs_f64()); + + let code = res.status(); + RELAY_STATUS_CODE.with_label_values(&[code.as_str(), tag, &relay.id]).inc(); + + Ok((res, request_latency)) +} + +/// Count a request-rejection in `BEACON_NODE_STATUS` before it short-circuits +/// the handler. Without this a client broken by e.g. the strict +/// `Eth-Consensus-Version` rule or a bad `Accept` header VANISHES from the +/// endpoint counter instead of showing up as a 4xx spike - the exact signal an +/// operator needs during a rollout. +pub(crate) fn record_client_error( + err: impl Into, + endpoint: &str, +) -> PbsClientError { + let err = err.into(); + crate::metrics::BEACON_NODE_STATUS + .with_label_values(&[err.status_code().as_str(), endpoint]) + .inc(); + err +} + +/// Count a relay response that CB rejected during validation. The relay's HTTP +/// status was already recorded when the response arrived, so without this a +/// relay serving invalid bids every slot is indistinguishable in metrics from +/// an honest empty auction. +pub(crate) fn record_invalid_relay_response(reason: &str, endpoint: &str, relay_id: &str) { + crate::metrics::RELAY_INVALID_RESPONSE.with_label_values(&[reason, endpoint, relay_id]).inc(); +} + +/// The ePBS write endpoints (`submitBuilderPreferences`, +/// `submitSignedBeaconBlock`) make 202 Accepted the only success: any other +/// status means the builder did not commit. One home for that rule. +pub(crate) fn expect_status(code: StatusCode, expected: StatusCode) -> Result<(), PbsError> { + if code != expected { + return Err(PbsError::RelayResponse { + error_msg: format!("expected {}", expected.as_u16()), + code: code.as_u16(), + }); + } + Ok(()) +} + +/// Base outbound headers shared by the ePBS endpoints: the versioned +/// `User-Agent` and `Eth-Consensus-Version`. All three relay hops send SSZ +/// bodies of fork-versioned wire types, so the builder needs the fork header; +/// it is re-derived as Gloas (these are Gloas-only endpoints), never echoed +/// from the inbound request. Callers add their endpoint-specific headers (bid +/// adds `Accept` and the timing headers). +pub(crate) fn epbs_base_send_headers(req_headers: &HeaderMap) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + USER_AGENT, + get_user_agent_with_version(req_headers).map_err(|_| PbsClientError::Internal)?, + ); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&ForkName::Gloas.to_string()) + .expect("fork name is always a valid header value"), + ); + Ok(headers) +} + const GAS_LIMIT_ADJUSTMENT_FACTOR: u64 = 1024; const GAS_LIMIT_MINIMUM: u64 = 5_000; /// Validates the gas limit against the parent gas limit, according to the /// execution spec https://github.com/ethereum/execution-specs/blob/98d6ddaaa709a2b7d0cd642f4cfcdadc8c0808e1/src/ethereum/cancun/fork.py#L1118-L1154 -pub fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { +pub(crate) fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { let max_adjustment_delta = parent_gas_limit / GAS_LIMIT_ADJUSTMENT_FACTOR; if gas_limit >= parent_gas_limit + max_adjustment_delta { return false; @@ -19,3 +128,235 @@ pub fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { true } + +/// Verifies the request auth signature when `verify_signature` is on. The +/// downstream builder verifies it regardless, which is why the crypto is +/// opt-in. Shared by the request-auth validators of both ePBS endpoints; the +/// slot rule differs between them and stays with each caller. +pub(crate) fn verify_auth_signature( + pubkey: &BlsPublicKey, + auth: &SignedRequestAuth, + chain: Chain, + verify_signature: bool, +) -> Result<(), PbsClientError> { + if verify_signature && + !verify_request_auth_signature(pubkey, &auth.message, &auth.signature, chain) + { + warn!(pubkey = %pubkey, "auth signature verification failed"); + return Err(PbsClientError::AuthSigVerify); + } + + Ok(()) +} + +/// Selects the relays an ePBS request is addressed to. +/// +/// Each `getExecutionPayloadBid` or `submitBuilderPreferences` call is for one +/// builder, designated by the caller's `auth.message.data`. Two layers, most +/// specific first: +/// +/// 1. A relay with `expected_auth_data` configured matches only that exact byte +/// string. This is the authoritative form for bilateral agreements where the +/// data is a shared secret rather than a URL. +/// 2. Otherwise, data carrying a builder URL (see [`decode_auth_data_url`]) +/// matches the relays whose configured URL it names. Comparison ignores +/// userinfo, so a bare URL matches a relay entry that embeds its pubkey. +/// +/// Data matching nothing selects no relay: CB then has no builder to proxy to +/// and the caller must get the same DataMismatch 400 a builder would return. +/// The result is usually one relay, several when multiple builders are +/// configured behind the same agreement. Comparing bids across different +/// builders is the beacon node's job across its per-entry calls; within the +/// matched set the winner is the highest total payment. +pub(crate) fn match_relays_by_auth_data<'a>( + relays: &'a [RelayClient], + received_data: &[u8], +) -> Vec<&'a RelayClient> { + let data_url = decode_auth_data_url(received_data); + relays + .iter() + .filter(|relay| { + if let Some(expected) = &relay.config.expected_auth_data { + return received_data == expected.as_ref(); + } + match &data_url { + Some(url) => url_matches(&relay.config.entry.url, url), + None => false, + } + }) + .collect() +} + +/// Extracts a builder URL from `auth.message.data` using Commit-Boost's +/// purely additive convention: the UTF-8 bytes of the builder's URL, optionally +/// followed by a NUL byte and opaque extra bytes. Data without extra bytes is +/// byte-identical to the spec's nothing-agreed default, so parties using the +/// default need no change; NUL cannot appear in a URL, so the split is +/// unambiguous. Returns None for opaque data carrying no URL. +pub(crate) fn decode_auth_data_url(data: &[u8]) -> Option { + let url_bytes = match data.iter().position(|&b| b == 0) { + Some(i) => &data[..i], + None => data, + }; + std::str::from_utf8(url_bytes).ok().and_then(|s| Url::parse(s).ok()) +} + +/// Compares two URLs without checking userinfo/path/queries/frags. A relay +/// entry URL embeds the relay pubkey as userinfo, so full equality would never +/// match a bare builder URL. +pub(crate) fn url_matches(a: &Url, b: &Url) -> bool { + a.scheme() == b.scheme() && + a.host_str() == b.host_str() && + a.port_or_known_default() == b.port_or_known_default() +} + +#[cfg(test)] +mod tests { + use cb_common::{ + config::{GetHeaderTransport, RelayConfig}, + pbs::RelayEntry, + types::BlsSecretKey, + }; + + use super::*; + + fn test_relay(url: &str, expected_auth_data: Option<&[u8]>) -> RelayClient { + let entry = RelayEntry { + id: url.to_string(), + pubkey: BlsSecretKey::random().public_key().into(), + url: Url::parse(url).unwrap(), + }; + let mut config = RelayConfig { + entry, + id: None, + headers: None, + get_params: None, + get_header: GetHeaderTransport::Http, + enable_timing_games: false, + target_first_request_ms: None, + frequency_get_header_ms: None, + bid_poll_timeout_ms: None, + validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: None, + }; + config.expected_auth_data = expected_auth_data.map(|d| d.to_vec().into()); + RelayClient::new(config).unwrap() + } + + #[test] + fn match_relays_configured_data_never_matches_empty() { + let relays = vec![test_relay("http://a.example.com", Some(&[0xaa]))]; + // An empty `data` field must not satisfy a relay that declared its data + assert!(match_relays_by_auth_data(&relays, &[]).is_empty()); + } + + #[test] + fn match_relays_prefers_configured_auth_data() { + let relays = vec![ + test_relay("http://a.example.com", Some(&[0xaa])), + test_relay("http://b.example.com", Some(&[0xbb])), + ]; + // Exact-bytes match selects exactly one relay + let matched = match_relays_by_auth_data(&relays, &[0xbb]); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].config.entry.url.host_str(), Some("b.example.com")); + // Data matching no configured value selects none, even though the data + // is a URL naming a configured relay: configured bytes take precedence + assert!(match_relays_by_auth_data(&relays, b"http://a.example.com").is_empty()); + } + + #[test] + fn match_relays_url_fallback_and_unmatched_selects_none() { + let relays = vec![ + test_relay("http://a.example.com", None), + test_relay("http://b.example.com", None), + ]; + // URL-carrying data selects the named relay only + let matched = match_relays_by_auth_data(&relays, b"http://a.example.com"); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].config.entry.url.host_str(), Some("a.example.com")); + // NUL-suffixed extra bytes route identically + let mut with_extra = b"http://a.example.com".to_vec(); + with_extra.push(0); + with_extra.extend_from_slice(&[0xde, 0xad]); + assert_eq!(match_relays_by_auth_data(&relays, &with_extra).len(), 1); + // A URL naming nothing configured selects none + assert!(match_relays_by_auth_data(&relays, b"http://z.example.com").is_empty()); + // Opaque non-URL data names nothing: no catch-all, no relay + assert!(match_relays_by_auth_data(&relays, &[0xde, 0xad]).is_empty()); + // Empty data carries no URL either + assert!(match_relays_by_auth_data(&relays, &[]).is_empty()); + } + + #[test] + fn url_matches_ignores_userinfo_and_default_port() { + let u = |s: &str| Url::parse(s).unwrap(); + // A bare builder URL matches a configured relay whose URL embeds the + // relay pubkey as userinfo and omits the default port. + assert!(url_matches( + &u("https://0xdeadbeef@builder.example.com"), + &u("https://builder.example.com") + )); + assert!(url_matches( + &u("https://builder.example.com:443"), + &u("https://builder.example.com") + )); + assert!(!url_matches(&u("http://a.com"), &u("https://a.com"))); + assert!(!url_matches(&u("https://a.com"), &u("https://b.com"))); + assert!(!url_matches(&u("http://a.com:8001"), &u("http://a.com:8002"))); + } + + #[test] + fn decode_auth_data_url_variants() { + // raw UTF-8 URL bytes (the spec's nothing-agreed default) + let url = decode_auth_data_url(b"https://builder.example.com").unwrap(); + assert_eq!(url.host_str(), Some("builder.example.com")); + // NUL-suffixed extra bytes decode to the same URL: purely additive + let mut with_extra = b"https://builder.example.com".to_vec(); + with_extra.push(0); + with_extra.extend_from_slice(&[0xde, 0xad]); + let url = decode_auth_data_url(&with_extra).unwrap(); + assert_eq!(url.host_str(), Some("builder.example.com")); + // empty extra after the NUL is also valid and identical + let url = decode_auth_data_url(b"https://builder.example.com\x00").unwrap(); + assert_eq!(url.host_str(), Some("builder.example.com")); + // opaque non-URL bytes carry no routing, with or without a NUL + assert!(decode_auth_data_url(&[0xde, 0xad, 0xbe, 0xef]).is_none()); + assert!(decode_auth_data_url(&[0xde, 0x00, 0xad]).is_none()); + assert!(decode_auth_data_url(b"not a url").is_none()); + } + + /// Pins that the helper lands in `BEACON_NODE_STATUS` with the declared + /// label ORDER (status, endpoint). Both labels are &str, so a swapped + /// order compiles and silently writes a different series - this read-back + /// with the correct order is the only thing that catches it. The endpoint + /// tag is unique to this test, so parallel tests cannot race it. + /// Same label-order pin as the beacon-node counter: all three labels are + /// &str, so a permuted order compiles and silently writes another series. + #[test] + fn record_invalid_relay_response_lands_in_relay_invalid_response() { + const TAG: &str = "invalid-relay-response-unit-test"; + + let before = crate::metrics::RELAY_INVALID_RESPONSE + .with_label_values(&["wrong_fork", TAG, "relay-x"]) + .get(); + record_invalid_relay_response("wrong_fork", TAG, "relay-x"); + let after = crate::metrics::RELAY_INVALID_RESPONSE + .with_label_values(&["wrong_fork", TAG, "relay-x"]) + .get(); + assert_eq!(after, before + 1); + } + + #[test] + fn record_client_error_lands_in_beacon_node_status() { + const TAG: &str = "record-client-error-unit-test"; + + let before = crate::metrics::BEACON_NODE_STATUS.with_label_values(&["400", TAG]).get(); + let err = + record_client_error(cb_common::wire::BodyDeserializeError::MissingVersionHeader, TAG); + assert_eq!(err.status_code(), reqwest::StatusCode::BAD_REQUEST); + let after = crate::metrics::BEACON_NODE_STATUS.with_label_values(&["400", TAG]).get(); + assert_eq!(after, before + 1, "the 400 must count under (status, endpoint)"); + } +} diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index 710d7ea6e..807057fe4 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -94,6 +94,16 @@ impl SigningService { return Ok(()); } + let listener = tokio::net::TcpListener::bind(config.endpoint).await?; + Self::run_with_listener(config, listener).await + } + + /// Serve from an already-bound listener. Prefer this in tests to avoid a + /// port rebind race (see `PbsService::run_with_listener`). + pub async fn run_with_listener( + config: StartSignerConfig, + listener: tokio::net::TcpListener, + ) -> eyre::Result<()> { let module_ids: Vec = config.mod_signing_configs.keys().cloned().map(Into::into).collect(); @@ -169,16 +179,17 @@ impl SigningService { } }); + let std_listener = listener.into_std()?; let server_result = if let Some(tls_config) = config.tls_certificates { let tls_config = RustlsConfig::from_pem(tls_config.0, tls_config.1).await?; - axum_server::bind_rustls(config.endpoint, tls_config) + axum_server::tls_rustls::from_tcp_rustls(std_listener, tls_config) .serve( signer_app.merge(admin_app).into_make_service_with_connect_info::(), ) .await } else { warn!("Running in insecure HTTP mode, no TLS certificates provided"); - axum_server::bind(config.endpoint) + axum_server::from_tcp(std_listener) .serve( signer_app.merge(admin_app).into_make_service_with_connect_info::(), ) diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 646f4994b..c8503378c 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -20,6 +20,7 @@ rcgen.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +ssz_types.workspace = true tempfile.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index becdc1f04..a72416b67 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -21,16 +21,21 @@ use axum::{ routing::{get, post}, }; use cb_common::{ + constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{ BUILDER_V1_API_PATH, BUILDER_V2_API_PATH, BlobsBundle, BuilderBid, BuilderBidFulu, - ExecutionPayloadElectra, ExecutionPayloadHeaderFulu, ExecutionRequests, ForkName, - GET_HEADER_PATH, GET_STATUS_PATH, GetHeaderParams, GetHeaderResponse, GetPayloadInfo, - PayloadAndBlobs, REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SignedBuilderBid, - SubmitBlindedBlockResponse, + BuilderPreferencesRequest, ExecutionPayloadBid, ExecutionPayloadElectra, + ExecutionPayloadHeaderFulu, ExecutionRequests, ForkName, ForkVersionDecode, + GET_EXECUTION_PAYLOAD_BID_PATH, GET_HEADER_PATH, GET_STATUS_PATH, + GetExecutionPayloadBidResponse, GetHeaderParams, GetHeaderResponse, GetPayloadInfo, + HEADER_TIMEOUT_MS, PayloadAndBlobs, REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, + SUBMIT_BUILDER_PREFERENCES_PATH, SUBMIT_SIGNED_BEACON_BLOCK_PATH, SignedBeaconBlock, + SignedBuilderBid, SignedExecutionPayloadBid, SignedRequestAuth, SubmitBlindedBlockResponse, }, - signature::sign_builder_root, - types::{BlsSecretKey, Chain}, - utils::{TestRandomSeed, timestamp_of_slot_start_sec}, + signature::{sign_builder_root, sign_execution_payload_bid_root}, + signer::random_secret, + types::{BlsPublicKey, BlsSecretKey, Chain}, + utils::{TestRandomSeed, timestamp_of_slot_start_sec, utcnow_ms}, wire::{ CONSENSUS_VERSION_HEADER, EncodingType, deserialize_body, get_accept_types, get_consensus_version_header, get_content_type, @@ -40,9 +45,9 @@ use cb_pbs::{ GET_HEADER_ENDPOINT_TAG, MAX_SIZE_SUBMIT_BLOCK_RESPONSE, REGISTER_VALIDATOR_ENDPOINT_TAG, STATUS_ENDPOINT_TAG, SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, }; -use lh_types::KzgProof; +use lh_types::{KzgProof, Slot}; use reqwest::header::{ACCEPT, CONTENT_TYPE}; -use ssz::Encode; +use ssz::{Decode, Encode}; use tokio::net::TcpListener; use tracing::{debug, error}; use tree_hash::TreeHash; @@ -65,6 +70,15 @@ pub async fn start_mock_relay_service_with_listener( Ok(()) } +/// One inbound `getExecutionPayloadBid` request, recorded on arrival. Both +/// fields are captured under a single lock so the two derived sequences cannot +/// interleave differently when polls overlap. +struct BidRequestRecord { + /// `X-Timeout-Ms` the caller granted this request + timeout_ms: u64, + arrival_ms: u64, +} + pub struct MockRelayState { pub chain: Chain, pub signer: BlsSecretKey, @@ -93,6 +107,34 @@ pub struct MockRelayState { received_get_status: Arc, received_register_validator: Arc, received_submit_block: Arc, + received_execution_payload_bid: Arc, + received_builder_preferences: Arc, + received_signed_beacon_block: Arc, + /// `slot` of the last signed beacon block forwarded, decoded from the SSZ + /// body PBS sent, so a test can assert the block survived the hop + received_block_slot: RwLock>, + /// `block_hash` the last forwarded block committed to in its bid + received_block_committed_hash: RwLock>, + /// The last `BuilderPreferencesRequest` submitted, so a test can assert + /// both the preferences and the auth were forwarded unchanged + received_preferences: RwLock>, + /// The `{proposer_pubkey}` path segment of the last preferences submission + received_preferences_pubkey: RwLock>, + /// Bid requests answered after `bid_delay_ms` elapsed. A poll the caller + /// timed out on is cancelled during the delay and never lands here, so this + /// counts the polls that actually got a response. + served_execution_payload_bid: Arc, + /// Every inbound bid request, in arrival order + received_bid_requests: RwLock>, + /// Each successive bid request serves this many more gwei than the last, + /// so a test can tell which poll's bid won + improving_bid_step_gwei: Option, + /// Hold every bid request this long before answering, simulating a builder + /// that sits on a request instead of answering promptly + bid_delay_ms: Option, + /// `data` bytes of the last `SignedRequestAuth` forwarded on a bid + /// request + received_auth: RwLock>, response_override: RwLock>, bid_value: RwLock, /// The raw `Accept` header PBS sent on the most recent get_header request, @@ -101,6 +143,21 @@ pub struct MockRelayState { /// Api key header seen per endpoint tag, so a test can assert the relay's /// configured key rides on every request PBS sends it. api_keys_seen: RwLock>, + /// Served as `bid.value` / `bid.execution_payment` by + /// `handle_get_execution_payload_bid` + trustless_bid_gwei: u64, // default 10 + trusted_bid_gwei: u64, + epbs_no_bid: bool, + epbs_invalid_signature: bool, + epbs_wrong_parent_hash: bool, + epbs_wrong_parent_root: bool, + /// When true, `handle_get_execution_payload_bid` omits the + /// `Eth-Consensus-Version` header on an SSZ 200. Drives the PBS error path + /// for an SSZ bid response that lacks the fork header. + epbs_omit_consensus_version: bool, + /// When true, the bid 200 claims `capella` instead of gloas (header on + /// SSZ, body `version` on JSON). Drives the PBS gloas-only response check. + epbs_wrong_fork: bool, } impl MockRelayState { @@ -119,6 +176,66 @@ impl MockRelayState { pub fn received_submit_block(&self) -> u64 { self.received_submit_block.load(Ordering::Relaxed) } + pub fn received_execution_payload_bid(&self) -> u64 { + self.received_execution_payload_bid.load(Ordering::Relaxed) + } + pub fn served_execution_payload_bid(&self) -> u64 { + self.served_execution_payload_bid.load(Ordering::Relaxed) + } + pub fn received_builder_preferences(&self) -> u64 { + self.received_builder_preferences.load(Ordering::Relaxed) + } + pub fn received_signed_beacon_block(&self) -> u64 { + self.received_signed_beacon_block.load(Ordering::Relaxed) + } + /// `slot` of the last signed beacon block PBS forwarded + pub fn received_block_slot(&self) -> Option { + *self.received_block_slot.read().unwrap() + } + /// `block_hash` the last forwarded block committed to + pub fn received_block_committed_hash(&self) -> Option { + *self.received_block_committed_hash.read().unwrap() + } + + /// `max_execution_payment` of the last submitted preferences + pub fn received_max_execution_payment(&self) -> Option { + self.received_preferences + .read() + .unwrap() + .as_ref() + .map(|r| r.preferences.max_execution_payment) + } + + /// The `SignedRequestAuth` carried by the last submitted preferences + pub fn received_preferences_auth(&self) -> Option { + self.received_preferences.read().unwrap().as_ref().map(|r| r.auth.clone()) + } + + /// The proposer the last preferences submission was filed under + pub fn received_preferences_pubkey(&self) -> Option { + self.received_preferences_pubkey.read().unwrap().clone() + } + + /// `X-Timeout-Ms` of every inbound bid request, in arrival order: the shape + /// of the poll ladder as the builder saw it. + pub fn received_bid_timeouts(&self) -> Vec { + self.received_bid_requests.read().unwrap().iter().map(|r| r.timeout_ms).collect() + } + + /// Arrival time of every inbound bid request, in arrival order, for + /// asserting the poll cadence. + pub fn received_bid_arrivals_ms(&self) -> Vec { + self.received_bid_requests.read().unwrap().iter().map(|r| r.arrival_ms).collect() + } + pub fn received_auth_data(&self) -> Option> { + self.received_auth.read().unwrap().as_ref().map(|a| a.message.data.to_vec()) + } + + /// The full `SignedRequestAuth` the relay saw, so a test can assert the + /// signature was forwarded byte-for-byte. + pub fn received_auth(&self) -> Option { + self.received_auth.read().unwrap().clone() + } pub fn large_body(&self) -> bool { self.large_body } @@ -166,10 +283,30 @@ impl MockRelayState { received_get_status: Default::default(), received_register_validator: Default::default(), received_submit_block: Default::default(), + received_execution_payload_bid: Default::default(), + received_builder_preferences: Default::default(), + received_signed_beacon_block: Default::default(), + received_block_slot: RwLock::new(None), + received_block_committed_hash: RwLock::new(None), + received_preferences: RwLock::new(None), + received_preferences_pubkey: RwLock::new(None), + served_execution_payload_bid: Default::default(), + received_bid_requests: RwLock::new(Vec::new()), + improving_bid_step_gwei: None, + bid_delay_ms: None, + received_auth: RwLock::new(None), response_override: RwLock::new(None), bid_value: RwLock::new(U256::from(10)), received_get_header_accept: RwLock::new(None), api_keys_seen: RwLock::new(HashMap::new()), + trustless_bid_gwei: 10, + trusted_bid_gwei: 0, + epbs_no_bid: false, + epbs_invalid_signature: false, + epbs_wrong_parent_hash: false, + epbs_wrong_parent_root: false, + epbs_omit_consensus_version: false, + epbs_wrong_fork: false, supported_content_types: Arc::new( [EncodingType::Json, EncodingType::Ssz].iter().cloned().collect(), ), @@ -218,6 +355,74 @@ impl MockRelayState { pub fn with_submit_block_version(self, fork: ForkName) -> Self { Self { submit_block_version_override: Some(fork), ..self } } + + pub fn with_trustless_bid_gwei(self, value: u64) -> Self { + Self { trustless_bid_gwei: value, ..self } + } + + pub fn with_trusted_bid_gwei(self, execution_payment: u64) -> Self { + Self { trusted_bid_gwei: execution_payment, ..self } + } + + pub fn with_no_epbs_bid(self) -> Self { + Self { epbs_no_bid: true, ..self } + } + + /// Signs the served bid with a throwaway key + pub fn with_epbs_invalid_signature(self) -> Self { + Self { epbs_invalid_signature: true, ..self } + } + + pub fn with_epbs_wrong_parent_hash(self) -> Self { + Self { epbs_wrong_parent_hash: true, ..self } + } + + pub fn with_epbs_wrong_parent_root(self) -> Self { + Self { epbs_wrong_parent_root: true, ..self } + } + + /// Restrict this relay to SSZ responses on the bid endpoint, so the bid + /// 200 is served as SSZ regardless of the caller's fallback preference. + pub fn with_ssz_only_response(self) -> Self { + Self { + supported_content_types: Arc::new([EncodingType::Ssz].into_iter().collect()), + ..self + } + } + + /// Restrict this relay to JSON responses on the bid endpoint. + pub fn with_json_only_response(self) -> Self { + Self { + supported_content_types: Arc::new([EncodingType::Json].into_iter().collect()), + ..self + } + } + + /// Serve a strictly better bid on each successive bid request: the nth + /// request (0-indexed) is worth `trustless_bid_gwei + n * step_gwei`. Lets + /// a test prove WHICH poll of a ladder produced the winning bid. + pub fn with_improving_bids(self, step_gwei: u64) -> Self { + Self { improving_bid_step_gwei: Some(step_gwei), ..self } + } + + /// Hold every bid request `delay_ms` before answering. Polls whose + /// `X-Timeout-Ms` is shorter than this are dropped by the caller, which is + /// what makes the early rungs of the ladder observable. + pub fn with_bid_delay_ms(self, delay_ms: u64) -> Self { + Self { bid_delay_ms: Some(delay_ms), ..self } + } + + /// Serve an SSZ bid 200 WITHOUT the `Eth-Consensus-Version` header, to + /// exercise the PBS missing-fork error path on the outbound SSZ decode. + pub fn with_epbs_omit_consensus_version(self) -> Self { + Self { epbs_omit_consensus_version: true, ..self } + } + + /// Label the bid 200 `capella` instead of gloas, to exercise the PBS + /// gloas-only response check (the bid must be dropped, not forwarded). + pub fn with_epbs_wrong_fork(self) -> Self { + Self { epbs_wrong_fork: true, ..self } + } } pub fn mock_relay_app_router(state: Arc) -> Router { @@ -225,7 +430,11 @@ pub fn mock_relay_app_router(state: Arc) -> Router { .route(GET_HEADER_PATH, get(handle_get_header)) .route(GET_STATUS_PATH, get(handle_get_status)) .route(REGISTER_VALIDATOR_PATH, post(handle_register_validator)) - .route(SUBMIT_BLOCK_PATH, post(handle_submit_block_v1)); + .route(SUBMIT_BLOCK_PATH, post(handle_submit_block_v1)) + // ePBS endpoints are v1 of new resources per builder-specs + .route(GET_EXECUTION_PAYLOAD_BID_PATH, post(handle_get_execution_payload_bid)) + .route(SUBMIT_BUILDER_PREFERENCES_PATH, post(handle_submit_builder_preferences)) + .route(SUBMIT_SIGNED_BEACON_BLOCK_PATH, post(handle_submit_signed_beacon_block)); let v2_builder_routes = if state.supports_submit_block_v2 { Router::new().route(SUBMIT_BLOCK_PATH, post(handle_submit_block_v2)) @@ -265,6 +474,155 @@ pub fn mock_signed_builder_bid( SignedBuilderBid { message, signature } } +async fn handle_get_execution_payload_bid( + State(state): State>, + Path((slot, parent_hash, parent_root, _pubkey)): Path<(u64, B256, B256, BlsPublicKey)>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Response { + let request_index = state.received_execution_payload_bid.fetch_add(1, Ordering::Relaxed); + state.received_bid_requests.write().unwrap().push(BidRequestRecord { + timeout_ms: headers + .get(HEADER_TIMEOUT_MS) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) + .unwrap_or_default(), + arrival_ms: utcnow_ms(), + }); + // Decode the optional request auth the way a real builder does: Content-Type + // selects JSON vs SSZ. The wire type is fork-versioned per builder-specs, + // so the SSZ form additionally requires Eth-Consensus-Version — PBS always + // forwards SSZ, making this the assertion that the header arrives as gloas. + if !body.is_empty() { + let auth = match get_content_type(&headers) { + EncodingType::Ssz => { + if get_consensus_version_header(&headers) != Some(ForkName::Gloas) { + return ( + StatusCode::BAD_REQUEST, + "missing Eth-Consensus-Version header".to_string(), + ) + .into_response(); + } + SignedRequestAuth::from_ssz_bytes(&body).ok() + } + EncodingType::Json => serde_json::from_slice::(&body).ok(), + }; + if let Some(auth) = auth { + *state.received_auth.write().unwrap() = Some(auth); + } + } + + // Honor a forced status like the other handlers, so a test can make a relay + // fail on the bid endpoint (its bid is then dropped by PBS). The request was + // already counted above. + if let Some(status) = *state.response_override.read().unwrap() { + return status.into_response(); + } + + // Sleep, never block: concurrent polls must overlap, not serialize + if let Some(delay_ms) = state.bid_delay_ms { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + state.served_execution_payload_bid.fetch_add(1, Ordering::Relaxed); + + if state.epbs_no_bid { + return StatusCode::NO_CONTENT.into_response(); + } + + let served_parent_hash = + if state.epbs_wrong_parent_hash { B256::repeat_byte(0xab) } else { parent_hash }; + let served_parent_root = + if state.epbs_wrong_parent_root { B256::repeat_byte(0xcd) } else { parent_root }; + + let mut block_hash = B256::ZERO; + block_hash.0[0] = 1; + + let message = ExecutionPayloadBid { + parent_block_hash: served_parent_hash.into(), + parent_block_root: served_parent_root, + block_hash: block_hash.into(), + gas_limit: 30_000_000, + builder_index: 42, + slot: Slot::new(slot), + value: state.trustless_bid_gwei.saturating_add( + state.improving_bid_step_gwei.unwrap_or(0).saturating_mul(request_index), + ), + execution_payment: state.trusted_bid_gwei, + ..Default::default() + }; + + let object_root = message.tree_hash_root(); + let signature = if state.epbs_invalid_signature { + let wrong_key = random_secret(); + sign_execution_payload_bid_root( + &wrong_key, + &object_root, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ) + } else { + sign_execution_payload_bid_root( + &state.signer, + &object_root, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ) + }; + + let data = SignedExecutionPayloadBid { message, signature }; + + // Negotiate the RESPONSE encoding from the forwarded Accept, mirroring + // handle_get_header: honor supported_content_types + the caller's Accept. + let accept_types = match get_accept_types(&headers) { + Ok(a) => a, + Err(e) => { + return (StatusCode::BAD_REQUEST, format!("error parsing accept header: {e}")) + .into_response(); + } + }; + let content_type = if state.supported_content_types.contains(&EncodingType::Ssz) && + accept_types.contains(EncodingType::Ssz) + { + EncodingType::Ssz + } else if state.supported_content_types.contains(&EncodingType::Json) && + accept_types.contains(EncodingType::Json) + { + EncodingType::Json + } else { + return (StatusCode::NOT_ACCEPTABLE, "No acceptable content type found".to_string()) + .into_response(); + }; + + let response_body = match content_type { + // SSZ carries the inner bid; the fork travels in Eth-Consensus-Version. + EncodingType::Ssz => data.as_ssz_bytes(), + // JSON carries the fork-versioned wrapper (fork is in the body). + EncodingType::Json => { + let versioned = GetExecutionPayloadBidResponse { + version: if state.epbs_wrong_fork { ForkName::Capella } else { ForkName::Gloas }, + data, + metadata: Default::default(), + }; + serde_json::to_vec(&versioned).unwrap() + } + }; + + let mut response = (StatusCode::OK, response_body).into_response(); + // A real builder tags the 200 with the fork so a client can decode the + // (non-self-describing) SSZ bytes. The omit knob drives the PBS + // "SSZ response missing Eth-Consensus-Version" error path. + if !state.epbs_omit_consensus_version { + let fork = if state.epbs_wrong_fork { ForkName::Capella } else { ForkName::Gloas }; + response + .headers_mut() + .insert(CONSENSUS_VERSION_HEADER, HeaderValue::from_str(&fork.to_string()).unwrap()); + } + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_str(&content_type.to_string()).unwrap()); + response +} + async fn handle_get_header( State(state): State>, Path(GetHeaderParams { parent_hash, slot, .. }): Path, @@ -351,6 +709,69 @@ async fn handle_get_status( StatusCode::OK } +/// Decodes the submission the way a real builder does (Content-Type selects +/// JSON vs SSZ), records it, and 202s unless the test overrode the response. +async fn handle_submit_builder_preferences( + Path(proposer_pubkey): Path, + headers: HeaderMap, + State(state): State>, + body: axum::body::Bytes, +) -> Response { + state.received_builder_preferences.fetch_add(1, Ordering::Relaxed); + // A real builder keys preferences by proposer, so the path segment PBS sent + // is part of what a test must be able to assert + *state.received_preferences_pubkey.write().unwrap() = Some(proposer_pubkey); + + let decoded = match get_content_type(&headers) { + EncodingType::Json => serde_json::from_slice::(&body).ok(), + // The wire type is fork-versioned per builder-specs, so a real builder + // requires Eth-Consensus-Version on the SSZ form — PBS always forwards + // SSZ, making this the assertion that the header arrives as gloas. + EncodingType::Ssz => get_consensus_version_header(&headers) + .filter(|fork| *fork == ForkName::Gloas) + .and_then(|_| BuilderPreferencesRequest::from_ssz_bytes(&body).ok()), + }; + let Some(request) = decoded else { + return StatusCode::BAD_REQUEST.into_response(); + }; + *state.received_preferences.write().unwrap() = Some(request); + + if let Some(status) = state.response_override.read().unwrap().as_ref() { + return (*status).into_response(); + } + + StatusCode::ACCEPTED.into_response() +} + +/// Decodes the forwarded block (PBS always sends SSZ with +/// `Eth-Consensus-Version`), records its slot and committed bid hash, and 202s +/// unless the test overrode the response. +async fn handle_submit_signed_beacon_block( + headers: HeaderMap, + State(state): State>, + body: axum::body::Bytes, +) -> Response { + state.received_signed_beacon_block.fetch_add(1, Ordering::Relaxed); + + if let Some(fork) = get_consensus_version_header(&headers) && + let Ok(block) = SignedBeaconBlock::from_ssz_bytes_by_fork(&body, fork) + { + *state.received_block_slot.write().unwrap() = Some(block.slot().as_u64()); + *state.received_block_committed_hash.write().unwrap() = match &block { + lh_types::SignedBeaconBlock::Gloas(b) => { + Some(b.message.body.signed_execution_payload_bid.message.block_hash.0) + } + _ => None, + }; + } + + if let Some(status) = state.response_override.read().unwrap().as_ref() { + return (*status).into_response(); + } + + StatusCode::ACCEPTED.into_response() +} + async fn handle_register_validator( State(state): State>, headers: HeaderMap, diff --git a/tests/src/mock_ssv_node.rs b/tests/src/mock_ssv_node.rs index 7f24569d4..fe0d77675 100644 --- a/tests/src/mock_ssv_node.rs +++ b/tests/src/mock_ssv_node.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::sync::Arc; use alloy::primitives::U256; use axum::{ @@ -34,7 +34,7 @@ struct SsvNodeValidatorsRequestBody { /// Creates a simple mock server to simulate the SSV API endpoint under /// various conditions for testing. Note this ignores pub async fn create_mock_ssv_node_server( - port: u16, + listener: TcpListener, state: Option, ) -> Result, axum::Error> { let data = include_str!("../../tests/data/ssv_valid_node.json"); @@ -50,8 +50,6 @@ pub async fn create_mock_ssv_node_server( .with_state(state) .into_make_service(); - let address = SocketAddr::from(([127, 0, 0, 1], port)); - let listener = TcpListener::bind(address).await.map_err(axum::Error::new)?; let server = axum::serve(listener, router).with_graceful_shutdown(async { tokio::signal::ctrl_c().await.expect("Failed to listen for shutdown signal"); }); @@ -60,7 +58,7 @@ pub async fn create_mock_ssv_node_server( eprintln!("Server error: {e}"); } })); - info!("Mock server started on http://localhost:{port}/"); + info!("mock SSV server started"); result } diff --git a/tests/src/mock_ssv_public.rs b/tests/src/mock_ssv_public.rs index a014db42e..4ead5c7be 100644 --- a/tests/src/mock_ssv_public.rs +++ b/tests/src/mock_ssv_public.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::sync::Arc; use axum::{ extract::{Path, State}, @@ -27,7 +27,7 @@ pub struct PublicSsvMockState { /// Creates a simple mock server to simulate the SSV API endpoint under /// various conditions for testing. Note this ignores pub async fn create_mock_public_ssv_server( - port: u16, + listener: TcpListener, state: Option, ) -> Result, axum::Error> { let data = include_str!("../../tests/data/ssv_valid_public.json"); @@ -46,8 +46,6 @@ pub async fn create_mock_public_ssv_server( .with_state(state) .into_make_service(); - let address = SocketAddr::from(([127, 0, 0, 1], port)); - let listener = TcpListener::bind(address).await.map_err(axum::Error::new)?; let server = axum::serve(listener, router).with_graceful_shutdown(async { tokio::signal::ctrl_c().await.expect("Failed to listen for shutdown signal"); }); @@ -56,7 +54,7 @@ pub async fn create_mock_public_ssv_server( eprintln!("Server error: {e}"); } })); - info!("Mock server started on http://localhost:{port}/"); + info!("mock SSV server started"); result } diff --git a/tests/src/mock_validator.rs b/tests/src/mock_validator.rs index b2da4a9c1..16eaa2bcd 100644 --- a/tests/src/mock_validator.rs +++ b/tests/src/mock_validator.rs @@ -1,8 +1,11 @@ use alloy::{primitives::B256, rpc::types::beacon::relay::ValidatorRegistration}; use cb_common::{ - pbs::{BuilderApiVersion, RelayClient, SignedBlindedBeaconBlock}, + pbs::{ + BuilderApiVersion, BuilderPreferencesRequest, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, + RelayClient, SignedBeaconBlock, SignedBlindedBeaconBlock, SignedRequestAuth, + }, types::{BlsPublicKey, KnownChain}, - utils::bls_pubkey_from_hex, + utils::{bls_pubkey_from_hex, utcnow_ms}, wire::{CONSENSUS_VERSION_HEADER, EncodingType}, }; use lh_types::ForkName; @@ -14,6 +17,10 @@ use ssz::Encode; use crate::utils::generate_mock_relay; +/// Timeout a test beacon node advertises on bid requests; long enough that the +/// deadline never bites in tests. +const DEFAULT_TEST_TIMEOUT_MS: u64 = 60_000; + pub struct MockValidator { pub comm_boost: RelayClient, } @@ -63,6 +70,127 @@ impl MockValidator { Ok(res) } + /// Submits builder preferences for `pubkey`, encoding the body as + /// `content_type` so a test can exercise both wire formats. The spec makes + /// `Eth-Consensus-Version` required, so a compliant BN always sets it to + /// Gloas; header-less negative tests build their request by hand. + pub async fn do_submit_builder_preferences( + &self, + pubkey: Option, + request: &BuilderPreferencesRequest, + content_type: EncodingType, + ) -> eyre::Result { + let default_pubkey = bls_pubkey_from_hex( + "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", + )?; + let url = + self.comm_boost.submit_builder_preferences_url(&pubkey.unwrap_or(default_pubkey))?; + + let body = match content_type { + EncodingType::Json => serde_json::to_vec(request)?, + EncodingType::Ssz => request.as_ssz_bytes(), + }; + let res = self + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, content_type.content_type_header().clone()) + .header(CONSENSUS_VERSION_HEADER, ForkName::Gloas.to_string()) + .body(body) + .send() + .await?; + Ok(res) + } + + /// Submits a `SignedBeaconBlock`, encoding the body as `content_type`. The + /// spec requires `Eth-Consensus-Version` on every submission, so it is + /// always set to Gloas (the only fork this endpoint serves). + pub async fn do_submit_signed_beacon_block( + &self, + block: &SignedBeaconBlock, + content_type: EncodingType, + ) -> eyre::Result { + let url = self.comm_boost.submit_signed_beacon_block_url()?; + let body = match content_type { + EncodingType::Json => serde_json::to_vec(block)?, + EncodingType::Ssz => block.as_ssz_bytes(), + }; + let res = self + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, content_type.content_type_header().clone()) + .header(CONSENSUS_VERSION_HEADER, ForkName::Gloas.to_string()) + .body(body) + .send() + .await?; + Ok(res) + } + + #[allow(clippy::too_many_arguments)] + pub async fn do_get_execution_payload_bid( + &self, + slot: u64, + parent_hash: B256, + parent_root: B256, + pubkey: Option, + auth: Option<&SignedRequestAuth>, + accept: Vec, + ) -> eyre::Result { + self.do_get_execution_payload_bid_with_timeout( + slot, + parent_hash, + parent_root, + pubkey, + auth, + accept, + DEFAULT_TEST_TIMEOUT_MS, + ) + .await + } + + /// Same, but with the proposer's `X-Timeout-Ms` under the test's control: + /// that header is what bounds the whole bid poll ladder. + #[allow(clippy::too_many_arguments)] + pub async fn do_get_execution_payload_bid_with_timeout( + &self, + slot: u64, + parent_hash: B256, + parent_root: B256, + pubkey: Option, + auth: Option<&SignedRequestAuth>, + accept: Vec, + timeout_ms: u64, + ) -> eyre::Result { + let default_pubkey = bls_pubkey_from_hex( + "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", + )?; + let url = self.comm_boost.get_execution_payload_bid_url( + slot, + &parent_hash, + &parent_root, + &pubkey.unwrap_or(default_pubkey), + )?; + // The spec requires both timing headers and `Eth-Consensus-Version` on + // every bid request; header-less negative tests build theirs by hand + let mut req = self + .comm_boost + .client + .post(url) + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, timeout_ms) + .header(CONSENSUS_VERSION_HEADER, ForkName::Gloas.to_string()); + if !accept.is_empty() { + let accept_header = accept.iter().map(|e| e.to_string()).collect::>().join(", "); + req = req.header(ACCEPT, accept_header); + } + let req = match auth { + Some(auth) => req.json(auth), + None => req, + }; + Ok(req.send().await?) + } + pub async fn do_get_status(&self) -> eyre::Result { let url = self.comm_boost.get_status_url()?; Ok(self.comm_boost.client.get(url).send().await?) diff --git a/tests/src/signer_service.rs b/tests/src/signer_service.rs index 550ac4ce7..8338a5829 100644 --- a/tests/src/signer_service.rs +++ b/tests/src/signer_service.rs @@ -10,6 +10,7 @@ use cb_common::{ use cb_signer::service::SigningService; use eyre::Result; use reqwest::{Certificate, Response, StatusCode}; +use tokio::net::TcpListener; use tracing::info; use crate::utils::{get_signer_config, get_start_signer_config}; @@ -17,7 +18,7 @@ use crate::utils::{get_signer_config, get_start_signer_config}; // Starts the signer moduler server on a separate task and returns its // configuration pub async fn start_server( - port: u16, + listener: TcpListener, mod_signing_configs: &HashMap, admin_secret: String, use_tls: bool, @@ -31,13 +32,14 @@ pub async fn start_server( format: ValidatorKeysFormat::Lighthouse, }; let mut config = get_signer_config(loader, use_tls); - config.port = port; + config.port = listener.local_addr()?.port(); config.jwt_auth_fail_limit = 3; // Set a low fail limit for testing config.jwt_auth_fail_timeout_seconds = 3; // Set a short timeout for testing let start_config = get_start_signer_config(config, chain, mod_signing_configs, admin_secret); // Run the Signer - let server_handle = tokio::spawn(SigningService::run(start_config.clone())); + let server_handle = + tokio::spawn(SigningService::run_with_listener(start_config.clone(), listener)); // Wait for the server to start let (url, client) = match start_config.tls_certificates { diff --git a/tests/src/utils.rs b/tests/src/utils.rs index fa71d5fac..37bd51206 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -3,6 +3,7 @@ use std::{ net::{Ipv4Addr, SocketAddr}, path::PathBuf, sync::{Arc, Once}, + time::Duration, }; use alloy::primitives::{B256, U256}; @@ -14,26 +15,39 @@ use cb_common::{ SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, SIGNER_PORT_DEFAULT, SignerConfig, SignerType, StartSignerConfig, StaticModuleConfig, StaticPbsConfig, TlsMode, }, - pbs::{RelayClient, RelayEntry}, - signer::SignerLoader, - types::{BlsPublicKey, Chain, ModuleId}, + pbs::{RelayClient, RelayEntry, RequestAuth, SignedRequestAuth}, + signature::sign_request_auth_root, + signer::{SignerLoader, random_secret}, + types::{BlsPublicKey, BlsSecretKey, BlsSignature, Chain, ModuleId}, utils::{bls_pubkey_from_hex, default_host}, }; +use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use eyre::Result; +use lh_types::Slot; use rcgen::generate_simple_self_signed; +use reqwest::StatusCode; +use tree_hash::TreeHash; use url::Url; +use crate::{ + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, + mock_validator::MockValidator, +}; + pub const HEADER_API_KEY: &str = "x-api-key"; pub const API_KEY: &str = "123e4567-e89b-12d3-a456-426614174000"; /// Distinct from [`API_KEY`], which `MockValidator` also sends to PBS: a relay /// that sees this one can only have got it from its own config. pub const RELAY_API_KEY: &str = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"; +/// The auth data the default mock relay declares and most ePBS tests send. +/// Unmatched auth data is a 400 (no catch-all), so a relay must declare the +/// data it serves for opaque-data tests to route. +pub const TEST_AUTH_DATA: &[u8] = &[0xde, 0xad]; pub fn get_local_address(port: u16) -> String { format!("http://0.0.0.0:{port}") } -/// Bind to port 0 and let the OS assign an unused ephemeral port. pub async fn get_free_listener() -> tokio::net::TcpListener { tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap() } @@ -59,7 +73,10 @@ fn mock_relay_config(port: u16, pubkey: BlsPublicKey) -> Result { enable_timing_games: false, target_first_request_ms: None, frequency_get_header_ms: None, + bid_poll_timeout_ms: None, validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: Some(TEST_AUTH_DATA.to_vec().into()), }) } @@ -67,6 +84,53 @@ pub fn generate_mock_relay(port: u16, pubkey: BlsPublicKey) -> Result Result { + let mut relay = generate_mock_relay(port, pubkey)?; + let mut config = (*relay.config).clone(); + config.max_execution_payment_gwei = Some(max_execution_payment_gwei); + relay.config = std::sync::Arc::new(config); + Ok(relay) +} + +pub fn generate_mock_relay_with_timing_games( + port: u16, + pubkey: BlsPublicKey, + frequency_get_header_ms: u64, + bid_poll_timeout_ms: Option, +) -> Result { + let mut relay = generate_mock_relay(port, pubkey)?; + let mut config = (*relay.config).clone(); + config.enable_timing_games = true; + config.frequency_get_header_ms = Some(frequency_get_header_ms); + config.bid_poll_timeout_ms = bid_poll_timeout_ms; + relay.config = std::sync::Arc::new(config); + Ok(relay) +} + +pub fn generate_mock_relay_url_only(port: u16, pubkey: BlsPublicKey) -> Result { + let mut relay = generate_mock_relay(port, pubkey)?; + let mut config = (*relay.config).clone(); + config.expected_auth_data = None; + relay.config = std::sync::Arc::new(config); + Ok(relay) +} + +pub fn generate_mock_relay_with_auth_data( + port: u16, + pubkey: BlsPublicKey, + expected_auth_data: &[u8], +) -> Result { + let mut relay = generate_mock_relay(port, pubkey)?; + let mut config = (*relay.config).clone(); + config.expected_auth_data = Some(expected_auth_data.to_vec().into()); + relay.config = std::sync::Arc::new(config); + Ok(relay) +} + pub fn generate_mock_relay_with_batch_size( port: u16, pubkey: BlsPublicKey, @@ -118,8 +182,11 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { timeout_register_validator_ms: u64::MAX, skip_sigverify: false, min_bid_wei: U256::ZERO, + max_execution_payment_gwei: 0, + fee_recipient: None, late_in_slot_time_ms: u64::MAX, extra_validation_enabled: false, + verify_request_auth: false, ssv_node_api_url: Url::parse("http://localhost:0").unwrap(), ssv_public_api_url: Url::parse("http://localhost:0").unwrap(), @@ -230,3 +297,136 @@ pub fn create_module_config(id: ModuleId, signing_id: B256) -> StaticModuleConfi pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { bls_pubkey_from_hex(hex).unwrap() } + +/// Build a `SignedRequestAuth` carrying opaque `data`. CB forwards it +/// unmodified; the signature is only verified when `verify_request_auth` is on, +/// so an empty one suffices elsewhere. +pub fn opaque_auth(data: &[u8], slot: u64) -> SignedRequestAuth { + SignedRequestAuth { + message: RequestAuth { + data: ssz_types::VariableList::new(data.to_vec()).expect("data fits in MAX_DATA_SIZE"), + slot: Slot::new(slot), + }, + signature: BlsSignature::empty(), + } +} + +/// Same, but signed under the spec's `DOMAIN_REQUEST_AUTH` by `secret_key`. +pub fn signed_auth( + secret_key: &BlsSecretKey, + data: &[u8], + slot: u64, + chain: Chain, +) -> SignedRequestAuth { + let mut auth = opaque_auth(data, slot); + auth.signature = sign_request_auth_root(secret_key, &auth.message.tree_hash_root(), chain); + auth +} + +/// Boot PBS in front of one mock relay, letting the test shape both the PBS +/// config and the relay entry. +pub async fn setup_relay( + chain: Chain, + tweak: impl FnOnce(&mut PbsConfig), + make_relay: impl FnOnce(u16, BlsPublicKey) -> Result, +) -> Result<(MockValidator, Arc)> { + setup_test_env(); + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = make_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let mut pbs_config = get_pbs_config(pbs_port); + tweak(&mut pbs_config); + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + Ok((mock_validator, mock_state)) +} + +/// Boot PBS in front of several mock relays, one per supplied `MockRelayState`, +/// so per-relay knobs and counters stay independent. Returns the validator and +/// the relay states in configuration order. +pub async fn setup_relays( + chain: Chain, + states: Vec, +) -> Result<(MockValidator, Vec>)> { + setup_test_env(); + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + + let mut relays = Vec::new(); + let mut arc_states = Vec::new(); + for state in states { + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + let state = Arc::new(state); + relays.push(generate_mock_relay(relay_port, state.signer.public_key())?); + tokio::spawn(start_mock_relay_service_with_listener(state.clone(), relay_listener)); + arc_states.push(state); + } + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + Ok((mock_validator, arc_states)) +} + +/// Like [`setup_relays`], but each relay declares the `expected_auth_data` it +/// serves, so tests can address one builder among several. +pub async fn setup_relays_with_auth_data( + chain: Chain, + states: Vec<(MockRelayState, &[u8])>, +) -> Result<(MockValidator, Vec>)> { + setup_test_env(); + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + + let mut relays = Vec::new(); + let mut arc_states = Vec::new(); + for (state, auth_data) in states { + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + let state = Arc::new(state); + relays.push(generate_mock_relay_with_auth_data( + relay_port, + state.signer.public_key(), + auth_data, + )?); + tokio::spawn(start_mock_relay_service_with_listener(state.clone(), relay_listener)); + arc_states.push(state); + } + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + Ok((mock_validator, arc_states)) +} + +/// Poll /status until PBS and its relays are up. relay_check makes a 200 mean +/// the whole chain is ready; the fixed 100ms sleep used elsewhere flakes under +/// parallel suite load. +pub async fn wait_for_ready(mock_validator: &MockValidator) -> Result<()> { + for _ in 0..100 { + if let Ok(res) = mock_validator.do_get_status().await && + res.status() == StatusCode::OK + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + eyre::bail!("PBS/relays did not become ready within 2s") +} diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index c576a6a62..f5e3e0b93 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -64,6 +64,9 @@ async fn test_cfg_file_update() -> Result<()> { timeout_register_validator_ms: 3000, skip_sigverify: true, min_bid_wei: U256::ZERO, + max_execution_payment_gwei: 0, + verify_request_auth: false, + fee_recipient: None, late_in_slot_time_ms: u64::MAX / 2, /* serde gets very upset about serializing u64::MAX * or anything close to it */ extra_validation_enabled: false, @@ -91,11 +94,14 @@ async fn test_cfg_file_update() -> Result<()> { id: Some(relay1.id.to_string()), enable_timing_games: false, frequency_get_header_ms: None, + bid_poll_timeout_ms: None, get_params: None, get_header: GetHeaderTransport::Http, headers: None, target_first_request_ms: None, validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: None, entry: RelayEntry { id: relay1.id.to_string(), url: Url::parse(&format!("http://localhost:{relay1_port}"))?, @@ -114,8 +120,7 @@ async fn test_cfg_file_update() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![relay1.clone()]); let state = PbsState::new(config, config_path.clone()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers - extra time for the file watcher tokio::time::sleep(Duration::from_millis(1000)).await; @@ -145,11 +150,14 @@ async fn test_cfg_file_update() -> Result<()> { id: Some(relay2_id.clone()), enable_timing_games: false, frequency_get_header_ms: None, + bid_poll_timeout_ms: None, get_params: None, get_header: GetHeaderTransport::Http, headers: None, target_first_request_ms: None, validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: None, entry: RelayEntry { id: relay2_id, url: Url::parse(&format!("http://{pubkey}@localhost:{relay2_port}"))?, diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs new file mode 100644 index 000000000..31adf43bb --- /dev/null +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -0,0 +1,1950 @@ +use std::{collections::HashMap, path::PathBuf, sync::Arc}; + +use alloy::primitives::{Address, B256, U256}; +use cb_common::{ + config::RuntimeMuxConfig, + constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, + pbs::{ + DEFAULT_BID_POLL_TIMEOUT_MS, GetExecutionPayloadBidInfo, GetExecutionPayloadBidResponse, + HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, SignedExecutionPayloadBid, + }, + signature::sign_execution_payload_bid_root, + signer::random_secret, + types::Chain, + utils::utcnow_ms, + wire::{CONSENSUS_VERSION_HEADER, EncodingType}, +}; +use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; +use cb_tests::{ + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, + mock_validator::MockValidator, + utils::{ + generate_mock_relay, generate_mock_relay_url_only, generate_mock_relay_with_auth_data, + generate_mock_relay_with_max_payment, generate_mock_relay_with_timing_games, + get_free_listener, get_pbs_config, opaque_auth, setup_relay, setup_relays, setup_test_env, + signed_auth, to_pbs_config, wait_for_ready, + }, +}; +use eyre::Result; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::{Decode, Encode}; +use tracing::info; +use tree_hash::TreeHash; + +const TEST_SLOT: u64 = 100; + +/// Test requesting a bid with a single default relay +#[tokio::test] +async fn test_get_execution_payload_bid() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret())], + StatusCode::OK, + &[1], + Some(10), + 0, + ) + .await +} + +/// Test that the relay returning 204 (no bid) results in a 204 from PBS +#[tokio::test] +async fn test_get_execution_payload_bid_no_bid() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_no_epbs_bid()], + StatusCode::NO_CONTENT, + &[1], + None, + 0, + ) + .await +} + +/// Test that a bid signed with the wrong key is dropped +#[tokio::test] +async fn test_get_execution_payload_bid_invalid_signature() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_epbs_invalid_signature()], + StatusCode::NO_CONTENT, + &[1], + None, + 0, + ) + .await +} + +/// Test that a bid with a mismatched parent hash is dropped +#[tokio::test] +async fn test_get_execution_payload_bid_wrong_parent_hash() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_epbs_wrong_parent_hash()], + StatusCode::NO_CONTENT, + &[1], + None, + 0, + ) + .await +} + +/// Test that a bid with a mismatched parent root is dropped +#[tokio::test] +async fn test_get_execution_payload_bid_wrong_parent_root() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_epbs_wrong_parent_root()], + StatusCode::NO_CONTENT, + &[1], + None, + 0, + ) + .await +} + +/// With the default `max_execution_payment_gwei` of 0, any bid with a nonzero +/// execution_payment is rejected as TrustedBidTooHigh +#[tokio::test] +async fn test_get_execution_payload_bid_nonzero_execution_payment_rejected() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_trusted_bid_gwei(1)], + StatusCode::NO_CONTENT, + &[1], + None, + 0, + ) + .await +} + +#[tokio::test] +async fn test_get_execution_payload_bid_highest_wins() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![ + MockRelayState::new(Chain::Hoodi, random_secret()).with_trustless_bid_gwei(10), + MockRelayState::new(Chain::Hoodi, random_secret()).with_trustless_bid_gwei(42), + ], + StatusCode::OK, + &[1, 1], + Some(42), + 0, + ) + .await +} + +/// Test that a bid with an execution payment within the configured +/// `max_execution_payment_gwei` is accepted +#[tokio::test] +async fn test_get_execution_payload_bid_execution_payment_within_cap() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_trusted_bid_gwei(5)], + StatusCode::OK, + &[1], + None, + 10, + ) + .await +} + +/// Test that selection is by TOTAL payment: trustless 5 + payment 10 beats +/// trustless 10 + payment 0. Asserting value == 5 proves the total won. +#[tokio::test] +async fn test_get_execution_payload_bid_highest_total_payment_wins() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![ + MockRelayState::new(Chain::Hoodi, random_secret()).with_trustless_bid_gwei(10), + MockRelayState::new(Chain::Hoodi, random_secret()) + .with_trustless_bid_gwei(5) + .with_trusted_bid_gwei(10), + ], + StatusCode::OK, + &[1, 1], + Some(5), + 10, + ) + .await +} + +/// Test that min_bid_eth also floors ePBS bids: a bid whose total payment (in +/// gwei) is below the configured minimum returns 204. Covers the wei -> gwei +/// conversion, which the unit tests don't. +#[tokio::test] +async fn test_get_execution_payload_bid_below_min_bid_rejected() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + // Default mock bid: trustless 10 gwei, no execution payment + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.min_bid_wei = U256::from(20_000_000_000u64); // 20 gwei + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::NO_CONTENT); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + Ok(()) +} + +/// Test that a bid whose fee_recipient differs from the configured expected +/// value is dropped. The mock serves Address::ZERO as fee_recipient. +#[tokio::test] +async fn test_get_execution_payload_bid_wrong_fee_recipient_rejected() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.fee_recipient = Some(Address::from([1; 20])); + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::NO_CONTENT); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + Ok(()) +} + +/// Test that a MUX-level fee_recipient reaches bid validation: the mux's +/// validator gets its bid (mock serves Address::ZERO) rejected, while the +/// default config (no expected fee_recipient) still accepts it. +#[tokio::test] +async fn test_get_execution_payload_bid_mux_fee_recipient() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + // Default config has no expected fee_recipient; only the mux does + let mut config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); + let mut mux_pbs_config = get_pbs_config(pbs_port); + mux_pbs_config.fee_recipient = Some(Address::from([1; 20])); + let mux = RuntimeMuxConfig { + id: String::from("fee-mux"), + config: Arc::new(mux_pbs_config), + relays: vec![mock_relay], + }; + let mux_pubkey = random_secret().public_key(); + config.mux_lookup = Some(HashMap::from([(mux_pubkey.clone(), mux)])); + + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + // The mux validator's bid fails the fee_recipient check + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + Some(mux_pubkey), + Some(&auth), + vec![EncodingType::Json], + ) + .await?; + assert_eq!(res.status(), StatusCode::NO_CONTENT); + + // A non-mux validator uses the default config and gets the bid + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 2); + Ok(()) +} + +/// The caller's auth data designates the downstream: only the relay whose +/// `expected_auth_data` matches is contacted, and its bid is returned. +#[tokio::test] +async fn test_get_execution_payload_bid_demux_routes_by_auth_data() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + + let data_a = vec![0xaa, 0x01]; + let data_b = vec![0xbb, 0x02]; + let mut relays = Vec::new(); + let mut states = Vec::new(); + for data in [&data_a, &data_b] { + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + let state = Arc::new(MockRelayState::new(chain, random_secret())); + relays.push(generate_mock_relay_with_auth_data( + relay_port, + state.signer.public_key(), + data, + )?); + tokio::spawn(start_mock_relay_service_with_listener(state.clone(), relay_listener)); + states.push(state); + } + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&data_a, TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(states[0].received_execution_payload_bid(), 1); + assert_eq!(states[1].received_execution_payload_bid(), 0); + Ok(()) +} + +/// Auth data carrying a builder URL (raw UTF-8 bytes, the spec default) routes +/// to the relay whose configured URL matches, ignoring the entry's userinfo. +#[tokio::test] +async fn test_get_execution_payload_bid_demux_by_url_bytes() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + + let mut relays = Vec::new(); + let mut states = Vec::new(); + let mut urls = Vec::new(); + for _ in 0..2 { + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + let state = Arc::new(MockRelayState::new(chain, random_secret())); + // No expected_auth_data: these relays are addressed by URL-carrying data + let relay = generate_mock_relay_url_only(relay_port, state.signer.public_key())?; + urls.push(format!("http://0.0.0.0:{relay_port}/")); + tokio::spawn(start_mock_relay_service_with_listener(state.clone(), relay_listener)); + relays.push(relay); + states.push(state); + } + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + // data = UTF-8 bytes of relay-0's URL + let auth = opaque_auth(urls[0].as_bytes(), TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(states[0].received_execution_payload_bid(), 1); + assert_eq!(states[1].received_execution_payload_bid(), 0); + + // URL bytes with a NUL-suffixed extra payload route the same way + let mut with_extra = urls[1].as_bytes().to_vec(); + with_extra.push(0); + with_extra.extend_from_slice(&[0xde, 0xad]); + let auth = opaque_auth(&with_extra, TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(states[0].received_execution_payload_bid(), 1); + assert_eq!(states[1].received_execution_payload_bid(), 1); + + // a URL matching no configured relay is a 400, nothing contacted + let auth = opaque_auth(b"https://unknown-builder.example:9999/", TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(states[0].received_execution_payload_bid(), 1); + assert_eq!(states[1].received_execution_payload_bid(), 1); + Ok(()) +} + +/// Auth data matching no configured relay is rejected with 400 and the spec +/// data-mismatch message; no relay is contacted. +#[tokio::test] +async fn test_get_execution_payload_bid_demux_no_match_400() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = + generate_mock_relay_with_auth_data(relay_port, mock_state.signer.public_key(), &[0xaa])?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&[0xbb], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_execution_payload_bid(), 0); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert_eq!( + body["message"], + "Invalid SignedRequestAuth: auth.message.data does not match the value agreed with this builder" + ); + Ok(()) +} + +/// Opaque auth data matching no configured relay is a 400 with the builder's +/// data-mismatch message, and no relay receives anything: with auth data +/// required and unique per entry, CB has no builder to proxy to and answers as +/// a builder would. +#[tokio::test] +async fn test_get_execution_payload_bid_unmatched_opaque_auth_400() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = generate_mock_relay_url_only(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&[0xcc], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "no relay receives anything"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert_eq!( + body["message"], + "Invalid SignedRequestAuth: auth.message.data does not match the value agreed with this builder" + ); + Ok(()) +} + +/// An opaque (non-URL) auth body is forwarded to the relays verbatim. +#[tokio::test] +async fn test_get_execution_payload_bid_forwards_opaque_auth() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let data = vec![0xde, 0xad, 0xbe, 0xef]; + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = + generate_mock_relay_with_auth_data(relay_port, mock_state.signer.public_key(), &data)?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&data, TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + assert_eq!(mock_state.received_auth_data(), Some(data)); + Ok(()) +} + +/// The spec makes the auth body mandatory: a request without one is a 400 with +/// an ErrorMessage body, before any relay is queried. +#[tokio::test] +async fn test_get_execution_payload_bid_missing_auth_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, None, vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "no auth means no relay call"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert!( + body["message"].as_str().unwrap_or_default().contains("missing request body"), + "error body must name the missing body, got: {}", + body["message"] + ); + Ok(()) +} + +/// `auth.message.slot` must match the proposal slot in the request path. +#[tokio::test] +async fn test_get_execution_payload_bid_auth_slot_mismatch_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT + 1); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!( + mock_state.received_execution_payload_bid(), + 0, + "slot mismatch precedes relay calls" + ); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert_eq!( + body["message"], + "Invalid SignedRequestAuth: auth.message.slot does not match the proposal slot in the request path" + ); + Ok(()) +} + +/// With `verify_request_auth` on, a bad auth signature is a 401 and a good one +/// passes through to the relay. +#[tokio::test] +async fn test_get_execution_payload_bid_verify_request_auth_enabled() -> Result<()> { + let secret_key = random_secret(); + let proposer_pubkey = secret_key.public_key(); + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |cfg| cfg.verify_request_auth = true, generate_mock_relay) + .await?; + + // An empty signature never verifies under DOMAIN_REQUEST_AUTH + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + Some(proposer_pubkey.clone()), + Some(&auth), + vec![EncodingType::Json], + ) + .await?; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "bad auth precedes relay calls"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 401); + assert_eq!(body["message"], "Invalid SignedRequestAuth: signature verification failed"); + + let auth = signed_auth(&secret_key, &[0xde, 0xad], TEST_SLOT, Chain::Hoodi); + let res = mock_validator + .do_get_execution_payload_bid( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + Some(proposer_pubkey), + Some(&auth), + vec![EncodingType::Json], + ) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + Ok(()) +} + +/// Both timing headers are required: the send time and the timeout are what +/// bound every downstream call, so a request missing either is a 400 and no +/// relay is contacted. +#[tokio::test] +async fn test_get_execution_payload_bid_missing_timing_headers_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + let body = opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes(); + + // no headers at all / only the send time / only the timeout / zero timeout + let cases: Vec> = vec![ + vec![], + vec![(HEADER_START_TIME_UNIX_MS, utcnow_ms().to_string())], + vec![(HEADER_TIMEOUT_MS, "1000".to_string())], + vec![ + (HEADER_START_TIME_UNIX_MS, utcnow_ms().to_string()), + (HEADER_TIMEOUT_MS, "0".to_string()), + ], + ]; + for headers in cases { + // Version header present throughout: only the timing headers vary + let mut req = mock_validator + .comm_boost + .client + .post(url.clone()) + .header("Eth-Consensus-Version", "gloas") + .body(body.clone()); + for (name, value) in &headers { + req = req.header(*name, value); + } + let res = req.send().await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "headers: {headers:?}"); + let json: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(json["code"], 400); + assert_eq!( + json["message"], + "Invalid request: Date-Milliseconds and X-Timeout-Ms headers are required" + ); + } + assert_eq!(mock_state.received_execution_payload_bid(), 0, "no relay call for a 400"); + Ok(()) +} + +/// A deadline that has already passed means there is no time to serve the +/// request: CB returns 204 rather than calling a relay it cannot beat. +#[tokio::test] +async fn test_get_execution_payload_bid_expired_deadline_204() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms() - 5_000) + .header(HEADER_TIMEOUT_MS, 1_000u64) + .header("Eth-Consensus-Version", "gloas") + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::NO_CONTENT); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "no relay call past the deadline"); + Ok(()) +} + +/// By default CB does not verify the auth signature: a bad one is forwarded to +/// the builder, which verifies it itself. +#[tokio::test] +async fn test_get_execution_payload_bid_bad_auth_signature_forwarded_by_default() -> Result<()> { + let proposer_pubkey = random_secret().public_key(); + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let data = vec![0xde, 0xad]; + let auth = opaque_auth(&data, TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + Some(proposer_pubkey), + Some(&auth), + vec![EncodingType::Json], + ) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + // The spec requires message AND signature to reach the builder unchanged + let seen = mock_state.received_auth().expect("relay saw an auth"); + assert_eq!(seen.message.data.to_vec(), data); + assert_eq!(seen.message.slot, auth.message.slot); + assert_eq!(seen.signature, auth.signature, "signature must be forwarded byte-for-byte"); + Ok(()) +} + +#[tokio::test] +async fn test_get_execution_payload_bid_spec_url() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let signer = random_secret(); + + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new(MockRelayState::new(chain, signer)); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let pubkey = "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae"; + let url = format!( + "{}eth/v1/builder/execution_payload_bid/{}/{}/{}/{}", + mock_validator.comm_boost.config.entry.url, + TEST_SLOT, + B256::ZERO, + B256::ZERO, + pubkey, + ); + // The auth body, timing headers and version header are required, so even + // the bare-URL shape test must carry them + let res = mock_validator + .comm_boost + .client + .post(url) + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .header("Eth-Consensus-Version", "gloas") + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + Ok(()) +} + +/// The bid response is served as SSZ when the caller sends +/// Accept: application/octet-stream, with Eth-Consensus-Version on the 200. +#[tokio::test] +async fn test_get_execution_payload_bid_ssz_response() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Ssz, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + + let content_type = + res.headers().get(CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or_default(); + assert_eq!(content_type, EncodingType::Ssz.to_string(), "response must be SSZ"); + + let version = + res.headers().get("eth-consensus-version").and_then(|v| v.to_str().ok()).map(str::to_owned); + assert_eq!(version.as_deref(), Some("gloas"), "200 must set Eth-Consensus-Version"); + + let bid = SignedExecutionPayloadBid::from_ssz_bytes(&res.bytes().await?) + .expect("body must SSZ-decode to a SignedExecutionPayloadBid"); + assert_ne!(bid.message.block_hash.0, B256::ZERO); + assert_eq!(bid.message.slot.as_u64(), TEST_SLOT); + Ok(()) +} + +/// With NO Accept header the response defaults to SSZ (this endpoint is +/// SSZ-by-default): the 200 carries Content-Type: application/octet-stream and +/// the body SSZ-decodes to a SignedExecutionPayloadBid. Proves the +/// no-preference tiebreak is SSZ, not the legacy JSON default. +#[tokio::test] +async fn test_get_execution_payload_bid_no_accept_defaults_to_ssz() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + // An empty accept vec makes MockValidator send NO Accept header at all. + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + + let content_type = + res.headers().get(CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or_default(); + assert_eq!(content_type, EncodingType::Ssz.to_string(), "no Accept header must default to SSZ"); + + let bid = SignedExecutionPayloadBid::from_ssz_bytes(&res.bytes().await?) + .expect("body must SSZ-decode to a SignedExecutionPayloadBid"); + assert_ne!(bid.message.block_hash.0, B256::ZERO); + assert_eq!(bid.message.slot.as_u64(), TEST_SLOT); + Ok(()) +} + +/// An explicit `Accept: application/json` is still obeyed even though the +/// endpoint defaults to SSZ when no preference is expressed. The 200 is JSON +/// and decodes to a GetExecutionPayloadBidResponse. The relay is JSON-only so +/// this also covers the JSON relay-leg decode path end to end. +#[tokio::test] +async fn test_get_execution_payload_bid_explicit_json_obeyed() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = + Arc::new(MockRelayState::new(chain, random_secret()).with_json_only_response()); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + + let content_type = + res.headers().get(CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or_default(); + assert_eq!( + content_type, + EncodingType::Json.to_string(), + "explicit Accept: application/json must be obeyed over the SSZ default" + ); + + let decoded = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!(decoded.slot(), TEST_SLOT); + assert_ne!(decoded.block_hash(), B256::ZERO); + Ok(()) +} + +/// End-to-end outbound SSZ decode: the relay serves the bid as SSZ (with the +/// Eth-Consensus-Version header), PBS decodes it on the relay leg and returns +/// 200 with the correct bid. A decode failure would drop the only relay and +/// yield 204, so a 200 with matching fields proves the SSZ relay-response +/// decode path actually ran. +#[tokio::test] +async fn test_get_execution_payload_bid_relay_ssz_response_roundtrip() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + // Relay serves ONLY SSZ, so PBS must decode the SSZ bid on the relay leg. + let mock_state = Arc::new( + MockRelayState::new(chain, random_secret()) + .with_ssz_only_response() + .with_trustless_bid_gwei(42), + ); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + // BN asks for JSON; PBS decodes SSZ from the relay and re-encodes to JSON. + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + + let decoded = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!(decoded.slot(), TEST_SLOT); + assert_eq!(decoded.value(), 42, "bid value must survive the SSZ relay-leg round-trip"); + assert_ne!(decoded.block_hash(), B256::ZERO); + Ok(()) +} + +/// The relay serves an SSZ bid 200 WITHOUT the Eth-Consensus-Version header. +/// PBS cannot decode the (non-self-describing) SSZ without the fork, so it +/// surfaces a clean PbsError and drops the bid rather than returning a bogus +/// 200 or panicking. With a single relay this drop yields a 204 to the BN. +#[tokio::test] +async fn test_get_execution_payload_bid_relay_ssz_missing_version_header() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new( + MockRelayState::new(chain, random_secret()) + .with_ssz_only_response() + .with_epbs_omit_consensus_version(), + ); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![]) + .await?; + // The relay was contacted, but its undecodable SSZ bid was dropped. + assert_eq!(mock_state.received_execution_payload_bid(), 1); + // Never a bogus 200; the response exists (no panic). Single dropped relay -> + // 204. + assert_ne!(res.status(), StatusCode::OK, "an undecodable SSZ bid must not yield 200"); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + Ok(()) +} + +/// A relay that labels its bid with a non-Gloas fork on this Gloas-only +/// endpoint is a bad relay response: the bid is dropped, never forwarded to +/// the BN under the bogus fork. Single relay -> 204. +#[tokio::test] +async fn test_get_execution_payload_bid_relay_wrong_fork_dropped() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new( + MockRelayState::new(chain, random_secret()).with_ssz_only_response().with_epbs_wrong_fork(), + ); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![]) + .await?; + // The relay was contacted and served, so the 204 below proves the DROP, + // not an empty auction + assert_eq!(mock_state.received_execution_payload_bid(), 1); + assert_ne!(res.status(), StatusCode::OK, "a capella-labelled bid must not become CB's 200"); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + Ok(()) +} + +/// Same drop on the JSON path, where `version` comes from the relay's BODY +/// rather than the header - the two paths derive it differently, so both need +/// pinning. A bad relay response: the bid is dropped, never forwarded to +/// the BN under the bogus fork. Single relay -> 204. +#[tokio::test] +async fn test_get_execution_payload_bid_relay_wrong_fork_json_dropped() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new( + MockRelayState::new(chain, random_secret()) + .with_json_only_response() + .with_epbs_wrong_fork(), + ); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![]) + .await?; + // The relay was contacted and served, so the 204 below proves the DROP, + // not an empty auction + assert_eq!(mock_state.received_execution_payload_bid(), 1); + assert_ne!(res.status(), StatusCode::OK, "a capella-labelled bid must not become CB's 200"); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + Ok(()) +} + +/// An unsupported Accept type is rejected with 406 before any relay is queried +/// (a typed error, not a 500). +#[tokio::test] +async fn test_get_execution_payload_bid_unsupported_accept_406() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let pubkey = "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae"; + let url = format!( + "{}eth/v1/builder/execution_payload_bid/{}/{}/{}/{}", + mock_validator.comm_boost.config.entry.url, + TEST_SLOT, + B256::ZERO, + B256::ZERO, + pubkey, + ); + let res = mock_validator + .comm_boost + .client + .post(url) + .header("accept", "application/xml") + .header("Eth-Consensus-Version", "gloas") + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "406 short-circuits before relays"); + Ok(()) +} + +/// An SSZ-encoded auth body (application/octet-stream) is decoded and forwarded +/// to the relay, same as JSON. +#[tokio::test] +async fn test_get_execution_payload_bid_ssz_auth_forwarded() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let data = vec![0xde, 0xad, 0xbe, 0xef]; + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = + generate_mock_relay_with_auth_data(relay_port, mock_state.signer.public_key(), &data)?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let ssz_body = opaque_auth(&data, TEST_SLOT).as_ssz_bytes(); + let url = format!( + "{}eth/v1/builder/execution_payload_bid/{}/{}/{}/{}", + mock_validator.comm_boost.config.entry.url, + TEST_SLOT, + B256::ZERO, + B256::ZERO, + "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", + ); + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/octet-stream") + .header("Eth-Consensus-Version", "gloas") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(ssz_body) + .send() + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_auth_data(), Some(data), "relay must receive the decoded auth"); + Ok(()) +} + +/// A present-but-malformed auth body is rejected with 400 and an ErrorMessage +/// JSON body, before any relay is queried. +#[tokio::test] +async fn test_get_execution_payload_bid_malformed_auth_400() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let url = format!( + "{}eth/v1/builder/execution_payload_bid/{}/{}/{}/{}", + mock_validator.comm_boost.config.entry.url, + TEST_SLOT, + B256::ZERO, + B256::ZERO, + "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", + ); + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/json") + .body(vec![0xff, 0x00, 0x99]) + .send() + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!( + mock_state.received_execution_payload_bid(), + 0, + "malformed auth rejected before relays" + ); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert!( + body["message"].as_str().unwrap_or_default().contains("decoding"), + "error body must be an ErrorMessage describing the decode failure" + ); + Ok(()) +} + +/// Boot PBS in front of a single timing-games relay driven by `mock_state`, so +/// a test can observe the bid poll ladder from the builder's side. +async fn setup_timing_games_relay( + mock_state: Arc, + frequency_get_header_ms: u64, + bid_poll_timeout_ms: Option, +) -> Result { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_relay = generate_mock_relay_with_timing_games( + relay_port, + mock_state.signer.public_key(), + frequency_get_header_ms, + bid_poll_timeout_ms, + )?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state, relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + Ok(mock_validator) +} + +/// Request a bid advertising `budget_ms` as the proposer's `X-Timeout-Ms`. +async fn get_bid_with_budget( + mock_validator: &MockValidator, + budget_ms: u64, +) -> Result { + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + Ok(mock_validator + .do_get_execution_payload_bid_with_timeout( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + None, + Some(&auth), + vec![EncodingType::Json], + budget_ms, + ) + .await?) +} + +/// No poll may promise the builder more time than the shared deadline still has +/// left when it is sent. Poll `i` goes out one cadence step after poll `i - 1`, +/// so the budget left for it is at most `budget_ms - i * frequency_ms`. +fn assert_no_poll_overspends(timeouts: &[u64], budget_ms: u64, frequency_ms: u64) { + for (i, timeout) in timeouts.iter().enumerate() { + assert!( + timeout + i as u64 * frequency_ms <= budget_ms, + "poll {i} promised more than the deadline had left: {timeouts:?}" + ); + } +} + +/// The ladder's shape: with timing games on, a known cadence and a generous +/// deadline, every poll but the last carries the bounded `bid_poll_timeout_ms` +/// and the last one carries the whole remaining budget. +#[tokio::test] +async fn test_get_execution_payload_bid_ladder_timeout_shape() -> Result<()> { + const FREQ_MS: u64 = 500; + const POLL_TIMEOUT_MS: u64 = 100; + const BUDGET_MS: u64 = 2_000; + + let mock_state = Arc::new(MockRelayState::new(Chain::Hoodi, random_secret())); + let mock_validator = + setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK); + + // The exact poll count is driven by real cadence sleeps, which only ever + // overrun under load, so assert the ladder SHAPE rather than a fixed count: + // every rung but the last is bounded by bid_poll_timeout_ms, and the last + // holds longer for the remaining budget. No poll may overspend the deadline. + let timeouts = mock_state.received_bid_timeouts(); + assert!( + timeouts.len() >= 2, + "the ladder must fire bounded rungs plus a final poll, got {timeouts:?}" + ); + let (last, bounded) = timeouts.split_last().unwrap(); + assert!( + bounded.iter().all(|timeout| *timeout == POLL_TIMEOUT_MS), + "every poll but the last must be bounded by bid_poll_timeout_ms: {timeouts:?}" + ); + assert!( + *last > POLL_TIMEOUT_MS, + "the last poll must hold longer than a bounded rung for the remaining budget, got {last}" + ); + assert_no_poll_overspends(&timeouts, BUDGET_MS, FREQ_MS); + + // A sleep never returns early, so arrivals only drift later; 10ms covers ms + // rounding + let arrivals = mock_state.received_bid_arrivals_ms(); + for pair in arrivals.windows(2) { + assert!( + pair[1].saturating_sub(pair[0]) + 10 >= FREQ_MS, + "polls must be spaced by the configured cadence: {arrivals:?}" + ); + } + Ok(()) +} + +/// Best-of across the whole ladder: with a builder improving its bid on every +/// poll, the returned bid is the LAST poll's, proving `select_max_bid` spans +/// every rung instead of returning the first one that landed. +#[tokio::test] +async fn test_get_execution_payload_bid_ladder_returns_best_poll() -> Result<()> { + const FREQ_MS: u64 = 400; + const POLL_TIMEOUT_MS: u64 = 300; + const BUDGET_MS: u64 = 1_200; + const STEP_GWEI: u64 = 7; + const BASE_GWEI: u64 = 10; + + let mock_state = Arc::new( + MockRelayState::new(Chain::Hoodi, random_secret()) + .with_trustless_bid_gwei(BASE_GWEI) + .with_improving_bids(STEP_GWEI), + ); + let mock_validator = + setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK); + + // This builder answers instantly, so every rung lands a bid and the last is the + // best + let polls = mock_state.received_execution_payload_bid(); + assert!(polls > 1, "the ladder must have fired more than one poll, got {polls}"); + + let decoded = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!( + decoded.value(), + BASE_GWEI + STEP_GWEI * (polls - 1), + "the winner must be the last poll's bid, not the first one to land" + ); + Ok(()) +} + +/// A builder that holds every request longer than `bid_poll_timeout_ms` times +/// out the early rungs, but the last poll holds for the full remainder, so the +/// run still yields a bid. +#[tokio::test] +async fn test_get_execution_payload_bid_ladder_slow_builder_still_bids() -> Result<()> { + const FREQ_MS: u64 = 500; + const POLL_TIMEOUT_MS: u64 = 100; + const DELAY_MS: u64 = 150; + const BUDGET_MS: u64 = 1_500; + + let mock_state = + Arc::new(MockRelayState::new(Chain::Hoodi, random_secret()).with_bid_delay_ms(DELAY_MS)); + let mock_validator = + setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK, "the last poll outlasts the builder's delay"); + + // Cadence sleeps only overrun under load, so the count can dip; assert the + // ladder shape (bounded early rungs plus a final poll that outlasts the + // builder's delay) rather than a fixed count. + let timeouts = mock_state.received_bid_timeouts(); + assert!( + timeouts.len() >= 2, + "the ladder must fire early rungs plus a final poll, got {timeouts:?}" + ); + let (last, early) = timeouts.split_last().unwrap(); + assert!( + early.iter().all(|timeout| *timeout < DELAY_MS), + "the early polls must expire before this builder answers: {timeouts:?}" + ); + assert!(*last > DELAY_MS, "the last poll must outlast the builder's delay, got {last}"); + assert_no_poll_overspends(&timeouts, BUDGET_MS, FREQ_MS); + Ok(()) +} + +/// The converse that motivates the ladder: when the builder answers inside +/// `bid_poll_timeout_ms`, the early rungs land bids in hand well before the +/// deadline instead of every poll being staked on the final instant. +#[tokio::test] +async fn test_get_execution_payload_bid_ladder_early_polls_land_bids() -> Result<()> { + const FREQ_MS: u64 = 400; + const POLL_TIMEOUT_MS: u64 = 300; + const DELAY_MS: u64 = 100; + const BUDGET_MS: u64 = 1_500; + + let mock_state = + Arc::new(MockRelayState::new(Chain::Hoodi, random_secret()).with_bid_delay_ms(DELAY_MS)); + let mock_validator = + setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK); + + // Each early poll has 200ms of slack over the builder's delay + let served = mock_state.served_execution_payload_bid(); + assert!(served > 1, "the early rungs must land bids, only {served} poll(s) were answered"); + let timeouts = mock_state.received_bid_timeouts(); + assert!( + timeouts.split_last().unwrap().1.iter().all(|timeout| *timeout > DELAY_MS), + "the early polls must outlast this builder's delay: {timeouts:?}" + ); + Ok(()) +} + +/// The proposer's deadline, not the cadence alone, sizes the ladder: a small +/// `X-Timeout-Ms` buys fewer polls than a large one, and no poll ever promises +/// more than the budget it was cut from. +#[tokio::test] +async fn test_get_execution_payload_bid_deadline_clamps_ladder() -> Result<()> { + const FREQ_MS: u64 = 200; + const POLL_TIMEOUT_MS: u64 = 100; + const SMALL_BUDGET_MS: u64 = 400; + const LARGE_BUDGET_MS: u64 = 1_600; + + let mut polls = Vec::new(); + for budget_ms in [SMALL_BUDGET_MS, LARGE_BUDGET_MS] { + let mock_state = Arc::new(MockRelayState::new(Chain::Hoodi, random_secret())); + let mock_validator = + setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, budget_ms).await?; + assert_eq!(res.status(), StatusCode::OK, "budget {budget_ms}"); + + let timeouts = mock_state.received_bid_timeouts(); + assert_no_poll_overspends(&timeouts, budget_ms, FREQ_MS); + polls.push(timeouts); + } + + // 400ms of budget buys the first poll plus a last one for the remainder + assert_eq!(polls[0].len(), 2, "a tight deadline must cut the ladder short: {:?}", polls[0]); + assert!( + polls[1].len() > polls[0].len(), + "a larger deadline must buy more polls: {:?} vs {:?}", + polls[1], + polls[0] + ); + Ok(()) +} + +/// Degradation to the pre-ladder behavior: a budget shorter than +/// `bid_poll_timeout_ms` leaves no room to bound anything, so CB sends exactly +/// one poll carrying the whole budget. +#[tokio::test] +async fn test_get_execution_payload_bid_short_budget_single_poll() -> Result<()> { + const FREQ_MS: u64 = 1_000; + const BUDGET_MS: u64 = 300; + + // Default bid_poll_timeout_ms, which is larger than the whole budget here + assert!(DEFAULT_BID_POLL_TIMEOUT_MS > BUDGET_MS); + let mock_state = Arc::new(MockRelayState::new(Chain::Hoodi, random_secret())); + let mock_validator = setup_timing_games_relay(mock_state.clone(), FREQ_MS, None).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK); + + let timeouts = mock_state.received_bid_timeouts(); + assert_eq!(timeouts.len(), 1, "a budget under one cadence step is a single poll: {timeouts:?}"); + // The single poll gets the budget minus transit; 150ms of slack for transit + assert!( + timeouts[0] <= BUDGET_MS && timeouts[0] >= 150, + "the only poll must carry the whole budget, got {}", + timeouts[0] + ); + Ok(()) +} + +/// A relay's `bid_poll_timeout_ms` overrides the default bound on every poll +/// but the last. +#[tokio::test] +async fn test_get_execution_payload_bid_poll_timeout_override() -> Result<()> { + const FREQ_MS: u64 = 600; + const BUDGET_MS: u64 = 2_000; + const CUSTOM_POLL_TIMEOUT_MS: u64 = 150; + + for (configured, expected) in [ + (None, DEFAULT_BID_POLL_TIMEOUT_MS), + (Some(CUSTOM_POLL_TIMEOUT_MS), CUSTOM_POLL_TIMEOUT_MS), + ] { + let mock_state = Arc::new(MockRelayState::new(Chain::Hoodi, random_secret())); + let mock_validator = + setup_timing_games_relay(mock_state.clone(), FREQ_MS, configured).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK, "configured={configured:?}"); + + let timeouts = mock_state.received_bid_timeouts(); + assert!(timeouts.len() > 1, "configured={configured:?}, got {timeouts:?}"); + assert!( + timeouts.split_last().unwrap().1.iter().all(|timeout| *timeout == expected), + "configured={configured:?} must bound the early polls at {expected}: {timeouts:?}" + ); + } + Ok(()) +} + +/// A relay with nothing to offer answers 204 on every poll. That is an answer, +/// not a failure, so the ladder must surface it exactly like the single-request +/// path does rather than reporting the relay as timed out. +#[tokio::test] +async fn test_get_execution_payload_bid_ladder_no_bid_is_204() -> Result<()> { + const FREQ_MS: u64 = 300; + const POLL_TIMEOUT_MS: u64 = 200; + const BUDGET_MS: u64 = 1_000; + + let mock_state = + Arc::new(MockRelayState::new(Chain::Hoodi, random_secret()).with_no_epbs_bid()); + let mock_validator = + setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::NO_CONTENT, "a 204 from every poll must stay a 204"); + + let polls = mock_state.received_execution_payload_bid(); + assert!(polls > 1, "the ladder must have polled more than once, got {polls}"); + Ok(()) +} + +/// One of two relays errors on the bid endpoint; the other still serves a valid +/// bid, so the request is a 200 carrying the surviving relay's bid. Guards +/// against a partial failure aborting the join or poisoning `select_max_bid`. +#[tokio::test] +async fn test_get_execution_payload_bid_one_relay_fails_other_wins_200() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + // The first relay errors; the second serves the default valid bid + states[0].set_response_override(StatusCode::INTERNAL_SERVER_ERROR); + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK, "a surviving relay still wins the auction"); + + let decoded = serde_json::from_slice::(&res.bytes().await?)?; + let object_root = decoded.data.message.tree_hash_root(); + assert_eq!( + decoded.data.signature, + sign_execution_payload_bid_root( + &states[1].signer, + &object_root, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ), + "the winning bid must be the surviving relay's" + ); + assert_ne!( + decoded.data.signature, + sign_execution_payload_bid_root( + &states[0].signer, + &object_root, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ), + "the failed relay must not have won" + ); + Ok(()) +} + +/// Every relay erroring on the bid endpoint degrades to 204 (no bid), never a +/// 502: this endpoint has no bad-gateway path, a dead or erroring relay simply +/// contributes no bid. This is the documented contract. +#[tokio::test] +async fn test_get_execution_payload_bid_all_relays_fail_204_not_502() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + for state in &states { + state.set_response_override(StatusCode::INTERNAL_SERVER_ERROR); + } + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!( + res.status(), + StatusCode::NO_CONTENT, + "all relays erroring is a no-bid 204, not a 502" + ); + assert_eq!(states[0].received_execution_payload_bid(), 1, "every relay is asked"); + assert_eq!(states[1].received_execution_payload_bid(), 1, "every relay is asked"); + Ok(()) +} + +/// An unsupported request `Content-Type` is a 415 before any relay is queried, +/// mirroring the preferences endpoint. +#[tokio::test] +async fn test_get_execution_payload_bid_unsupported_content_type_415() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "text/plain") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!( + mock_state.received_execution_payload_bid(), + 0, + "415 short-circuits before any relay call" + ); + Ok(()) +} + +/// An SSZ auth body missing `Eth-Consensus-Version` is a 400: builder-specs +/// fork-versions the request wire type, so the header is required to accept the +/// SSZ form (and the same request with the header is served). +#[tokio::test] +async fn test_get_execution_payload_bid_ssz_missing_version_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + // Note: SSZ Content-Type but no Eth-Consensus-Version + let res = mock_validator + .comm_boost + .client + .post(url.clone()) + .header(CONTENT_TYPE, "application/octet-stream") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!( + mock_state.received_execution_payload_bid(), + 0, + "an undecodable request must not forward" + ); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + + // The identical request carrying the literal spec header is served + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/octet-stream") + .header("Eth-Consensus-Version", "gloas") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + Ok(()) +} + +/// builder-specs marks `Eth-Consensus-Version` required for JSON and SSZ alike +/// on this endpoint (builder-specs #165): a JSON auth body without it is a +/// 400, superseding the earlier best-effort policy. +#[tokio::test] +async fn test_get_execution_payload_bid_json_no_version_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + // No Eth-Consensus-Version: required regardless of encoding + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/json") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(serde_json::to_vec(&opaque_auth(&[0xde, 0xad], TEST_SLOT))?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("missing consensus version"), + "the 400 must name the missing header: {body}" + ); + + // A deprecated fork name (outside the gloas-only window) is also a 400, + // and the error echoes the VALUE rather than claiming the header is missing + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/json") + .header(CONSENSUS_VERSION_HEADER, "electra") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(serde_json::to_vec(&opaque_auth(&[0xde, 0xad], TEST_SLOT))?) + .send() + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("electra"), + "the 400 must echo the unsupported value: {body}" + ); + // Rejected at the wire layer: the request never reaches a relay + assert_eq!(mock_state.received_execution_payload_bid(), 0); + Ok(()) +} + +/// Without timing games, a relay slower than the proposer's `X-Timeout-Ms` is +/// dropped on the single-poll `send_one_*` path, so the request degrades to 204 +/// rather than waiting the relay out. +#[tokio::test] +async fn test_get_execution_payload_bid_slow_relay_times_out_204() -> Result<()> { + const DELAY_MS: u64 = 600; + const BUDGET_MS: u64 = 200; + + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()).with_bid_delay_ms(DELAY_MS), + ]) + .await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid_with_timeout( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + None, + Some(&auth), + vec![EncodingType::Json], + BUDGET_MS, + ) + .await?; + assert_eq!( + res.status(), + StatusCode::NO_CONTENT, + "a relay slower than the deadline is dropped, not awaited" + ); + assert_eq!(states[0].received_execution_payload_bid(), 1, "the relay was still contacted"); + Ok(()) +} + +/// A per-relay `max_execution_payment_gwei` cap stricter than the global one +/// rejects a bid whose execution payment exceeds it. Same served bid, same high +/// global cap: only the per-relay override flips accept (200) to reject (204), +/// isolating it as the cause. +#[tokio::test] +async fn test_get_execution_payload_bid_per_relay_max_payment_override() -> Result<()> { + const GLOBAL_CAP_GWEI: u64 = 100; + const RELAY_CAP_GWEI: u64 = 5; + const SERVED_TRUSTED_GWEI: u64 = 10; + + for (relay_cap, expected) in + [(None, StatusCode::OK), (Some(RELAY_CAP_GWEI), StatusCode::NO_CONTENT)] + { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let mock_state = Arc::new( + MockRelayState::new(chain, random_secret()).with_trusted_bid_gwei(SERVED_TRUSTED_GWEI), + ); + let mock_relay = match relay_cap { + Some(cap) => generate_mock_relay_with_max_payment( + relay_port, + mock_state.signer.public_key(), + cap, + )?, + None => generate_mock_relay(relay_port, mock_state.signer.public_key())?, + }; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + // Global cap comfortably above the served payment, so only a stricter + // per-relay cap can reject the bid + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.max_execution_payment_gwei = GLOBAL_CAP_GWEI; + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + None, + Some(&auth), + vec![EncodingType::Json], + ) + .await?; + assert_eq!(res.status(), expected, "relay_cap={relay_cap:?}"); + assert_eq!(mock_state.received_execution_payload_bid(), 1, "relay_cap={relay_cap:?}"); + } + Ok(()) +} + +async fn test_get_execution_payload_bid_impl( + relay_states: Vec, + expected_code: StatusCode, + expected_relay_counts: &[u64], + expected_value: Option, + max_execution_payment_gwei: u64, +) -> Result<()> { + // Setup test environment + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + + // Run one mock relay per state so per-relay knobs and counters work + let mut relays = Vec::new(); + let mut states = Vec::new(); + for state in relay_states { + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + let state = Arc::new(state); + let relay = generate_mock_relay(relay_port, state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(state.clone(), relay_listener)); + relays.push(relay); + states.push(state); + } + + // Run the PBS service + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.max_execution_payment_gwei = max_execution_payment_gwei; + let config = to_pbs_config(chain, pbs_config, relays); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + info!("Sending get execution payload bid"); + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), expected_code); + for (state, expected) in states.iter().zip(expected_relay_counts) { + assert_eq!(state.received_execution_payload_bid(), *expected); + } + if expected_code != StatusCode::OK { + return Ok(()); + } + + // Eth-Consensus-Version required on 200 + let version_header = + res.headers().get("eth-consensus-version").and_then(|v| v.to_str().ok()).map(str::to_owned); + assert_eq!( + version_header.as_deref(), + Some("gloas"), + "200 response must set Eth-Consensus-Version: gloas" + ); + + // Get the data + let res = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!(res.version.to_string(), "gloas"); + assert_eq!(res.slot(), TEST_SLOT); + assert_eq!(res.parent_hash(), B256::ZERO); + assert_eq!(res.parent_root(), B256::ZERO); + assert_ne!(res.block_hash(), B256::ZERO); + assert!(res.execution_payment() <= max_execution_payment_gwei); + if let Some(expected_value) = expected_value { + assert_eq!(res.value(), expected_value); + } + // The winning bid must be signed by one of the configured relays + let object_root = res.data.message.tree_hash_root(); + assert!( + states.iter().any(|s| sign_execution_payload_bid_root( + &s.signer, + &object_root, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ) == res.data.signature), + "bid signature does not match any configured relay" + ); + Ok(()) +} diff --git a/tests/tests/pbs_get_header.rs b/tests/tests/pbs_get_header.rs index 21ff3e6ef..e155041bf 100644 --- a/tests/tests/pbs_get_header.rs +++ b/tests/tests/pbs_get_header.rs @@ -255,8 +255,7 @@ async fn test_get_header_impl( pbs_config.rpc_url = rpc_url; let config = to_pbs_config(chain, pbs_config, vec![mock_relay.clone()]); let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -336,8 +335,7 @@ async fn test_get_header_returns_204_if_no_relay_reachable() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -371,8 +369,7 @@ async fn test_get_header_returns_400_if_request_is_invalid() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -534,8 +531,7 @@ async fn test_get_header_tolerates_mime_params_in_content_type() -> Result<()> { let pbs_config = get_pbs_config(pbs_port); let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); tokio::time::sleep(Duration::from_millis(100)).await; @@ -574,8 +570,7 @@ async fn test_get_header_tolerates_json_charset_param() -> Result<()> { let pbs_config = get_pbs_config(pbs_port); let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/tests/tests/pbs_get_status.rs b/tests/tests/pbs_get_status.rs index cd2ab51dc..68439e71d 100644 --- a/tests/tests/pbs_get_status.rs +++ b/tests/tests/pbs_get_status.rs @@ -3,9 +3,11 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; use cb_common::{signer::random_secret, types::Chain}; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, + utils::{ + generate_mock_relay, get_free_listener, get_pbs_config, setup_test_env, to_pbs_config, + }, }; use eyre::Result; use reqwest::StatusCode; @@ -18,21 +20,24 @@ async fn test_get_status() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3500; - let relay_0_port = pbs_port + 1; - let relay_1_port = pbs_port + 2; + let pbs_listener = get_free_listener().await; + let relay_0_listener = get_free_listener().await; + let relay_1_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_0_port = relay_0_listener.local_addr()?.port(); + let relay_1_port = relay_1_listener.local_addr()?.port(); let relays = vec![ generate_mock_relay(relay_0_port, pubkey.clone())?, generate_mock_relay(relay_1_port, pubkey)?, ]; let mock_state = Arc::new(MockRelayState::new(chain, signer)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_0_port)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_1_port)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_0_listener)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_1_listener)); let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -54,18 +59,21 @@ async fn test_get_status_returns_502_if_relay_down() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3600; - let relay_port = pbs_port + 1; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_port = relay_listener.local_addr()?.port(); + // Release the relay's port so nothing listens on it: the relay is down + drop(relay_listener); let relays = vec![generate_mock_relay(relay_port, pubkey)?]; let mock_state = Arc::new(MockRelayState::new(chain, signer)); // Don't start the relay - // tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index a34e5f861..af617069b 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; -use alloy::primitives::U256; +use alloy::primitives::{Address, U256}; use cb_common::{ config::{ HTTP_TIMEOUT_SECONDS_DEFAULT, MUXER_HTTP_MAX_LENGTH, MuxConfig, MuxKeysLoader, PbsMuxes, @@ -39,8 +39,7 @@ async fn test_ssv_public_network_fetch() -> Result<()> { // Start the mock server let listener = get_free_listener().await; let port = listener.local_addr().unwrap().port(); - drop(listener); - let server_handle = create_mock_public_ssv_server(port, None).await?; + let server_handle = create_mock_public_ssv_server(listener, None).await?; let url = Url::parse(&format!("http://localhost:{port}/api/v4/test_chain/validators/in_operator/1")) .unwrap(); @@ -79,9 +78,8 @@ async fn test_ssv_network_fetch_big_data() -> Result<()> { // Start the mock server let listener = get_free_listener().await; let port = listener.local_addr().unwrap().port(); - drop(listener); let server_handle = - cb_tests::mock_ssv_public::create_mock_public_ssv_server(port, None).await?; + cb_tests::mock_ssv_public::create_mock_public_ssv_server(listener, None).await?; let url = Url::parse(&format!("http://localhost:{port}/big_data")).unwrap(); let response = request_ssv_pubkeys_from_public_api(url.clone(), Duration::from_secs(120)).await; @@ -113,12 +111,11 @@ async fn test_ssv_network_fetch_timeout() -> Result<()> { // Start the mock server let listener = get_free_listener().await; let port = listener.local_addr().unwrap().port(); - drop(listener); let state = PublicSsvMockState { validators: Arc::new(RwLock::new(vec![])), force_timeout: Arc::new(RwLock::new(true)), }; - let server_handle = create_mock_public_ssv_server(port, Some(state)).await?; + let server_handle = create_mock_public_ssv_server(listener, Some(state)).await?; let url = Url::parse(&format!("http://localhost:{port}/api/v4/test_chain/validators/in_operator/1")) .unwrap(); @@ -144,9 +141,8 @@ async fn test_ssv_network_fetch_big_data_without_content_length() -> Result<()> // Start the mock server let listener = get_free_listener().await; let port = listener.local_addr().unwrap().port(); - drop(listener); set_ignore_content_length(true); - let server_handle = create_mock_public_ssv_server(port, None).await?; + let server_handle = create_mock_public_ssv_server(listener, None).await?; let url = Url::parse(&format!("http://localhost:{port}/big_data")).unwrap(); let response = request_ssv_pubkeys_from_public_api(url.clone(), Duration::from_secs(120)).await; @@ -178,8 +174,7 @@ async fn test_ssv_node_network_fetch() -> Result<()> { // Start the mock server let listener = get_free_listener().await; let port = listener.local_addr().unwrap().port(); - drop(listener); - let _server_handle = create_mock_ssv_node_server(port, None).await?; + let _server_handle = create_mock_ssv_node_server(listener, None).await?; let url = Url::parse(&format!("http://localhost:{port}/v1/validators")).unwrap(); let response = request_ssv_pubkeys_from_ssv_node( url, @@ -248,8 +243,7 @@ async fn test_mux() -> Result<()> { // Run PBS service let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -337,10 +331,6 @@ async fn test_ssv_multi_with_node() -> Result<()> { let ssv_node_port = ssv_node_listener.local_addr().unwrap().port(); let ssv_public_port = ssv_public_listener.local_addr().unwrap().port(); let relay_port = relay_listener.local_addr().unwrap().port(); - // Drop SSV node + public listeners because their mock server helpers bind the - // port themselves. - drop(ssv_node_listener); - drop(ssv_public_listener); // Start the mock SSV node let ssv_node_url = Url::parse(&format!("http://localhost:{ssv_node_port}/v1/"))?; @@ -352,7 +342,7 @@ async fn test_ssv_multi_with_node() -> Result<()> { force_timeout: Arc::new(RwLock::new(false)), }; let ssv_node_handle = - create_mock_ssv_node_server(ssv_node_port, Some(mock_ssv_node_state.clone())).await?; + create_mock_ssv_node_server(ssv_node_listener, Some(mock_ssv_node_state.clone())).await?; // Start the mock SSV public API let ssv_public_url = Url::parse(&format!("http://localhost:{ssv_public_port}/api/v4/"))?; @@ -361,7 +351,8 @@ async fn test_ssv_multi_with_node() -> Result<()> { force_timeout: Arc::new(RwLock::new(false)), }; let ssv_public_handle = - create_mock_public_ssv_server(ssv_public_port, Some(mock_ssv_public_state.clone())).await?; + create_mock_public_ssv_server(ssv_public_listener, Some(mock_ssv_public_state.clone())) + .await?; // Start a mock relay to be used by the mux let relay = generate_mock_relay(relay_port, pubkey.clone())?; @@ -386,6 +377,7 @@ async fn test_ssv_multi_with_node() -> Result<()> { relays: vec![(*relay.config).clone()], timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], + fee_recipient: None, }], }; @@ -403,8 +395,8 @@ async fn test_ssv_multi_with_node() -> Result<()> { // Run PBS service let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + let pbs_server = + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); info!("Started PBS server with pubkey {pubkey}"); // Wait for the server to start @@ -449,8 +441,6 @@ async fn test_ssv_multi_with_public() -> Result<()> { let relay_port = relay_listener.local_addr().unwrap().port(); // SSV node is intentionally down — release its reserved port. drop(ssv_node_listener); - // SSV public mock helper binds the port itself. - drop(ssv_public_listener); // Start the mock SSV node let ssv_node_url = Url::parse(&format!("http://localhost:{ssv_node_port}/v1/"))?; @@ -469,7 +459,8 @@ async fn test_ssv_multi_with_public() -> Result<()> { force_timeout: Arc::new(RwLock::new(false)), }; let ssv_public_handle = - create_mock_public_ssv_server(ssv_public_port, Some(mock_ssv_public_state.clone())).await?; + create_mock_public_ssv_server(ssv_public_listener, Some(mock_ssv_public_state.clone())) + .await?; // Start a mock relay to be used by the mux let relay = generate_mock_relay(relay_port, pubkey.clone())?; @@ -494,6 +485,7 @@ async fn test_ssv_multi_with_public() -> Result<()> { relays: vec![(*relay.config).clone()], timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], + fee_recipient: None, }], }; @@ -511,8 +503,8 @@ async fn test_ssv_multi_with_public() -> Result<()> { // Run PBS service let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + let pbs_server = + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); info!("Started PBS server with pubkey {pubkey}"); // Wait for the server to start @@ -535,3 +527,40 @@ async fn test_ssv_multi_with_public() -> Result<()> { Ok(()) } + +/// Mux-level fee_recipient overrides the default config's for that mux's keys +#[tokio::test] +async fn test_mux_fee_recipient_resolution() -> Result<()> { + setup_test_env(); + let relay = generate_mock_relay(30100, random_secret().public_key())?; + let validator_pubkey = random_secret().public_key(); + let expected = Address::from([1; 20]); + let muxes = PbsMuxes { + muxes: vec![MuxConfig { + id: "fee-mux".to_string(), + loader: None, + late_in_slot_time_ms: None, + relays: vec![(*relay.config).clone()], + timeout_get_header_ms: Some(u64::MAX - 1), + validator_pubkeys: vec![validator_pubkey.clone()], + fee_recipient: Some(expected), + }], + }; + + let pbs_config = get_pbs_config(30101); + let (mux_lookup, _) = muxes.clone().validate_and_fill(Chain::Hoodi, &pbs_config).await?; + let mux = mux_lookup.get(&validator_pubkey).unwrap(); + assert_eq!(mux.config.fee_recipient, Some(expected)); + assert_eq!(pbs_config.fee_recipient, None); + + // The inherit direction: a mux without its own fee_recipient gets the default's + let mut muxes = muxes; + muxes.muxes[0].fee_recipient = None; + let mut pbs_config = pbs_config; + let default_recipient = Address::from([2; 20]); + pbs_config.fee_recipient = Some(default_recipient); + let (mux_lookup, _) = muxes.validate_and_fill(Chain::Hoodi, &pbs_config).await?; + let mux = mux_lookup.get(&validator_pubkey).unwrap(); + assert_eq!(mux.config.fee_recipient, Some(default_recipient)); + Ok(()) +} diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index 5bfb4aabf..01131b36c 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -47,8 +47,6 @@ async fn test_auto_refresh() -> Result<()> { let ssv_api_port = ssv_api_listener.local_addr().unwrap().port(); let default_relay_port = default_relay_listener.local_addr().unwrap().port(); let mux_relay_port = mux_relay_listener.local_addr().unwrap().port(); - // create_mock_public_ssv_server binds the port itself. - drop(ssv_api_listener); // Start the mock SSV API server // Intentionally missing a trailing slash to ensure this is handled properly @@ -60,7 +58,7 @@ async fn test_auto_refresh() -> Result<()> { force_timeout: Arc::new(RwLock::new(false)), }; let ssv_server_handle = - create_mock_public_ssv_server(ssv_api_port, Some(mock_ssv_state.clone())).await?; + create_mock_public_ssv_server(ssv_api_listener, Some(mock_ssv_state.clone())).await?; // Start a default relay for non-mux keys let default_relay = generate_mock_relay(default_relay_port, default_pubkey.clone())?; @@ -95,6 +93,7 @@ async fn test_auto_refresh() -> Result<()> { relays: vec![(*mux_relay.config).clone()], timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], + fee_recipient: None, }], }; @@ -111,8 +110,8 @@ async fn test_auto_refresh() -> Result<()> { // Run PBS service let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + let pbs_server = + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); info!("Started PBS server with pubkey {default_pubkey}"); // Wait for the server to start diff --git a/tests/tests/pbs_post_blinded_blocks.rs b/tests/tests/pbs_post_blinded_blocks.rs index 06e143c2b..3f9566116 100644 --- a/tests/tests/pbs_post_blinded_blocks.rs +++ b/tests/tests/pbs_post_blinded_blocks.rs @@ -259,8 +259,7 @@ async fn test_submit_block_too_large() -> Result<()> { let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -476,8 +475,7 @@ async fn submit_block_impl( let pbs_config = get_pbs_config(pbs_port); let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -549,8 +547,7 @@ async fn submit_block_ssz_override( let pbs_config = get_pbs_config(pbs_port); let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); tokio::time::sleep(Duration::from_millis(100)).await; @@ -689,8 +686,7 @@ async fn test_submit_block_tolerates_mime_params_in_content_type() -> Result<()> let pbs_config = get_pbs_config(pbs_port); let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/tests/tests/pbs_post_validators.rs b/tests/tests/pbs_post_validators.rs index 12601cdad..22bd1a5d2 100644 --- a/tests/tests/pbs_post_validators.rs +++ b/tests/tests/pbs_post_validators.rs @@ -7,9 +7,11 @@ use cb_common::{ }; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, + utils::{ + generate_mock_relay, get_free_listener, get_pbs_config, setup_test_env, to_pbs_config, + }, }; use eyre::Result; use reqwest::StatusCode; @@ -22,17 +24,20 @@ async fn test_register_validators() -> Result<()> { let pubkey: BlsPublicKey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 4000; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_port = relay_listener.local_addr()?.port(); // Run a mock relay - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; + let relays = vec![generate_mock_relay(relay_port, pubkey)?]; let mock_state = Arc::new(MockRelayState::new(chain, signer)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -68,20 +73,26 @@ async fn test_register_validators_does_not_retry_on_429() -> Result<()> { let pubkey: BlsPublicKey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 4200; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_port = relay_listener.local_addr()?.port(); // Set up mock relay state and override response to 429 let mock_state = Arc::new(MockRelayState::new(chain, signer)); mock_state.set_response_override(StatusCode::TOO_MANY_REQUESTS); // Run a mock relay - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + let relays = vec![generate_mock_relay(relay_port, pubkey)?]; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>( + state.clone(), + pbs_listener, + )); // Leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -121,14 +132,17 @@ async fn test_register_validators_retries_on_500() -> Result<()> { let pubkey: BlsPublicKey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 4300; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_port = relay_listener.local_addr()?.port(); // Set up internal mock relay with 500 response override let mock_state = Arc::new(MockRelayState::new(chain, signer)); mock_state.set_response_override(StatusCode::INTERNAL_SERVER_ERROR); // 500 - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + let relays = vec![generate_mock_relay(relay_port, pubkey)?]; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Set retry limit to 3 let mut pbs_config = get_pbs_config(pbs_port); @@ -136,7 +150,10 @@ async fn test_register_validators_retries_on_500() -> Result<()> { let config = to_pbs_config(chain, pbs_config, relays); let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>( + state.clone(), + pbs_listener, + )); tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/tests/tests/pbs_submit_builder_preferences.rs b/tests/tests/pbs_submit_builder_preferences.rs new file mode 100644 index 000000000..5088890e3 --- /dev/null +++ b/tests/tests/pbs_submit_builder_preferences.rs @@ -0,0 +1,656 @@ +use cb_common::{ + pbs::{BuilderPreferences, BuilderPreferencesRequest, SignedRequestAuth}, + signer::random_secret, + types::Chain, + utils::utcnow_ms, + wire::{CONSENSUS_VERSION_HEADER, EncodingType}, +}; +use cb_tests::{ + mock_relay::MockRelayState, + utils::{ + TEST_AUTH_DATA, generate_mock_relay, generate_mock_relay_url_only, + generate_mock_relay_with_auth_data, opaque_auth, setup_relay, setup_relays, + setup_relays_with_auth_data, signed_auth, + }, +}; +use eyre::Result; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::Encode; +const TEST_MAX_EXECUTION_PAYMENT: u64 = 1_000_000_000; + +/// A slot comfortably ahead of now. Preferences name the proposal slot they +/// apply to, and one that has already ended is rejected, so tests cannot use a +/// fixed constant the way the bid tests do. +fn future_slot(chain: Chain) -> u64 { + let now_sec = utcnow_ms() / 1_000; + (now_sec.saturating_sub(chain.genesis_time_sec())) / chain.slot_time_sec() + 10 +} + +/// A slot that has already ended. Saturating: a chain whose genesis is under 10 +/// slots old would otherwise underflow rather than yield slot 0. +fn past_slot(chain: Chain) -> u64 { + let now_sec = utcnow_ms() / 1_000; + ((now_sec.saturating_sub(chain.genesis_time_sec())) / chain.slot_time_sec()).saturating_sub(10) +} + +fn preferences(auth: SignedRequestAuth, max_execution_payment: u64) -> BuilderPreferencesRequest { + BuilderPreferencesRequest { auth, preferences: BuilderPreferences { max_execution_payment } } +} + +/// The happy path: an SSZ submission reaches the builder as a 202, with the +/// preferences and the auth forwarded unchanged. +#[tokio::test] +async fn test_submit_builder_preferences() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let auth = opaque_auth(TEST_AUTH_DATA, future_slot(chain)); + let request = preferences(auth.clone(), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1); + assert_eq!(mock_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + + // The builder verifies what the proposer signed, so the auth must survive + // the hop byte for byte + let forwarded = mock_state.received_preferences_auth().expect("auth forwarded"); + assert_eq!(forwarded.message.data.to_vec(), TEST_AUTH_DATA.to_vec()); + assert_eq!(forwarded.message.slot, auth.message.slot); + assert_eq!(forwarded.signature, auth.signature); + + // A builder files preferences per proposer, so the wrong path segment would + // store them against the wrong validator + assert_eq!( + mock_state.received_preferences_pubkey(), + Some(mock_validator.comm_boost.pubkey().clone()), + "preferences must be filed under the proposer from the request path" + ); + Ok(()) +} + +/// The JSON wire form is the spec's, not merely whatever our own Serialize +/// produces: Gwei and slot are quoted strings and `data` is hex. Built from a +/// literal so that dropping the serde attributes fails here instead of +/// round-tripping through the same impl the assertion uses. +#[tokio::test] +async fn test_submit_builder_preferences_json_wire_form() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + let slot = future_slot(chain); + + let body = serde_json::json!({ + "preferences": { "max_execution_payment": TEST_MAX_EXECUTION_PAYMENT.to_string() }, + "auth": { + "message": { "data": "0xdead", "slot": slot.to_string() }, + "signature": format!("0x{}", "0".repeat(192)), + } + }); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, EncodingType::Json.content_type_header().clone()) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(serde_json::to_vec(&body)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED, "the spec's JSON wire form must be accepted"); + assert_eq!(mock_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + let forwarded = mock_state.received_preferences_auth().expect("auth forwarded"); + assert_eq!(forwarded.message.data.to_vec(), TEST_AUTH_DATA.to_vec()); + assert_eq!(forwarded.message.slot.as_u64(), slot); + Ok(()) +} + +/// Quotes are optional on decode by ecosystem convention, so a client sending +/// an unquoted Gwei number is still accepted even though the spec's wire form +/// is the quoted string. +#[tokio::test] +async fn test_submit_builder_preferences_unquoted_gwei_accepted() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let body = serde_json::json!({ + "preferences": { "max_execution_payment": TEST_MAX_EXECUTION_PAYMENT }, + "auth": { + "message": { "data": "0xdead", "slot": future_slot(chain).to_string() }, + "signature": format!("0x{}", "0".repeat(192)), + } + }); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, EncodingType::Json.content_type_header().clone()) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(serde_json::to_vec(&body)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + Ok(()) +} + +/// Only 202 means the builder committed to storing the preferences. Another 2xx +/// must not be reported to the proposer as acceptance. +#[tokio::test] +async fn test_submit_builder_preferences_non_202_success_is_failure() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + mock_state.set_response_override(StatusCode::OK); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_ne!(res.status(), StatusCode::ACCEPTED, "a 200 from the builder is not an acceptance"); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + Ok(()) +} + +/// A signature valid under one key submitted at another proposer's path must be +/// rejected: the auth is only meaningful bound to the pubkey it is filed under. +#[tokio::test] +async fn test_submit_builder_preferences_signature_bound_to_path_pubkey() -> Result<()> { + let chain = Chain::Hoodi; + let signer = random_secret(); + let other_pubkey = random_secret().public_key().into(); + let (mock_validator, mock_state) = + setup_relay(chain, |config| config.verify_request_auth = true, generate_mock_relay).await?; + + // Genuinely signed, just not by the proposer named in the path + let auth = signed_auth(&signer, TEST_AUTH_DATA, future_slot(chain), chain); + let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT); + let res = mock_validator + .do_submit_builder_preferences(Some(other_pubkey), &request, EncodingType::Ssz) + .await?; + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(mock_state.received_builder_preferences(), 0); + Ok(()) +} + +/// With no `Content-Type` the body is SSZ, which is this endpoint's documented +/// no-preference default and differs from the shared JSON default. The version +/// header still travels: an SSZ-default body requires it like explicit SSZ. +#[tokio::test] +async fn test_submit_builder_preferences_no_content_type_is_ssz() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header("Eth-Consensus-Version", "gloas") + .body(request.as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + Ok(()) +} + +/// An unsupported media type is a 415, distinct from the 400 a malformed body +/// of a supported type produces. +#[tokio::test] +async fn test_submit_builder_preferences_unsupported_media_type_415() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "text/plain") + .body("nonsense") + .send() + .await?; + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!(mock_state.received_builder_preferences(), 0); + Ok(()) +} + +/// A JSON submission is accepted too and decodes to the same values, including +/// the quoted-string Gwei on the JSON wire. +#[tokio::test] +async fn test_submit_builder_preferences_json() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Json).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + Ok(()) +} + +/// An SSZ submission missing `Eth-Consensus-Version` is a 400: builder-specs +/// fork-versions the request wire type, so the header is required to accept the +/// SSZ form (and the same submission with the header is a 202). +#[tokio::test] +async fn test_submit_builder_preferences_ssz_missing_version_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + // Note: SSZ Content-Type but no Eth-Consensus-Version + let res = mock_validator + .comm_boost + .client + .post(url.clone()) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(request.as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!( + mock_state.received_builder_preferences(), + 0, + "an undecodable request must not forward" + ); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + + // The identical submission carrying the literal spec header is accepted + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .header("Eth-Consensus-Version", "gloas") + .body(request.as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1); + Ok(()) +} + +/// builder-specs marks `Eth-Consensus-Version` required for JSON and SSZ alike +/// (builder-specs #165): a JSON submission without it is a 400. +#[tokio::test] +async fn test_submit_builder_preferences_json_no_version_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + // No Eth-Consensus-Version: required regardless of encoding + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, EncodingType::Json.content_type_header().clone()) + .body(serde_json::to_vec(&request)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("missing consensus version"), + "the 400 must name the missing header: {body}" + ); + assert_eq!(mock_state.received_builder_preferences(), 0, "rejected before any builder"); + Ok(()) +} + +/// Preferences naming a slot that has already ended are rejected before any +/// builder is contacted: a replay must not roll preferences back to a stale +/// value. +#[tokio::test] +async fn test_submit_builder_preferences_slot_passed_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, past_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert_eq!(body["message"], "Invalid SignedRequestAuth: auth.message.slot has already passed"); + assert_eq!(mock_state.received_builder_preferences(), 0, "no builder should be contacted"); + Ok(()) +} + +/// The body is required, exactly as on the bid endpoint. +#[tokio::test] +async fn test_submit_builder_preferences_missing_body_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + let res = mock_validator.comm_boost.client.post(url).send().await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_builder_preferences(), 0); + + // Distinguish the missing-body guard from the decode failure an empty SSZ + // body would otherwise produce, which is also a 400 + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert!( + body["message"].as_str().unwrap_or_default().contains("missing request body"), + "expected the missing-body error, got {}", + body["message"] + ); + Ok(()) +} + +/// Preferences addressed to a builder this PBS does not serve are rejected by +/// the demux, not blindly fanned out. +#[tokio::test] +async fn test_submit_builder_preferences_auth_data_mismatch_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay( + chain, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, TEST_AUTH_DATA), + ) + .await?; + + let request = + preferences(opaque_auth(&[0xbe, 0xef], future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_builder_preferences(), 0, "the demux must not fan out"); + Ok(()) +} + +/// Opaque data matching no relay is a 400 with the builder's data-mismatch +/// message even when a relay declares no `expected_auth_data`: unmatched means +/// CB has no builder to proxy to, and proposer-private preferences must never +/// broadcast to builders the proposer did not address. +#[tokio::test] +async fn test_submit_builder_preferences_unmatched_opaque_auth_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = + setup_relay(chain, |_| {}, generate_mock_relay_url_only).await?; + + let request = + preferences(opaque_auth(&[0xbe, 0xef], future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_builder_preferences(), 0, "no relay receives anything"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert_eq!( + body["message"], + "Invalid SignedRequestAuth: auth.message.data does not match the value agreed with this builder" + ); + Ok(()) +} + +/// Preferences addressed by matching auth data reach that builder. +#[tokio::test] +async fn test_submit_builder_preferences_auth_data_match() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay( + chain, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, TEST_AUTH_DATA), + ) + .await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1); + Ok(()) +} + +/// With verification on, an unsigned submission is a 401 and never reaches a +/// builder. +#[tokio::test] +async fn test_submit_builder_preferences_bad_signature_401() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = + setup_relay(chain, |config| config.verify_request_auth = true, generate_mock_relay).await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["message"], "Invalid SignedRequestAuth: signature verification failed"); + assert_eq!(mock_state.received_builder_preferences(), 0); + Ok(()) +} + +/// With verification on, a properly signed submission is accepted. +#[tokio::test] +async fn test_submit_builder_preferences_valid_signature() -> Result<()> { + let chain = Chain::Hoodi; + let secret = random_secret(); + let pubkey = secret.public_key().into(); + let (mock_validator, mock_state) = + setup_relay(chain, |config| config.verify_request_auth = true, generate_mock_relay).await?; + + let auth = signed_auth(&secret, TEST_AUTH_DATA, future_slot(chain), chain); + let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT); + let res = mock_validator + .do_submit_builder_preferences(Some(pubkey), &request, EncodingType::Ssz) + .await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1); + Ok(()) +} + +/// A builder that refuses the submission is surfaced as a failure rather than +/// reported as accepted. +#[tokio::test] +async fn test_submit_builder_preferences_lone_builder_400_propagates() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + mock_state.set_response_override(StatusCode::BAD_REQUEST); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_builder_preferences(), 1, "the builder was still asked"); + + // The builder's own body is untrusted and must not be relayed onward + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert_eq!(body["message"], "The addressed builder rejected the submission with status 400"); + Ok(()) +} + +/// A lone builder's 401 propagates too, so the spec's authentication failure is +/// reachable at all rather than always collapsing into the blanket failure. +#[tokio::test] +async fn test_submit_builder_preferences_lone_builder_401_propagates() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + mock_state.set_response_override(StatusCode::UNAUTHORIZED); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) +} + +/// A status the spec does not sanction for this endpoint is not passed through: +/// only the builder's 400 and 401 are meaningful to the proposer. The override +/// is 503, NOT 500, so passthrough and the NoBuilderResponse fallback produce +/// different codes and the assertion discriminates (a 500 override could not). +#[tokio::test] +async fn test_submit_builder_preferences_builder_503_is_500() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + mock_state.set_response_override(StatusCode::SERVICE_UNAVAILABLE); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + Ok(()) +} + +/// Two addressed builders both reject: with more than one addressed builder no +/// single verdict is unambiguous, so even a 400 (which passes through for a +/// lone builder) must NOT be handed back — the result is a blanket 500 +/// (`NoBuilderResponse`; 502 is not in the spec's declared response set). The +/// inverse of `lone_builder_400_propagates`, guarding the `relays.len() == 1` +/// condition in the route. +#[tokio::test] +async fn test_submit_builder_preferences_two_relays_all_reject_500() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + // Both reject with a 400: a lone builder's 400 propagates, so two of them + // must prove the multi-builder path collapses to the blanket 500 instead. + for state in &states { + state.set_response_override(StatusCode::BAD_REQUEST); + } + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!( + res.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "two rejecting builders collapse to 500, not a 400 passthrough" + ); + assert_eq!(states[0].received_builder_preferences(), 1, "each addressed builder is asked"); + assert_eq!(states[1].received_builder_preferences(), 1, "each addressed builder is asked"); + Ok(()) +} + +/// Two relays with distinct auth data: preferences addressing one must reach +/// only that builder, the other's received counter stays 0. +#[tokio::test] +async fn test_submit_builder_preferences_two_relays_addressed_one_only() -> Result<()> { + let chain = Chain::Hoodi; + let other_auth_data: &[u8] = &[0xbe, 0xef]; + let (mock_validator, states) = setup_relays_with_auth_data(chain, vec![ + (MockRelayState::new(chain, random_secret()), TEST_AUTH_DATA), + (MockRelayState::new(chain, random_secret()), other_auth_data), + ]) + .await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(states[0].received_builder_preferences(), 1, "the addressed builder is asked"); + assert_eq!(states[1].received_builder_preferences(), 0, "the unaddressed builder is not"); + Ok(()) +} + +/// Two builders behind the same auth data, one 202 and one 400: the documented +/// any-success policy makes the submission a 202. +#[tokio::test] +async fn test_submit_builder_preferences_two_relays_one_202_one_400_is_202() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays_with_auth_data(chain, vec![ + (MockRelayState::new(chain, random_secret()), TEST_AUTH_DATA), + (MockRelayState::new(chain, random_secret()), TEST_AUTH_DATA), + ]) + .await?; + + // The first rejects with a 400; the second accepts by default + states[0].set_response_override(StatusCode::BAD_REQUEST); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED, "any-success: one acceptance is a 202"); + assert_eq!(states[0].received_builder_preferences(), 1, "each addressed builder is asked"); + assert_eq!(states[1].received_builder_preferences(), 1, "each addressed builder is asked"); + Ok(()) +} + +/// Two addressed builders, one accepts and one rejects: they are separate +/// destinations, not replicas, so a single acceptance is a successful 202. +#[tokio::test] +async fn test_submit_builder_preferences_two_relays_one_accepts_202() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + // The first rejects; the second accepts by default + states[0].set_response_override(StatusCode::INTERNAL_SERVER_ERROR); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!( + res.status(), + StatusCode::ACCEPTED, + "one accepting builder makes the submission a success" + ); + assert_eq!(states[0].received_builder_preferences(), 1, "each addressed builder is asked"); + assert_eq!(states[1].received_builder_preferences(), 1, "each addressed builder is asked"); + Ok(()) +} diff --git a/tests/tests/pbs_submit_signed_beacon_block.rs b/tests/tests/pbs_submit_signed_beacon_block.rs new file mode 100644 index 000000000..c199fa665 --- /dev/null +++ b/tests/tests/pbs_submit_signed_beacon_block.rs @@ -0,0 +1,374 @@ +use alloy::primitives::B256; +use cb_common::{ + pbs::SignedBeaconBlock, + signer::random_secret, + types::Chain, + utils::TestRandomSeed, + wire::{CONSENSUS_VERSION_HEADER, EncodingType}, +}; +use cb_tests::{ + mock_relay::MockRelayState, + utils::{generate_mock_relay, setup_relay, setup_relays}, +}; +use eyre::Result; +use lh_types::{MainnetEthSpec, SignedBeaconBlockElectra, SignedBeaconBlockGloas, Slot}; +use reqwest::{ + StatusCode, + header::{CONTENT_TYPE, HeaderValue}, +}; +use ssz::Encode; + +const TEST_SLOT: u64 = 100; + +/// A fixed committed bid `block_hash` for building test blocks. +fn mock_bid_block_hash() -> B256 { + let mut hash = B256::ZERO; + hash.0[0] = 1; + hash +} + +/// A Gloas `SignedBeaconBlock` at `slot` whose committed bid names +/// `committed_block_hash`. Built from a random block with just those two fields +/// pinned. +fn gloas_block(slot: u64, committed_block_hash: B256) -> SignedBeaconBlock { + let mut block = SignedBeaconBlockGloas::::test_random(); + block.message.slot = Slot::new(slot); + block.message.body.signed_execution_payload_bid.message.block_hash = + committed_block_hash.into(); + SignedBeaconBlock::Gloas(block) +} + +/// The endpoint is stateless: the block is always broadcast to every configured +/// builder, and each relay's received counter increments. +#[tokio::test] +async fn test_submit_signed_beacon_block_broadcasts_to_all_relays() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let res = mock_validator.do_submit_signed_beacon_block(&block, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(states[0].received_signed_beacon_block(), 1, "every builder receives the block"); + assert_eq!(states[1].received_signed_beacon_block(), 1, "every builder receives the block"); + assert_eq!(states[0].received_block_slot(), Some(TEST_SLOT)); + assert_eq!(states[1].received_block_slot(), Some(TEST_SLOT)); + Ok(()) +} + +/// A valid SSZ submission is a 202 and reaches the builder decoded: the mock +/// recovers the same slot and committed bid hash from the forwarded SSZ. +#[tokio::test] +async fn test_submit_signed_beacon_block_ssz_202() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let committed = B256::repeat_byte(0x07); + let block = gloas_block(TEST_SLOT, committed); + let res = mock_validator.do_submit_signed_beacon_block(&block, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(state.received_signed_beacon_block(), 1); + assert_eq!(state.received_block_slot(), Some(TEST_SLOT)); + assert_eq!(state.received_block_committed_hash(), Some(committed)); + Ok(()) +} + +/// A JSON submission is accepted and decodes to the same block: CB re-encodes +/// it to SSZ for the builder, which recovers the committed hash unchanged. +#[tokio::test] +async fn test_submit_signed_beacon_block_json_202() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let committed = B256::repeat_byte(0x5a); + let block = gloas_block(TEST_SLOT, committed); + let res = mock_validator.do_submit_signed_beacon_block(&block, EncodingType::Json).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED, "the spec's JSON wire form must be accepted"); + assert_eq!(state.received_signed_beacon_block(), 1); + assert_eq!(state.received_block_committed_hash(), Some(committed)); + Ok(()) +} + +/// Pin the literal spec route: `POST /eth/v1/builder/beacon_blocks`. +#[tokio::test] +async fn test_submit_signed_beacon_block_spec_url() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, B256::repeat_byte(0x09)); + let url = format!("{}eth/v1/builder/beacon_blocks", mock_validator.comm_boost.config.entry.url); + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/octet-stream") + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(block.as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(state.received_signed_beacon_block(), 1); + Ok(()) +} + +/// A non-Gloas block, submitted as JSON under the (mandatory) gloas label, +/// is a 400 +/// the endpoint is Gloas-only per spec. +#[tokio::test] +async fn test_submit_signed_beacon_block_non_gloas_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + // gloas is the only header the wire layer admits, so a non-Gloas BLOCK can + // only reach the route's Gloas-only check as JSON. The untagged decode + // picks the FIRST field-matching variant - Electra (a Fulu body would also + // land on Electra; the two are JSON-identical) - and the route rejects it. + let block = + SignedBeaconBlock::Electra(SignedBeaconBlockElectra::::test_random()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(serde_json::to_vec(&block)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(state.received_signed_beacon_block(), 0, "a non-Gloas block must not be forwarded"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert!( + body["message"].as_str().unwrap_or_default().contains("Gloas"), + "the rejection must name the Gloas-only constraint, got: {}", + body["message"] + ); + Ok(()) +} + +/// builder-specs requires `Eth-Consensus-Version` on every body-carrying +/// request (spec PR #165, superseding the earlier best-effort policy): a JSON +/// body with no version header is a 400 and nothing is broadcast. +#[tokio::test] +async fn test_submit_signed_beacon_block_json_no_version_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + // No CONSENSUS_VERSION_HEADER: required regardless of encoding + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(serde_json::to_vec(&block)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("missing consensus version"), + "the 400 must name the missing header: {body}" + ); + assert_eq!(state.received_signed_beacon_block(), 0, "rejected before any broadcast"); + + // Present but unrecognized is also a 400, and the error names the VALUE + // rather than claiming the header is missing + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header(CONSENSUS_VERSION_HEADER, HeaderValue::from_static("futurefork")) + .body(serde_json::to_vec(&block)?) + .send() + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("futurefork"), + "the 400 must echo the unrecognized value: {body}" + ); + assert_eq!(state.received_signed_beacon_block(), 0); + Ok(()) +} + +/// The block is broadcast; if every builder rejects it, PBS maps the all-reject +/// outcome to a 500 (no builder accepted). +#[tokio::test] +async fn test_submit_signed_beacon_block_broadcast_all_reject_500() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + // Make every builder reject the broadcast + for state in &states { + state.set_response_override(StatusCode::INTERNAL_SERVER_ERROR); + } + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let res = mock_validator.do_submit_signed_beacon_block(&block, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(states[0].received_signed_beacon_block(), 1, "every builder is asked"); + assert_eq!(states[1].received_signed_beacon_block(), 1, "every builder is asked"); + Ok(()) +} + +/// A broadcast where one builder accepts and the other rejects is still a 202: +/// one acceptance across the broadcast is success. This is the core of the +/// stateless broadcast model, distinct from the all-accept and all-reject +/// extremes the other tests cover. +#[tokio::test] +async fn test_submit_signed_beacon_block_broadcast_one_accepts_202() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + // Only the second builder accepts; the first rejects. PBS must still 202. + states[0].set_response_override(StatusCode::INTERNAL_SERVER_ERROR); + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let res = mock_validator.do_submit_signed_beacon_block(&block, EncodingType::Ssz).await?; + + assert_eq!( + res.status(), + StatusCode::ACCEPTED, + "one accepting builder makes the broadcast a success" + ); + assert_eq!(states[0].received_signed_beacon_block(), 1, "every builder is asked"); + assert_eq!(states[1].received_signed_beacon_block(), 1, "every builder is asked"); + Ok(()) +} + +/// gloas is the only value the wire layer admits on these post-fork endpoints: +/// a Gloas JSON block labelled `fulu` is rejected with a 400 naming the value, +/// like every other deprecated fork. +#[tokio::test] +async fn test_submit_signed_beacon_block_fulu_label_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header(CONSENSUS_VERSION_HEADER, "fulu") + .body(serde_json::to_vec(&block)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("fulu"), + "the 400 must echo the unsupported value: {body}" + ); + assert_eq!(state.received_signed_beacon_block(), 0, "nothing is broadcast"); + Ok(()) +} + +/// An unsupported request `Content-Type` is a 415, distinct from the 400 a +/// malformed body of a supported type produces. Mirrors the preferences and bid +/// endpoints. +#[tokio::test] +async fn test_submit_signed_beacon_block_unsupported_content_type_415() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "text/plain") + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body("nonsense") + .send() + .await?; + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!( + state.received_signed_beacon_block(), + 0, + "an unsupported media type must not be forwarded" + ); + Ok(()) +} + +/// An SSZ submission missing `Eth-Consensus-Version` is a 400: the SSZ block is +/// not self-describing, so the fork header is required to select the variant +/// (and the spec mandates the header regardless of encoding). +#[tokio::test] +async fn test_submit_signed_beacon_block_ssz_missing_version_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + // Note: SSZ Content-Type but no CONSENSUS_VERSION_HEADER + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(block.as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(state.received_signed_beacon_block(), 0, "an undecodable request must not forward"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + Ok(()) +} + +/// A submission with no body is a 400 (`MissingBody`), distinguished from the +/// decode failure an empty SSZ body would otherwise produce. +#[tokio::test] +async fn test_submit_signed_beacon_block_missing_body_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + // A version header, but no body at all + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(state.received_signed_beacon_block(), 0); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert!( + body["message"].as_str().unwrap_or_default().contains("missing request body"), + "expected the missing-body error, got {}", + body["message"] + ); + Ok(()) +} diff --git a/tests/tests/signer_jwt_auth.rs b/tests/tests/signer_jwt_auth.rs index d1b65b3f7..61cd6defc 100644 --- a/tests/tests/signer_jwt_auth.rs +++ b/tests/tests/signer_jwt_auth.rs @@ -12,7 +12,7 @@ use cb_common::{ }; use cb_tests::{ signer_service::{start_server, verify_pubkeys}, - utils::{self, setup_test_env}, + utils::{self, get_free_listener, setup_test_env}, }; use eyre::Result; use reqwest::StatusCode; @@ -41,7 +41,8 @@ async fn test_signer_jwt_auth_success() -> Result<()> { setup_test_env(); let module_id = ModuleId(JWT_MODULE.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20100, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); // Run a pubkeys request @@ -61,7 +62,8 @@ async fn test_signer_jwt_auth_fail() -> Result<()> { setup_test_env(); let module_id = ModuleId(JWT_MODULE.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20101, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; // Run a pubkeys request - this should fail due to invalid JWT let jwt = create_jwt(&module_id, "incorrect secret", GET_PUBKEYS_PATH, None)?; @@ -82,7 +84,8 @@ async fn test_signer_jwt_rate_limit() -> Result<()> { setup_test_env(); let module_id = ModuleId(JWT_MODULE.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20102, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; let mod_cfg = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); // Run as many pubkeys requests as the fail limit @@ -116,7 +119,8 @@ async fn test_signer_revoked_jwt_fail() -> Result<()> { let admin_secret = ADMIN_SECRET.to_string(); let module_id = ModuleId(JWT_MODULE.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20400, &mod_cfgs, admin_secret.clone(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, admin_secret.clone(), false).await?; // Run as many pubkeys requests as the fail limit let jwt = create_jwt(&module_id, JWT_SECRET, GET_PUBKEYS_PATH, None)?; @@ -149,7 +153,8 @@ async fn test_signer_only_admin_can_revoke() -> Result<()> { let admin_secret = ADMIN_SECRET.to_string(); let module_id = ModuleId(JWT_MODULE.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20500, &mod_cfgs, admin_secret.clone(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, admin_secret.clone(), false).await?; let revoke_body = RevokeModuleRequest { module_id: ModuleId(JWT_MODULE.to_string()) }; let body_bytes = serde_json::to_vec(&revoke_body)?; @@ -177,7 +182,8 @@ async fn test_signer_admin_jwt_rate_limit() -> Result<()> { let admin_secret = ADMIN_SECRET.to_string(); let module_id = ModuleId(JWT_MODULE.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20510, &mod_cfgs, admin_secret.clone(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, admin_secret.clone(), false).await?; let revoke_body = RevokeModuleRequest { module_id: ModuleId(JWT_MODULE.to_string()) }; let body_bytes = serde_json::to_vec(&revoke_body)?; diff --git a/tests/tests/signer_jwt_auth_cleanup.rs b/tests/tests/signer_jwt_auth_cleanup.rs index d6fde2a43..09f64721d 100644 --- a/tests/tests/signer_jwt_auth_cleanup.rs +++ b/tests/tests/signer_jwt_auth_cleanup.rs @@ -9,7 +9,7 @@ use cb_common::{ }; use cb_tests::{ signer_service::start_server, - utils::{self}, + utils::{self, get_free_listener}, }; use eyre::Result; use reqwest::StatusCode; @@ -38,7 +38,8 @@ async fn test_signer_jwt_fail_cleanup() -> Result<()> { // setup_test_env() isn't used because we want to capture logs with tracing_test let module_id = ModuleId(JWT_MODULE.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20102, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; let mod_cfg = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); // Run as many pubkeys requests as the fail limit diff --git a/tests/tests/signer_request_sig.rs b/tests/tests/signer_request_sig.rs index 78efbf9e8..a60416c4c 100644 --- a/tests/tests/signer_request_sig.rs +++ b/tests/tests/signer_request_sig.rs @@ -12,7 +12,7 @@ use cb_common::{ }; use cb_tests::{ signer_service::start_server, - utils::{self, setup_test_env}, + utils::{self, get_free_listener, setup_test_env}, }; use eyre::Result; use reqwest::StatusCode; @@ -53,7 +53,8 @@ async fn test_signer_sign_request_good() -> Result<()> { setup_test_env(); let module_id = ModuleId(MODULE_ID_1.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20200, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); // Send a signing request @@ -96,7 +97,8 @@ async fn test_signer_sign_request_different_module() -> Result<()> { setup_test_env(); let module_id = ModuleId(MODULE_ID_2.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20201, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for 2nd test module not found"); // Send a signing request @@ -142,7 +144,8 @@ async fn test_signer_sign_request_incorrect_hash() -> Result<()> { setup_test_env(); let module_id = ModuleId(MODULE_ID_2.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20202, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for 2nd test module not found"); // Send a signing request @@ -178,7 +181,8 @@ async fn test_signer_sign_request_missing_hash() -> Result<()> { setup_test_env(); let module_id = ModuleId(MODULE_ID_2.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20203, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for 2nd test module not found"); // Send a signing request diff --git a/tests/tests/signer_tls.rs b/tests/tests/signer_tls.rs index 2df98d73a..718100eba 100644 --- a/tests/tests/signer_tls.rs +++ b/tests/tests/signer_tls.rs @@ -9,7 +9,7 @@ use cb_common::{ }; use cb_tests::{ signer_service::{start_server, verify_pubkeys}, - utils::{self, setup_test_env}, + utils::{self, get_free_listener, setup_test_env}, }; use eyre::{Result, bail}; use reqwest::Certificate; @@ -37,7 +37,8 @@ async fn test_signer_tls() -> Result<()> { setup_test_env(); let module_id = ModuleId(JWT_MODULE.to_string()); let mod_cfgs = create_mod_signing_configs().await; - let start_config = start_server(20100, &mod_cfgs, ADMIN_SECRET.to_string(), true).await?; + let start_config = + start_server(get_free_listener().await, &mod_cfgs, ADMIN_SECRET.to_string(), true).await?; let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); // Run a pubkeys request From a07bcac6dd1a66277b8bce9fc0e25e8be019bfc1 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 18 Aug 2026 12:07:22 -0700 Subject: [PATCH 02/80] fix(pbs): order BuilderPreferencesRequest SSZ fields as (preferences, auth) builder-specs types/gloas/builder_preferences.yaml declares the container as [preferences, auth]; the struct had them reversed, so its SSZ encoding and tree-hash did not match the spec. Swap the fields and re-pin the spec-vector test to the canonical examples/gloas/builder_preferences_request.ssz fixed-part bytes. --- crates/common/src/pbs/types/mod.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index fec7c9308..ceffab7bc 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -223,11 +223,12 @@ pub struct BuilderPreferences { } /// The `submitBuilderPreferences` request body. -/// SSZ field order per builder-specs `gloas/validator.md` +/// SSZ field order `(preferences, auth)` per builder-specs +/// `types/gloas/builder_preferences.yaml`. #[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] pub struct BuilderPreferencesRequest { - pub auth: SignedRequestAuth, pub preferences: BuilderPreferences, + pub auth: SignedRequestAuth, } /// Path params for `POST /eth/v1/builder/builder_preferences/{proposer_pubkey}` @@ -275,7 +276,9 @@ mod tests { } /// Spec vector for the SSZ layout of `BuilderPreferencesRequest`: - /// `(auth, preferences)` per specs/gloas/validator.md + /// `(preferences, auth)` per builder-specs `types/gloas/builder_preferences.yaml`. + /// The order-determining fixed part is cross-checked byte-for-byte against + /// the canonical example `examples/gloas/builder_preferences_request.ssz`. #[test] fn test_builder_preferences_request_ssz_spec_vector() { use ssz::{Decode, Encode}; @@ -293,21 +296,28 @@ mod tests { signature: BlsSignature::deserialize(&infinity_sig).unwrap(), }; let request = BuilderPreferencesRequest { - auth: auth.clone(), preferences: BuilderPreferences { max_execution_payment: 1_000_000_000 }, + auth: auth.clone(), }; - // Hand-assembled outer container: 4-byte offset to the variable-size - // `auth` (12 = 4-byte offset + 8-byte fixed `preferences`), the 8-byte - // `max_execution_payment` LE, then the `auth` bytes + // Outer container is `(preferences, auth)`: the 8-byte fixed + // `max_execution_payment` LE, then a 4-byte offset to the variable-size + // `auth` (12 = 8-byte fixed `preferences` + 4-byte offset), then `auth`. let auth_bytes = auth.as_ssz_bytes(); let mut expected = Vec::new(); - expected.extend_from_slice(&12u32.to_le_bytes()); expected.extend_from_slice(&1_000_000_000u64.to_le_bytes()); + expected.extend_from_slice(&12u32.to_le_bytes()); expected.extend_from_slice(&auth_bytes); assert_eq!(request.as_ssz_bytes(), expected); + // The fixed part matches the canonical spec example byte-for-byte: + // `max_execution_payment` (1_000_000_000 Gwei LE) precedes the `auth` + // offset (12). A `(auth, preferences)` layout would put the offset first. + assert_eq!(&request.as_ssz_bytes()[..12], &[ + 0x00, 0xca, 0x9a, 0x3b, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, + ]); + let decoded = BuilderPreferencesRequest::from_ssz_bytes(&expected).unwrap(); assert_eq!(decoded.preferences.max_execution_payment, 1_000_000_000); assert_eq!(decoded.auth.message.slot, Slot::new(1234)); From 27f8dead907accd147ae67389f4552f0fd537992 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 18 Aug 2026 12:07:22 -0700 Subject: [PATCH 03/80] fix(pbs): reject empty auth.message.data with 400 A zero-length auth.message.data is invalid per builder-specs types/gloas/request_auth.yaml (pattern {1,4096}) and addresses no builder. It was only caught incidentally by the relay demux and could slip through a catch-all relay match. Add a shared validate_auth_data check (EmptyAuthData -> 400) run first in both the bid and preferences auth validators. --- crates/pbs/src/error.rs | 6 +++ crates/pbs/src/routes/builder_preferences.rs | 34 +++++++++++++-- .../pbs/src/routes/execution_payload_bid.rs | 42 +++++++++++++++---- crates/pbs/src/utils.rs | 33 +++++++++++++++ 4 files changed, 105 insertions(+), 10 deletions(-) diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index 7957d37be..b4be01cd9 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -29,6 +29,8 @@ pub enum PbsClientError { NoBuilderResponse, #[error("auth data does not match a configured builder")] AuthDataMismatch, + #[error("auth data is empty")] + EmptyAuthData, #[error("missing or invalid timing headers")] MissingTimingHeader, #[error("auth slot does not match the request path")] @@ -57,6 +59,7 @@ impl PbsClientError { PbsClientError::NoResponse => StatusCode::BAD_GATEWAY, PbsClientError::NoBuilderResponse => StatusCode::INTERNAL_SERVER_ERROR, PbsClientError::AuthDataMismatch => StatusCode::BAD_REQUEST, + PbsClientError::EmptyAuthData => StatusCode::BAD_REQUEST, PbsClientError::MissingTimingHeader => StatusCode::BAD_REQUEST, PbsClientError::AuthSlotMismatch => StatusCode::BAD_REQUEST, PbsClientError::AuthSlotPassed => StatusCode::BAD_REQUEST, @@ -88,6 +91,9 @@ impl IntoResponse for PbsClientError { PbsClientError::AuthDataMismatch => { "Invalid SignedRequestAuth: auth.message.data does not match the value agreed with this builder".to_string() } + PbsClientError::EmptyAuthData => { + "Invalid SignedRequestAuth: auth.message.data must not be empty".to_string() + } PbsClientError::MissingTimingHeader => { "Invalid request: Date-Milliseconds and X-Timeout-Ms headers are required".to_string() } diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index 4d37c7d15..fd83fb686 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -28,7 +28,7 @@ use crate::{ state::{BuilderApiState, PbsState}, utils::{ epbs_base_send_headers, expect_status, match_relays_by_auth_data, record_client_error, - send_to_relay, verify_auth_signature, + send_to_relay, validate_auth_data, verify_auth_signature, }, }; @@ -173,14 +173,16 @@ pub async fn submit_builder_preferences( /// request path here, so instead of matching one we reject a slot that has /// already ended: preferences are submitted an epoch ahead, and a replayed /// submission must not be able to roll a proposer's preferences back to a stale -/// value. The `auth.message.data` check is the demux's job -/// (`match_relays_by_auth_data`). +/// value. `auth.message.data` must be non-empty; which builder it addresses is +/// the demux's job (`match_relays_by_auth_data`). fn validate_preferences_auth( auth: &SignedRequestAuth, params: &SubmitBuilderPreferencesParams, chain: Chain, verify_signature: bool, ) -> Result<(), PbsClientError> { + validate_auth_data(auth)?; + if slot_has_passed(auth.message.slot.as_u64(), chain) { warn!(auth_slot = %auth.message.slot, "auth slot already passed"); return Err(PbsClientError::AuthSlotPassed); @@ -273,6 +275,32 @@ mod tests { assert!(slot_has_passed(now - 1, chain)); } + // Empty `auth.message.data` is rejected before slot_has_passed / sigverify, + // so it cannot slip through a catch-all relay match. Guards the wiring of + // the shared `validate_auth_data` into this endpoint. + #[test] + fn validate_preferences_auth_rejects_empty_data() { + use cb_common::types::BlsSecretKey; + + let chain = Chain::Hoodi; + let params = SubmitBuilderPreferencesParams { + proposer_pubkey: BlsSecretKey::random().public_key(), + }; + let empty = SignedRequestAuth { + message: RequestAuth { + data: Default::default(), + slot: lh_types::Slot::new(current_slot(chain)), + }, + signature: BlsSignature::empty(), + }; + for verify in [false, true] { + assert!(matches!( + validate_preferences_auth(&empty, ¶ms, chain, verify), + Err(PbsClientError::EmptyAuthData) + )); + } + } + #[test] fn decode_rejects_an_empty_body() { let err = decode_versioned_request_body::( diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index fe0b39539..7a1082c55 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -51,7 +51,7 @@ use crate::{ state::{BuilderApiState, PbsState}, utils::{ check_gas_limit, epbs_base_send_headers, match_relays_by_auth_data, record_client_error, - send_to_relay, verify_auth_signature, + send_to_relay, validate_auth_data, verify_auth_signature, }, }; @@ -366,16 +366,18 @@ fn request_budget_ms(req_headers: &HeaderMap, now_ms: u64) -> Result Result<(), PbsClientError> { + validate_auth_data(auth)?; + if auth.message.slot.as_u64() != params.slot { warn!(auth_slot = %auth.message.slot, path_slot = params.slot, "auth slot mismatch"); return Err(PbsClientError::AuthSlotMismatch); @@ -1064,12 +1066,38 @@ mod tests { fn test_auth(slot: u64, signature: BlsSignature) -> SignedRequestAuth { SignedRequestAuth { - // `data` is the demux's input, not this validator's: it is unused here - message: RequestAuth { data: Default::default(), slot: Slot::new(slot) }, + // Non-empty so it clears the empty-data guard; the value itself is the + // demux's input, exercised elsewhere, not this validator's slot/sig path + message: RequestAuth { data: vec![0x01].try_into().unwrap(), slot: Slot::new(slot) }, signature, } } + // Empty `auth.message.data` is rejected before the slot/sig checks, so it + // cannot slip through a catch-all relay match. Guards the wiring of the + // shared `validate_auth_data` into this endpoint. + #[test] + fn validate_request_auth_rejects_empty_data() { + let chain = Chain::Hoodi; + let slot = 5; + let params = GetExecutionPayloadBidParams { + slot, + parent_hash: B256::ZERO, + parent_root: B256::ZERO, + proposer_pubkey: BlsSecretKey::test_random().public_key(), + }; + let empty = SignedRequestAuth { + message: RequestAuth { data: Default::default(), slot: Slot::new(slot) }, + signature: BlsSignature::empty(), + }; + for verify in [false, true] { + assert!(matches!( + validate_request_auth(&empty, ¶ms, chain, verify), + Err(PbsClientError::EmptyAuthData) + )); + } + } + // An empty body is as invalid as a malformed one: the spec requires the auth #[test] fn test_decode_request_auth_rejects_empty_body() { diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index f488af05f..cc003dd3a 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -129,6 +129,20 @@ pub(crate) fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { true } +/// A zero-length `auth.message.data` is invalid per builder-specs +/// `types/gloas/request_auth.yaml` (pattern `{1,4096}`, "A zero-length `data` +/// is invalid"). It addresses no builder, so it must be rejected up front +/// rather than slip through a catch-all relay match in +/// [`match_relays_by_auth_data`]. Shared by both ePBS request-auth validators. +pub(crate) fn validate_auth_data(auth: &SignedRequestAuth) -> Result<(), PbsClientError> { + if auth.message.data.is_empty() { + warn!("auth data is empty"); + return Err(PbsClientError::EmptyAuthData); + } + + Ok(()) +} + /// Verifies the request auth signature when `verify_signature` is on. The /// downstream builder verifies it regardless, which is why the crypto is /// opt-in. Shared by the request-auth validators of both ePBS endpoints; the @@ -251,6 +265,25 @@ mod tests { assert!(match_relays_by_auth_data(&relays, &[]).is_empty()); } + #[test] + fn validate_auth_data_requires_nonempty_data() { + use cb_common::{pbs::RequestAuth, types::BlsSignature}; + use lh_types::Slot; + + let with_data = |data: Vec| SignedRequestAuth { + message: RequestAuth { data: data.try_into().unwrap(), slot: Slot::new(1) }, + signature: BlsSignature::empty(), + }; + + // Zero-length data is invalid per builder-specs request_auth.yaml ({1,4096}) + assert!(matches!( + validate_auth_data(&with_data(vec![])), + Err(PbsClientError::EmptyAuthData) + )); + // A single byte clears the guard + assert!(validate_auth_data(&with_data(vec![0xaa])).is_ok()); + } + #[test] fn match_relays_prefers_configured_auth_data() { let relays = vec![ From 9dfa5a45b07a70a961250821473544b7b5f4a610 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 18 Aug 2026 17:05:05 -0700 Subject: [PATCH 04/80] chore(deps): bump lighthouse v8.1.3 -> v8.2.2 for spec-correct gloas types --- Cargo.lock | 214 +++++------------- Cargo.toml | 9 +- crates/common/Cargo.toml | 1 + crates/common/src/config/utils.rs | 7 +- crates/common/src/pbs/types/mod.rs | 11 +- crates/common/src/signature.rs | 4 +- crates/common/src/utils.rs | 15 +- crates/pbs/src/mev_boost/get_header.rs | 2 +- crates/pbs/src/routes/builder_preferences.rs | 5 +- .../pbs/src/routes/execution_payload_bid.rs | 11 +- tests/tests/pbs_submit_builder_preferences.rs | 4 +- 11 files changed, 97 insertions(+), 186 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f2b668456..ac45a39aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,6 +365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f007e257069855bdf21d27762fd3f3705a613f805c9a08309bf353503f081d71" dependencies = [ "alloy-rlp", + "arbitrary", "bytes", "cfg-if", "const-hex", @@ -379,6 +380,7 @@ dependencies = [ "keccak-asm", "paste", "proptest", + "proptest-derive", "rand 0.9.5", "rapidhash", "ruint", @@ -1636,7 +1638,7 @@ dependencies = [ [[package]] name = "bls" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "alloy-primitives 1.6.1", "arbitrary", @@ -1867,6 +1869,7 @@ version = "0.11.0" dependencies = [ "aes", "alloy", + "arbitrary", "async-trait", "axum 0.8.9", "base64 0.22.1", @@ -1914,7 +1917,7 @@ dependencies = [ "types", "unicode-normalization", "url", - "uuid 1.24.0", + "uuid", ] [[package]] @@ -1961,7 +1964,7 @@ dependencies = [ "tree_hash", "types", "url", - "uuid 1.24.0", + "uuid", "webpki-roots 1.0.9", ] @@ -1993,7 +1996,7 @@ dependencies = [ "tonic-build", "tracing", "tree_hash", - "uuid 1.24.0", + "uuid", ] [[package]] @@ -3030,7 +3033,7 @@ dependencies = [ [[package]] name = "eth2" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "bls", "context_deserialize", @@ -3055,20 +3058,20 @@ dependencies = [ [[package]] name = "eth2_interop_keypairs" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "bls", "ethereum_hashing", "hex", "num-bigint", "serde", - "serde_yaml", + "yaml_serde", ] [[package]] name = "eth2_key_derivation" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "bls", "num-bigint-dig", @@ -3080,7 +3083,7 @@ dependencies = [ [[package]] name = "eth2_keystore" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "aes", "bls", @@ -3097,7 +3100,7 @@ dependencies = [ "serde_repr", "sha2", "unicode-normalization", - "uuid 0.8.2", + "uuid", "zeroize", ] @@ -3145,6 +3148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e462875ad8693755ea8913d6e905715c76ea4836e2254e18c9cf0f7a8f8c2a13" dependencies = [ "alloy-primitives 1.6.1", + "arbitrary", "context_deserialize", "ethereum_serde_utils 0.8.1", "itertools 0.14.0", @@ -3257,7 +3261,7 @@ dependencies = [ [[package]] name = "fixed_bytes" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "alloy-primitives 1.6.1", "safe_arith", @@ -3296,21 +3300,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3878,22 +3867,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.11.0", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -4095,6 +4068,7 @@ version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ + "arbitrary", "equivalent", "hashbrown 0.17.1", "serde", @@ -4133,7 +4107,7 @@ dependencies = [ [[package]] name = "int_to_bytes" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "bytes", ] @@ -4373,10 +4347,9 @@ dependencies = [ [[package]] name = "kzg" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "arbitrary", - "c-kzg", "educe", "ethereum_hashing", "ethereum_serde_utils 0.8.1", @@ -4412,6 +4385,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4519,7 +4498,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "merkle_proof" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "alloy-primitives 1.6.1", "ethereum_hashing", @@ -4569,6 +4548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "259dd9da2ae5e0278b95da0b7ecef9c18c309d0a2d9e6db57ed33b9e8910c5e7" dependencies = [ "alloy-primitives 1.6.1", + "arbitrary", "context_deserialize", "educe", "ethereum_hashing", @@ -4624,23 +4604,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nom" version = "7.1.3" @@ -4827,59 +4790,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-src" -version = "300.6.1+3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - [[package]] name = "owo-colors" version = "4.3.0" @@ -5159,7 +5075,7 @@ dependencies = [ [[package]] name = "pretty_reqwest_error" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "reqwest 0.12.28", "sensitive_url", @@ -5260,6 +5176,17 @@ dependencies = [ "unarray", ] +[[package]] +name = "proptest-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57924a81864dddafba92e1bf92f9bf82f97096c44489548a60e888e1547549b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "prost" version = "0.13.5" @@ -5688,11 +5615,9 @@ dependencies = [ "http-body-util", "hyper 1.11.0", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -5703,7 +5628,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower 0.5.3", @@ -5842,6 +5766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" dependencies = [ "alloy-rlp", + "arbitrary", "ark-ff 0.3.0", "ark-ff 0.4.2", "ark-ff 0.5.0", @@ -6510,7 +6435,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ - "arbitrary", "serde", ] @@ -6556,6 +6480,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d625e4de8e0057eefe7e0b1510ba1dd7adf10cd375fad6cc7fcceac7c39623c9" dependencies = [ + "arbitrary", "context_deserialize", "educe", "ethereum_serde_utils 0.8.1", @@ -6647,7 +6572,7 @@ dependencies = [ [[package]] name = "swap_or_not_shuffle" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "alloy-primitives 1.6.1", "ethereum_hashing", @@ -6798,15 +6723,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" -[[package]] -name = "test_random_derive" -version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" -dependencies = [ - "quote", - "syn 2.0.119", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -6987,16 +6903,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7421,10 +7327,11 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "types" version = "0.2.1" -source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" +source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" dependencies = [ "alloy-primitives 1.6.1", "alloy-rlp", + "arbitrary", "bls", "compare_fields", "context_deserialize", @@ -7437,13 +7344,14 @@ dependencies = [ "fixed_bytes", "hex", "int_to_bytes", - "itertools 0.10.5", + "itertools 0.14.0", "kzg", "maplit", "merkle_proof", "metastruct", "milhouse", "parking_lot", + "paste", "rand 0.9.5", "rand_xorshift 0.4.0", "rayon", @@ -7452,17 +7360,16 @@ dependencies = [ "safe_arith", "serde", "serde_json", - "serde_yaml", "smallvec", "ssz_types", "superstruct", "swap_or_not_shuffle", "tempfile", - "test_random_derive", "tracing", "tree_hash", "tree_hash_derive", "typenum", + "yaml_serde", ] [[package]] @@ -7571,16 +7478,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.17", - "serde", -] - [[package]] name = "uuid" version = "1.24.0" @@ -7600,12 +7497,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "vec_map" version = "0.8.2" @@ -8216,6 +8107,19 @@ dependencies = [ "tap", ] +[[package]] +name = "yaml_serde" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yasna" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 9ffd37754..cc5b92290 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ const_format = "0.2.34" ctr = "0.9.2" derive_more = { version = "2.0.1", features = ["deref", "display", "from", "into"] } docker-compose-types = "0.16.0" +arbitrary = "1.4" docker-image = "0.2.1" ethereum_serde_utils = "0.7.0" ethereum_ssz = "0.10" @@ -53,10 +54,10 @@ indexmap = "2.2.6" jsonwebtoken = { version = "9.3.1", default-features = false } lazy_static = "1.5.0" mediatype = "0.20.0" -lh_eth2 = { package = "eth2", git = "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/sigp/lighthouse", tag = "v8.1.3", features = ["events"] } -lh_eth2_keystore = { package = "eth2_keystore", git = "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/sigp/lighthouse", tag = "v8.1.3" } -lh_bls = { package = "bls", git = "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/sigp/lighthouse", tag = "v8.1.3" } -lh_types = { package = "types", git = "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/sigp/lighthouse", tag = "v8.1.3" } +lh_eth2 = { package = "eth2", git = "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/sigp/lighthouse", tag = "v8.2.2", features = ["events"] } +lh_eth2_keystore = { package = "eth2_keystore", git = "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/sigp/lighthouse", tag = "v8.2.2" } +lh_bls = { package = "bls", git = "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/sigp/lighthouse", tag = "v8.2.2", features = ["arbitrary"] } +lh_types = { package = "types", git = "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/sigp/lighthouse", tag = "v8.2.2", features = ["arbitrary"] } notify = "8.2.0" parking_lot = "0.12.3" pbkdf2 = "0.12.2" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index affcba71b..11687a05c 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -11,6 +11,7 @@ testing-flags = [] [dependencies] aes.workspace = true alloy.workspace = true +arbitrary.workspace = true async-trait.workspace = true axum.workspace = true base64.workspace = true diff --git a/crates/common/src/config/utils.rs b/crates/common/src/config/utils.rs index 9bcf1595d..5e52dc8fe 100644 --- a/crates/common/src/config/utils.rs +++ b/crates/common/src/config/utils.rs @@ -74,7 +74,6 @@ mod tests { use std::sync::Mutex; use super::*; - use crate::utils::TestRandomSeed; // Serializes all tests that read/write environment variables. // std::env::set_var is unsafe (Rust 1.81+) because mutating `environ` @@ -135,8 +134,10 @@ mod tests { #[test] fn test_remove_duplicate_keys() { - let key1 = BlsPublicKey::test_random(); - let key2 = BlsPublicKey::test_random(); + // Real, distinct keys: `arbitrary` for a validated point falls back to + // the (invalid) all-zeros pubkey, so derive from random secret keys. + let key1 = crate::types::BlsSecretKey::random().public_key(); + let key2 = crate::types::BlsSecretKey::random().public_key(); let keys = vec![key1.clone(), key2.clone(), key1.clone()]; let unique_keys = remove_duplicate_keys(keys); diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index ceffab7bc..70d396445 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -67,8 +67,8 @@ pub struct GetHeaderParams { pub pubkey: BlsPublicKey, } -pub type ExecutionPayloadBid = lh_types::ExecutionPayloadBid; -pub type SignedExecutionPayloadBid = lh_types::SignedExecutionPayloadBid; +pub type ExecutionPayloadBid = lh_types::ExecutionPayloadBid; +pub type SignedExecutionPayloadBid = lh_types::SignedExecutionPayloadBid; /// Whether `block` is a Gloas block. The submit endpoint is Gloas-only per /// spec. @@ -276,9 +276,10 @@ mod tests { } /// Spec vector for the SSZ layout of `BuilderPreferencesRequest`: - /// `(preferences, auth)` per builder-specs `types/gloas/builder_preferences.yaml`. - /// The order-determining fixed part is cross-checked byte-for-byte against - /// the canonical example `examples/gloas/builder_preferences_request.ssz`. + /// `(preferences, auth)` per builder-specs + /// `types/gloas/builder_preferences.yaml`. The order-determining fixed + /// part is cross-checked byte-for-byte against the canonical example + /// `examples/gloas/builder_preferences_request.ssz`. #[test] fn test_builder_preferences_request_ssz_spec_vector() { use ssz::{Decode, Encode}; diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index 946fe664d..56bd0b272 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -275,7 +275,7 @@ mod tests { #[test] fn test_builder_bid_sign_and_verify() { - let secret_key = BlsSecretKey::test_random(); + let secret_key = BlsSecretKey::random(); let pubkey = secret_key.public_key(); let message = BuilderBid::Electra(BuilderBidElectra { @@ -300,7 +300,7 @@ mod tests { #[test] fn test_blinded_block_sign_and_verify() { - let secret_key = BlsSecretKey::test_random(); + let secret_key = BlsSecretKey::random(); let pubkey = secret_key.public_key(); let block = BlindedBeaconBlockElectra::test_random(); diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 1632cf32d..659a4cb55 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -7,8 +7,7 @@ use alloy::{ hex, primitives::{U256, keccak256}, }; -use lh_types::test_utils::{SeedableRng, TestRandom, XorShiftRng}; -use rand::{Rng, distr::Alphanumeric}; +use rand::{Rng, RngCore, distr::Alphanumeric}; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use tracing::Level; @@ -430,17 +429,21 @@ pub async fn wait_for_signal() -> eyre::Result<()> { Ok(()) } -pub trait TestRandomSeed: TestRandom { +// lighthouse v8.2.x replaced the `TestRandom` trait with an `arbitrary`-based +// generator; build test instances from OS entropy so each call differs. +pub trait TestRandomSeed: for<'a> arbitrary::Arbitrary<'a> { fn test_random() -> Self where Self: Sized, { - let mut rng = XorShiftRng::from_os_rng(); - Self::random_for_test(&mut rng) + let mut bytes = vec![0u8; 256 * 1024]; + rand::rng().fill_bytes(&mut bytes); + let mut u = arbitrary::Unstructured::new(&bytes); + Self::arbitrary(&mut u).expect("enough entropy for an arbitrary test instance") } } -impl TestRandomSeed for T {} +impl arbitrary::Arbitrary<'a>> TestRandomSeed for T {} pub fn bls_pubkey_from_hex(hex: &str) -> eyre::Result { let Ok(bytes) = hex::decode(hex) else { diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index 67fbad138..de4fdf0d2 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -802,7 +802,7 @@ mod tests { #[test] fn test_validate_signature() { - let secret_key = BlsSecretKey::test_random(); + let secret_key = BlsSecretKey::random(); let pubkey = secret_key.public_key(); let wrong_pubkey = BlsPublicKeyBytes::test_random(); let wrong_signature = BlsSignature::test_random(); diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index fd83fb686..7dca2c049 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -283,9 +283,8 @@ mod tests { use cb_common::types::BlsSecretKey; let chain = Chain::Hoodi; - let params = SubmitBuilderPreferencesParams { - proposer_pubkey: BlsSecretKey::random().public_key(), - }; + let params = + SubmitBuilderPreferencesParams { proposer_pubkey: BlsSecretKey::random().public_key() }; let empty = SignedRequestAuth { message: RequestAuth { data: Default::default(), diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 7a1082c55..dc74fb9a6 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -369,7 +369,8 @@ fn request_budget_ms(req_headers: &HeaderMap, now_ms: u64) -> Result Result< async fn test_submit_builder_preferences_signature_bound_to_path_pubkey() -> Result<()> { let chain = Chain::Hoodi; let signer = random_secret(); - let other_pubkey = random_secret().public_key().into(); + let other_pubkey = random_secret().public_key(); let (mock_validator, mock_state) = setup_relay(chain, |config| config.verify_request_auth = true, generate_mock_relay).await?; @@ -468,7 +468,7 @@ async fn test_submit_builder_preferences_bad_signature_401() -> Result<()> { async fn test_submit_builder_preferences_valid_signature() -> Result<()> { let chain = Chain::Hoodi; let secret = random_secret(); - let pubkey = secret.public_key().into(); + let pubkey = secret.public_key(); let (mock_validator, mock_state) = setup_relay(chain, |config| config.verify_request_auth = true, generate_mock_relay).await?; From 4a80e20556fa882e79814674b61fe1f897603771 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 18 Aug 2026 17:05:33 -0700 Subject: [PATCH 05/80] fix cargo audit --- Cargo.lock | 805 +++++++++++++++++---------------------- Cargo.toml | 1 - crates/common/Cargo.toml | 1 - 3 files changed, 339 insertions(+), 468 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac45a39aa..33aa08dd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,9 +71,9 @@ dependencies = [ [[package]] name = "alloy-chains" -version = "0.2.36" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c36ddb69f5e41407e7a93aead3480f7c8ff076e73d01a951ae8f7ea4542c1ca0" +checksum = "e5fdcfed8f106be3df944054aaa42bc13ae103a3ac8a9f4b08d4f053e3a743f8" dependencies = [ "alloy-primitives 1.6.1", "num_enum", @@ -104,7 +104,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -141,7 +141,7 @@ dependencies = [ "futures", "futures-util", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -184,14 +184,14 @@ dependencies = [ "alloy-rlp", "crc", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "alloy-eip2930" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" +checksum = "e64579d931b3f8eacc7c9ab0b220e87e9c4816e5c724ede1947b55c2f8e92ae5" dependencies = [ "alloy-primitives 1.6.1", "alloy-rlp", @@ -210,7 +210,7 @@ dependencies = [ "borsh", "k256", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -224,7 +224,7 @@ dependencies = [ "borsh", "once_cell", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -285,10 +285,10 @@ checksum = "422d110f1c40f1f8d0e5562b0b649c35f345fccb7093d9f02729943dcd1eef71" dependencies = [ "alloy-primitives 1.6.1", "alloy-sol-types", - "http 1.5.0", + "http", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -315,7 +315,7 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -429,7 +429,7 @@ dependencies = [ "reqwest 0.13.4", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "url", @@ -560,7 +560,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -610,7 +610,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -624,7 +624,7 @@ dependencies = [ "alloy-serde", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -662,7 +662,7 @@ dependencies = [ "either", "elliptic-curve", "k256", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -678,7 +678,7 @@ dependencies = [ "async-trait", "k256", "rand 0.8.7", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -762,14 +762,14 @@ checksum = "8098f965442a9feb620965ba4b4be5e2b320f4ec5a3fff6bfa9e1ff7ef42bed1" dependencies = [ "alloy-json-rpc", "auto_impl", - "base64 0.22.1", + "base64", "derive_more", "futures", "futures-utils-wasm", "parking_lot", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tower 0.5.3", "tracing", @@ -822,7 +822,7 @@ dependencies = [ "alloy-pubsub", "alloy-transport", "futures", - "http 1.5.0", + "http", "rustls", "serde_json", "tokio", @@ -844,7 +844,7 @@ dependencies = [ "nybbles", "serde", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -862,9 +862,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -911,7 +911,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -922,7 +922,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1272,9 +1272,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -1317,9 +1317,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -1327,9 +1327,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -1348,8 +1348,8 @@ dependencies = [ "axum-core 0.4.5", "bytes", "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", "itoa", "matchit 0.7.3", @@ -1359,7 +1359,7 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", - "sync_wrapper 1.0.2", + "sync_wrapper", "tower 0.5.3", "tower-layer", "tower-service", @@ -1376,10 +1376,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", - "hyper 1.11.0", + "hyper", "hyper-util", "itoa", "matchit 0.8.4", @@ -1391,7 +1391,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower 0.5.3", "tower-layer", @@ -1408,13 +1408,13 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper 1.0.2", + "sync_wrapper", "tower-layer", "tower-service", ] @@ -1427,12 +1427,12 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -1449,8 +1449,8 @@ dependencies = [ "bytes", "futures-util", "headers", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", "mime", "pin-project-lite", @@ -1481,9 +1481,9 @@ dependencies = [ "arc-swap", "bytes", "fs-err", - "http 1.5.0", - "http-body 1.1.0", - "hyper 1.11.0", + "http", + "http-body", + "hyper", "hyper-util", "pin-project-lite", "rustls", @@ -1515,12 +1515,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -1559,9 +1553,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitcoin-consensus-encoding" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" dependencies = [ "bitcoin-internals", "hex-conservative 1.2.0", @@ -1762,9 +1756,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -1872,7 +1866,7 @@ dependencies = [ "arbitrary", "async-trait", "axum 0.8.9", - "base64 0.22.1", + "base64", "bimap", "bls", "bytes", @@ -1897,14 +1891,13 @@ dependencies = [ "rand 0.9.5", "rayon", "reqwest 0.13.4", - "reqwest-eventsource 0.5.0", "serde", "serde_json", "serde_yaml", "sha2", "ssz_types", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "toml", "tonic", @@ -1928,7 +1921,7 @@ dependencies = [ "cb-common", "eyre", "prometheus", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -1956,7 +1949,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tower-http", @@ -1990,7 +1983,7 @@ dependencies = [ "prost", "rand 0.9.5", "rustls", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tonic", "tonic-build", @@ -2032,9 +2025,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -2116,9 +2109,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -2126,9 +2119,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -2631,6 +2624,37 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "der" version = "0.7.10" @@ -3027,7 +3051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3046,7 +3070,7 @@ dependencies = [ "mediatype 0.19.20", "pretty_reqwest_error", "reqwest 0.12.28", - "reqwest-eventsource 0.6.0", + "reqwest-eventsource", "sensitive_url", "serde", "serde_json", @@ -3183,10 +3207,11 @@ dependencies = [ [[package]] name = "eyre" -version = "0.6.12" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" dependencies = [ + "autocfg", "indenter", "once_cell", ] @@ -3232,9 +3257,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixed-cache" @@ -3342,9 +3367,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -3357,9 +3382,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -3367,15 +3392,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -3384,32 +3409,32 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" @@ -3419,9 +3444,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -3453,9 +3478,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" dependencies = [ "rustversion", "typenum", @@ -3527,35 +3552,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.5.0", + "http", "indexmap 2.14.0", "slab", "tokio", @@ -3624,10 +3630,10 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "headers-core", - "http 1.5.0", + "http", "httpdate", "mime", "sha1", @@ -3640,7 +3646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479bcb872e714e11f72fcc6a71afadbc86d0dbe887bc44252b04cfbc63272897" dependencies = [ "headers-core", - "http 1.5.0", + "http", "mediatype 0.20.0", ] @@ -3650,7 +3656,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.5.0", + "http", ] [[package]] @@ -3695,7 +3701,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "099d45a031296a7a40e01137b56c0c552f2a545568ef6058e47d674046def0db" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3716,17 +3722,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.5.0" @@ -3737,17 +3732,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.1.0" @@ -3755,19 +3739,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.5.0", + "http", ] [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "pin-project-lite", ] @@ -3792,30 +3776,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.11.0" @@ -3826,9 +3786,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.15", - "http 1.5.0", - "http-body 1.1.0", + "h2", + "http", + "http-body", "httparse", "httpdate", "itoa", @@ -3844,8 +3804,8 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.5.0", - "hyper 1.11.0", + "http", + "hyper", "hyper-util", "rustls", "tokio", @@ -3860,7 +3820,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.11.0", + "hyper", "hyper-util", "pin-project-lite", "tokio", @@ -3873,19 +3833,19 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", - "http 1.5.0", - "http-body 1.1.0", - "hyper 1.11.0", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", - "system-configuration 0.7.0", + "socket2 0.5.10", + "system-configuration", "tokio", "tower-service", "tracing", @@ -3918,9 +3878,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3932,9 +3892,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3945,9 +3905,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3959,16 +3919,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3979,15 +3940,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -4077,9 +4038,9 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ "bitflags 2.13.1", "inotify-sys", @@ -4141,7 +4102,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4183,6 +4144,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -4195,7 +4209,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -4244,9 +4258,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -4259,7 +4273,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64 0.22.1", + "base64", "js-sys", "ring", "serde", @@ -4291,9 +4305,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -4326,9 +4340,9 @@ checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -4399,9 +4413,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -4653,7 +4667,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4691,9 +4705,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -4884,7 +4898,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.22.1", + "base64", "serde_core", ] @@ -4896,9 +4910,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -4986,9 +5000,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plotters" @@ -5018,11 +5032,26 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -5154,7 +5183,7 @@ dependencies = [ "memchr", "parking_lot", "protobuf", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5278,8 +5307,8 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.5", - "thiserror 2.0.19", + "socket2 0.5.10", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -5287,9 +5316,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -5302,7 +5331,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -5317,9 +5346,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5546,9 +5575,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5561,59 +5590,21 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "ipnet", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration 0.5.1", - "tokio", - "tokio-util", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", - "winreg", -] - [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", - "hyper 1.11.0", + "hyper", "hyper-rustls", "hyper-util", "js-sys", @@ -5626,7 +5617,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-rustls", "tokio-util", @@ -5647,16 +5638,16 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2 0.4.15", - "http 1.5.0", - "http-body 1.1.0", + "h2", + "http", + "http-body", "http-body-util", - "hyper 1.11.0", + "hyper", "hyper-rustls", "hyper-util", "js-sys", @@ -5670,7 +5661,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-rustls", "tokio-util", @@ -5684,22 +5675,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "reqwest-eventsource" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f529a5ff327743addc322af460761dff5b50e0c826b9e6ac44c3195c50bb2026" -dependencies = [ - "eventsource-stream", - "futures-core", - "futures-timer", - "mime", - "nom", - "pin-project-lite", - "reqwest 0.11.27", - "thiserror 1.0.69", -] - [[package]] name = "reqwest-eventsource" version = "0.6.0" @@ -5858,7 +5833,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5926,7 +5901,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5937,9 +5912,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -6261,16 +6236,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ - "base64 0.22.1", + "base64", "bs58", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", "schemars 1.2.2", "serde_core", @@ -6281,9 +6257,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -6353,7 +6329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.1", ] [[package]] @@ -6455,7 +6431,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6630,12 +6606,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -6656,17 +6626,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys 0.5.0", -] - [[package]] name = "system-configuration" version = "0.7.0" @@ -6675,17 +6634,7 @@ checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.13.1", "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", + "system-configuration-sys", ] [[package]] @@ -6711,10 +6660,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6734,11 +6683,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -6754,9 +6703,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -6842,9 +6791,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -7035,13 +6984,13 @@ dependencies = [ "async-stream", "async-trait", "axum 0.7.9", - "base64 0.22.1", + "base64", "bytes", - "h2 0.4.15", - "http 1.5.0", - "http-body 1.1.0", + "h2", + "http", + "http-body", "http-body-util", - "hyper 1.11.0", + "hyper", "hyper-timeout", "hyper-util", "percent-encoding", @@ -7101,7 +7050,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -7117,8 +7066,8 @@ dependencies = [ "bitflags 2.13.1", "bytes", "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "pin-project-lite", "tower 0.5.3", "tower-layer", @@ -7159,7 +7108,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -7307,14 +7256,14 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http 1.5.0", + "http", "httparse", "log", "rand 0.9.5", "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.19", + "thiserror 2.0.20", "utf-8", ] @@ -7480,9 +7429,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -7517,7 +7466,7 @@ checksum = "8fabeca519a296f0b39428cfe496b600c0179c9498687986449d61fa40e60806" dependencies = [ "crypto-bigint", "elliptic-curve", - "generic-array 1.4.4", + "generic-array 1.4.5", "rand_core 0.6.4", "serde", "sha3 0.10.9", @@ -7570,9 +7519,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -7583,9 +7532,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -7593,9 +7542,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7603,9 +7552,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -7616,9 +7565,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -7665,9 +7614,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -7738,7 +7687,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7817,15 +7766,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -7853,21 +7793,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -7901,12 +7826,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7919,12 +7838,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7937,12 +7850,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7967,12 +7874,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7985,12 +7886,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8003,12 +7898,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8021,12 +7910,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8057,16 +7940,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -8075,9 +7948,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "ws_stream_wasm" @@ -8092,7 +7965,7 @@ dependencies = [ "pharos", "rustc_version 0.4.1", "send_wrapper", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -8154,18 +8027,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -8216,9 +8089,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -8227,9 +8100,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -8238,13 +8111,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cc5b92290..7eecdc451 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,7 +68,6 @@ rand = { version = "0.9", features = ["os_rng"] } rayon = "1.10.0" reqwest = { version = "^0.13.2", features = ["json", "stream", "rustls"] } rcgen = "0.13.2" -reqwest-eventsource = "=0.5.0" rustls = "0.23.23" serde = { version = "1.0.202", features = ["derive"] } serde_json = "1.0.117" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 11687a05c..41cbae213 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -58,7 +58,6 @@ typenum.workspace = true unicode-normalization.workspace = true url.workspace = true uuid.workspace = true -reqwest-eventsource.workspace = true [dev-dependencies] tempfile.workspace = true From fff2bb42a09ba6123fe60d1667e788226ecfb3a9 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 22:50:10 -0700 Subject: [PATCH 06/80] test(pbs): pin auth_data demux contract vectors for external projectors External KM projection tooling round-trips auth_data through match_relays_by_auth_data, so pin the behaviors it relies on: userinfo and default-port are ignored when matching a relay entry URL, configured expected_auth_data takes exact-byte precedence per relay (cross-form collision with another relay's URL bytes yields the union set), and one auth_data may match several relays sharing the same configured bytes. --- crates/pbs/src/utils.rs | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index cc003dd3a..9f193b91f 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -299,6 +299,53 @@ mod tests { assert!(match_relays_by_auth_data(&relays, b"http://a.example.com").is_empty()); } + // Contract vectors: external KM projection tooling round-trips auth_data + // through this demux, so the behaviors below are a compatibility contract, + // not incidental implementation detail. + + // A relay entry URL embeds its pubkey as userinfo and may omit the default + // port; a bare builder URL in auth_data must still match it. + #[test] + fn match_relays_contract_userinfo_and_default_port_ignored() { + let relays = vec![test_relay("https://0xdeadbeef@builder.example.com", None)]; + assert_eq!(match_relays_by_auth_data(&relays, b"https://builder.example.com").len(), 1); + assert_eq!(match_relays_by_auth_data(&relays, b"https://builder.example.com:443").len(), 1); + // A non-default port must not match + assert!(match_relays_by_auth_data(&relays, b"https://builder.example.com:8443").is_empty()); + } + + // Cross-form collision: relay A's `expected_auth_data` equals relay B's URL + // bytes. For A the exact-byte layer decides (its own URL never enters into + // it); B, with no configured bytes, still matches by URL. The matched SET + // is {A, B}: configured bytes take precedence per relay, they do not + // subtract other relays' URL matches. + #[test] + fn match_relays_contract_cross_form_collision() { + let relays = vec![ + test_relay("http://a.example.com", Some(b"http://b.example.com")), + test_relay("http://b.example.com", None), + ]; + let matched = match_relays_by_auth_data(&relays, b"http://b.example.com"); + let hosts: Vec<_> = + matched.iter().map(|r| r.config.entry.url.host_str().unwrap()).collect(); + assert_eq!(hosts, vec!["a.example.com", "b.example.com"]); + // A's own URL no longer matches anything: configured bytes replace + // URL-derived matching for that relay (layer-1 precedence) + assert!(match_relays_by_auth_data(&relays, b"http://a.example.com").is_empty()); + } + + // Matched-SET semantics: one auth_data may select several relays (multiple + // builders behind one agreement); the winner is picked later by payment. + #[test] + fn match_relays_contract_shared_auth_data_matches_all() { + let relays = vec![ + test_relay("http://a.example.com", Some(&[0xcc])), + test_relay("http://b.example.com", Some(&[0xcc])), + ]; + let matched = match_relays_by_auth_data(&relays, &[0xcc]); + assert_eq!(matched.len(), 2); + } + #[test] fn match_relays_url_fallback_and_unmatched_selects_none() { let relays = vec![ From ada0b9c93b68f129f755fd402e6c440dbf54ca6d Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 22:57:23 -0700 Subject: [PATCH 07/80] feat(pbs): drop ePBS min_bid/payment-cap rejects, keep the cap as a ranking clamp beacon-APIs #630 makes the beacon node the enforcer on the ePBS path: it MUST-rejects a bid below the per-key min_bid and values every bid at value + min(execution_payment, max_execution_payment), clamping an over-claimed trusted payment instead of rejecting it. CB's copies of both checks therefore over-reject: a bid the BN would clamp-and-consider or floor-check per key was dropped outright (worse still, the old default cap of 0 rejected every nonzero execution payment). The legacy get_header path keeps its own min_bid check (no per-key BN enforcement exists there). Since CB returns a single winner, the cap survives as a RANKING clamp so CB's winner agrees with the BN's valuation: bids rank at value + min(execution_payment, relay cap), where the per-relay max_execution_payment_gwei overrides the global one. The config default becomes u64::MAX (unclamped, matching the spec's MAX_EXECUTION_PAYMENT); a 0 default would zero out every execution payment in ranking. Dead plumbing removed: ValidationContext.{min_bid_gwei, max_trusted_bid_gwei}, the validate_header_data params, the TotalPaymentTooLow/TrustedBidTooHigh error variants, and the unread HeaderInfo payment fields. --- config.example.toml | 16 +- crates/common/src/config/pbs.rs | 11 +- crates/common/src/pbs/error.rs | 6 - .../pbs/src/routes/execution_payload_bid.rs | 198 +++++++----------- tests/src/utils.rs | 3 +- tests/tests/pbs_get_execution_payload_bid.rs | 97 +++++---- 6 files changed, 150 insertions(+), 181 deletions(-) diff --git a/config.example.toml b/config.example.toml index 7a4959c21..259c8211e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -44,11 +44,12 @@ skip_sigverify = false # Can be specified as a float or a string for extra precision (e.g. "0.01") # OPTIONAL, DEFAULT: 0.0 min_bid_eth = 0.0 -# Maximum trusted execution payment in Gwei accepted in an ePBS bid. With the default of 0 any bid -# carrying an execution payment is rejected, so payments only flow through the on-chain trustless -# mechanism. Can be overridden per relay with `max_execution_payment_gwei` on the relay entry -# OPTIONAL, DEFAULT: 0 -max_execution_payment_gwei = 0 +# Execution-payment cap in Gwei used when ranking ePBS bids: a bid ranks at +# `value + min(execution_payment, cap)`, matching how the beacon node values it (the BN clamps the +# trusted payment at the cap instead of rejecting the bid). This does not accept or reject bids. +# Can be overridden per relay with `max_execution_payment_gwei` on the relay entry +# OPTIONAL, DEFAULT: 18446744073709551615 (u64::MAX, unclamped) +# max_execution_payment_gwei = 18446744073709551615 # Expected fee recipient in ePBS bids. When set, bids whose fee_recipient differs are rejected # OPTIONAL, DEFAULT: unset (no check) # fee_recipient = "0x1234567890123456789012345678901234567890" @@ -149,9 +150,10 @@ frequency_get_header_ms = 300 # the builder, or every early poll times out # OPTIONAL, DEFAULT: 500 # bid_poll_timeout_ms = 500 -# Maximum trusted execution payment in Gwei accepted in an ePBS bid from this relay +# Per-relay override of the ePBS bid-ranking execution-payment cap in Gwei (see the PBS-level +# `max_execution_payment_gwei`) # OPTIONAL, DEFAULT: the PBS-level `max_execution_payment_gwei` -# max_execution_payment_gwei = 0 +# max_execution_payment_gwei = 18446744073709551615 # The ePBS auth data this relay serves: a bid request routes here only when its `auth.message.data` # equals this value. When unset, the relay is matched only by auth data carrying its URL; auth data # matching no configured relay is rejected with 400 diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 94d8ad096..1577dbe3f 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -75,7 +75,8 @@ pub struct RelayConfig { /// request #[serde(deserialize_with = "empty_string_as_none", default)] pub validator_registration_batch_size: Option, - /// Maximum trusted execution payment in Gwei accepted in an ePBS bid + /// Per-relay override of the ePBS bid-ranking execution-payment cap in + /// Gwei (see `PbsConfig::max_execution_payment_gwei`) pub max_execution_payment_gwei: Option, /// ePBS auth data this relay serves: a bid request routes here only when /// its `auth.message.data` equals this value. When unset, the relay is @@ -138,8 +139,12 @@ pub struct PbsConfig { /// Minimum bid that will be accepted from get_header #[serde(rename = "min_bid_eth", with = "as_eth_str", default = "default_u256")] pub min_bid_wei: U256, - /// Maximum trusted execution payment in Gwei accepted in an ePBS bid - #[serde(default = "default_u64::<0>")] + /// Execution-payment cap in Gwei used when RANKING ePBS bids: a bid ranks + /// at `value + min(execution_payment, cap)`, mirroring the BN's valuation + /// (beacon-APIs #630 clamps at `max_execution_payment` instead of + /// rejecting). Not an accept/reject check; the BN enforces the cap. + /// Default u64::MAX = unclamped + #[serde(default = "default_u64::<{ u64::MAX }>")] pub max_execution_payment_gwei: u64, /// When enabled, the BLS signature of an ePBS request's /// `SignedRequestAuth` is verified against the proposer pubkey. False by diff --git a/crates/common/src/pbs/error.rs b/crates/common/src/pbs/error.rs index 26d89c65a..61457770d 100644 --- a/crates/common/src/pbs/error.rs +++ b/crates/common/src/pbs/error.rs @@ -115,15 +115,9 @@ pub enum ValidationError { #[error("bid below minimum: min: {min} got {got}")] BidTooLow { min: U256, got: U256 }, - #[error("total payment below minimum bid (gwei): min: {min} got {got}")] - TotalPaymentTooLow { min: u64, got: u64 }, - #[error("fee recipient mismatch: expected {expected} got {got}")] FeeRecipientMismatch { expected: Address, got: Address }, - #[error("trusted bid above maximum (gwei): max: {max} got {got}")] - TrustedBidTooHigh { max: u64, got: u64 }, - #[error("empty parent root")] EmptyParentRoot, diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index dc74fb9a6..83b5d5b09 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -13,6 +13,7 @@ use axum::{ response::IntoResponse, }; use cb_common::{ + config::PbsConfig, constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{ DEFAULT_BID_POLL_TIMEOUT_MS, ForkName, GetExecutionPayloadBidInfo, @@ -245,16 +246,9 @@ pub async fn get_execution_payload_bid( send_headers.clone(), ms_into_slot, max_timeout_ms, + ranking_cap_gwei(relay, pbs_config), ValidationContext { skip_sigverify: pbs_config.skip_sigverify, - // the ePBS floor is the same min_bid policy knob, in gwei - min_bid_gwei: (pbs_config.min_bid_wei / U256::from(1_000_000_000)) - .try_into() - .unwrap_or(u64::MAX), - max_trusted_bid_gwei: relay - .config - .max_execution_payment_gwei - .unwrap_or(pbs_config.max_execution_payment_gwei), expected_fee_recipient: pbs_config.fee_recipient, extra_validation_enabled: state.extra_validation_enabled(), parent_block: parent_block.clone(), @@ -277,7 +271,7 @@ pub async fn get_execution_payload_bid( .unwrap_or_default(); RELAY_HEADER_VALUE.with_label_values(&[relay_id]).set(value_gwei); - relay_bids.push((relay_id, res)) + relay_bids.push((relay_id, res, ranking_cap_gwei(relay, pbs_config))) } Ok(_) => {} Err(err) if err.is_timeout() => error!(err = "Timed Out", relay_id), @@ -391,10 +385,28 @@ fn total_payment(bid: &impl GetExecutionPayloadBidInfo) -> u64 { bid.value().saturating_add(bid.execution_payment()) } +/// The execution-payment cap used when ranking a relay's bids: the per-relay +/// override, else the global config value (default u64::MAX = unclamped). +fn ranking_cap_gwei(relay: &RelayClient, pbs_config: &PbsConfig) -> u64 { + relay.config.max_execution_payment_gwei.unwrap_or(pbs_config.max_execution_payment_gwei) +} + +/// A bid's ranking value per beacon-APIs #630: the BN values a bid at +/// `value + min(execution_payment, max_execution_payment)` (the cap CLAMPS the +/// trusted payment, it does not reject the bid). CB returns a single winner, +/// so it must rank with the same clamp or its winner can disagree with the +/// BN's valuation. +fn ranking_payment(bid: &impl GetExecutionPayloadBidInfo, cap_gwei: u64) -> u64 { + bid.value().saturating_add(bid.execution_payment().min(cap_gwei)) +} + // `L` is an opaque label (relay id for the cross-relay layer, request start -// time for the per-relay in-flight layer) carried through to the winner. -fn select_max_bid(bids: Vec<(L, I)>) -> Option<(L, I)> { - bids.into_iter().max_by_key(|(_, bid)| total_payment(bid)) +// time for the per-relay in-flight layer) carried through to the winner; the +// u64 is that bid's relay execution-payment cap in gwei. +fn select_max_bid(bids: Vec<(L, I, u64)>) -> Option<(L, I)> { + bids.into_iter() + .max_by_key(|(_, bid, cap_gwei)| ranking_payment(bid, *cap_gwei)) + .map(|(label, bid, _)| (label, bid)) } /// Fetch the parent block from the RPC URL for extra validation of the header. @@ -422,6 +434,7 @@ async fn fetch_parent_block( } } +#[allow(clippy::too_many_arguments)] async fn send_timed_get_execution_payload_bid( params: GetExecutionPayloadBidParams, body: Arc, @@ -429,6 +442,7 @@ async fn send_timed_get_execution_payload_bid( headers: HeaderMap, ms_into_slot: u64, timeout_left_ms: u64, + ranking_cap_gwei: u64, validation: ValidationContext, ) -> Result, PbsError> { let url = relay.get_execution_payload_bid_url( @@ -531,7 +545,7 @@ async fn send_timed_get_execution_payload_bid( res.ok().and_then(|inner_res| match inner_res { Ok((start_time, Some(header))) => { n_headers += 1; - Some((start_time, header)) + Some((start_time, header, ranking_cap_gwei)) } // a 204 is the relay answering "no bid", not failing Ok((_, None)) => { @@ -588,8 +602,6 @@ struct RequestContext { #[derive(Clone)] struct ValidationContext { skip_sigverify: bool, - min_bid_gwei: u64, - max_trusted_bid_gwei: u64, expected_fee_recipient: Option
, extra_validation_enabled: bool, parent_block: Arc>>, @@ -713,19 +725,11 @@ async fn send_one_get_execution_payload_bid( parent_hash: get_header_response.parent_hash(), parent_root: get_header_response.parent_root(), slot: get_header_response.slot(), - trustless_payment: get_header_response.value(), - trusted_payment: get_header_response.execution_payment(), fee_recipient: get_header_response.fee_recipient(), gas_limit: get_header_response.gas_limit(), }; - validate_header_data( - &header_info, - ¶ms, - validation.min_bid_gwei, - validation.max_trusted_bid_gwei, - validation.expected_fee_recipient, - )?; + validate_header_data(&header_info, ¶ms, validation.expected_fee_recipient)?; if !validation.skip_sigverify { validate_signature( @@ -755,17 +759,18 @@ struct HeaderInfo { parent_hash: B256, parent_root: B256, slot: u64, - trustless_payment: u64, - trusted_payment: u64, fee_recipient: Address, gas_limit: u64, } +// No min_bid or execution-payment-cap check here: on the ePBS path the BN is +// the enforcer (beacon-APIs #630 MUST-rejects below the per-key min_bid and +// CLAMPS the payment at max_execution_payment); a CB-side copy would over- +// reject bids the BN would still consider. The cap survives only as a ranking +// clamp (see `ranking_payment`). fn validate_header_data( header_info: &HeaderInfo, params: &GetExecutionPayloadBidParams, - min_bid_gwei: u64, - max_trusted_bid_gwei: u64, expected_fee_recipient: Option
, ) -> Result<(), ValidationError> { if header_info.block_hash == B256::ZERO { @@ -793,18 +798,6 @@ fn validate_header_data( }); } - let total_payment = header_info.trustless_payment.saturating_add(header_info.trusted_payment); - if total_payment < min_bid_gwei { - return Err(ValidationError::TotalPaymentTooLow { min: min_bid_gwei, got: total_payment }); - } - - if header_info.trusted_payment > max_trusted_bid_gwei { - return Err(ValidationError::TrustedBidTooHigh { - max: max_trusted_bid_gwei, - got: header_info.trusted_payment, - }); - } - if let Some(expected) = expected_fee_recipient && header_info.fee_recipient != expected { @@ -895,8 +888,6 @@ mod tests { let slot = 5; let parent_hash = B256::from_slice(&[1; 32]); let parent_root = B256::from_slice(&[2; 32]); - let min_bid = 500; - let max_trusted_payment = 1000; let secret_key = BlsSecretKey::random(); let pubkey = secret_key.public_key(); @@ -912,33 +903,19 @@ mod tests { parent_hash: B256::default(), parent_root: B256::default(), slot: 0, - trustless_payment: min_bid - 1, - trusted_payment: 0, fee_recipient: Address::ZERO, gas_limit: 0, }; assert_eq!( - validate_header_data( - &mock_header_data, - &mock_params, - min_bid, - max_trusted_payment, - None - ), + validate_header_data(&mock_header_data, &mock_params, None), Err(ValidationError::EmptyBlockhash) ); mock_header_data.block_hash.0[1] = 1; assert_eq!( - validate_header_data( - &mock_header_data, - &mock_params, - min_bid, - max_trusted_payment, - None - ), + validate_header_data(&mock_header_data, &mock_params, None), Err(ValidationError::ParentHashMismatch { expected: mock_params.parent_hash, got: B256::default() @@ -948,13 +925,7 @@ mod tests { mock_header_data.parent_hash = parent_hash; assert_eq!( - validate_header_data( - &mock_header_data, - &mock_params, - min_bid, - max_trusted_payment, - None - ), + validate_header_data(&mock_header_data, &mock_params, None), Err(ValidationError::ParentRootMismatch { expected: mock_params.parent_root, got: B256::default() @@ -964,60 +935,16 @@ mod tests { mock_header_data.parent_root = parent_root; assert_eq!( - validate_header_data( - &mock_header_data, - &mock_params, - min_bid, - max_trusted_payment, - None - ), + validate_header_data(&mock_header_data, &mock_params, None), Err(ValidationError::SlotNumberMismatch { expected: slot, got: 0 }) ); mock_header_data.slot = slot; - assert_eq!( - validate_header_data( - &mock_header_data, - &mock_params, - min_bid, - max_trusted_payment, - None - ), - Err(ValidationError::TotalPaymentTooLow { - min: min_bid, - got: mock_header_data.trustless_payment, - }) - ); - - mock_header_data.trusted_payment = max_trusted_payment + 1; - - assert_eq!( - validate_header_data( - &mock_header_data, - &mock_params, - min_bid, - max_trusted_payment, - None - ), - Err(ValidationError::TrustedBidTooHigh { - max: max_trusted_payment, - got: mock_header_data.trusted_payment, - }) - ); - - mock_header_data.trusted_payment = max_trusted_payment; - let expected_fee_recipient = Address::from([1; 20]); assert_eq!( - validate_header_data( - &mock_header_data, - &mock_params, - min_bid, - max_trusted_payment, - Some(expected_fee_recipient), - ), + validate_header_data(&mock_header_data, &mock_params, Some(expected_fee_recipient)), Err(ValidationError::FeeRecipientMismatch { expected: expected_fee_recipient, got: Address::ZERO, @@ -1026,16 +953,11 @@ mod tests { mock_header_data.fee_recipient = expected_fee_recipient; - validate_header_data( - &mock_header_data, - &mock_params, - min_bid, - max_trusted_payment, - Some(expected_fee_recipient), - ) - .unwrap(); + validate_header_data(&mock_header_data, &mock_params, Some(expected_fee_recipient)) + .unwrap(); } + #[test] fn test_validate_signature() { let secret_key = BlsSecretKey::random(); @@ -1334,21 +1256,45 @@ mod tests { #[test] fn test_select_max_bid_by_total_payment() { let bids = vec![ - ("value_winner", MockBid { value: 6, execution_payment: 0 }), - ("total_winner", MockBid { value: 5, execution_payment: 10 }), + ("value_winner", MockBid { value: 6, execution_payment: 0 }, u64::MAX), + ("total_winner", MockBid { value: 5, execution_payment: 10 }, u64::MAX), ]; let (winner, _) = select_max_bid(bids).unwrap(); assert_eq!(winner, "total_winner"); // A saturating sum must not misrank a near-overflow bid let bids = vec![ - ("honest", MockBid { value: 7, execution_payment: 0 }), - ("overflow", MockBid { value: u64::MAX, execution_payment: u64::MAX }), + ("honest", MockBid { value: 7, execution_payment: 0 }, u64::MAX), + ("overflow", MockBid { value: u64::MAX, execution_payment: u64::MAX }, u64::MAX), ]; let (winner, _) = select_max_bid(bids).unwrap(); assert_eq!(winner, "overflow"); } + // Ranking clamps the execution payment at the relay's cap (beacon-APIs + // #630): a bid over-claiming a huge trusted payment behind a low cap must + // lose to a moderate honest bid the BN would value higher. + #[test] + fn test_select_max_bid_clamps_execution_payment_at_relay_cap() { + let bids = vec![ + // ranks at 5 + min(1_000_000, 10) = 15 + ("overclaimer", MockBid { value: 5, execution_payment: 1_000_000 }, 10), + // ranks at 20 + 0 = 20 + ("honest", MockBid { value: 20, execution_payment: 0 }, u64::MAX), + ]; + let (winner, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner, "honest"); + + // The default cap (u64::MAX) leaves ranking unclamped: the same + // over-claimed payment wins on its full total + let bids = vec![ + ("overclaimer", MockBid { value: 5, execution_payment: 1_000_000 }, u64::MAX), + ("honest", MockBid { value: 20, execution_payment: 0 }, u64::MAX), + ]; + let (winner, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner, "overclaimer"); + } + // Per-relay in-flight aggregation (timing games) must pick the highest // TOTAL payment, not the latest-started response. #[test] @@ -1360,9 +1306,9 @@ mod tests { // Max total is neither first nor last, and the later-started response // pays LESS: this fails both latest-wins and first-wins. let bids = vec![ - (late, MockBid { value: 3, execution_payment: 1 }), // total 4 - (early, MockBid { value: 10, execution_payment: 5 }), // total 15 (winner) - (mid, MockBid { value: 6, execution_payment: 2 }), // total 8 + (late, MockBid { value: 3, execution_payment: 1 }, u64::MAX), // total 4 + (early, MockBid { value: 10, execution_payment: 5 }, u64::MAX), // total 15 (winner) + (mid, MockBid { value: 6, execution_payment: 2 }, u64::MAX), // total 8 ]; let (winner_start, _) = select_max_bid(bids).unwrap(); assert_eq!(winner_start, early, "must pick highest total, not latest- or first-started"); diff --git a/tests/src/utils.rs b/tests/src/utils.rs index 37bd51206..2c2d3bf8e 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -182,7 +182,8 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { timeout_register_validator_ms: u64::MAX, skip_sigverify: false, min_bid_wei: U256::ZERO, - max_execution_payment_gwei: 0, + // matches the config default: the cap is a ranking clamp, MAX = unclamped + max_execution_payment_gwei: u64::MAX, fee_recipient: None, late_in_slot_time_ms: u64::MAX, extra_validation_enabled: false, diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 31adf43bb..5552df643 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -98,15 +98,16 @@ async fn test_get_execution_payload_bid_wrong_parent_root() -> Result<()> { .await } -/// With the default `max_execution_payment_gwei` of 0, any bid with a nonzero -/// execution_payment is rejected as TrustedBidTooHigh +/// `max_execution_payment_gwei` is a ranking clamp, not a reject: even the +/// strictest cap (0) passes a bid carrying an execution payment through +/// validation. The BN enforces the cap by clamping (beacon-APIs #630) #[tokio::test] -async fn test_get_execution_payload_bid_nonzero_execution_payment_rejected() -> Result<()> { +async fn test_get_execution_payload_bid_execution_payment_over_cap_accepted() -> Result<()> { test_get_execution_payload_bid_impl( vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_trusted_bid_gwei(1)], - StatusCode::NO_CONTENT, + StatusCode::OK, &[1], - None, + Some(10), 0, ) .await @@ -160,11 +161,11 @@ async fn test_get_execution_payload_bid_highest_total_payment_wins() -> Result<( .await } -/// Test that min_bid_eth also floors ePBS bids: a bid whose total payment (in -/// gwei) is below the configured minimum returns 204. Covers the wei -> gwei -/// conversion, which the unit tests don't. +/// `min_bid_eth` does NOT floor ePBS bids: the BN enforces the per-key +/// min_bid on this path (beacon-APIs #630), so a bid below the global CB +/// minimum still passes through #[tokio::test] -async fn test_get_execution_payload_bid_below_min_bid_rejected() -> Result<()> { +async fn test_get_execution_payload_bid_below_min_bid_passes() -> Result<()> { setup_test_env(); let chain = Chain::Hoodi; let pbs_listener = get_free_listener().await; @@ -192,7 +193,7 @@ async fn test_get_execution_payload_bid_below_min_bid_rejected() -> Result<()> { EncodingType::Json, ]) .await?; - assert_eq!(res.status(), StatusCode::NO_CONTENT); + assert_eq!(res.status(), StatusCode::OK); assert_eq!(mock_state.received_execution_payload_bid(), 1); Ok(()) } @@ -1803,44 +1804,61 @@ async fn test_get_execution_payload_bid_slow_relay_times_out_204() -> Result<()> Ok(()) } -/// A per-relay `max_execution_payment_gwei` cap stricter than the global one -/// rejects a bid whose execution payment exceeds it. Same served bid, same high -/// global cap: only the per-relay override flips accept (200) to reject (204), -/// isolating it as the cause. +/// A per-relay `max_execution_payment_gwei` cap clamps that relay's bids in +/// RANKING: a bid with a huge execution payment behind a low cap ranks at +/// `value + cap` and loses to a moderate honest bid, matching the BN's +/// clamped valuation (beacon-APIs #630). Without the per-relay cap the same +/// over-claimed bid wins on its full total, isolating the clamp as the cause. #[tokio::test] -async fn test_get_execution_payload_bid_per_relay_max_payment_override() -> Result<()> { - const GLOBAL_CAP_GWEI: u64 = 100; +async fn test_get_execution_payload_bid_per_relay_cap_clamps_ranking() -> Result<()> { const RELAY_CAP_GWEI: u64 = 5; - const SERVED_TRUSTED_GWEI: u64 = 10; - - for (relay_cap, expected) in - [(None, StatusCode::OK), (Some(RELAY_CAP_GWEI), StatusCode::NO_CONTENT)] - { + const OVERCLAIMED_TRUSTED_GWEI: u64 = 1_000; + const OVERCLAIMER_TRUSTLESS_GWEI: u64 = 5; + const HONEST_TRUSTLESS_GWEI: u64 = 20; + + // (overclaimer's per-relay cap, expected winning trustless value) + for (relay_cap, expected_value) in [ + // unclamped: 5 + 1000 beats 20 + (None, OVERCLAIMER_TRUSTLESS_GWEI), + // clamped: 5 + min(1000, 5) = 10 loses to 20 + (Some(RELAY_CAP_GWEI), HONEST_TRUSTLESS_GWEI), + ] { setup_test_env(); let chain = Chain::Hoodi; let pbs_listener = get_free_listener().await; let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - let mock_state = Arc::new( - MockRelayState::new(chain, random_secret()).with_trusted_bid_gwei(SERVED_TRUSTED_GWEI), + let overclaimer_listener = get_free_listener().await; + let overclaimer_port = overclaimer_listener.local_addr()?.port(); + let overclaimer_state = Arc::new( + MockRelayState::new(chain, random_secret()) + .with_trustless_bid_gwei(OVERCLAIMER_TRUSTLESS_GWEI) + .with_trusted_bid_gwei(OVERCLAIMED_TRUSTED_GWEI), ); - let mock_relay = match relay_cap { + let overclaimer_relay = match relay_cap { Some(cap) => generate_mock_relay_with_max_payment( - relay_port, - mock_state.signer.public_key(), + overclaimer_port, + overclaimer_state.signer.public_key(), cap, )?, - None => generate_mock_relay(relay_port, mock_state.signer.public_key())?, + None => generate_mock_relay(overclaimer_port, overclaimer_state.signer.public_key())?, }; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + tokio::spawn(start_mock_relay_service_with_listener( + overclaimer_state.clone(), + overclaimer_listener, + )); + + let honest_listener = get_free_listener().await; + let honest_port = honest_listener.local_addr()?.port(); + let honest_state = Arc::new( + MockRelayState::new(chain, random_secret()) + .with_trustless_bid_gwei(HONEST_TRUSTLESS_GWEI), + ); + let honest_relay = generate_mock_relay(honest_port, honest_state.signer.public_key())?; + tokio::spawn(start_mock_relay_service_with_listener(honest_state.clone(), honest_listener)); - // Global cap comfortably above the served payment, so only a stricter - // per-relay cap can reject the bid - let mut pbs_config = get_pbs_config(pbs_port); - pbs_config.max_execution_payment_gwei = GLOBAL_CAP_GWEI; - let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let pbs_config = get_pbs_config(pbs_port); + let config = to_pbs_config(chain, pbs_config, vec![overclaimer_relay, honest_relay]); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); @@ -1858,8 +1876,12 @@ async fn test_get_execution_payload_bid_per_relay_max_payment_override() -> Resu vec![EncodingType::Json], ) .await?; - assert_eq!(res.status(), expected, "relay_cap={relay_cap:?}"); - assert_eq!(mock_state.received_execution_payload_bid(), 1, "relay_cap={relay_cap:?}"); + assert_eq!(res.status(), StatusCode::OK, "relay_cap={relay_cap:?}"); + assert_eq!(overclaimer_state.received_execution_payload_bid(), 1); + assert_eq!(honest_state.received_execution_payload_bid(), 1); + + let res = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!(res.value(), expected_value, "relay_cap={relay_cap:?}"); } Ok(()) } @@ -1931,7 +1953,6 @@ async fn test_get_execution_payload_bid_impl( assert_eq!(res.parent_hash(), B256::ZERO); assert_eq!(res.parent_root(), B256::ZERO); assert_ne!(res.block_hash(), B256::ZERO); - assert!(res.execution_payment() <= max_execution_payment_gwei); if let Some(expected_value) = expected_value { assert_eq!(res.value(), expected_value); } From 906009e7122a8a0e65de246649fa217d967f47f3 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:00:51 -0700 Subject: [PATCH 08/80] feat(km-tool): new cb-km-tool crate with KM wire types and overlay config BuilderConfigDoc/BuilderEntryDoc follow keymanager-APIs builder_config: Uint64s as JSON strings, auth_data as 0x-hex, omitted fields off the wire. CanonicalDoc compares stored docs by value (hex decoded, entries sorted, Uint64 strings parsed) since the spec promises neither entry order nor hex case. The overlay carries the operational side (advertised_url, VCs, per-mux fallbacks) that does not belong in the CB fleet config. --- Cargo.lock | 13 ++ crates/km-tool/Cargo.toml | 17 +++ crates/km-tool/src/doc.rs | 237 ++++++++++++++++++++++++++++++++++ crates/km-tool/src/lib.rs | 10 ++ crates/km-tool/src/overlay.rs | 131 +++++++++++++++++++ 5 files changed, 408 insertions(+) create mode 100644 crates/km-tool/Cargo.toml create mode 100644 crates/km-tool/src/doc.rs create mode 100644 crates/km-tool/src/lib.rs create mode 100644 crates/km-tool/src/overlay.rs diff --git a/Cargo.lock b/Cargo.lock index 33aa08dd9..032298c3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1913,6 +1913,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "cb-km-tool" +version = "0.11.0" +dependencies = [ + "eyre", + "serde", + "serde_json", + "tempfile", + "toml", + "tracing", + "url", +] + [[package]] name = "cb-metrics" version = "0.11.0" diff --git a/crates/km-tool/Cargo.toml b/crates/km-tool/Cargo.toml new file mode 100644 index 000000000..7d46382e7 --- /dev/null +++ b/crates/km-tool/Cargo.toml @@ -0,0 +1,17 @@ +[package] +edition.workspace = true +name = "cb-km-tool" +publish = false +rust-version.workspace = true +version.workspace = true + +[dependencies] +eyre.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +tracing.workspace = true +url.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/km-tool/src/doc.rs b/crates/km-tool/src/doc.rs new file mode 100644 index 000000000..274c872d2 --- /dev/null +++ b/crates/km-tool/src/doc.rs @@ -0,0 +1,237 @@ +//! Wire types for the keymanager builder_config document +//! (keymanager-APIs `types/builder_entry.yaml`): Uint64s are JSON strings, +//! `auth_data` is 0x-prefixed hex, omitted fields are omitted on the wire. + +use eyre::{Result, bail, ensure}; +use serde::{Deserialize, Serialize}; + +/// `BuilderConfig` as POSTed to / returned by the keymanager API. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct BuilderConfigDoc { + #[serde(skip_serializing_if = "Option::is_none")] + pub min_bid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub builders: Option>, +} + +/// `BuilderEntry` as it appears in `BuilderConfigDoc.builders`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuilderEntryDoc { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub builder_pubkeys: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_execution_payment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_bid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, +} + +/// Encodes bytes as the KM `auth_data` wire form: 0x-prefixed lowercase hex. +pub fn encode_auth_data(bytes: &[u8]) -> String { + let mut out = String::with_capacity(2 + bytes.len() * 2); + out.push_str("0x"); + for b in bytes { + out.push_str(&format!("{b:02x}")); + } + out +} + +/// Decodes a 0x-prefixed hex `auth_data`, accepting either hex case. +pub fn decode_auth_data(hex: &str) -> Result> { + let Some(body) = hex.strip_prefix("0x") else { + bail!("auth_data must be 0x-prefixed: {hex}"); + }; + ensure!(!body.is_empty(), "auth_data must not be empty"); + ensure!(body.len() % 2 == 0, "auth_data has odd hex length: {hex}"); + (0..body.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&body[i..i + 2], 16) + .map_err(|err| eyre::eyre!("invalid hex in auth_data {hex}: {err}")) + }) + .collect() +} + +/// A `BuilderConfigDoc` reduced to comparable values: hex decoded, Uint64 +/// strings parsed, entries sorted by `(url, auth_data bytes)`, pubkeys treated +/// as a case-insensitive set. The spec promises none of entry order, hex case, +/// or pubkey order, so stored docs are compared canonically, never byte-wise. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalDoc { + pub min_bid: Option, + pub builder_boost_factor: Option, + pub builders: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct CanonicalEntry { + pub url: String, + pub auth_data: Option>, + pub builder_pubkeys: Vec, + pub max_execution_payment: Option, + pub min_bid: Option, + pub builder_boost_factor: Option, +} + +fn parse_uint64(field: &str, value: &Option) -> Result> { + match value { + None => Ok(None), + Some(s) => { + let n = s.parse::().map_err(|err| { + eyre::eyre!("{field} must be a Uint64 JSON string, got {s:?}: {err}") + })?; + Ok(Some(n)) + } + } +} + +impl CanonicalDoc { + pub fn from_doc(doc: &BuilderConfigDoc) -> Result { + let builders = match &doc.builders { + None => None, + Some(entries) => { + let mut out = Vec::with_capacity(entries.len()); + for entry in entries { + let auth_data = match &entry.auth_data { + None => None, + Some(hex) => Some(decode_auth_data(hex)?), + }; + let mut builder_pubkeys: Vec = entry + .builder_pubkeys + .clone() + .unwrap_or_default() + .iter() + .map(|pk| pk.to_lowercase()) + .collect(); + builder_pubkeys.sort(); + out.push(CanonicalEntry { + url: entry.url.clone(), + auth_data, + builder_pubkeys, + max_execution_payment: parse_uint64( + "max_execution_payment", + &entry.max_execution_payment, + )?, + min_bid: parse_uint64("min_bid", &entry.min_bid)?, + builder_boost_factor: parse_uint64( + "builder_boost_factor", + &entry.builder_boost_factor, + )?, + }); + } + out.sort(); + Some(out) + } + }; + Ok(Self { + min_bid: parse_uint64("min_bid", &doc.min_bid)?, + builder_boost_factor: parse_uint64("builder_boost_factor", &doc.builder_boost_factor)?, + builders, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_data_round_trips_lowercase() { + let bytes = b"https://builder.example.com"; + let hex = encode_auth_data(bytes); + assert_eq!(hex, "0x68747470733a2f2f6275696c6465722e6578616d706c652e636f6d"); + assert_eq!(decode_auth_data(&hex).unwrap(), bytes); + } + + #[test] + fn decode_accepts_uppercase_hex() { + assert_eq!(decode_auth_data("0xDEADBEEF").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]); + } + + #[test] + fn decode_rejects_bad_forms() { + assert!(decode_auth_data("deadbeef").is_err()); + assert!(decode_auth_data("0x").is_err()); + assert!(decode_auth_data("0xabc").is_err()); + assert!(decode_auth_data("0xzz").is_err()); + } + + #[test] + fn empty_doc_serializes_to_empty_object() { + let doc = BuilderConfigDoc::default(); + assert_eq!(serde_json::to_string(&doc).unwrap(), "{}"); + } + + #[test] + fn omitted_fields_stay_off_the_wire() { + let doc = BuilderConfigDoc { + min_bid: Some("10000000".into()), + builder_boost_factor: None, + builders: Some(vec![BuilderEntryDoc { + url: "https://cb.example.com".into(), + auth_data: Some("0xaa".into()), + builder_pubkeys: Some(vec![]), + max_execution_payment: None, + min_bid: None, + builder_boost_factor: None, + }]), + }; + let json = serde_json::to_string(&doc).unwrap(); + assert_eq!( + json, + r#"{"min_bid":"10000000","builders":[{"url":"https://cb.example.com","auth_data":"0xaa","builder_pubkeys":[]}]}"# + ); + } + + #[test] + fn canonical_compare_ignores_order_and_hex_case() { + let entry = |auth: &str, pks: Vec<&str>| BuilderEntryDoc { + url: "https://cb.example.com".into(), + auth_data: Some(auth.into()), + builder_pubkeys: Some(pks.into_iter().map(String::from).collect()), + max_execution_payment: None, + min_bid: Some("5".into()), + builder_boost_factor: None, + }; + let a = BuilderConfigDoc { + min_bid: Some("5".into()), + builder_boost_factor: None, + builders: Some(vec![entry("0xaabb", vec!["0xAA", "0xBB"]), entry("0x0102", vec![])]), + }; + let b = BuilderConfigDoc { + min_bid: Some("5".into()), + builder_boost_factor: None, + builders: Some(vec![entry("0x0102", vec![]), entry("0xAABB", vec!["0xbb", "0xaa"])]), + }; + assert_eq!(CanonicalDoc::from_doc(&a).unwrap(), CanonicalDoc::from_doc(&b).unwrap()); + } + + #[test] + fn canonical_compare_distinguishes_values() { + let a = BuilderConfigDoc { min_bid: Some("5".into()), ..Default::default() }; + let b = BuilderConfigDoc { min_bid: Some("6".into()), ..Default::default() }; + assert_ne!(CanonicalDoc::from_doc(&a).unwrap(), CanonicalDoc::from_doc(&b).unwrap()); + } + + #[test] + fn canonical_rejects_non_numeric_uint64() { + let doc = BuilderConfigDoc { min_bid: Some("1e9".into()), ..Default::default() }; + assert!(CanonicalDoc::from_doc(&doc).is_err()); + } + + #[test] + fn canonical_empty_builders_differs_from_omitted() { + let empty = BuilderConfigDoc { builders: Some(vec![]), ..Default::default() }; + let omitted = BuilderConfigDoc::default(); + assert_ne!( + CanonicalDoc::from_doc(&empty).unwrap(), + CanonicalDoc::from_doc(&omitted).unwrap() + ); + } +} diff --git a/crates/km-tool/src/lib.rs b/crates/km-tool/src/lib.rs new file mode 100644 index 000000000..a1f789044 --- /dev/null +++ b/crates/km-tool/src/lib.rs @@ -0,0 +1,10 @@ +//! cb-km-tool: reference keymanager builder_config projector for Commit-Boost +//! mux configs (keymanager-APIs #88). Library-first: `project` turns a CB +//! config + operational overlay into per-key KM docs; `apply` POSTs them to +//! validator clients; `check` compares stored docs canonically. + +pub mod doc; +pub mod overlay; + +pub use doc::{BuilderConfigDoc, BuilderEntryDoc, CanonicalDoc}; +pub use overlay::Overlay; diff --git a/crates/km-tool/src/overlay.rs b/crates/km-tool/src/overlay.rs new file mode 100644 index 000000000..94d35f66d --- /dev/null +++ b/crates/km-tool/src/overlay.rs @@ -0,0 +1,131 @@ +//! The operational overlay: where/how to apply, kept out of the CB config. +//! Fleet-describing fields stay in the CB mux config; this file carries only +//! the advertised sidecar URL, the VC endpoints, and the per-mux fallbacks for +//! MuxConfig fields this cb-common revision does not carry yet (see mux_ext). + +use std::{collections::BTreeMap, path::Path}; + +use eyre::{Context, Result, ensure}; +use serde::Deserialize; +use url::Url; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Overlay { + /// URL the VCs should send builder requests to (Commit-Boost's endpoint). + /// Kept as the exact configured string: `Url` round-trip serialization + /// normalizes (adds a trailing slash) and the entry `url` must stay as + /// written. + pub advertised_url: String, + #[serde(default)] + pub vcs: Vec, + #[serde(default)] + pub per_mux: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VcConfig { + pub url: Url, + pub token_path: String, + /// Per-VC override of the advertised URL + pub advertised_url: Option, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PerMuxOverlay { + pub builder_boost_factor: Option, + pub min_bid_gwei: Option, +} + +impl Overlay { + pub fn parse_str(s: &str) -> Result { + let overlay: Self = toml::from_str(s).wrap_err("could not parse overlay TOML")?; + overlay.validate()?; + Ok(overlay) + } + + pub fn from_file(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .wrap_err_with(|| format!("unable to read overlay file: {path:?}"))?; + Self::parse_str(&text) + } + + fn validate(&self) -> Result<()> { + for url in std::iter::once(&self.advertised_url) + .chain(self.vcs.iter().filter_map(|vc| vc.advertised_url.as_ref())) + { + ensure!(Url::parse(url).is_ok(), "advertised_url is not a valid URL: {url}"); + } + Ok(()) + } + + /// The advertised URL in effect for one VC. + pub fn advertised_url_for<'a>(&'a self, vc: &'a VcConfig) -> &'a str { + vc.advertised_url.as_deref().unwrap_or(&self.advertised_url) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal_overlay() { + let overlay = Overlay::parse_str(r#"advertised_url = "https://cb.example.com""#).unwrap(); + assert_eq!(overlay.advertised_url, "https://cb.example.com"); + assert!(overlay.vcs.is_empty()); + assert!(overlay.per_mux.is_empty()); + } + + #[test] + fn parses_full_overlay_and_vc_override() { + let overlay = Overlay::parse_str( + r#" + advertised_url = "https://cb.example.com" + + [[vcs]] + url = "http://vc1:7500" + token_path = "/tmp/token1" + + [[vcs]] + url = "http://vc2:7500" + token_path = "/tmp/token2" + advertised_url = "https://cb2.example.com" + + [per_mux.mux1] + builder_boost_factor = 90 + min_bid_gwei = 10000000 + "#, + ) + .unwrap(); + assert_eq!(overlay.vcs.len(), 2); + assert_eq!(overlay.advertised_url_for(&overlay.vcs[0]), "https://cb.example.com"); + assert_eq!(overlay.advertised_url_for(&overlay.vcs[1]), "https://cb2.example.com"); + let per_mux = overlay.per_mux.get("mux1").unwrap(); + assert_eq!(per_mux.builder_boost_factor, Some(90)); + assert_eq!(per_mux.min_bid_gwei, Some(10_000_000)); + } + + #[test] + fn requires_advertised_url() { + assert!(Overlay::parse_str("").is_err()); + } + + #[test] + fn rejects_invalid_advertised_url() { + assert!(Overlay::parse_str(r#"advertised_url = "not a url""#).is_err()); + } + + #[test] + fn rejects_unknown_fields() { + assert!( + Overlay::parse_str( + r#"advertised_url = "https://a.com" +typo_field = 1"# + ) + .is_err() + ); + } +} From b427ef09f101f0e6280ec963b91e9e50143df956 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:01:14 -0700 Subject: [PATCH 09/80] feat(common): advertised_urls + projection-only mux fields + warn-on-unknown mux keys Three config additions for the ePBS KM builder_config flow: - PbsConfig.advertised_urls: CB's externally-reachable URLs, consumed by the ePBS pipe self-URL guard (an unconfigured key's auth_data defaults to CB's own URL; the guard must recognize it instead of self-dialing). - MuxConfig.builder_boost_factor and MuxConfig.min_bid_eth (as_opt_eth_str, same codec as the global min_bid_eth): projection-only fields consumed by KM tooling that projects the mux config into per-key keymanager builder_config docs; the PBS runtime parses them, logs an INFO note at mux load, and otherwise ignores them. - warn-on-unknown [[mux]] keys: MuxConfig is shared with the legacy get_header path, so serde(deny_unknown_fields) would reject previously-valid configs; instead the config loaders make a best-effort pass over the raw TOML and WARN on any unrecognized mux key, giving typo visibility without a breaking change. --- config.example.toml | 13 +++ crates/common/src/config/mod.rs | 2 + crates/common/src/config/mux.rs | 145 ++++++++++++++++++++++++++++- crates/common/src/config/pbs.rs | 6 +- crates/common/src/config/signer.rs | 1 + crates/common/src/utils.rs | 43 +++++++++ tests/src/utils.rs | 1 + tests/tests/pbs_cfg_file_update.rs | 1 + 8 files changed, 210 insertions(+), 2 deletions(-) diff --git a/config.example.toml b/config.example.toml index 259c8211e..a6d442a67 100644 --- a/config.example.toml +++ b/config.example.toml @@ -92,6 +92,11 @@ validator_registration_batch_size = "" # enabled, this value will not be used. # OPTIONAL, DEFAULT: 384 mux_registry_refresh_interval_seconds = 384 +# Commit-Boost's externally-reachable URLs, i.e. the ones a validator client's builder config points +# at. Used by the ePBS pipe self-URL guard: auth data routing back to one of these URLs is rejected +# instead of dialed, preventing a self-dial loop +# OPTIONAL, DEFAULT: [] +# advertised_urls = ["http://cb.example.com:18550"] # The PBS module needs one or more [[relays]] as defined below. [[relays]] @@ -177,6 +182,14 @@ validator_pubkeys = [ # Expected fee recipient in ePBS bids for this mux's validators # OPTIONAL, DEFAULT: the PBS-level `fee_recipient` # fee_recipient = "0x1234567890123456789012345678901234567890" +# Projection-only: consumed by KM tooling (builder_config projection for this mux's keys), not read +# by the PBS runtime +# OPTIONAL, DEFAULT: unset +# builder_boost_factor = 100 +# Projection-only: the per-key-group minimum total payment for this mux's keys, in ETH (same format +# as the PBS-level `min_bid_eth`). Consumed by KM tooling, not read by the PBS runtime +# OPTIONAL, DEFAULT: unset +# min_bid_eth = 0.0 # Loader for validator pubkeys. Three types of loaders are supported: # - File: path to a file containing a list of validator pubkeys in JSON format # - URL: URL to an HTTP endpoint returning a list of validator pubkeys in JSON format diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index e0958342c..616ec9f4c 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -57,6 +57,7 @@ impl CommitBoostConfig { pub fn from_file(path: &PathBuf) -> Result { let (config, _): (Self, _) = load_from_file(path)?; + warn_unknown_mux_fields(path); Ok(config) } @@ -64,6 +65,7 @@ impl CommitBoostConfig { // is replaced with the correct value if the config is loaded inside a container pub fn from_env_path() -> Result<(Self, PathBuf)> { let (helper_config, config_path): (HelperConfig, PathBuf) = load_file_from_env(CONFIG_ENV)?; + warn_unknown_mux_fields(&config_path); let chain = match helper_config.chain { ChainLoader::Path { path, genesis_time_secs } => { diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index fb8390c3f..bdd934015 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -24,7 +24,7 @@ use crate::{ interop::{lido::utils::*, ssv::utils::*, stader::utils::*}, pbs::RelayClient, types::{BlsPublicKey, Chain, StaderPool}, - utils::default_bool, + utils::{as_opt_eth_str, default_bool}, wire::safe_read_http_response, }; @@ -100,6 +100,19 @@ impl PbsMuxes { "using mux" ); + if mux.builder_boost_factor.is_some() { + info!( + "field builder_boost_factor on mux {} is applied via KM tooling, not by the PBS runtime", + mux.id + ); + } + if mux.min_bid_wei.is_some() { + info!( + "field min_bid_eth on mux {} is applied via KM tooling, not by the PBS runtime", + mux.id + ); + } + let mut relay_clients = Vec::with_capacity(mux.relays.len()); for config in mux.relays.into_iter() { relay_clients.push(RelayClient::new(config)?); @@ -156,6 +169,13 @@ pub struct MuxConfig { pub late_in_slot_time_ms: Option, /// Expected fee recipient in ePBS bids for this mux's validators pub fee_recipient: Option
, + /// Projection-only: consumed by KM tooling, not read by the PBS runtime. + /// The ePBS builder_boost_factor for this mux's keys + pub builder_boost_factor: Option, + /// Projection-only: consumed by KM tooling, not read by the PBS runtime. + /// The ePBS per-key-group minimum total payment for this mux's keys + #[serde(rename = "min_bid_eth", with = "as_opt_eth_str", default)] + pub min_bid_wei: Option, } impl MuxConfig { @@ -309,6 +329,56 @@ impl MuxKeysLoader { } } +/// The keys serde recognizes on a `[[mux]]` table. Kept in lockstep with +/// [`MuxConfig`] (use the serde-renamed form). +const KNOWN_MUX_FIELDS: &[&str] = &[ + "id", + "relays", + "validator_pubkeys", + "loader", + "timeout_get_header_ms", + "late_in_slot_time_ms", + "fee_recipient", + "builder_boost_factor", + "min_bid_eth", +]; + +/// Unknown keys on the `[[mux]]` tables of a raw config document, as +/// (mux id, key) pairs. `MuxConfig` cannot take `serde(deny_unknown_fields)` +/// (it would reject previously-valid configs carrying stray fields), so typo +/// visibility comes from this extra pass over the raw TOML instead. +pub fn unknown_mux_fields(raw: &toml::Value) -> Vec<(String, String)> { + let Some(muxes) = raw.get("mux").and_then(|value| value.as_array()) else { + return Vec::new(); + }; + + let mut unknown = Vec::new(); + for (i, mux) in muxes.iter().enumerate() { + let Some(table) = mux.as_table() else { continue }; + let id = table + .get("id") + .and_then(|value| value.as_str()) + .map(str::to_owned) + .unwrap_or_else(|| format!("#{i}")); + for key in table.keys() { + if !KNOWN_MUX_FIELDS.contains(&key.as_str()) { + unknown.push((id.clone(), key.clone())); + } + } + } + unknown +} + +/// WARN-logs every unknown `[[mux]]` key in the config file at `path`. +/// Best-effort: unreadable/unparseable input is serde's problem to report. +pub fn warn_unknown_mux_fields(path: &Path) { + let Ok(raw) = std::fs::read_to_string(path) else { return }; + let Ok(value) = raw.parse::() else { return }; + for (mux_id, key) in unknown_mux_fields(&value) { + warn!("unknown field `{key}` on mux `{mux_id}` is ignored by the PBS runtime"); + } +} + fn load_file + std::fmt::Debug>(path: P) -> eyre::Result { std::fs::read_to_string(&path).wrap_err(format!("Unable to find mux keys file: {path:?}")) } @@ -550,3 +620,76 @@ async fn fetch_ssv_pubkeys_from_public_api( Ok(pubkeys) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_projection_only_mux_fields() { + let mux: MuxConfig = toml::from_str( + r#" + id = "test" + relays = [] + builder_boost_factor = 120 + min_bid_eth = "0.5" + "#, + ) + .unwrap(); + assert_eq!(mux.builder_boost_factor, Some(120)); + assert_eq!(mux.min_bid_wei, Some(U256::from(500_000_000_000_000_000u64))); + + // Float form, matching the global min_bid_eth + let mux: MuxConfig = toml::from_str( + r#" + id = "test" + relays = [] + min_bid_eth = 0.5 + "#, + ) + .unwrap(); + assert_eq!(mux.min_bid_wei, Some(U256::from(500_000_000_000_000_000u64))); + + // Absent fields stay None (legacy configs parse unchanged) + let mux: MuxConfig = toml::from_str( + r#" + id = "test" + relays = [] + "#, + ) + .unwrap(); + assert_eq!(mux.builder_boost_factor, None); + assert_eq!(mux.min_bid_wei, None); + } + + #[test] + fn unknown_mux_fields_flags_typos_only() { + let raw: toml::Value = r#" + [[mux]] + id = "a" + relays = [] + builder_boost_factor = 100 + min_bid_eth = "0.1" + bulder_boost_factor = 100 + + [[mux]] + id = "b" + relays = [] + timeout_get_header_ms = 900 + + [[mux]] + relays = [] + stray = 1 + "# + .parse() + .unwrap(); + assert_eq!(unknown_mux_fields(&raw), vec![ + ("a".to_string(), "bulder_boost_factor".to_string()), + ("#2".to_string(), "stray".to_string()), + ]); + + // No [[mux]] tables at all + let raw: toml::Value = "[pbs]\nport = 1".parse().unwrap(); + assert!(unknown_mux_fields(&raw).is_empty()); + } +} diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 1577dbe3f..8f01bbe72 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -184,6 +184,9 @@ pub struct PbsConfig { /// from the registry, in seconds #[serde(default = "default_u64::<{ DEFAULT_REGISTRY_REFRESH_SECONDS }>")] pub mux_registry_refresh_interval_seconds: u64, + /// CB's externally-reachable URLs; used by the ePBS pipe self-URL guard + #[serde(default)] + pub advertised_urls: Vec, } impl PbsConfig { @@ -389,7 +392,8 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC } // load module config including the extra data (if any) - let (cb_config, _): (StubConfig, _) = load_file_from_env(CONFIG_ENV)?; + let (cb_config, config_path): (StubConfig, _) = load_file_from_env(CONFIG_ENV)?; + super::warn_unknown_mux_fields(&config_path); cb_config.pbs.static_config.validate(cb_config.chain).await?; // use endpoint from env if set, otherwise use default host and port diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index a0bf66f3a..0811100ab 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -487,6 +487,7 @@ mod tests { mux_registry_refresh_interval_seconds: 5, ssv_node_api_url: Url::parse("https://example.net").unwrap(), ssv_public_api_url: Url::parse("https://example.net").unwrap(), + advertised_urls: vec![], }, with_signer: true, }, diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 659a4cb55..50a3bb711 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -121,6 +121,49 @@ pub mod as_eth_str { } } +/// `as_eth_str` for an optional field: absent stays `None` instead of being +/// forced through the ETH-string codec. +pub mod as_opt_eth_str { + use alloy::primitives::{ + U256, + utils::{format_ether, parse_ether}, + }; + use serde::{Deserialize, Deserializer, Serializer}; + + use super::eth_to_wei; + + pub fn serialize(data: &Option, serializer: S) -> Result + where + S: Serializer, + { + match data { + Some(wei) => serializer.serialize_str(&format_ether(*wei)), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrF64 { + Str(String), + F64(f64), + } + + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(StringOrF64::Str(s)) => Some( + parse_ether(&s).map_err(|_| serde::de::Error::custom("invalid eth amount"))?, + ), + Some(StringOrF64::F64(f)) => Some(eth_to_wei(f)), + None => None, + }) + } +} + pub const fn default_u64() -> u64 { U } diff --git a/tests/src/utils.rs b/tests/src/utils.rs index 2c2d3bf8e..000b1a69d 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -196,6 +196,7 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { register_validator_retry_limit: u32::MAX, validator_registration_batch_size: None, mux_registry_refresh_interval_seconds: 5, + advertised_urls: vec![], } } diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index f5e3e0b93..9d1614a4b 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -77,6 +77,7 @@ async fn test_cfg_file_update() -> Result<()> { register_validator_retry_limit: 3, validator_registration_batch_size: None, mux_registry_refresh_interval_seconds: 384, + advertised_urls: vec![], }; let cb_config = CommitBoostConfig { chain, From 3adeb9bf7c97c18d80b6b67b6f827d7a2dcbc7a2 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:01:35 -0700 Subject: [PATCH 10/80] feat(km-tool): projection library project() turns a CB mux config plus overlay into per-key KM docs: one entry per auth_data equivalence class (identical candidate bytes: expected_auth_data, else the configured relay URL with userinfo stripped by string surgery so no normalization touches the bytes), builder_pubkeys as the class union, entries sorted by (url, auth_data bytes). The raw TOML is parsed alongside cb-common's parser because RelayEntry holds a normalized Url while the auth_data convention wants the URL bytes exactly as configured. MuxProjectionFields is the seam for the upcoming MuxConfig builder_boost_factor/min_bid fields; the overlay per_mux map is the fallback source until then. File key-loaders resolve offline; HTTP and registry loaders are skipped with a warning. Enforced at projection time: 64-entry and 64-pubkey KM maxima, auth_data 1..=4096 bytes, per-class max_execution_payment_gwei agreement, no duplicate keys across muxes. --- Cargo.lock | 2 + crates/km-tool/Cargo.toml | 2 + crates/km-tool/src/lib.rs | 3 + crates/km-tool/src/mux_ext.rs | 24 + crates/km-tool/src/project.rs | 833 ++++++++++++++++++++++++++++++++++ 5 files changed, 864 insertions(+) create mode 100644 crates/km-tool/src/mux_ext.rs create mode 100644 crates/km-tool/src/project.rs diff --git a/Cargo.lock b/Cargo.lock index 032298c3d..2a7a07848 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1917,6 +1917,8 @@ dependencies = [ name = "cb-km-tool" version = "0.11.0" dependencies = [ + "alloy-primitives 1.6.1", + "cb-common", "eyre", "serde", "serde_json", diff --git a/crates/km-tool/Cargo.toml b/crates/km-tool/Cargo.toml index 7d46382e7..17b3dba72 100644 --- a/crates/km-tool/Cargo.toml +++ b/crates/km-tool/Cargo.toml @@ -6,6 +6,8 @@ rust-version.workspace = true version.workspace = true [dependencies] +alloy-primitives.workspace = true +cb-common.workspace = true eyre.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/km-tool/src/lib.rs b/crates/km-tool/src/lib.rs index a1f789044..851f7d301 100644 --- a/crates/km-tool/src/lib.rs +++ b/crates/km-tool/src/lib.rs @@ -4,7 +4,10 @@ //! validator clients; `check` compares stored docs canonically. pub mod doc; +pub mod mux_ext; pub mod overlay; +pub mod project; pub use doc::{BuilderConfigDoc, BuilderEntryDoc, CanonicalDoc}; pub use overlay::Overlay; +pub use project::{Projection, ProjectionInput, project, project_with_url}; diff --git a/crates/km-tool/src/mux_ext.rs b/crates/km-tool/src/mux_ext.rs new file mode 100644 index 000000000..545ebb454 --- /dev/null +++ b/crates/km-tool/src/mux_ext.rs @@ -0,0 +1,24 @@ +//! Seam for MuxConfig fields the projection reads but this cb-common revision +//! does not carry yet (`builder_boost_factor`, `min_bid`). Once cb-common +//! gains them, implement these accessors to read the config fields; the +//! overlay's per-mux map stays as the fallback source below them. + +use alloy_primitives::U256; +use cb_common::config::MuxConfig; + +pub trait MuxProjectionFields { + /// Per-mux builder boost factor, when the config schema carries it. + fn projected_boost_factor(&self) -> Option; + /// Per-mux minimum bid in wei, when the config schema carries it. + fn projected_min_bid_wei(&self) -> Option; +} + +impl MuxProjectionFields for MuxConfig { + fn projected_boost_factor(&self) -> Option { + None + } + + fn projected_min_bid_wei(&self) -> Option { + None + } +} diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs new file mode 100644 index 000000000..efe1ad208 --- /dev/null +++ b/crates/km-tool/src/project.rs @@ -0,0 +1,833 @@ +//! Pure projection: CB mux config + overlay -> per-key KM builder_config docs. +//! +//! One KM entry per auth_data EQUIVALENCE CLASS: relays whose candidate +//! auth_data byte-strings are identical share one entry whose +//! `builder_pubkeys` is the union of the class's relay pubkeys. The candidate +//! is `expected_auth_data` when set, else the UTF-8 bytes of the relay URL as +//! configured with the userinfo stripped. Grouping-by-identical-bytes is a +//! reimplementation of the demux contract of cb-pbs's +//! `match_relays_by_auth_data` (pub(crate) there); the CB-side contract tests +//! pin those semantics. CAVEAT (also in the plan): CB's own matching is LAXER +//! (`url_matches` ignores userinfo/path/case), so a projected auth_data can +//! round-trip against CB and still mismatch a builder's exact-byte check; +//! prefer `expected_auth_data` when the relay URL is not byte-identical to +//! the builder's advertised URL. + +use std::{ + collections::{BTreeMap, BTreeSet, HashSet}, + path::Path, +}; + +use alloy_primitives::U256; +use cb_common::{ + config::{CommitBoostConfig, MUX_PATH_ENV, MuxConfig, RelayConfig, load_optional_env_var}, + types::BlsPublicKey, +}; +use eyre::{Context, Result, bail, ensure}; +use tracing::warn; + +use crate::{ + doc::{BuilderConfigDoc, BuilderEntryDoc, encode_auth_data}, + mux_ext::MuxProjectionFields, + overlay::Overlay, +}; + +/// KM spec limits (builder_entry.yaml) +pub const MAX_BUILDER_ENTRIES: usize = 64; +pub const MAX_BUILDER_PUBKEYS: usize = 64; +pub const MAX_AUTH_DATA_SIZE: usize = 4096; + +const WEI_PER_GWEI: u64 = 1_000_000_000; + +/// A BLS pubkey ordered by its compressed bytes, so projections are +/// deterministic maps (lighthouse's `PublicKey` has no `Ord`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrderedPubkey(pub BlsPublicKey); + +impl Ord for OrderedPubkey { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.serialize().cmp(&other.0.serialize()) + } +} + +impl PartialOrd for OrderedPubkey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::fmt::Display for OrderedPubkey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.as_hex_string()) + } +} + +/// The parsed CB config plus the RAW relay URL strings from the same TOML +/// text. The raw strings matter: `RelayEntry` holds a parsed `Url`, and `Url` +/// serialization normalizes (adds the trailing slash to an empty path, drops +/// default ports), while the auth_data convention wants the URL bytes exactly +/// as configured. +pub struct ProjectionInput { + pub cfg: CommitBoostConfig, + mux_relay_urls: Vec>, + default_relay_urls: Vec, +} + +impl ProjectionInput { + pub fn parse_str(text: &str) -> Result { + let cfg: CommitBoostConfig = + toml::from_str(text).wrap_err("could not parse Commit-Boost config")?; + let raw: toml::Value = toml::from_str(text)?; + + let mux_relay_urls = raw_urls_per_mux(&raw)?; + let mux_count = cfg.muxes.as_ref().map(|m| m.muxes.len()).unwrap_or(0); + ensure!( + mux_relay_urls.len() == mux_count, + "raw TOML mux count {} does not match parsed config {}", + mux_relay_urls.len(), + mux_count + ); + if let Some(muxes) = &cfg.muxes { + for (mux, urls) in muxes.muxes.iter().zip(&mux_relay_urls) { + ensure!( + mux.relays.len() == urls.len(), + "raw TOML relay count does not match parsed config in mux {}", + mux.id + ); + } + } + + let default_relay_urls = raw_url_array(raw.get("relays"))?; + ensure!( + default_relay_urls.len() == cfg.relays.len(), + "raw TOML default-relay count does not match parsed config" + ); + + Ok(Self { cfg, mux_relay_urls, default_relay_urls }) + } + + pub fn from_file(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .wrap_err_with(|| format!("unable to read config file: {path:?}"))?; + Self::parse_str(&text) + } +} + +fn raw_urls_per_mux(raw: &toml::Value) -> Result>> { + let Some(muxes) = raw.get("mux") else { + return Ok(vec![]); + }; + let muxes = muxes.as_array().ok_or_else(|| eyre::eyre!("mux is not an array"))?; + muxes.iter().map(|mux| raw_url_array(mux.get("relays"))).collect() +} + +fn raw_url_array(relays: Option<&toml::Value>) -> Result> { + let Some(relays) = relays else { + return Ok(vec![]); + }; + let relays = relays.as_array().ok_or_else(|| eyre::eyre!("relays is not an array"))?; + relays + .iter() + .map(|relay| { + relay + .get("url") + .and_then(|u| u.as_str()) + .map(String::from) + .ok_or_else(|| eyre::eyre!("relay entry has no url string")) + }) + .collect() +} + +/// Strips the userinfo from a URL by string surgery, leaving everything else +/// exactly as written (no trailing-slash or port normalization). +pub fn strip_userinfo(url: &str) -> String { + let Some(scheme_end) = url.find("://") else { + return url.to_string(); + }; + let after = &url[scheme_end + 3..]; + let authority_end = after.find(['/', '?', '#']).unwrap_or(after.len()); + match after[..authority_end].rfind('@') { + Some(at) => format!("{}{}", &url[..scheme_end + 3], &after[at + 1..]), + None => url.to_string(), + } +} + +/// One relay's candidate auth_data bytes: `expected_auth_data` when set, else +/// the UTF-8 bytes of the configured URL with userinfo stripped. +fn candidate_auth_data(relay: &RelayConfig, raw_url: &str) -> Vec { + match &relay.expected_auth_data { + Some(expected) => expected.to_vec(), + None => strip_userinfo(raw_url).into_bytes(), + } +} + +/// Where a relay's candidate auth_data came from, for check-side routing and +/// reference findings. +#[derive(Debug, Clone)] +pub struct RelayAuthCandidate { + /// Mux id, or "[[relays]]" for the default relay list + pub source: String, + pub relay_id: String, + pub bytes: Vec, + /// Whether this relay is part of a projected mux (default relays are not) + pub projected: bool, +} + +#[derive(Debug)] +pub struct Projection { + pub docs: BTreeMap, + pub warnings: Vec, + /// Candidate auth_data of every configured relay (muxes and defaults) + pub relay_candidates: Vec, +} + +/// Projects per-key KM docs with the overlay's global advertised URL. +pub fn project(input: &ProjectionInput, overlay: &Overlay) -> Result { + project_with_url(input, overlay, &overlay.advertised_url) +} + +/// Projects with an explicit advertised URL (per-VC overrides). +pub fn project_with_url( + input: &ProjectionInput, + overlay: &Overlay, + advertised_url: &str, +) -> Result { + let mut warnings = Vec::new(); + let mut docs = BTreeMap::new(); + let mut relay_candidates = Vec::new(); + let mut seen_keys: HashSet> = HashSet::new(); + + for id in overlay.per_mux.keys() { + let known = input + .cfg + .muxes + .as_ref() + .is_some_and(|muxes| muxes.muxes.iter().any(|mux| &mux.id == id)); + if !known { + push_warn(&mut warnings, format!("overlay per_mux entry {id:?} matches no mux")); + } + } + + if let Some(muxes) = &input.cfg.muxes { + for (mux, raw_urls) in muxes.muxes.iter().zip(&input.mux_relay_urls) { + let keys = resolve_mux_keys(mux, &mut warnings)?; + let doc = project_mux(input, overlay, mux, raw_urls, advertised_url, &mut warnings)?; + + for relay in mux.relays.iter().zip(raw_urls) { + relay_candidates.push(RelayAuthCandidate { + source: mux.id.clone(), + relay_id: relay.0.id().to_string(), + bytes: candidate_auth_data(relay.0, relay.1), + projected: !keys.is_empty(), + }); + } + + for key in keys { + let bytes = key.serialize().to_vec(); + if !seen_keys.insert(bytes) { + bail!("duplicate validator pubkey in muxes: {}", key.as_hex_string()); + } + docs.insert(OrderedPubkey(key), doc.clone()); + } + } + } + + for (relay, raw_url) in input.cfg.relays.iter().zip(&input.default_relay_urls) { + relay_candidates.push(RelayAuthCandidate { + source: "[[relays]]".to_string(), + relay_id: relay.id().to_string(), + bytes: candidate_auth_data(relay, raw_url), + projected: false, + }); + } + + Ok(Projection { docs, warnings, relay_candidates }) +} + +fn push_warn(warnings: &mut Vec, msg: String) { + warn!("{msg}"); + warnings.push(msg); +} + +/// Resolves a mux's key set. Explicit `validator_pubkeys` always project. Of +/// the loaders only the File variant resolves offline; HTTP and Registry +/// loaders need the network and are skipped with a warning in v1 (schedule +/// km-apply/km-check when using them, per the plan's drift bounds). +fn resolve_mux_keys(mux: &MuxConfig, warnings: &mut Vec) -> Result> { + let mut keys = mux.validator_pubkeys.clone(); + + if let Some(loader) = &mux.loader { + match loader { + cb_common::config::MuxKeysLoader::File(path) => { + // same semantics as MuxKeysLoader::load: env var overrides path + let path = load_optional_env_var(&format!("{MUX_PATH_ENV}_{}", mux.id)) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| path.clone()); + let file = std::fs::read_to_string(&path) + .wrap_err_with(|| format!("unable to read mux keys file: {path:?}"))?; + let extra: Vec = + serde_json::from_str(&file).wrap_err("failed to parse mux keys file")?; + keys.extend(extra); + } + other => { + push_warn( + warnings, + format!( + "mux {}: loader {:?} needs the network and is not resolved by this tool; \ + its keys are NOT projected (best-effort only)", + mux.id, other + ), + ); + } + } + } + + let keys = cb_common::config::remove_duplicate_keys(keys); + if keys.is_empty() { + push_warn(warnings, format!("mux {}: no projectable keys, skipping", mux.id)); + } + Ok(keys) +} + +struct AuthClass { + relay_ids: Vec, + builder_pubkeys: BTreeSet, + max_execution_payment_gwei: Option, +} + +fn project_mux( + input: &ProjectionInput, + overlay: &Overlay, + mux: &MuxConfig, + raw_urls: &[String], + advertised_url: &str, + warnings: &mut Vec, +) -> Result { + ensure!(!mux.relays.is_empty(), "mux {} has no relays", mux.id); + + // group relays into auth_data equivalence classes by identical bytes + let mut classes: BTreeMap, AuthClass> = BTreeMap::new(); + for (relay, raw_url) in mux.relays.iter().zip(raw_urls) { + let bytes = candidate_auth_data(relay, raw_url); + ensure!( + !bytes.is_empty() && bytes.len() <= MAX_AUTH_DATA_SIZE, + "mux {} relay {}: auth_data must be 1..={MAX_AUTH_DATA_SIZE} bytes, got {}", + mux.id, + relay.id(), + bytes.len() + ); + let class = classes.entry(bytes).or_insert_with(|| AuthClass { + relay_ids: vec![], + builder_pubkeys: BTreeSet::new(), + max_execution_payment_gwei: relay.max_execution_payment_gwei, + }); + ensure!( + class.max_execution_payment_gwei == relay.max_execution_payment_gwei, + "mux {}: relays {} and {} share an auth_data class but disagree on \ + max_execution_payment_gwei ({:?} vs {:?}); a KM entry carries one cap", + mux.id, + class.relay_ids.first().cloned().unwrap_or_default(), + relay.id(), + class.max_execution_payment_gwei, + relay.max_execution_payment_gwei + ); + class.relay_ids.push(relay.id().to_string()); + class.builder_pubkeys.insert(relay.entry.pubkey.as_hex_string()); + } + + ensure!( + classes.len() <= MAX_BUILDER_ENTRIES, + "mux {}: {} builder entries exceed the KM maximum of {MAX_BUILDER_ENTRIES}", + mux.id, + classes.len() + ); + + let min_bid = resolve_min_bid(input, overlay, mux, warnings)?; + let boost = mux + .projected_boost_factor() + .or_else(|| overlay.per_mux.get(&mux.id).and_then(|m| m.builder_boost_factor)) + .map(|b| b.to_string()); + + // `(url, auth_data-bytes)` uniqueness: classes are keyed by bytes and all + // entries share the advertised URL, so uniqueness holds by construction; + // asserted anyway to keep the invariant loud. + let mut seen: HashSet<(&str, &[u8])> = HashSet::new(); + let mut entries = Vec::with_capacity(classes.len()); + // BTreeMap iterates classes in byte order = the KM (url, bytes) sort + for (bytes, class) in &classes { + ensure!( + seen.insert((advertised_url, bytes)), + "mux {}: duplicate (url, auth_data) pair", + mux.id + ); + ensure!( + class.builder_pubkeys.len() <= MAX_BUILDER_PUBKEYS, + "mux {}: entry has {} builder_pubkeys, KM maximum is {MAX_BUILDER_PUBKEYS}", + mux.id, + class.builder_pubkeys.len() + ); + entries.push(BuilderEntryDoc { + url: advertised_url.to_string(), + auth_data: Some(encode_auth_data(bytes)), + builder_pubkeys: Some(class.builder_pubkeys.iter().cloned().collect()), + max_execution_payment: class.max_execution_payment_gwei.map(|g| g.to_string()), + min_bid: Some(min_bid.clone()), + builder_boost_factor: boost.clone(), + }); + } + + Ok(BuilderConfigDoc { + min_bid: Some(min_bid), + builder_boost_factor: boost, + builders: Some(entries), + }) +} + +/// Per-mux min_bid in Gwei: the MuxConfig field when the schema has it (wei), +/// else the overlay per-mux value (already Gwei), else the global +/// `min_bid_wei`. Wei sources floor-divide with a warning on a sub-Gwei +/// remainder. +fn resolve_min_bid( + input: &ProjectionInput, + overlay: &Overlay, + mux: &MuxConfig, + warnings: &mut Vec, +) -> Result { + let gwei = if let Some(wei) = mux.projected_min_bid_wei() { + wei_to_gwei_floor(&mux.id, wei, warnings)? + } else if let Some(gwei) = overlay.per_mux.get(&mux.id).and_then(|m| m.min_bid_gwei) { + gwei + } else { + wei_to_gwei_floor(&mux.id, input.cfg.pbs.pbs_config.min_bid_wei, warnings)? + }; + Ok(gwei.to_string()) +} + +fn wei_to_gwei_floor(mux_id: &str, wei: U256, warnings: &mut Vec) -> Result { + let divisor = U256::from(WEI_PER_GWEI); + let gwei = wei / divisor; + if wei % divisor != U256::ZERO { + push_warn( + warnings, + format!( + "mux {mux_id}: min_bid {wei} wei has a sub-Gwei remainder, flooring to {gwei} Gwei" + ), + ); + } + gwei.try_into().map_err(|_| eyre::eyre!("mux {mux_id}: min_bid {wei} wei exceeds u64 Gwei")) +} + +#[cfg(test)] +mod tests { + use super::*; + + // relay pubkeys from config.example.toml (valid BLS points) + const RELAY_PK_A: &str = "0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2"; + const RELAY_PK_B: &str = "0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09fe73ccd21f88eab31d6de16194d17782e"; + + fn random_key_hex() -> String { + cb_common::types::BlsSecretKey::random().public_key().as_hex_string() + } + + fn overlay() -> Overlay { + Overlay::parse_str(r#"advertised_url = "https://cb.example.com""#).unwrap() + } + + #[test] + fn strip_userinfo_surgery() { + assert_eq!(strip_userinfo("https://0xabc@relay.example.com"), "https://relay.example.com"); + assert_eq!( + strip_userinfo("https://0xabc@relay.example.com:8443/path"), + "https://relay.example.com:8443/path" + ); + // no trailing slash is ADDED (Url::as_str would add one) + assert_eq!(strip_userinfo("http://pk@host"), "http://host"); + // no userinfo: unchanged + assert_eq!(strip_userinfo("https://host/x?q=1"), "https://host/x?q=1"); + // '@' after the authority is not userinfo + assert_eq!(strip_userinfo("https://host/a@b"), "https://host/a@b"); + } + + fn config_toml(keys: &[String]) -> String { + let keys = keys.iter().map(|k| format!("\"{k}\"")).collect::>().join(", "); + format!( + r#" +chain = "Holesky" + +[pbs] +port = 18550 +min_bid_eth = 0.5 + +[[mux]] +id = "mux1" +validator_pubkeys = [{keys}] + +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" + +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "0x736563726574" +"# + ) + } + + #[test] + fn projects_literal_json_doc() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key.clone()])).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + + assert_eq!(projection.docs.len(), 1); + let (pk, doc) = projection.docs.iter().next().unwrap(); + assert_eq!(pk.to_string(), key); + + // entries sorted by auth_data bytes: 0x736563726574 ("secret") sorts + // after the https URL bytes (0x68...) + let json = serde_json::to_string(doc).unwrap(); + assert_eq!( + json, + format!( + concat!( + r#"{{"min_bid":"500000000","builders":["#, + r#"{{"url":"https://cb.example.com","#, + r#""auth_data":"0x68747470733a2f2f72656c61792d612e6578616d706c652e636f6d","#, + r#""builder_pubkeys":["{pk_a}"],"min_bid":"500000000"}},"#, + r#"{{"url":"https://cb.example.com","#, + r#""auth_data":"0x736563726574","#, + r#""builder_pubkeys":["{pk_b}"],"min_bid":"500000000"}}]}}"# + ), + pk_a = RELAY_PK_A, + pk_b = RELAY_PK_B, + ) + ); + } + + #[test] + fn stripped_userinfo_no_trailing_slash_in_auth_data() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + let auth = doc.builders.as_ref().unwrap()[0].auth_data.clone().unwrap(); + // "https://relay-a.example.com" exactly: no pubkey, no trailing slash + assert_eq!( + crate::doc::decode_auth_data(&auth).unwrap(), + b"https://relay-a.example.com".to_vec() + ); + } + + #[test] + fn equivalence_class_unions_builder_pubkeys() { + let key = random_key_hex(); + // both relays carry the same expected_auth_data -> one entry, two pubkeys + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +expected_auth_data = "0xaabb" +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "0xaabb" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + let entries = doc.builders.as_ref().unwrap(); + assert_eq!(entries.len(), 1); + let mut expected = vec![RELAY_PK_A.to_string(), RELAY_PK_B.to_string()]; + expected.sort(); + assert_eq!(entries[0].builder_pubkeys.as_ref().unwrap(), &expected); + } + + #[test] + fn class_cap_disagreement_errors() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +expected_auth_data = "0xaabb" +max_execution_payment_gwei = 100 +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "0xaabb" +max_execution_payment_gwei = 200 +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let err = project(&input, &overlay()).unwrap_err(); + assert!(err.to_string().contains("max_execution_payment_gwei"), "{err}"); + } + + #[test] + fn relay_cap_projects_or_omits() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +max_execution_payment_gwei = 250000000 +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + let entries = doc.builders.as_ref().unwrap(); + assert_eq!(entries.len(), 2); + let by_cap: Vec<_> = entries.iter().map(|e| e.max_execution_payment.clone()).collect(); + assert!(by_cap.contains(&Some("250000000".to_string()))); + // the un-capped relay's entry OMITS the field (resolves to VC config) + assert!(by_cap.contains(&None)); + } + + #[test] + fn min_bid_falls_back_global_with_floor_warning() { + let key = random_key_hex(); + // 1.5 gwei in eth: 0.0000000015 eth = 1500000000 wei... use + // min_bid_eth for a sub-gwei remainder: 0.0000000000015 ETH = 1500 wei + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +min_bid_eth = 0.0000000000015 +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.min_bid, Some("0".to_string())); + assert!( + projection.warnings.iter().any(|w| w.contains("sub-Gwei")), + "{:?}", + projection.warnings + ); + } + + #[test] + fn overlay_per_mux_supplies_min_bid_and_boost() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); + let overlay = Overlay::parse_str( + r#" +advertised_url = "https://cb.example.com" +[per_mux.mux1] +builder_boost_factor = 90 +min_bid_gwei = 12345 +"#, + ) + .unwrap(); + let projection = project(&input, &overlay).unwrap(); + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.min_bid, Some("12345".to_string())); + assert_eq!(doc.builder_boost_factor, Some("90".to_string())); + let entry = &doc.builders.as_ref().unwrap()[0]; + assert_eq!(entry.min_bid, Some("12345".to_string())); + assert_eq!(entry.builder_boost_factor, Some("90".to_string())); + } + + #[test] + fn boost_omitted_without_source() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.builder_boost_factor, None); + assert_eq!(doc.builders.as_ref().unwrap()[0].builder_boost_factor, None); + } + + #[test] + fn unknown_per_mux_overlay_warns() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); + let overlay = Overlay::parse_str( + r#" +advertised_url = "https://cb.example.com" +[per_mux.no_such_mux] +builder_boost_factor = 90 +"#, + ) + .unwrap(); + let projection = project(&input, &overlay).unwrap(); + assert!(projection.warnings.iter().any(|w| w.contains("no_such_mux"))); + } + + #[test] + fn determinism_under_toml_permutation() { + let key_a = random_key_hex(); + let key_b = random_key_hex(); + let base = config_toml(&[key_a.clone(), key_b.clone()]); + // permute validator key order AND relay order + let permuted = format!( + r#" +chain = "Holesky" + +[pbs] +port = 18550 +min_bid_eth = 0.5 + +[[mux]] +id = "mux1" +validator_pubkeys = ["{key_b}", "{key_a}"] + +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "0x736563726574" + +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let overlay = overlay(); + let a = project(&ProjectionInput::parse_str(&base).unwrap(), &overlay).unwrap(); + let b = project(&ProjectionInput::parse_str(&permuted).unwrap(), &overlay).unwrap(); + let ser = |p: &Projection| { + p.docs + .iter() + .map(|(k, d)| (k.to_string(), serde_json::to_string(d).unwrap())) + .collect::>() + }; + assert_eq!(ser(&a), ser(&b)); + } + + #[test] + fn duplicate_key_across_muxes_errors() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m1" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +[[mux]] +id = "m2" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + assert!(project(&input, &overlay()).unwrap_err().to_string().contains("duplicate")); + } + + #[test] + fn network_loader_mux_warns_and_projects_explicit_keys() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +loader = {{ url = "http://localhost:8000/keys" }} +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + assert_eq!(projection.docs.len(), 1); + assert!(projection.warnings.iter().any(|w| w.contains("NOT projected"))); + } + + #[test] + fn file_loader_resolves_keys() { + let key_a = random_key_hex(); + let key_b = random_key_hex(); + let dir = tempfile::tempdir().unwrap(); + let keys_path = dir.path().join("keys.json"); + std::fs::write(&keys_path, format!(r#"["{key_b}"]"#)).unwrap(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "filemux" +validator_pubkeys = ["{key_a}"] +loader = "{}" +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"#, + keys_path.display() + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let keys: Vec = projection.docs.keys().map(|k| k.to_string()).collect(); + assert_eq!(projection.docs.len(), 2); + assert!(keys.contains(&key_a) && keys.contains(&key_b)); + } + + #[test] + fn default_relays_are_candidates_but_not_projected() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[[relays]] +url = "https://{RELAY_PK_B}@default-relay.example.com" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let default_candidate = + projection.relay_candidates.iter().find(|c| c.source == "[[relays]]").unwrap(); + assert!(!default_candidate.projected); + assert_eq!(default_candidate.bytes, b"https://default-relay.example.com".to_vec()); + // the projected doc references only the mux relay + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.builders.as_ref().unwrap().len(), 1); + } + + #[test] + fn auth_data_size_limit_enforced() { + let key = random_key_hex(); + let big = "ab".repeat(4097); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +expected_auth_data = "0x{big}" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + assert!(project(&input, &overlay()).unwrap_err().to_string().contains("4096")); + } +} From 5f30622a09dc8567d914f15261fd03e28e209b7c Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:02:01 -0700 Subject: [PATCH 11/80] feat(km-tool): KM client, apply/check flows, cb-km CLI apply: per-VC preflight (authed keystores listing plus a builder_config probe on an enumerated key, so a 404 means missing keymanager-APIs #88 support rather than key-elsewhere), POST per projected key with 202/404/ 403 handling, exit non-zero unless each key is accepted by exactly one VC (zero acceptors errors; several is a duplicate-key slashing alarm). --dry-run prints, --emit writes per-key JSON plus a manifest, --prune POSTs {} (spec-equal to DELETE) for stored-but-unprojected keys. check: read-only canonical comparison with tiered findings and --fail-on; fields the projection omits resolve to VC config on GET and are skipped rather than flagged as drift. --- Cargo.lock | 4 + crates/km-tool/Cargo.toml | 8 ++ crates/km-tool/src/apply.rs | 208 ++++++++++++++++++++++++++++++ crates/km-tool/src/check.rs | 242 +++++++++++++++++++++++++++++++++++ crates/km-tool/src/client.rs | 130 +++++++++++++++++++ crates/km-tool/src/lib.rs | 3 + crates/km-tool/src/main.rs | 120 +++++++++++++++++ 7 files changed, 715 insertions(+) create mode 100644 crates/km-tool/src/apply.rs create mode 100644 crates/km-tool/src/check.rs create mode 100644 crates/km-tool/src/client.rs create mode 100644 crates/km-tool/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 2a7a07848..7e404bcc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1919,12 +1919,16 @@ version = "0.11.0" dependencies = [ "alloy-primitives 1.6.1", "cb-common", + "clap", "eyre", + "reqwest 0.13.4", "serde", "serde_json", "tempfile", + "tokio", "toml", "tracing", + "tracing-subscriber", "url", ] diff --git a/crates/km-tool/Cargo.toml b/crates/km-tool/Cargo.toml index 17b3dba72..dd6ba1649 100644 --- a/crates/km-tool/Cargo.toml +++ b/crates/km-tool/Cargo.toml @@ -5,14 +5,22 @@ publish = false rust-version.workspace = true version.workspace = true +[[bin]] +name = "cb-km" +path = "src/main.rs" + [dependencies] alloy-primitives.workspace = true cb-common.workspace = true +clap.workspace = true eyre.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true +tokio.workspace = true toml.workspace = true tracing.workspace = true +tracing-subscriber.workspace = true url.workspace = true [dev-dependencies] diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs new file mode 100644 index 000000000..3f70d33b2 --- /dev/null +++ b/crates/km-tool/src/apply.rs @@ -0,0 +1,208 @@ +//! `cb-km apply`: push projected docs to every VC and verify each projected +//! key was accepted by exactly one of them. + +use std::{ + collections::{BTreeMap, BTreeSet}, + path::{Path, PathBuf}, +}; + +use eyre::{Context, Result, ensure}; +use tracing::{info, warn}; + +use crate::{ + client::{GetConfigOutcome, KmClient, PostOutcome, read_token}, + doc::BuilderConfigDoc, + overlay::Overlay, + project::{Projection, ProjectionInput, project, project_with_url}, +}; + +#[derive(Debug, Default, Clone)] +pub struct ApplyOptions { + pub dry_run: bool, + pub emit_dir: Option, + pub prune: bool, +} + +#[derive(Debug, Default)] +pub struct ApplyReport { + pub errors: Vec, + pub warnings: Vec, + pub info: Vec, + /// projected key -> VCs that accepted it (202) + pub accepted: BTreeMap>, + /// (vc, key) pairs pruned with `POST {}` + pub pruned: Vec<(String, String)>, +} + +impl ApplyReport { + pub fn ok(&self) -> bool { + self.errors.is_empty() + } + + fn error(&mut self, msg: String) { + warn!("{msg}"); + self.errors.push(msg); + } + + fn warn(&mut self, msg: String) { + warn!("{msg}"); + self.warnings.push(msg); + } + + fn note(&mut self, msg: String) { + info!("{msg}"); + self.info.push(msg); + } +} + +/// Whether a VC preflight proved #88 builder_config support. +async fn preflight_supports_builder_config( + client: &KmClient, + enumerated: &[String], + fallback_probe_key: Option<&str>, +) -> Result { + // Probe a key the VC itself enumerated: a 404 then means the ROUTE is + // missing (no #88 support), never "key elsewhere". Without any enumerated + // key fall back to a projected key, where 404 stays ambiguous and is + // treated as unsupported to fail loud. + let probe_key = enumerated.first().map(String::as_str).or(fallback_probe_key); + let Some(probe_key) = probe_key else { + return Ok(false); + }; + match client.get_builder_config(probe_key).await? { + GetConfigOutcome::Ok(_) => Ok(true), + GetConfigOutcome::NotFound => Ok(false), + } +} + +pub async fn run_apply( + input: &ProjectionInput, + overlay: &Overlay, + opts: &ApplyOptions, +) -> Result { + let mut report = ApplyReport::default(); + let global = project(input, overlay)?; + for w in &global.warnings { + report.warnings.push(w.clone()); + } + let projected_keys: BTreeSet = global.docs.keys().map(|k| k.to_string()).collect(); + + if opts.dry_run { + for (key, doc) in &global.docs { + report.note(format!("would apply {key}: {}", serde_json::to_string(doc)?)); + } + report.note(format!("dry-run: {} keys projected, nothing sent", global.docs.len())); + return Ok(report); + } + + if let Some(dir) = &opts.emit_dir { + emit(dir, &global)?; + report.note(format!("emitted {} per-key docs to {dir:?}", global.docs.len())); + return Ok(report); + } + + ensure!(!overlay.vcs.is_empty(), "no [[vcs]] configured in the overlay"); + + let mut all_enumerated: BTreeSet = BTreeSet::new(); + + for vc in &overlay.vcs { + let vc_name = vc.url.to_string(); + let token = read_token(Path::new(&vc.token_path))?; + let client = KmClient::new(vc.url.clone(), token)?; + + let enumerated = match client.list_keystores().await { + Ok(keys) => keys, + Err(err) => { + report.error(format!("{vc_name}: keystores preflight failed: {err}")); + continue; + } + }; + all_enumerated.extend(enumerated.iter().cloned()); + + let supports = preflight_supports_builder_config( + &client, + &enumerated, + projected_keys.iter().next().map(String::as_str), + ) + .await + .unwrap_or(false); + if !supports { + report.error(format!( + "{vc_name}: no builder_config support (keymanager-APIs #88); skipping" + )); + continue; + } + + let vc_projection = project_with_url(input, overlay, overlay.advertised_url_for(vc))?; + for (key, doc) in &vc_projection.docs { + let key = key.to_string(); + match client.post_builder_config(&key, doc).await { + Ok(PostOutcome::Accepted) => { + report.accepted.entry(key).or_default().push(vc_name.clone()); + } + Ok(PostOutcome::KeyNotFound) => {} + Ok(PostOutcome::ConfigFileManaged) => { + report + .warn(format!("{vc_name}: {key} is config-file-managed, cannot override")); + } + Err(err) => report.error(format!("{vc_name}: POST {key} failed: {err}")), + } + } + + if opts.prune { + for key in &enumerated { + if !projected_keys.contains(key) { + // POST {} is spec-equal to DELETE and a no-op when nothing + // is stored + match client.post_builder_config(key, &BuilderConfigDoc::default()).await { + Ok(PostOutcome::Accepted) => { + report.pruned.push((vc_name.clone(), key.clone())); + } + Ok(other) => report + .warn(format!("{vc_name}: prune of {key} not accepted: {other:?}")), + Err(err) => { + report.error(format!("{vc_name}: prune of {key} failed: {err}")) + } + } + } + } + } + } + + // coverage warning (default on): enumerated keys outside the projection + // route to CB's own URL under the pipe default and are best-effort only + for key in all_enumerated.difference(&projected_keys) { + report.warn(format!("{key} enumerated but unprojected: best-effort only")); + } + + // exit rule: every projected key accepted by exactly one VC + for key in &projected_keys { + match report.accepted.get(key).map(Vec::len).unwrap_or(0) { + 0 => report.error(format!("{key} was accepted by NO vc")), + 1 => {} + n => report.error(format!( + "{key} was accepted by {n} VCs: DUPLICATE KEY across VCs, slashing risk; \ + partition your keys" + )), + } + } + + Ok(report) +} + +/// Writes per-key JSON docs plus a manifest instead of POSTing (GitOps / +/// orchestrator-consumable). +fn emit(dir: &Path, projection: &Projection) -> Result<()> { + std::fs::create_dir_all(dir).wrap_err_with(|| format!("cannot create emit dir {dir:?}"))?; + let mut manifest = Vec::new(); + for (key, doc) in &projection.docs { + let file = format!("{key}.json"); + std::fs::write(dir.join(&file), serde_json::to_string_pretty(doc)?)?; + manifest.push(serde_json::json!({ "pubkey": key.to_string(), "file": file })); + } + std::fs::write( + dir.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ "keys": manifest }))?, + )?; + Ok(()) +} diff --git a/crates/km-tool/src/check.rs b/crates/km-tool/src/check.rs new file mode 100644 index 000000000..d01cc68e2 --- /dev/null +++ b/crates/km-tool/src/check.rs @@ -0,0 +1,242 @@ +//! `cb-km check`: read-only comparison of stored VC docs against the +//! projection. Comparison is CANONICAL, never a byte-diff: the spec promises +//! neither entry order nor hex case, and a GET returns the doc fully +//! RESOLVED, so fields the projection intentionally omits (they resolve to +//! the VC's own config) are skipped rather than reported as drift. + +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, +}; + +use eyre::Result; + +use crate::{ + client::{GetConfigOutcome, KmClient, read_token}, + doc::{CanonicalDoc, CanonicalEntry}, + overlay::Overlay, + project::{ProjectionInput, project, project_with_url}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Tier { + Info, + Warn, + Error, +} + +impl std::fmt::Display for Tier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Tier::Info => write!(f, "INFO"), + Tier::Warn => write!(f, "WARN"), + Tier::Error => write!(f, "ERROR"), + } + } +} + +#[derive(Debug)] +pub struct Finding { + pub tier: Tier, + pub code: &'static str, + pub msg: String, +} + +#[derive(Debug, Default)] +pub struct CheckReport { + pub findings: Vec, +} + +impl CheckReport { + fn push(&mut self, tier: Tier, code: &'static str, msg: String) { + self.findings.push(Finding { tier, code, msg }); + } + + /// Whether any finding is at or above the given tier. + pub fn fails(&self, fail_on: Tier) -> bool { + self.findings.iter().any(|f| f.tier >= fail_on) + } +} + +pub async fn run_check(input: &ProjectionInput, overlay: &Overlay) -> Result { + let mut report = CheckReport::default(); + let global = project(input, overlay)?; + let projected_keys: BTreeSet = global.docs.keys().map(|k| k.to_string()).collect(); + + // every auth_data byte-string a projected entry carries + let projected_auth: BTreeSet> = global + .docs + .values() + .flat_map(|doc| doc.builders.iter().flatten()) + .filter_map(|entry| entry.auth_data.as_ref()) + .filter_map(|hex| crate::doc::decode_auth_data(hex).ok()) + .collect(); + + for candidate in &global.relay_candidates { + if !projected_auth.contains(&candidate.bytes) { + report.push( + Tier::Info, + "relay-unreferenced", + format!( + "relay {} ({}) is referenced by no projected key", + candidate.relay_id, candidate.source + ), + ); + } + } + + let mut key_holders: BTreeMap> = BTreeMap::new(); + + for vc in &overlay.vcs { + let vc_name = vc.url.to_string(); + let token = read_token(Path::new(&vc.token_path))?; + let client = KmClient::new(vc.url.clone(), token)?; + let enumerated = client.list_keystores().await?; + for key in &enumerated { + key_holders.entry(key.clone()).or_default().push(vc_name.clone()); + } + + let vc_projection = project_with_url(input, overlay, overlay.advertised_url_for(vc))?; + let vc_docs: BTreeMap = + vc_projection.docs.iter().map(|(k, d)| (k.to_string(), d)).collect(); + + for key in &enumerated { + let stored = match client.get_builder_config(key).await? { + GetConfigOutcome::Ok(doc) => doc, + GetConfigOutcome::NotFound => continue, + }; + let stored = CanonicalDoc::from_doc(&stored)?; + + // unroutable stored auth_data: no configured relay's candidate + // bytes equal it + for entry in stored.builders.iter().flatten() { + if let Some(bytes) = &entry.auth_data + && !global.relay_candidates.iter().any(|c| &c.bytes == bytes) + { + report.push( + Tier::Error, + "unroutable-auth-data", + format!( + "{vc_name}: {key} stores auth_data {} matching no configured relay", + crate::doc::encode_auth_data(bytes) + ), + ); + } + } + + match vc_docs.get(key) { + Some(projected) => { + let projected = CanonicalDoc::from_doc(projected)?; + for drift in drift_lines(&projected, &stored) { + report.push( + Tier::Warn, + "drift", + format!("{vc_name}: {key} drifted: {drift}"), + ); + } + } + None => { + if stored.builders.as_ref().is_some_and(|b| !b.is_empty()) { + report.push( + Tier::Warn, + "stored-unprojected", + format!("{vc_name}: {key} has a stored doc but is not projected"), + ); + } + } + } + } + } + + for (key, holders) in &key_holders { + if holders.len() > 1 { + report.push( + Tier::Error, + "duplicate-key", + format!("{key} is held by {} VCs ({holders:?}): slashing risk", holders.len()), + ); + } + if !projected_keys.contains(key) { + report.push( + Tier::Warn, + "unprojected", + format!("{key} enumerated but unprojected: best-effort only"), + ); + } + } + + Ok(report) +} + +/// Compares a projected doc against a stored (resolved) one. A field the +/// projection left unset resolves to the VC's own config on GET, so only +/// projected values are compared; extra or missing entries are always drift. +fn drift_lines(projected: &CanonicalDoc, stored: &CanonicalDoc) -> Vec { + let mut lines = Vec::new(); + compare_field(&mut lines, "min_bid", &projected.min_bid, &stored.min_bid); + compare_field( + &mut lines, + "builder_boost_factor", + &projected.builder_boost_factor, + &stored.builder_boost_factor, + ); + + let Some(projected_entries) = &projected.builders else { + return lines; + }; + let stored_entries = stored.builders.clone().unwrap_or_default(); + let stored_by_key: BTreeMap<(String, Option>), &CanonicalEntry> = stored_entries + .iter() + .map(|entry| ((entry.url.clone(), entry.auth_data.clone()), entry)) + .collect(); + + for entry in projected_entries { + let key = (entry.url.clone(), entry.auth_data.clone()); + let Some(stored_entry) = stored_by_key.get(&key) else { + lines.push(format!( + "entry ({}, {}) is missing", + entry.url, + entry.auth_data.as_deref().map(crate::doc::encode_auth_data).unwrap_or_default() + )); + continue; + }; + if entry.builder_pubkeys != stored_entry.builder_pubkeys { + lines.push(format!("entry {}: builder_pubkeys differ", entry.url)); + } + compare_field(&mut lines, "entry min_bid", &entry.min_bid, &stored_entry.min_bid); + compare_field( + &mut lines, + "entry max_execution_payment", + &entry.max_execution_payment, + &stored_entry.max_execution_payment, + ); + compare_field( + &mut lines, + "entry builder_boost_factor", + &entry.builder_boost_factor, + &stored_entry.builder_boost_factor, + ); + } + + if stored_entries.len() > projected_entries.len() { + lines.push(format!( + "stored doc has {} entries, projection has {}", + stored_entries.len(), + projected_entries.len() + )); + } + lines +} + +fn compare_field( + lines: &mut Vec, + name: &str, + projected: &Option, + stored: &Option, +) { + if let Some(expected) = projected + && stored != &Some(*expected) + { + lines.push(format!("{name}: projected {expected}, stored {stored:?}")); + } +} diff --git a/crates/km-tool/src/client.rs b/crates/km-tool/src/client.rs new file mode 100644 index 000000000..08157c3c2 --- /dev/null +++ b/crates/km-tool/src/client.rs @@ -0,0 +1,130 @@ +//! Minimal keymanager API client: bearer-token auth, keystores listing and +//! the builder_config endpoints (keymanager-APIs #88). + +use std::{path::Path, time::Duration}; + +use eyre::{Context, Result, bail}; +use serde::Deserialize; +use tracing::warn; +use url::Url; + +use crate::doc::BuilderConfigDoc; + +const HTTP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Reads a bearer token file, trimming surrounding whitespace. Warns when the +/// file is world-readable. +pub fn read_token(path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(path) + && meta.permissions().mode() & 0o004 != 0 + { + warn!("token file {path:?} is world-readable"); + } + } + let token = std::fs::read_to_string(path) + .wrap_err_with(|| format!("unable to read token file: {path:?}"))?; + let token = token.trim().to_string(); + if token.is_empty() { + bail!("token file {path:?} is empty"); + } + Ok(token) +} + +#[derive(Debug, Deserialize)] +struct KeystoresResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct KeystoreEntry { + validating_pubkey: String, +} + +#[derive(Debug, Deserialize)] +struct GetBuilderConfigResponse { + data: BuilderConfigDoc, +} + +/// Result of a builder_config GET +#[derive(Debug)] +pub enum GetConfigOutcome { + Ok(BuilderConfigDoc), + NotFound, +} + +/// Result of a builder_config POST +#[derive(Debug, PartialEq, Eq)] +pub enum PostOutcome { + /// 202: stored + Accepted, + /// 404: this VC does not hold the key (or does not serve the route) + KeyNotFound, + /// 403: the config is file-managed on this VC and cannot be overridden + ConfigFileManaged, +} + +pub struct KmClient { + http: reqwest::Client, + base: Url, + token: String, +} + +impl KmClient { + pub fn new(base: Url, token: String) -> Result { + let http = reqwest::Client::builder().timeout(HTTP_TIMEOUT).build()?; + Ok(Self { http, base, token }) + } + + pub fn base(&self) -> &Url { + &self.base + } + + fn endpoint(&self, path: &str) -> Result { + self.base.join(path).wrap_err_with(|| format!("invalid endpoint {path}")) + } + + /// GET /eth/v1/keystores; returns lowercased validating pubkeys. Doubles + /// as the auth + keymanager-API preflight. + pub async fn list_keystores(&self) -> Result> { + let url = self.endpoint("/eth/v1/keystores")?; + let resp = self.http.get(url).bearer_auth(&self.token).send().await?; + let status = resp.status(); + if !status.is_success() { + bail!("keystores listing failed on {}: {status}", self.base); + } + let body: KeystoresResponse = resp.json().await.wrap_err("invalid keystores response")?; + Ok(body.data.into_iter().map(|k| k.validating_pubkey.to_lowercase()).collect()) + } + + pub async fn get_builder_config(&self, pubkey: &str) -> Result { + let url = self.endpoint(&format!("/eth/v1/validator/{pubkey}/builder_config"))?; + let resp = self.http.get(url).bearer_auth(&self.token).send().await?; + match resp.status().as_u16() { + 200 => { + let body: GetBuilderConfigResponse = + resp.json().await.wrap_err("invalid builder_config response")?; + Ok(GetConfigOutcome::Ok(body.data)) + } + 404 => Ok(GetConfigOutcome::NotFound), + status => bail!("builder_config GET for {pubkey} on {} failed: {status}", self.base), + } + } + + pub async fn post_builder_config( + &self, + pubkey: &str, + doc: &BuilderConfigDoc, + ) -> Result { + let url = self.endpoint(&format!("/eth/v1/validator/{pubkey}/builder_config"))?; + let resp = self.http.post(url).bearer_auth(&self.token).json(doc).send().await?; + match resp.status().as_u16() { + 202 => Ok(PostOutcome::Accepted), + 404 => Ok(PostOutcome::KeyNotFound), + 403 => Ok(PostOutcome::ConfigFileManaged), + status => bail!("builder_config POST for {pubkey} on {} failed: {status}", self.base), + } + } +} diff --git a/crates/km-tool/src/lib.rs b/crates/km-tool/src/lib.rs index 851f7d301..685c54404 100644 --- a/crates/km-tool/src/lib.rs +++ b/crates/km-tool/src/lib.rs @@ -3,6 +3,9 @@ //! config + operational overlay into per-key KM docs; `apply` POSTs them to //! validator clients; `check` compares stored docs canonically. +pub mod apply; +pub mod check; +pub mod client; pub mod doc; pub mod mux_ext; pub mod overlay; diff --git a/crates/km-tool/src/main.rs b/crates/km-tool/src/main.rs new file mode 100644 index 000000000..c154230fe --- /dev/null +++ b/crates/km-tool/src/main.rs @@ -0,0 +1,120 @@ +use std::path::PathBuf; + +use cb_km_tool::{ + Overlay, ProjectionInput, + apply::{ApplyOptions, run_apply}, + check::{Tier, run_check}, +}; +use clap::{Parser, Subcommand, ValueEnum}; +use eyre::Result; + +#[derive(Parser)] +#[command( + name = "cb-km", + about = "Project a Commit-Boost mux config into keymanager builder_config docs" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(clap::Args)] +struct CommonArgs { + /// Commit-Boost config TOML + #[arg(long, default_value = "config.toml")] + config: PathBuf, + /// Operational overlay TOML (advertised_url, vcs, per_mux) + #[arg(long, default_value = "km-overlay.toml")] + overlay: PathBuf, +} + +#[derive(Subcommand)] +enum Command { + /// POST projected docs to the configured VCs + Apply { + #[command(flatten)] + common: CommonArgs, + /// Print the projected docs without contacting any VC + #[arg(long)] + dry_run: bool, + /// Write per-key JSON docs plus a manifest to a directory instead of + /// POSTing + #[arg(long, value_name = "DIR")] + emit: Option, + /// POST {} for stored-but-unprojected enumerated keys + #[arg(long)] + prune: bool, + }, + /// Compare the stored VC docs against the projection (read-only) + Check { + #[command(flatten)] + common: CommonArgs, + /// Lowest finding tier that makes the exit code non-zero + #[arg(long, value_enum, default_value_t = FailOn::Error)] + fail_on: FailOn, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum FailOn { + Info, + Warn, + Error, +} + +impl From for Tier { + fn from(value: FailOn) -> Self { + match value { + FailOn::Info => Tier::Info, + FailOn::Warn => Tier::Warn, + FailOn::Error => Tier::Error, + } + } +} + +fn load(common: &CommonArgs) -> Result<(ProjectionInput, Overlay)> { + Ok((ProjectionInput::from_file(&common.config)?, Overlay::from_file(&common.overlay)?)) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt().with_env_filter("info").init(); + let cli = Cli::parse(); + + match cli.command { + Command::Apply { common, dry_run, emit, prune } => { + let (input, overlay) = load(&common)?; + let opts = ApplyOptions { dry_run, emit_dir: emit, prune }; + let report = run_apply(&input, &overlay, &opts).await?; + for msg in &report.info { + println!("{msg}"); + } + for msg in &report.warnings { + println!("WARN: {msg}"); + } + for (key, vcs) in &report.accepted { + println!("accepted: {key} on {vcs:?}"); + } + for (vc, key) in &report.pruned { + println!("pruned: {key} on {vc}"); + } + for msg in &report.errors { + eprintln!("ERROR: {msg}"); + } + if !report.ok() { + std::process::exit(1); + } + } + Command::Check { common, fail_on } => { + let (input, overlay) = load(&common)?; + let report = run_check(&input, &overlay).await?; + for finding in &report.findings { + println!("{} [{}] {}", finding.tier, finding.code, finding.msg); + } + if report.fails(fail_on.into()) { + std::process::exit(1); + } + } + } + Ok(()) +} From 831c9c37d263c4c257b5691f5106da3032db544b Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:02:49 -0700 Subject: [PATCH 12/80] test(pbs): carry new MuxConfig fields through mux test literals --- tests/tests/pbs_mux.rs | 6 ++++++ tests/tests/pbs_mux_refresh.rs | 2 ++ 2 files changed, 8 insertions(+) diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index af617069b..f0703f365 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -378,6 +378,8 @@ async fn test_ssv_multi_with_node() -> Result<()> { timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], fee_recipient: None, + builder_boost_factor: None, + min_bid_wei: None, }], }; @@ -486,6 +488,8 @@ async fn test_ssv_multi_with_public() -> Result<()> { timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], fee_recipient: None, + builder_boost_factor: None, + min_bid_wei: None, }], }; @@ -544,6 +548,8 @@ async fn test_mux_fee_recipient_resolution() -> Result<()> { timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![validator_pubkey.clone()], fee_recipient: Some(expected), + builder_boost_factor: None, + min_bid_wei: None, }], }; diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index 01131b36c..8bcce13f2 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -94,6 +94,8 @@ async fn test_auto_refresh() -> Result<()> { timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], fee_recipient: None, + builder_boost_factor: None, + min_bid_wei: None, }], }; From cac2fa9498d2c568cdaf8b6ed970eeabf55f3058 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:04:25 -0700 Subject: [PATCH 13/80] test(km-tool): mock-KM integration suite axum mock VC covering: partitioned-key happy path (POST body equals the projected doc), 401 preflight, missing-#88 detection, zero-acceptor non-zero result, duplicate-key slashing alarm, 403 config-file-managed, prune body exactly {}, dry-run/emit sending nothing, canonical check (reordered entries + uppercase hex + VC-resolved fields produce no drift), drift + unroutable auth_data + duplicate-key findings. --- Cargo.lock | 1 + crates/km-tool/Cargo.toml | 1 + crates/km-tool/src/project.rs | 2 +- crates/km-tool/tests/mock_km.rs | 398 ++++++++++++++++++++++++++++++++ 4 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 crates/km-tool/tests/mock_km.rs diff --git a/Cargo.lock b/Cargo.lock index 7e404bcc7..2a41a9071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1918,6 +1918,7 @@ name = "cb-km-tool" version = "0.11.0" dependencies = [ "alloy-primitives 1.6.1", + "axum 0.8.9", "cb-common", "clap", "eyre", diff --git a/crates/km-tool/Cargo.toml b/crates/km-tool/Cargo.toml index dd6ba1649..54c8ba7a9 100644 --- a/crates/km-tool/Cargo.toml +++ b/crates/km-tool/Cargo.toml @@ -24,4 +24,5 @@ tracing-subscriber.workspace = true url.workspace = true [dev-dependencies] +axum.workspace = true tempfile.workspace = true diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index efe1ad208..f9116ef76 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -475,7 +475,7 @@ expected_auth_data = "0x736563726574" #[test] fn projects_literal_json_doc() { let key = random_key_hex(); - let input = ProjectionInput::parse_str(&config_toml(&[key.clone()])).unwrap(); + let input = ProjectionInput::parse_str(&config_toml(std::slice::from_ref(&key))).unwrap(); let projection = project(&input, &overlay()).unwrap(); assert_eq!(projection.docs.len(), 1); diff --git a/crates/km-tool/tests/mock_km.rs b/crates/km-tool/tests/mock_km.rs new file mode 100644 index 000000000..33887999c --- /dev/null +++ b/crates/km-tool/tests/mock_km.rs @@ -0,0 +1,398 @@ +//! Integration tests against a mock keymanager server (axum): auth, +//! preflight, POST outcome handling, prune body shape, dry-run/emit network +//! silence, and canonical check comparison. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use axum::{ + Router, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::get, +}; +use cb_km_tool::{ + Overlay, ProjectionInput, + apply::{ApplyOptions, run_apply}, + check::{Tier, run_check}, + project, +}; + +const RELAY_PK_A: &str = "0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2"; +const RELAY_PK_B: &str = "0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09fe73ccd21f88eab31d6de16194d17782e"; +const TOKEN: &str = "test-token"; + +#[derive(Clone, Default)] +struct MockVc { + /// keys this VC holds (lowercase hex) + keystores: Vec, + /// whether the VC serves the #88 builder_config route + supports_builder_config: bool, + /// stored docs returned on GET (raw JSON, so tests control hex case and + /// entry order) + stored: HashMap, + /// POST status override per key (default: 202 when held, else 404) + post_status: HashMap, + /// recorded (pubkey, raw body) of every builder_config POST + posts: Arc>>, +} + +impl MockVc { + fn holding(keys: &[String]) -> Self { + Self { keystores: keys.to_vec(), supports_builder_config: true, ..Default::default() } + } + + fn posts(&self) -> Vec<(String, String)> { + self.posts.lock().unwrap().clone() + } +} + +fn authed(headers: &HeaderMap) -> bool { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == format!("Bearer {TOKEN}")) +} + +async fn keystores(State(vc): State, headers: HeaderMap) -> impl IntoResponse { + if !authed(&headers) { + return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + let data: Vec<_> = + vc.keystores.iter().map(|pk| serde_json::json!({ "validating_pubkey": pk })).collect(); + axum::Json(serde_json::json!({ "data": data })).into_response() +} + +async fn get_config( + State(vc): State, + Path(pubkey): Path, + headers: HeaderMap, +) -> impl IntoResponse { + if !authed(&headers) { + return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + if !vc.supports_builder_config || !vc.keystores.contains(&pubkey) { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + let doc = vc.stored.get(&pubkey).cloned().unwrap_or(serde_json::json!({})); + axum::Json(serde_json::json!({ "data": doc })).into_response() +} + +async fn post_config( + State(vc): State, + Path(pubkey): Path, + headers: HeaderMap, + body: String, +) -> impl IntoResponse { + if !authed(&headers) { + return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + if !vc.supports_builder_config { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + vc.posts.lock().unwrap().push((pubkey.clone(), body)); + let status = vc + .post_status + .get(&pubkey) + .copied() + .unwrap_or_else(|| if vc.keystores.contains(&pubkey) { 202 } else { 404 }); + StatusCode::from_u16(status).unwrap().into_response() +} + +/// Serves a mock VC on an ephemeral port, returning its base URL. +async fn serve(vc: MockVc) -> String { + let app = Router::new() + .route("/eth/v1/keystores", get(keystores)) + .route("/eth/v1/validator/{pubkey}/builder_config", get(get_config).post(post_config)) + .with_state(vc); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") +} + +fn random_key() -> String { + cb_common::types::BlsSecretKey::random().public_key().as_hex_string() +} + +fn config_toml(keys: &[String]) -> String { + let keys = keys.iter().map(|k| format!("\"{k}\"")).collect::>().join(", "); + format!( + r#" +chain = "Holesky" + +[pbs] +min_bid_eth = 0.5 + +[[mux]] +id = "mux1" +validator_pubkeys = [{keys}] + +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" + +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "0x736563726574" +"# + ) +} + +struct TestEnv { + input: ProjectionInput, + overlay: Overlay, + _token_file: tempfile::NamedTempFile, +} + +fn env_for(keys: &[String], vc_urls: &[String]) -> TestEnv { + let token_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(token_file.path(), format!("{TOKEN}\n")).unwrap(); + let vcs = vc_urls + .iter() + .map(|url| { + format!("[[vcs]]\nurl = \"{url}\"\ntoken_path = \"{}\"\n", token_file.path().display()) + }) + .collect::>() + .join("\n"); + let overlay = + Overlay::parse_str(&format!("advertised_url = \"https://cb.example.com\"\n{vcs}")).unwrap(); + let input = ProjectionInput::parse_str(&config_toml(keys)).unwrap(); + TestEnv { input, overlay, _token_file: token_file } +} + +#[tokio::test] +async fn apply_partitioned_keys_accepted_once_each() { + let (k1, k2) = (random_key(), random_key()); + let vc1 = MockVc::holding(std::slice::from_ref(&k1)); + let vc2 = MockVc::holding(std::slice::from_ref(&k2)); + let (url1, url2) = (serve(vc1.clone()).await, serve(vc2.clone()).await); + + let env = env_for(&[k1.clone(), k2.clone()], &[url1, url2]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + + assert!(report.ok(), "{:?}", report.errors); + assert_eq!(report.accepted.get(&k1).map(Vec::len), Some(1)); + assert_eq!(report.accepted.get(&k2).map(Vec::len), Some(1)); + + // the accepted POST body is the projected doc + let posts = vc1.posts(); + let accepted: Vec<_> = posts.iter().filter(|(pk, _)| pk == &k1).collect(); + assert_eq!(accepted.len(), 1); + let projection = project(&env.input, &env.overlay).unwrap(); + let expected = projection + .docs + .iter() + .find(|(pk, _)| pk.to_string() == k1) + .map(|(_, doc)| serde_json::to_string(doc).unwrap()) + .unwrap(); + assert_eq!(accepted[0].1, expected); +} + +#[tokio::test] +async fn apply_401_reports_preflight_error() { + let key = random_key(); + let vc = MockVc::holding(std::slice::from_ref(&key)); + let url = serve(vc).await; + + let mut env = env_for(std::slice::from_ref(&key), &[url]); + // wrong token + std::fs::write(env._token_file.path(), "wrong-token").unwrap(); + env.overlay = Overlay::parse_str(&format!( + "advertised_url = \"https://cb.example.com\"\n[[vcs]]\nurl = \"{}\"\ntoken_path = \"{}\"\n", + env.overlay.vcs[0].url, + env._token_file.path().display() + )) + .unwrap(); + + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(!report.ok()); + assert!(report.errors.iter().any(|e| e.contains("keystores preflight failed"))); +} + +#[tokio::test] +async fn apply_detects_missing_88_support() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + vc.supports_builder_config = false; + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(!report.ok()); + assert!( + report.errors.iter().any(|e| e.contains("no builder_config support")), + "{:?}", + report.errors + ); +} + +#[tokio::test] +async fn apply_zero_acceptors_is_an_error() { + let (projected, other) = (random_key(), random_key()); + // the VC holds a DIFFERENT key: POST of the projected key 404s + let vc = MockVc::holding(std::slice::from_ref(&other)); + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&projected), &[url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(!report.ok()); + assert!(report.errors.iter().any(|e| e.contains("accepted by NO vc")), "{:?}", report.errors); + // coverage warning fires for the enumerated-but-unprojected key + assert!(report.warnings.iter().any(|w| w.contains(&other) && w.contains("unprojected"))); +} + +#[tokio::test] +async fn apply_duplicate_acceptance_raises_slashing_alarm() { + let key = random_key(); + let vc1 = MockVc::holding(std::slice::from_ref(&key)); + let vc2 = MockVc::holding(std::slice::from_ref(&key)); + let (url1, url2) = (serve(vc1).await, serve(vc2).await); + + let env = env_for(std::slice::from_ref(&key), &[url1, url2]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(!report.ok()); + assert!( + report.errors.iter().any(|e| e.contains("DUPLICATE KEY") && e.contains("slashing")), + "{:?}", + report.errors + ); +} + +#[tokio::test] +async fn apply_403_is_config_file_managed_warning() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + vc.post_status.insert(key.clone(), 403); + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!( + report.warnings.iter().any(|w| w.contains("config-file-managed")), + "{:?}", + report.warnings + ); + // not accepted anywhere -> still an error overall + assert!(!report.ok()); +} + +#[tokio::test] +async fn prune_posts_exactly_empty_object() { + let (projected, stale) = (random_key(), random_key()); + let vc = MockVc::holding(&[projected.clone(), stale.clone()]); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&projected), &[url]); + let opts = ApplyOptions { prune: true, ..Default::default() }; + let report = run_apply(&env.input, &env.overlay, &opts).await.unwrap(); + + let posts = vc.posts(); + let prune_posts: Vec<_> = posts.iter().filter(|(pk, _)| pk == &stale).collect(); + assert_eq!(prune_posts.len(), 1); + assert_eq!(prune_posts[0].1, "{}"); + assert_eq!(report.pruned.len(), 1); +} + +#[tokio::test] +async fn dry_run_and_emit_post_nothing() { + let key = random_key(); + let vc = MockVc::holding(std::slice::from_ref(&key)); + let url = serve(vc.clone()).await; + let env = env_for(std::slice::from_ref(&key), &[url]); + + let report = + run_apply(&env.input, &env.overlay, &ApplyOptions { dry_run: true, ..Default::default() }) + .await + .unwrap(); + assert!(report.ok()); + assert!(vc.posts().is_empty()); + + let emit_dir = tempfile::tempdir().unwrap(); + let report = run_apply( + &env.input, + &env.overlay, + &ApplyOptions { emit_dir: Some(emit_dir.path().to_path_buf()), ..Default::default() }, + ) + .await + .unwrap(); + assert!(report.ok()); + assert!(vc.posts().is_empty()); + assert!(emit_dir.path().join(format!("{key}.json")).exists()); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(emit_dir.path().join("manifest.json")).unwrap(), + ) + .unwrap(); + assert_eq!(manifest["keys"][0]["pubkey"], serde_json::json!(key)); +} + +#[tokio::test] +async fn check_reordered_uppercase_stored_doc_is_not_drift() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + + // the stored doc: same content as the projection, but entries REVERSED, + // auth_data hex UPPERCASED, and VC-resolved values for fields the + // projection omits + let env0 = env_for(std::slice::from_ref(&key), &[]); + let projection = project(&env0.input, &env0.overlay).unwrap(); + let projected_doc = projection.docs.values().next().unwrap(); + let mut stored = serde_json::to_value(projected_doc).unwrap(); + let builders = stored["builders"].as_array_mut().unwrap(); + builders.reverse(); + for entry in builders.iter_mut() { + let upper = entry["auth_data"].as_str().unwrap().to_uppercase().replacen("0X", "0x", 1); + entry["auth_data"] = serde_json::json!(upper); + // VC-side resolution fills fields the projection left out + entry["builder_boost_factor"] = serde_json::json!("100"); + } + stored["builder_boost_factor"] = serde_json::json!("100"); + vc.stored.insert(key.clone(), stored); + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_check(&env.input, &env.overlay).await.unwrap(); + assert!(!report.fails(Tier::Warn), "{:?}", report.findings); +} + +#[tokio::test] +async fn check_flags_drift_and_unroutable_auth_data() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + vc.stored.insert( + key.clone(), + serde_json::json!({ + "min_bid": "1", + "builders": [ + { "url": "https://cb.example.com", "auth_data": "0xdead" } + ] + }), + ); + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_check(&env.input, &env.overlay).await.unwrap(); + assert!( + report.findings.iter().any(|f| f.code == "unroutable-auth-data" && f.tier == Tier::Error) + ); + assert!(report.findings.iter().any(|f| f.code == "drift" && f.tier == Tier::Warn)); + assert!(report.fails(Tier::Error)); +} + +#[tokio::test] +async fn check_flags_duplicate_key_across_vcs() { + let key = random_key(); + let vc1 = MockVc::holding(std::slice::from_ref(&key)); + let vc2 = MockVc::holding(std::slice::from_ref(&key)); + let (url1, url2) = (serve(vc1).await, serve(vc2).await); + + let env = env_for(std::slice::from_ref(&key), &[url1, url2]); + let report = run_check(&env.input, &env.overlay).await.unwrap(); + assert!( + report.findings.iter().any(|f| f.code == "duplicate-key" && f.tier == Tier::Error), + "{:?}", + report.findings + ); +} From d419bbcad98f61bbb7673b89d94503fe19d3f719 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:08:46 -0700 Subject: [PATCH 14/80] feat(pbs): transient pipe relay resolver with fail-closed self-URL guard --- crates/pbs/src/utils.rs | 114 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 9f193b91f..8159de3b0 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -1,9 +1,10 @@ use std::time::{Duration, Instant}; use cb_common::{ - pbs::{ForkName, RelayClient, SignedRequestAuth, error::PbsError}, + config::{GetHeaderTransport, RelayConfig}, + pbs::{ForkName, RelayClient, RelayEntry, SignedRequestAuth, error::PbsError}, signature::verify_request_auth_signature, - types::{BlsPublicKey, Chain}, + types::{BlsPublicKey, BlsSecretKey, Chain}, wire::{CONSENSUS_VERSION_HEADER, get_user_agent_with_version}, }; use reqwest::{ @@ -215,6 +216,53 @@ pub(crate) fn decode_auth_data_url(data: &[u8]) -> Option { std::str::from_utf8(url_bytes).ok().and_then(|s| Url::parse(s).ok()) } +/// Builds the transient client the ePBS pipe dials when `auth.message.data` +/// names a builder URL no configured relay serves: CB is a pure pipe and +/// routes the request to the builder the proposer's signed auth data names +/// (trust is the VC's job via its KM `builder_pubkeys`). +/// +/// Fail-closed self-URL guard: an unconfigured key's auth data defaults to +/// CB's own URL, so a decoded URL matching an `advertised_urls` entry - or an +/// empty `advertised_urls`, which cannot rule that out - is an +/// `AuthDataMismatch`, never a self-dial. Data carrying no URL at all names +/// no builder and mismatches as before. +pub(crate) fn transient_pipe_relay( + received_data: &[u8], + advertised_urls: &[Url], +) -> Result { + let Some(url) = decode_auth_data_url(received_data) else { + return Err(PbsClientError::AuthDataMismatch); + }; + if advertised_urls.is_empty() || advertised_urls.iter().any(|own| url_matches(own, &url)) { + warn!(%url, "auth data URL is CB's own or advertised_urls is unset, not dialing"); + return Err(PbsClientError::AuthDataMismatch); + } + + let id = url.host_str().map(str::to_owned).unwrap_or_else(|| url.to_string()); + let config = RelayConfig { + // The placeholder pubkey is never used: bid sigverify is skipped for + // the pipe relay because bid trust is the VC's job via KM + // builder_pubkeys and CB cannot know a pipe builder's key (bids carry + // builder_index, not a pubkey) + entry: RelayEntry { id, pubkey: BlsSecretKey::random().public_key(), url }, + id: None, + headers: None, + get_params: None, + get_header: GetHeaderTransport::Http, + enable_timing_games: false, + target_first_request_ms: None, + frequency_get_header_ms: None, + bid_poll_timeout_ms: None, + validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: None, + }; + RelayClient::new(config).map_err(|err| { + warn!(%err, "failed to build the pipe relay client"); + PbsClientError::Internal + }) +} + /// Compares two URLs without checking userinfo/path/queries/frags. A relay /// entry URL embeds the relay pubkey as userinfo, so full equality would never /// match a bare builder URL. @@ -369,6 +417,68 @@ mod tests { assert!(match_relays_by_auth_data(&relays, &[]).is_empty()); } + // The pipe never dials blind: with no advertised_urls the self-URL guard + // cannot rule out CB's own URL (an unconfigured key's auth data defaults + // to it), so it fails closed with the same mismatch a builder would return. + #[test] + fn transient_pipe_relay_fails_closed_without_advertised_urls() { + assert!(matches!( + transient_pipe_relay(b"http://builder.example.com", &[]), + Err(PbsClientError::AuthDataMismatch) + )); + } + + // A decoded URL naming CB itself is never dialed: matching follows + // `url_matches`, so userinfo/path/default-port variants still guard. + #[test] + fn transient_pipe_relay_guards_own_advertised_urls() { + let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; + for own in [ + "http://cb.example.com:18550", + "http://cb.example.com:18550/eth/v1/builder", + "http://0xdeadbeef@cb.example.com:18550", + ] { + assert!( + matches!( + transient_pipe_relay(own.as_bytes(), &advertised), + Err(PbsClientError::AuthDataMismatch) + ), + "{own} must not be dialed" + ); + } + } + + // Data carrying no URL names no builder: mismatch, no dial. + #[test] + fn transient_pipe_relay_rejects_non_url_data() { + let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; + for data in [&[0xde, 0xad][..], b"not a url", &[]] { + assert!(matches!( + transient_pipe_relay(data, &advertised), + Err(PbsClientError::AuthDataMismatch) + )); + } + } + + // A decodable, non-self URL gets a transient client carrying no configured + // relay's headers and no per-relay cap (the global default applies), so + // pipe bids rank unclamped and leak no credentials. + #[test] + fn transient_pipe_relay_builds_a_bare_client() { + let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; + let mut data = b"http://builder.example.com:8551".to_vec(); + data.push(0); + data.extend_from_slice(&[0xde, 0xad]); + + let relay = transient_pipe_relay(&data, &advertised).unwrap(); + assert_eq!(relay.config.entry.url.as_str(), "http://builder.example.com:8551/"); + assert_eq!(relay.id.as_str(), "builder.example.com"); + assert!(relay.config.headers.is_none()); + assert!(relay.config.max_execution_payment_gwei.is_none()); + assert!(relay.config.expected_auth_data.is_none()); + assert!(!relay.config.enable_timing_games); + } + #[test] fn url_matches_ignores_userinfo_and_default_port() { let u = |s: &str| Url::parse(s).unwrap(); From e6ec516898ec85dd876da903e91e5f0aba52a8cf Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:10:23 -0700 Subject: [PATCH 15/80] feat(pbs): pipe unmatched ePBS bid requests to the auth_data builder URL An unmatched getExecutionPayloadBid no longer 400s outright: the decoded auth_data URL is dialed via a transient RelayClient through the shared send path, guarded fail-closed against CB's own advertised_urls. Bid sigverify is skipped per-context for the pipe relay only; the global skip_sigverify and configured-relay verification are unchanged. --- .../pbs/src/routes/execution_payload_bid.rs | 22 ++- tests/tests/pbs_get_execution_payload_bid.rs | 142 ++++++++++++++++++ 2 files changed, 157 insertions(+), 7 deletions(-) diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 83b5d5b09..229aee747 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -52,7 +52,7 @@ use crate::{ state::{BuilderApiState, PbsState}, utils::{ check_gas_limit, epbs_base_send_headers, match_relays_by_auth_data, record_client_error, - send_to_relay, validate_auth_data, verify_auth_signature, + send_to_relay, transient_pipe_relay, validate_auth_data, verify_auth_signature, }, }; @@ -191,10 +191,15 @@ pub async fn get_execution_payload_bid( ); } - let relays = match_relays_by_auth_data(relays, body.message.data.as_ref()); - if relays.is_empty() { - return Err(PbsClientError::AuthDataMismatch); - } + let matched = match_relays_by_auth_data(relays, body.message.data.as_ref()); + let is_pipe = matched.is_empty(); + let relays: Vec = if is_pipe { + // No configured relay serves this auth data: pipe the request to the + // builder URL the proposer's signed auth data names (self-URL guarded) + vec![transient_pipe_relay(body.message.data.as_ref(), &pbs_config.advertised_urls)?] + } else { + matched.into_iter().cloned().collect() + }; let max_timeout_ms = pbs_config .timeout_get_header_ms @@ -237,7 +242,7 @@ pub async fn get_execution_payload_bid( send_headers.insert(ACCEPT, build_outbound_accept(relay_accept)); let mut handles = Vec::with_capacity(relays.len()); - for &relay in relays.iter() { + for relay in relays.iter() { handles.push( send_timed_get_execution_payload_bid( params.clone(), @@ -248,7 +253,10 @@ pub async fn get_execution_payload_bid( max_timeout_ms, ranking_cap_gwei(relay, pbs_config), ValidationContext { - skip_sigverify: pbs_config.skip_sigverify, + // Pipe bids skip sigverify: bid trust is the VC's job via + // KM builder_pubkeys, and CB cannot know a pipe builder's + // key (bids carry builder_index, not a pubkey) + skip_sigverify: pbs_config.skip_sigverify || is_pipe, expected_fee_recipient: pbs_config.fee_recipient, extra_validation_enabled: state.extra_validation_enabled(), parent_block: parent_block.clone(), diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 5552df643..2d195c3bf 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -406,6 +406,148 @@ async fn test_get_execution_payload_bid_demux_by_url_bytes() -> Result<()> { Ok(()) } +/// PIPE: auth data naming a builder URL outside CB's config dials it via a +/// transient client and returns its bid. The bid is signed by the builder's +/// own key, which no configured relay entry carries, so the 200 also proves +/// bid sigverify is skipped for the pipe relay (per-context, not globally: +/// `skip_sigverify` stays false here). +#[tokio::test] +async fn test_get_execution_payload_bid_pipe_dials_unconfigured_builder() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + + // A configured relay, addressed by opaque bytes only + let cfg_listener = get_free_listener().await; + let cfg_port = cfg_listener.local_addr()?.port(); + let cfg_state = Arc::new(MockRelayState::new(chain, random_secret())); + let cfg_relay = + generate_mock_relay_with_auth_data(cfg_port, cfg_state.signer.public_key(), &[0xaa])?; + tokio::spawn(start_mock_relay_service_with_listener(cfg_state.clone(), cfg_listener)); + + // The pipe builder runs but is NOT in CB's config + let pipe_listener = get_free_listener().await; + let pipe_port = pipe_listener.local_addr()?.port(); + let pipe_state = Arc::new(MockRelayState::new(chain, random_secret())); + tokio::spawn(start_mock_relay_service_with_listener(pipe_state.clone(), pipe_listener)); + + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.advertised_urls = vec!["http://cb.self.example:18550".parse()?]; + let config = to_pbs_config(chain, pbs_config, vec![cfg_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let pipe_url = format!("http://0.0.0.0:{pipe_port}/"); + let auth = opaque_auth(pipe_url.as_bytes(), TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(pipe_state.received_execution_payload_bid(), 1); + assert_eq!(cfg_state.received_execution_payload_bid(), 0, "only the piped builder is dialed"); + + // The pipe builder receives the auth verbatim and its bid comes back + // unchanged, still carrying its own signature + assert_eq!(pipe_state.received_auth_data(), Some(pipe_url.as_bytes().to_vec())); + let bid = serde_json::from_slice::(&res.bytes().await?)?; + let expected_sig = sign_execution_payload_bid_root( + &pipe_state.signer, + &bid.data.message.tree_hash_root(), + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ); + assert_eq!(bid.data.signature, expected_sig, "the piped bid must flow through unmodified"); + Ok(()) +} + +/// PIPE self-URL guard: auth data decoding to one of CB's `advertised_urls` +/// is a clean 400 and nothing is dialed - an unconfigured key's auth data +/// defaults to CB's own URL, which must not become a self-dial loop. +#[tokio::test] +async fn test_get_execution_payload_bid_pipe_self_url_not_dialed() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + // The mock stands in for whatever answers at CB's advertised URL: anything + // it receives means the guard failed and a dial went out + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = + generate_mock_relay_with_auth_data(relay_port, mock_state.signer.public_key(), &[0xaa])?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let self_url = format!("http://0.0.0.0:{relay_port}/"); + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.advertised_urls = vec![self_url.parse()?]; + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(self_url.as_bytes(), TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "a self URL is never dialed"); + Ok(()) +} + +/// PIPE fail-closed: with `advertised_urls` unset the guard cannot verify the +/// URL is not CB's own, so an unmatched builder URL stays a 400 and nothing +/// is dialed - even though the named builder is alive. +#[tokio::test] +async fn test_get_execution_payload_bid_pipe_requires_advertised_urls() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + let cfg_state = Arc::new(MockRelayState::new(chain, random_secret())); + let cfg_relay = + generate_mock_relay_with_auth_data(relay_port, cfg_state.signer.public_key(), &[0xaa])?; + tokio::spawn(start_mock_relay_service_with_listener(cfg_state.clone(), relay_listener)); + + let pipe_listener = get_free_listener().await; + let pipe_port = pipe_listener.local_addr()?.port(); + let pipe_state = Arc::new(MockRelayState::new(chain, random_secret())); + tokio::spawn(start_mock_relay_service_with_listener(pipe_state.clone(), pipe_listener)); + + // get_pbs_config leaves advertised_urls empty + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![cfg_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(format!("http://0.0.0.0:{pipe_port}/").as_bytes(), TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(pipe_state.received_execution_payload_bid(), 0, "fail closed: no blind dial"); + assert_eq!(cfg_state.received_execution_payload_bid(), 0); + Ok(()) +} + /// Auth data matching no configured relay is rejected with 400 and the spec /// data-mismatch message; no relay is contacted. #[tokio::test] From 13bd8b363a2b002cfe0562773788684b3e17f9cb Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:11:41 -0700 Subject: [PATCH 16/80] feat(pbs): pipe unmatched builder preferences to the auth_data builder URL Same demux, same semantics as the bid endpoint: empty match -> self-URL guard -> transient client through the shared send path, expecting the builder's 202. --- crates/pbs/src/routes/builder_preferences.rs | 17 ++- tests/tests/pbs_submit_builder_preferences.rs | 110 +++++++++++++++++- 2 files changed, 118 insertions(+), 9 deletions(-) diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index 7dca2c049..477b2d4e9 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -28,7 +28,7 @@ use crate::{ state::{BuilderApiState, PbsState}, utils::{ epbs_base_send_headers, expect_status, match_relays_by_auth_data, record_client_error, - send_to_relay, validate_auth_data, verify_auth_signature, + send_to_relay, transient_pipe_relay, validate_auth_data, verify_auth_signature, }, }; @@ -101,10 +101,15 @@ pub async fn submit_builder_preferences( pbs_config.verify_request_auth, )?; - let relays = match_relays_by_auth_data(relays, request.auth.message.data.as_ref()); - if relays.is_empty() { - return Err(PbsClientError::AuthDataMismatch); - } + let matched = match_relays_by_auth_data(relays, request.auth.message.data.as_ref()); + let relays: Vec = if matched.is_empty() { + // No configured relay serves this auth data: pipe the preferences to + // the builder URL the proposer's signed auth data names (self-URL + // guarded), mirroring the bid endpoint's demux semantics + vec![transient_pipe_relay(request.auth.message.data.as_ref(), &pbs_config.advertised_urls)?] + } else { + matched.into_iter().cloned().collect() + }; let send_headers = epbs_base_send_headers(&req_headers)?; @@ -116,7 +121,7 @@ pub async fn submit_builder_preferences( // in-flight writes mid-fan-out, leaving some builders with the prefs and // others without let mut handles = Vec::with_capacity(relays.len()); - for &relay in relays.iter() { + for relay in relays.iter() { handles.push( tokio::spawn( send_one_submit_builder_preferences( diff --git a/tests/tests/pbs_submit_builder_preferences.rs b/tests/tests/pbs_submit_builder_preferences.rs index 3e7c99788..f2d4f233a 100644 --- a/tests/tests/pbs_submit_builder_preferences.rs +++ b/tests/tests/pbs_submit_builder_preferences.rs @@ -5,12 +5,17 @@ use cb_common::{ utils::utcnow_ms, wire::{CONSENSUS_VERSION_HEADER, EncodingType}, }; +use std::{path::PathBuf, sync::Arc}; + +use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::MockRelayState, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, + mock_validator::MockValidator, utils::{ TEST_AUTH_DATA, generate_mock_relay, generate_mock_relay_url_only, - generate_mock_relay_with_auth_data, opaque_auth, setup_relay, setup_relays, - setup_relays_with_auth_data, signed_auth, + generate_mock_relay_with_auth_data, get_free_listener, get_pbs_config, opaque_auth, + setup_relay, setup_relays, setup_relays_with_auth_data, setup_test_env, signed_auth, + to_pbs_config, wait_for_ready, }, }; use eyre::Result; @@ -374,6 +379,105 @@ async fn test_submit_builder_preferences_missing_body_400() -> Result<()> { Ok(()) } +/// PIPE: preferences whose auth data names a builder URL outside CB's config +/// are forwarded to it via the same transient-client pipe as the bid endpoint +/// and accepted with the builder's 202. +#[tokio::test] +async fn test_submit_builder_preferences_pipe_dials_unconfigured_builder() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + + // A configured relay, addressed by opaque bytes only + let cfg_listener = get_free_listener().await; + let cfg_port = cfg_listener.local_addr()?.port(); + let cfg_state = Arc::new(MockRelayState::new(chain, random_secret())); + let cfg_relay = + generate_mock_relay_with_auth_data(cfg_port, cfg_state.signer.public_key(), &[0xaa])?; + tokio::spawn(start_mock_relay_service_with_listener(cfg_state.clone(), cfg_listener)); + + // The pipe builder runs but is NOT in CB's config + let pipe_listener = get_free_listener().await; + let pipe_port = pipe_listener.local_addr()?.port(); + let pipe_state = Arc::new(MockRelayState::new(chain, random_secret())); + tokio::spawn(start_mock_relay_service_with_listener(pipe_state.clone(), pipe_listener)); + + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.advertised_urls = vec!["http://cb.self.example:18550".parse()?]; + let config = to_pbs_config(chain, pbs_config, vec![cfg_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let pipe_url = format!("http://0.0.0.0:{pipe_port}/"); + let auth = opaque_auth(pipe_url.as_bytes(), future_slot(chain)); + let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(pipe_state.received_builder_preferences(), 1); + assert_eq!(cfg_state.received_builder_preferences(), 0, "only the piped builder is dialed"); + assert_eq!(pipe_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + let forwarded = pipe_state.received_preferences_auth().expect("auth forwarded"); + assert_eq!(forwarded.message.data.to_vec(), pipe_url.as_bytes().to_vec()); + Ok(()) +} + +/// PIPE self-URL guard, preferences side: auth data decoding to one of CB's +/// `advertised_urls` is a clean 400 and nothing is dialed; with +/// `advertised_urls` unset the guard fails closed the same way. +#[tokio::test] +async fn test_submit_builder_preferences_pipe_self_url_not_dialed() -> Result<()> { + setup_test_env(); + let chain = Chain::Hoodi; + + for advertise_self in [true, false] { + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr()?.port(); + + // The mock stands in for whatever answers at the named URL: anything + // it receives means a dial went out + let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); + let mock_relay = generate_mock_relay_with_auth_data( + relay_port, + mock_state.signer.public_key(), + &[0xaa], + )?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let self_url = format!("http://0.0.0.0:{relay_port}/"); + let mut pbs_config = get_pbs_config(pbs_port); + if advertise_self { + pbs_config.advertised_urls = vec![self_url.parse()?]; + } + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); + + let mock_validator = MockValidator::new(pbs_port)?; + wait_for_ready(&mock_validator).await?; + + let auth = opaque_auth(self_url.as_bytes(), future_slot(chain)); + let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "advertise_self={advertise_self}"); + assert_eq!( + mock_state.received_builder_preferences(), + 0, + "no dial (advertise_self={advertise_self})" + ); + } + Ok(()) +} + /// Preferences addressed to a builder this PBS does not serve are rejected by /// the demux, not blindly fanned out. #[tokio::test] From a49d7dd28584cc89758095def7e90f57c9d20f22 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:12:52 -0700 Subject: [PATCH 17/80] docs: document how advertised_urls gates the ePBS pipe --- config.example.toml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/config.example.toml b/config.example.toml index a6d442a67..6dc7446cc 100644 --- a/config.example.toml +++ b/config.example.toml @@ -93,8 +93,12 @@ validator_registration_batch_size = "" # OPTIONAL, DEFAULT: 384 mux_registry_refresh_interval_seconds = 384 # Commit-Boost's externally-reachable URLs, i.e. the ones a validator client's builder config points -# at. Used by the ePBS pipe self-URL guard: auth data routing back to one of these URLs is rejected -# instead of dialed, preventing a self-dial loop +# at. Gates the ePBS pipe: when a request's auth data names a builder URL no configured relay serves, +# CB dials that URL directly through a transient client (trust is the validator client's job via its +# keymanager builder_pubkeys). Since an unconfigured key's auth data defaults to CB's own URL, the +# pipe first checks the decoded URL against this list and rejects a match instead of dialing itself. +# The guard fails closed: while this list is empty, unmatched auth data is always rejected and the +# pipe never dials, so set it to enable piping to builders outside the [[relays]] below. # OPTIONAL, DEFAULT: [] # advertised_urls = ["http://cb.example.com:18550"] From 783b69a40cd0944d4b0e38e0d3ff10a267ee2454 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:18:05 -0700 Subject: [PATCH 18/80] fix(km-tool): fail projection when byte-distinct auth_data classes are lax-URL equivalent CB's url_matches ignores userinfo, host case and the default port, so two byte-distinct classes it treats as one relay would split builder_pubkeys across KM entries and the VC would silently reject the missing pubkey's winning bids. Conservative tool-local mirror; CB-side contract tests pin the real matcher. --- crates/km-tool/src/project.rs | 123 ++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index f9116ef76..5c93d268b 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -295,6 +295,43 @@ struct AuthClass { max_execution_payment_gwei: Option, } +/// The key cb-pbs's `url_matches` compares by: scheme, host (lowercased for +/// good measure; `Url` already lowercases domains) and effective port. +/// Userinfo never enters the key. `None` for bytes that are not a URL with a +/// host (e.g. an opaque `expected_auth_data`). +fn lax_url_key(bytes: &[u8]) -> Option<(String, String, Option)> { + let text = std::str::from_utf8(bytes).ok()?; + let url = url::Url::parse(text).ok()?; + let host = url.host_str()?.to_lowercase(); + Some((url.scheme().to_string(), host, url.port_or_known_default())) +} + +/// Two byte-distinct auth_data classes whose bytes parse to lax-equivalent +/// URLs would be matched as ONE relay by CB (`url_matches` ignores userinfo, +/// host case and the default port), so the matched set could include a relay +/// whose pubkey is missing from the entry's builder_pubkeys and the VC would +/// silently reject its winning bids. This mirrors cb-pbs `url_matches` +/// semantics conservatively; the CB-side contract tests pin the real matcher. +fn ensure_no_lax_ambiguity(mux_id: &str, classes: &BTreeMap, AuthClass>) -> Result<()> { + let mut by_lax: BTreeMap<(String, String, Option), &[u8]> = BTreeMap::new(); + for bytes in classes.keys() { + let Some(lax) = lax_url_key(bytes) else { + continue; + }; + if let Some(first) = by_lax.get(&lax) { + bail!( + "mux {mux_id}: auth_data {:?} and {:?} differ in bytes but CB's lax URL matching \ + would treat them as the same relay, splitting builder_pubkeys across KM entries; \ + make the relay URLs byte-identical or set explicit expected_auth_data", + String::from_utf8_lossy(first), + String::from_utf8_lossy(bytes) + ); + } + by_lax.insert(lax, bytes); + } + Ok(()) +} + fn project_mux( input: &ProjectionInput, overlay: &Overlay, @@ -342,6 +379,8 @@ fn project_mux( classes.len() ); + ensure_no_lax_ambiguity(&mux.id, &classes)?; + let min_bid = resolve_min_bid(input, overlay, mux, warnings)?; let boost = mux .projected_boost_factor() @@ -811,6 +850,90 @@ url = "https://{RELAY_PK_A}@relay-a.example.com" assert_eq!(doc.builders.as_ref().unwrap().len(), 1); } + #[test] + fn lax_equivalent_urls_across_classes_error() { + let key = random_key_hex(); + // byte-distinct candidates, but CB's url_matches sees one relay: + // https://h vs https://h:443 (default port) + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay.example.com" +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay.example.com:443" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let err = project(&input, &overlay()).unwrap_err(); + assert!(err.to_string().contains("byte-identical"), "{err}"); + } + + #[test] + fn lax_collision_via_expected_auth_data_url_errors() { + let key = random_key_hex(); + // expected_auth_data holds URL bytes lax-equivalent to the other + // relay's URL-derived candidate: hex of "HTTPS://RELAY.EXAMPLE.COM" + // is byte-distinct but host-case-insensitively the same relay + let upper_hex = crate::doc::encode_auth_data(b"https://RELAY.EXAMPLE.COM"); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay.example.com" +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "{upper_hex}" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let err = project(&input, &overlay()).unwrap_err(); + assert!(err.to_string().contains("byte-identical"), "{err}"); + } + + #[test] + fn distinct_hosts_do_not_lax_collide() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); + // relay-a and relay-b: different hosts, projection succeeds + assert_eq!(project(&input, &overlay()).unwrap().docs.len(), 1); + } + + #[test] + fn same_byte_urls_still_group_into_one_class() { + let key = random_key_hex(); + // identical URL bytes after userinfo strip: one class, no ambiguity + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +id = "r1" +url = "https://{RELAY_PK_A}@relay.example.com" +[[mux.relays]] +id = "r2" +url = "https://{RELAY_PK_B}@relay.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + let entries = doc.builders.as_ref().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].builder_pubkeys.as_ref().unwrap().len(), 2); + } + #[test] fn auth_data_size_limit_enforced() { let key = random_key_hex(); From aa7b02544eec52ff6cdc5f7794634303f551f754 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:19:17 -0700 Subject: [PATCH 19/80] fix(km-tool): check errors on missing builder_config route for enumerated keys A 404 on a key the VC itself enumerated means the #88 route is absent, so a no-#88 fleet must not check green; silent continue hid it. --- crates/km-tool/src/check.rs | 14 +++++++++++++- crates/km-tool/tests/mock_km.rs | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/km-tool/src/check.rs b/crates/km-tool/src/check.rs index d01cc68e2..948da8971 100644 --- a/crates/km-tool/src/check.rs +++ b/crates/km-tool/src/check.rs @@ -103,7 +103,19 @@ pub async fn run_check(input: &ProjectionInput, overlay: &Overlay) -> Result doc, - GetConfigOutcome::NotFound => continue, + GetConfigOutcome::NotFound => { + // 404 on a key the VC itself enumerated: the ROUTE is + // missing, not the key + report.push( + Tier::Error, + "no-builder-config-route", + format!( + "{vc_name}: {key} enumerated but has no builder_config route \ + (keymanager-APIs #88 unsupported?)" + ), + ); + continue; + } }; let stored = CanonicalDoc::from_doc(&stored)?; diff --git a/crates/km-tool/tests/mock_km.rs b/crates/km-tool/tests/mock_km.rs index 33887999c..da21d1486 100644 --- a/crates/km-tool/tests/mock_km.rs +++ b/crates/km-tool/tests/mock_km.rs @@ -381,6 +381,28 @@ async fn check_flags_drift_and_unroutable_auth_data() { assert!(report.fails(Tier::Error)); } +#[tokio::test] +async fn check_errors_when_no_builder_config_route() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + vc.supports_builder_config = false; + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_check(&env.input, &env.overlay).await.unwrap(); + assert!( + report + .findings + .iter() + .any(|f| f.code == "no-builder-config-route" && + f.tier == Tier::Error && + f.msg.contains("#88")), + "{:?}", + report.findings + ); + assert!(report.fails(Tier::Error)); +} + #[tokio::test] async fn check_flags_duplicate_key_across_vcs() { let key = random_key(); From 244fa3566f778dccfb4aaf335d653ceeb2eadfa7 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:19:34 -0700 Subject: [PATCH 20/80] fix(km-tool): downgrade unroutable-auth-data to WARN v1 CB is a pure pipe: a stored builder URL outside the configured relays still routes, so it is operator-notable, not a failure. --- crates/km-tool/src/check.rs | 7 +++++-- crates/km-tool/tests/mock_km.rs | 9 +++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/km-tool/src/check.rs b/crates/km-tool/src/check.rs index 948da8971..e660152de 100644 --- a/crates/km-tool/src/check.rs +++ b/crates/km-tool/src/check.rs @@ -125,11 +125,14 @@ pub async fn run_check(input: &ProjectionInput, overlay: &Overlay) -> Result Date: Thu, 20 Aug 2026 23:19:42 -0700 Subject: [PATCH 21/80] fix: dedupe unknown-mux-field warnings and skip None KM fields in TOML output The log-settings pre-load went through CommitBoostConfig::from_env_path, so every service warned about each unknown mux key twice (once for the logs load, once for the primary config load). Route the logs load through a silent variant so each load path warns exactly once. Also skip serializing builder_boost_factor and min_bid_eth when None so a MuxConfig re-serialized to TOML round-trips cleanly. --- crates/common/src/config/log.rs | 4 +++- crates/common/src/config/mod.rs | 10 +++++++++- crates/common/src/config/mux.rs | 31 ++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/common/src/config/log.rs b/crates/common/src/config/log.rs index a792ebc82..0235ba1a9 100644 --- a/crates/common/src/config/log.rs +++ b/crates/common/src/config/log.rs @@ -16,7 +16,9 @@ pub struct LogsSettings { impl LogsSettings { pub fn from_env_config() -> Result { - let (mut config, _) = CommitBoostConfig::from_env_path()?; + // Silent load: the service's primary config load already warns about + // unknown mux fields; warning here too would log each key twice + let (mut config, _) = CommitBoostConfig::from_env_path_silent()?; // Override log dir path if env var is set if let Some(log_dir) = load_optional_env_var(LOGS_DIR_ENV) { diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 616ec9f4c..558bbf29f 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -64,8 +64,16 @@ impl CommitBoostConfig { // When loading the config from the environment, it's important that every path // is replaced with the correct value if the config is loaded inside a container pub fn from_env_path() -> Result<(Self, PathBuf)> { - let (helper_config, config_path): (HelperConfig, PathBuf) = load_file_from_env(CONFIG_ENV)?; + let (config, config_path) = Self::from_env_path_silent()?; warn_unknown_mux_fields(&config_path); + Ok((config, config_path)) + } + + /// [`Self::from_env_path`] without the unknown-mux-field warnings. For + /// secondary loads (e.g. log settings) in processes whose primary config + /// load already warns, so each unknown key is logged once per process. + pub(crate) fn from_env_path_silent() -> Result<(Self, PathBuf)> { + let (helper_config, config_path): (HelperConfig, PathBuf) = load_file_from_env(CONFIG_ENV)?; let chain = match helper_config.chain { ChainLoader::Path { path, genesis_time_secs } => { diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index bdd934015..a626b8ccd 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -171,10 +171,16 @@ pub struct MuxConfig { pub fee_recipient: Option
, /// Projection-only: consumed by KM tooling, not read by the PBS runtime. /// The ePBS builder_boost_factor for this mux's keys + #[serde(skip_serializing_if = "Option::is_none")] pub builder_boost_factor: Option, /// Projection-only: consumed by KM tooling, not read by the PBS runtime. /// The ePBS per-key-group minimum total payment for this mux's keys - #[serde(rename = "min_bid_eth", with = "as_opt_eth_str", default)] + #[serde( + rename = "min_bid_eth", + with = "as_opt_eth_str", + default, + skip_serializing_if = "Option::is_none" + )] pub min_bid_wei: Option, } @@ -662,6 +668,29 @@ mod tests { assert_eq!(mux.min_bid_wei, None); } + #[test] + fn mux_config_none_fields_roundtrip() { + // None-valued KM projection fields must be skipped on serialization so + // a config re-serialized to TOML stays loadable + let mux: MuxConfig = toml::from_str( + r#" + id = "test" + relays = [] + "#, + ) + .unwrap(); + assert_eq!(mux.builder_boost_factor, None); + assert_eq!(mux.min_bid_wei, None); + + let serialized = toml::to_string(&mux).unwrap(); + assert!(!serialized.contains("builder_boost_factor")); + assert!(!serialized.contains("min_bid_eth")); + + let roundtripped: MuxConfig = toml::from_str(&serialized).unwrap(); + assert_eq!(roundtripped.builder_boost_factor, None); + assert_eq!(roundtripped.min_bid_wei, None); + } + #[test] fn unknown_mux_fields_flags_typos_only() { let raw: toml::Value = r#" From 3553527ef4eb41bbacda461f595f93351a0065d9 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:20:30 -0700 Subject: [PATCH 22/80] fix(km-tool): apply preflight separates probe failure from missing #88 support A transport error or 5xx on the probe is its own error, never evidence of no support; an empty-keystore VC warns and POSTs anyway (the 404/405 responses answer the support question). --- crates/km-tool/src/apply.rs | 60 ++++++++++++++++++--------------- crates/km-tool/tests/mock_km.rs | 41 ++++++++++++++++++++++ 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index 3f70d33b2..0fd989a9f 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -55,23 +55,24 @@ impl ApplyReport { } } -/// Whether a VC preflight proved #88 builder_config support. -async fn preflight_supports_builder_config( - client: &KmClient, - enumerated: &[String], - fallback_probe_key: Option<&str>, -) -> Result { - // Probe a key the VC itself enumerated: a 404 then means the ROUTE is - // missing (no #88 support), never "key elsewhere". Without any enumerated - // key fall back to a projected key, where 404 stays ambiguous and is - // treated as unsupported to fail loud. - let probe_key = enumerated.first().map(String::as_str).or(fallback_probe_key); - let Some(probe_key) = probe_key else { - return Ok(false); +enum Preflight { + Supported, + Unsupported, + /// No enumerated key to probe: proceed and let the POST responses tell + Unknown, +} + +/// Probes #88 builder_config support with a key the VC itself enumerated: a +/// 404 then means the ROUTE is missing, never "key elsewhere". Transport +/// errors and non-404 failures propagate; they are not evidence about +/// support. +async fn preflight_builder_config(client: &KmClient, enumerated: &[String]) -> Result { + let Some(probe_key) = enumerated.first() else { + return Ok(Preflight::Unknown); }; match client.get_builder_config(probe_key).await? { - GetConfigOutcome::Ok(_) => Ok(true), - GetConfigOutcome::NotFound => Ok(false), + GetConfigOutcome::Ok(_) => Ok(Preflight::Supported), + GetConfigOutcome::NotFound => Ok(Preflight::Unsupported), } } @@ -119,18 +120,23 @@ pub async fn run_apply( }; all_enumerated.extend(enumerated.iter().cloned()); - let supports = preflight_supports_builder_config( - &client, - &enumerated, - projected_keys.iter().next().map(String::as_str), - ) - .await - .unwrap_or(false); - if !supports { - report.error(format!( - "{vc_name}: no builder_config support (keymanager-APIs #88); skipping" - )); - continue; + match preflight_builder_config(&client, &enumerated).await { + Ok(Preflight::Supported) => {} + Ok(Preflight::Unsupported) => { + report.error(format!( + "{vc_name}: no builder_config support (keymanager-APIs #88); skipping" + )); + continue; + } + Ok(Preflight::Unknown) => { + report.warn(format!( + "{vc_name}: no keys to probe for builder_config support; POSTing anyway" + )); + } + Err(err) => { + report.error(format!("{vc_name}: builder_config probe failed: {err}")); + continue; + } } let vc_projection = project_with_url(input, overlay, overlay.advertised_url_for(vc))?; diff --git a/crates/km-tool/tests/mock_km.rs b/crates/km-tool/tests/mock_km.rs index c167c2f60..891b5a273 100644 --- a/crates/km-tool/tests/mock_km.rs +++ b/crates/km-tool/tests/mock_km.rs @@ -36,6 +36,8 @@ struct MockVc { stored: HashMap, /// POST status override per key (default: 202 when held, else 404) post_status: HashMap, + /// blanket GET builder_config status override (e.g. 500) + get_status: Option, /// recorded (pubkey, raw body) of every builder_config POST posts: Arc>>, } @@ -74,6 +76,9 @@ async fn get_config( if !authed(&headers) { return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); } + if let Some(status) = vc.get_status { + return (StatusCode::from_u16(status).unwrap(), "overridden").into_response(); + } if !vc.supports_builder_config || !vc.keystores.contains(&pubkey) { return (StatusCode::NOT_FOUND, "not found").into_response(); } @@ -229,6 +234,42 @@ async fn apply_detects_missing_88_support() { ); } +#[tokio::test] +async fn apply_probe_transport_failure_is_its_own_error() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + vc.get_status = Some(500); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(!report.ok()); + assert!( + report.errors.iter().any(|e| e.contains("builder_config probe failed")), + "{:?}", + report.errors + ); + assert!(!report.errors.iter().any(|e| e.contains("no builder_config support"))); + // a failed probe skips the VC: nothing POSTed + assert!(vc.posts().is_empty()); +} + +#[tokio::test] +async fn apply_empty_keystore_vc_warns_and_still_posts() { + let key = random_key(); + let empty_vc = MockVc::holding(&[]); + let holder_vc = MockVc::holding(std::slice::from_ref(&key)); + let (empty_url, holder_url) = (serve(empty_vc.clone()).await, serve(holder_vc).await); + + let env = env_for(std::slice::from_ref(&key), &[empty_url, holder_url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(report.ok(), "{:?}", report.errors); + assert!(report.warnings.iter().any(|w| w.contains("no keys to probe")), "{:?}", report.warnings); + // supported-unknown: the empty VC still gets the POST; its 404 answers + assert_eq!(empty_vc.posts().len(), 1); + assert_eq!(report.accepted.get(&key).map(Vec::len), Some(1)); +} + #[tokio::test] async fn apply_zero_acceptors_is_an_error() { let (projected, other) = (random_key(), random_key()); From df996a38b7b2f3722684e28b523ae3b669da7d0b Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:21:28 -0700 Subject: [PATCH 23/80] fix(km-tool): preserve base URL path prefix when building endpoints Url::join with an absolute path drops a /prefix base; concat on the trimmed base keeps it. --- crates/km-tool/src/client.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/km-tool/src/client.rs b/crates/km-tool/src/client.rs index 08157c3c2..59dac2c5a 100644 --- a/crates/km-tool/src/client.rs +++ b/crates/km-tool/src/client.rs @@ -83,7 +83,10 @@ impl KmClient { } fn endpoint(&self, path: &str) -> Result { - self.base.join(path).wrap_err_with(|| format!("invalid endpoint {path}")) + // string concat, not Url::join: an absolute path would drop a base + // path prefix (https://vc.example/prefix) + let base = self.base.as_str().trim_end_matches('/'); + Url::parse(&format!("{base}{path}")).wrap_err_with(|| format!("invalid endpoint {path}")) } /// GET /eth/v1/keystores; returns lowercased validating pubkeys. Doubles @@ -128,3 +131,27 @@ impl KmClient { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn client(base: &str) -> KmClient { + KmClient::new(Url::parse(base).unwrap(), "t".into()).unwrap() + } + + #[test] + fn endpoint_preserves_base_path_prefix() { + let url = client("https://vc.example/prefix").endpoint("/eth/v1/keystores").unwrap(); + assert_eq!(url.as_str(), "https://vc.example/prefix/eth/v1/keystores"); + // trailing slash on the base collapses, no double slash + let url = client("https://vc.example/prefix/").endpoint("/eth/v1/keystores").unwrap(); + assert_eq!(url.as_str(), "https://vc.example/prefix/eth/v1/keystores"); + } + + #[test] + fn endpoint_without_prefix_unchanged() { + let url = client("http://127.0.0.1:5062").endpoint("/eth/v1/keystores").unwrap(); + assert_eq!(url.as_str(), "http://127.0.0.1:5062/eth/v1/keystores"); + } +} From 54f5d562d071701fc018dcdff0737a97575e93aa Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:21:47 -0700 Subject: [PATCH 24/80] fix(km-tool): warn on group-readable token files too Group read (0o040) leaks the bearer token the same way world read does. --- crates/km-tool/src/client.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/km-tool/src/client.rs b/crates/km-tool/src/client.rs index 59dac2c5a..a8fa18430 100644 --- a/crates/km-tool/src/client.rs +++ b/crates/km-tool/src/client.rs @@ -12,16 +12,22 @@ use crate::doc::BuilderConfigDoc; const HTTP_TIMEOUT: Duration = Duration::from_secs(30); +/// Whether a token file mode leaks reads beyond the owner. +#[cfg(unix)] +fn token_mode_overexposed(mode: u32) -> bool { + mode & 0o044 != 0 +} + /// Reads a bearer token file, trimming surrounding whitespace. Warns when the -/// file is world-readable. +/// file is group- or world-readable. pub fn read_token(path: &Path) -> Result { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Ok(meta) = std::fs::metadata(path) - && meta.permissions().mode() & 0o004 != 0 + && token_mode_overexposed(meta.permissions().mode()) { - warn!("token file {path:?} is world-readable"); + warn!("token file {path:?} is group- or world-readable"); } } let token = std::fs::read_to_string(path) @@ -154,4 +160,14 @@ mod tests { let url = client("http://127.0.0.1:5062").endpoint("/eth/v1/keystores").unwrap(); assert_eq!(url.as_str(), "http://127.0.0.1:5062/eth/v1/keystores"); } + + #[cfg(unix)] + #[test] + fn token_mode_overexposure() { + assert!(token_mode_overexposed(0o644)); + assert!(token_mode_overexposed(0o640)); + assert!(token_mode_overexposed(0o604)); + assert!(!token_mode_overexposed(0o600)); + assert!(!token_mode_overexposed(0o620)); + } } From b9df5c8d1f9b8ddc2ea4655aa9900b07c0987973 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:22:07 -0700 Subject: [PATCH 25/80] docs(km-tool): warn in --help that check-green does not make apply a no-op --- crates/km-tool/src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/km-tool/src/main.rs b/crates/km-tool/src/main.rs index c154230fe..0cad19c04 100644 --- a/crates/km-tool/src/main.rs +++ b/crates/km-tool/src/main.rs @@ -11,7 +11,10 @@ use eyre::Result; #[derive(Parser)] #[command( name = "cb-km", - about = "Project a Commit-Boost mux config into keymanager builder_config docs" + about = "Project a Commit-Boost mux config into keymanager builder_config docs", + after_help = "WARNING: check-green does not mean apply-is-a-no-op: GET returns resolved docs, \ + so third-party-pinned values for fields the projection omits (boost, cap) are \ + invisible to check and will be ERASED by apply (POST replaces in full)." )] struct Cli { #[command(subcommand)] From 7a30371284925982059991bd6339c1b7a4cdb517 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:22:56 -0700 Subject: [PATCH 26/80] style(km-tool): rustfmt with the pinned toolchain --- crates/km-tool/src/check.rs | 8 ++++---- crates/km-tool/src/client.rs | 4 ++-- crates/km-tool/tests/mock_km.rs | 24 ++++++++++++------------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/km-tool/src/check.rs b/crates/km-tool/src/check.rs index e660152de..81766f6c1 100644 --- a/crates/km-tool/src/check.rs +++ b/crates/km-tool/src/check.rs @@ -122,8 +122,8 @@ pub async fn run_check(input: &ProjectionInput, overlay: &Overlay) -> Result, stored: &Option, ) { - if let Some(expected) = projected - && stored != &Some(*expected) + if let Some(expected) = projected && + stored != &Some(*expected) { lines.push(format!("{name}: projected {expected}, stored {stored:?}")); } diff --git a/crates/km-tool/src/client.rs b/crates/km-tool/src/client.rs index a8fa18430..106ff0c81 100644 --- a/crates/km-tool/src/client.rs +++ b/crates/km-tool/src/client.rs @@ -24,8 +24,8 @@ pub fn read_token(path: &Path) -> Result { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = std::fs::metadata(path) - && token_mode_overexposed(meta.permissions().mode()) + if let Ok(meta) = std::fs::metadata(path) && + token_mode_overexposed(meta.permissions().mode()) { warn!("token file {path:?} is group- or world-readable"); } diff --git a/crates/km-tool/tests/mock_km.rs b/crates/km-tool/tests/mock_km.rs index 891b5a273..682172258 100644 --- a/crates/km-tool/tests/mock_km.rs +++ b/crates/km-tool/tests/mock_km.rs @@ -264,7 +264,11 @@ async fn apply_empty_keystore_vc_warns_and_still_posts() { let env = env_for(std::slice::from_ref(&key), &[empty_url, holder_url]); let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); assert!(report.ok(), "{:?}", report.errors); - assert!(report.warnings.iter().any(|w| w.contains("no keys to probe")), "{:?}", report.warnings); + assert!( + report.warnings.iter().any(|w| w.contains("no keys to probe")), + "{:?}", + report.warnings + ); // supported-unknown: the empty VC still gets the POST; its 404 answers assert_eq!(empty_vc.posts().len(), 1); assert_eq!(report.accepted.get(&key).map(Vec::len), Some(1)); @@ -352,11 +356,10 @@ async fn dry_run_and_emit_post_nothing() { assert!(vc.posts().is_empty()); let emit_dir = tempfile::tempdir().unwrap(); - let report = run_apply( - &env.input, - &env.overlay, - &ApplyOptions { emit_dir: Some(emit_dir.path().to_path_buf()), ..Default::default() }, - ) + let report = run_apply(&env.input, &env.overlay, &ApplyOptions { + emit_dir: Some(emit_dir.path().to_path_buf()), + ..Default::default() + }) .await .unwrap(); assert!(report.ok()); @@ -437,12 +440,9 @@ async fn check_errors_when_no_builder_config_route() { let env = env_for(std::slice::from_ref(&key), &[url]); let report = run_check(&env.input, &env.overlay).await.unwrap(); assert!( - report - .findings - .iter() - .any(|f| f.code == "no-builder-config-route" && - f.tier == Tier::Error && - f.msg.contains("#88")), + report.findings.iter().any(|f| f.code == "no-builder-config-route" && + f.tier == Tier::Error && + f.msg.contains("#88")), "{:?}", report.findings ); From 0ccd34f013a58b21ed90b963f0e7e530538a43e9 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 20 Aug 2026 23:25:29 -0700 Subject: [PATCH 27/80] feat(km-tool): read projection fields from MuxConfig post-merge The mux_ext seam was written against pre-merge cb-common; now that builder_boost_factor and min_bid_eth exist on MuxConfig, the accessors read them directly (precedence: mux field > overlay > global). --- crates/km-tool/src/mux_ext.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/km-tool/src/mux_ext.rs b/crates/km-tool/src/mux_ext.rs index 545ebb454..2821dd695 100644 --- a/crates/km-tool/src/mux_ext.rs +++ b/crates/km-tool/src/mux_ext.rs @@ -15,10 +15,10 @@ pub trait MuxProjectionFields { impl MuxProjectionFields for MuxConfig { fn projected_boost_factor(&self) -> Option { - None + self.builder_boost_factor } fn projected_min_bid_wei(&self) -> Option { - None + self.min_bid_wei } } From d6c680120ccdcde67cf306fad0d311cba6fefb1e Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 13:19:32 -0700 Subject: [PATCH 28/80] feat(pbs): configurable bid signing-domain params Add optional PbsConfig fields gloas_fork_version (0x-hex, 4 bytes) and genesis_validators_root (0x-hex, 32 bytes) and thread them into the ePBS bid signature verification. Unset = the built-in constants, so existing configs verify exactly as before; devnets/networks whose gloas fork version or genesis root differ can now pass bid sigverify without skip_sigverify. Deriving these from the fork schedule is tracked separately. --- Cargo.lock | 1 + config.example.toml | 8 ++ crates/common/src/config/pbs.rs | 64 ++++++++++- crates/common/src/config/signer.rs | 2 + crates/pbs/Cargo.toml | 3 + .../pbs/src/routes/execution_payload_bid.rs | 103 ++++++++++++++++-- tests/src/utils.rs | 2 + tests/tests/pbs_cfg_file_update.rs | 2 + 8 files changed, 177 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a41a9071..1787da4f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1972,6 +1972,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tokio-tungstenite", + "toml", "tower-http", "tracing", "tree_hash", diff --git a/config.example.toml b/config.example.toml index 6dc7446cc..86a6028d7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -40,6 +40,14 @@ timeout_register_validator_ms = 3000 # Whether to skip signature verification of headers and pubkey matching against the relay pubkey # OPTIONAL, DEFAULT: false skip_sigverify = false +# Gloas fork version (0x-hex, 4 bytes) used in the ePBS bid signing domain. Override for +# devnets/networks whose gloas fork version differs from the built-in constant +# OPTIONAL, DEFAULT: unset (built-in constant) +# gloas_fork_version = "0x80435048" +# Genesis validators root (0x-hex, 32 bytes) used in the ePBS bid signing domain. Override for +# devnets/networks whose genesis root differs from the built-in constant +# OPTIONAL, DEFAULT: unset (built-in constant) +# genesis_validators_root = "0x0000000000000000000000000000000000000000000000000000000000000000" # Minimum bid in ETH that will be accepted from `get_header` # Can be specified as a float or a string for extra precision (e.g. "0.01") # OPTIONAL, DEFAULT: 0.0 diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 8f01bbe72..5dc1428d8 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -8,7 +8,7 @@ use std::{ }; use alloy::{ - primitives::{Address, Bytes, U256, utils::format_ether}, + primitives::{Address, B256, Bytes, U256, aliases::B32, utils::format_ether}, providers::{Provider, ProviderBuilder}, }; use docker_image::DockerImage; @@ -27,6 +27,7 @@ use crate::{ PbsMuxes, SIGNER_TLS_CERTIFICATE_NAME, SIGNER_TLS_CERTIFICATES_PATH_ENV, SIGNER_URL_ENV, SignerConfig, TlsMode, load_env_var, load_file_from_env, }, + constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{ DEFAULT_PBS_PORT, DEFAULT_REGISTRY_REFRESH_SECONDS, DefaultTimeout, LATE_IN_SLOT_TIME_MS, REGISTER_VALIDATOR_RETRY_LIMIT, RelayClient, RelayEntry, @@ -187,9 +188,33 @@ pub struct PbsConfig { /// CB's externally-reachable URLs; used by the ePBS pipe self-URL guard #[serde(default)] pub advertised_urls: Vec, + /// Gloas fork version (0x-hex, 4 bytes) used in the ePBS bid signing + /// domain. Override for devnets/networks whose gloas fork version differs + /// from the built-in constant — see the ticket on deriving these from the + /// fork schedule. Unset = the built-in constant (behavior unchanged) + #[serde(default)] + pub gloas_fork_version: Option, + /// Genesis validators root (0x-hex, 32 bytes) used in the ePBS bid signing + /// domain. Override for devnets/networks whose genesis root differs from + /// the built-in constant — see the ticket on deriving these from the fork + /// schedule. Unset = the built-in constant (behavior unchanged) + #[serde(default)] + pub genesis_validators_root: Option, } impl PbsConfig { + /// Gloas fork version for the ePBS bid signing domain: the configured + /// override, else the built-in constant. + pub fn bid_fork_version(&self) -> [u8; 4] { + self.gloas_fork_version.map(|v| v.0).unwrap_or(GLOAS_FORK_VERSION) + } + + /// Genesis validators root for the ePBS bid signing domain: the configured + /// override, else the built-in constant. + pub fn bid_genesis_validators_root(&self) -> B256 { + self.genesis_validators_root.unwrap_or_else(|| B256::from(GENESIS_VALIDATORS_ROOT)) + } + /// Validate PBS config parameters pub async fn validate(&self, chain: Chain) -> Result<()> { // timeouts must be positive @@ -492,3 +517,40 @@ fn default_ssv_node_api_url() -> Url { fn default_public_ssv_api_url() -> Url { Url::parse("https://api.ssv.network/api/v4/").expect("default URL is valid") } + +#[cfg(test)] +mod tests { + use alloy::primitives::{B256, aliases::B32, b256}; + + use super::*; + use crate::constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}; + + // Absent overrides fall back to the built-in constants: existing configs + // keep verifying bids exactly as before. + #[test] + fn bid_domain_params_default_to_constants() { + let cfg: PbsConfig = toml::from_str("").unwrap(); + assert_eq!(cfg.gloas_fork_version, None); + assert_eq!(cfg.genesis_validators_root, None); + assert_eq!(cfg.bid_fork_version(), GLOAS_FORK_VERSION); + assert_eq!(cfg.bid_genesis_validators_root(), B256::from(GENESIS_VALIDATORS_ROOT)); + } + + // Configured overrides are what the bid domain sees, parsed from 0x-hex. + #[test] + fn bid_domain_params_use_configured_overrides() { + let cfg: PbsConfig = toml::from_str( + r#" + gloas_fork_version = "0x80000038" + genesis_validators_root = "0x6c74d2e46eee2b5ec9b3512975fda3e1e97cee8cf2be0f2f6bcbf36002d17f3c" + "#, + ) + .unwrap(); + assert_eq!(cfg.gloas_fork_version, Some(B32::new([0x80, 0x00, 0x00, 0x38]))); + assert_eq!(cfg.bid_fork_version(), [0x80, 0x00, 0x00, 0x38]); + assert_eq!( + cfg.bid_genesis_validators_root(), + b256!("6c74d2e46eee2b5ec9b3512975fda3e1e97cee8cf2be0f2f6bcbf36002d17f3c") + ); + } +} diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 0811100ab..ba68c45bf 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -488,6 +488,8 @@ mod tests { ssv_node_api_url: Url::parse("https://example.net").unwrap(), ssv_public_api_url: Url::parse("https://example.net").unwrap(), advertised_urls: vec![], + gloas_fork_version: None, + genesis_validators_root: None, }, with_signer: true, }, diff --git a/crates/pbs/Cargo.toml b/crates/pbs/Cargo.toml index 7497aa930..7f591a283 100644 --- a/crates/pbs/Cargo.toml +++ b/crates/pbs/Cargo.toml @@ -35,3 +35,6 @@ url.workspace = true uuid.workspace = true webpki-roots.workspace = true thiserror.workspace = true + +[dev-dependencies] +toml.workspace = true diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 229aee747..9133e3862 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -14,7 +14,6 @@ use axum::{ }; use cb_common::{ config::PbsConfig, - constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{ DEFAULT_BID_POLL_TIMEOUT_MS, ForkName, GetExecutionPayloadBidInfo, GetExecutionPayloadBidParams, GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, @@ -260,6 +259,8 @@ pub async fn get_execution_payload_bid( expected_fee_recipient: pbs_config.fee_recipient, extra_validation_enabled: state.extra_validation_enabled(), parent_block: parent_block.clone(), + fork_version: pbs_config.bid_fork_version(), + genesis_validators_root: pbs_config.bid_genesis_validators_root(), }, ) .in_current_span(), @@ -613,6 +614,10 @@ struct ValidationContext { expected_fee_recipient: Option
, extra_validation_enabled: bool, parent_block: Arc>>, + /// Bid signing-domain parameters: config overrides when set (devnets whose + /// gloas fork version / genesis root differ), else the built-in constants + fork_version: [u8; 4], + genesis_validators_root: B256, } async fn send_one_get_execution_payload_bid( @@ -744,6 +749,8 @@ async fn send_one_get_execution_payload_bid( relay.pubkey(), &get_header_response.data.message, &get_header_response.data.signature, + validation.fork_version, + validation.genesis_validators_root, )?; } @@ -822,13 +829,15 @@ fn validate_signature( expected_pubkey: &BlsPublicKey, message: &T, signature: &BlsSignature, + fork_version: [u8; 4], + genesis_validators_root: B256, ) -> Result<(), ValidationError> { if !verify_execution_payload_bid_signature( expected_pubkey, &message, signature, - GLOAS_FORK_VERSION, - GENESIS_VALIDATORS_ROOT.into(), + fork_version, + genesis_validators_root, ) { return Err(ValidationError::Sigverify); } @@ -965,7 +974,6 @@ mod tests { .unwrap(); } - #[test] fn test_validate_signature() { let secret_key = BlsSecretKey::random(); @@ -985,14 +993,95 @@ mod tests { ); assert!(matches!( - validate_signature(&pubkey, &message, &wrong_signature), + validate_signature( + &pubkey, + &message, + &wrong_signature, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into() + ), Err(ValidationError::Sigverify) )); assert!(matches!( - validate_signature(&pubkey, &message, &builder_domain_sig), + validate_signature( + &pubkey, + &message, + &builder_domain_sig, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into() + ), Err(ValidationError::Sigverify) )); - assert!(validate_signature(&pubkey, &message, &bid_domain_sig).is_ok()); + assert!( + validate_signature( + &pubkey, + &message, + &bid_domain_sig, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into() + ) + .is_ok() + ); + } + + // A devnet whose fork version / genesis root differ from the built-in + // constants must verify bids under ITS domain (config override), and a + // signature made under the built-in domain must then be rejected. + #[test] + fn test_validate_signature_uses_configured_domain_params() { + let secret_key = BlsSecretKey::random(); + let pubkey = secret_key.public_key(); + let message = B256::random(); + + let devnet_fork: [u8; 4] = [0x80, 0x00, 0x00, 0x38]; + let devnet_root = B256::from([0x6c; 32]); + assert_ne!(devnet_fork, GLOAS_FORK_VERSION); + + let devnet_sig = sign_execution_payload_bid_root( + &secret_key, + &message.tree_hash_root(), + devnet_fork, + devnet_root, + ); + + // Verifies under the devnet domain + assert!( + validate_signature(&pubkey, &message, &devnet_sig, devnet_fork, devnet_root).is_ok() + ); + // The built-in domain must reject the devnet signature (and vice versa) + assert!(matches!( + validate_signature( + &pubkey, + &message, + &devnet_sig, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into() + ), + Err(ValidationError::Sigverify) + )); + + // The config helpers are what the route threads through: default = + // constants, override = the devnet values + let default_cfg: PbsConfig = toml::from_str("").unwrap(); + assert_eq!(default_cfg.bid_fork_version(), GLOAS_FORK_VERSION); + assert_eq!(default_cfg.bid_genesis_validators_root(), B256::from(GENESIS_VALIDATORS_ROOT)); + let override_cfg: PbsConfig = toml::from_str( + r#" + gloas_fork_version = "0x80000038" + genesis_validators_root = "0x6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c" + "#, + ) + .unwrap(); + assert!( + validate_signature( + &pubkey, + &message, + &devnet_sig, + override_cfg.bid_fork_version(), + override_cfg.bid_genesis_validators_root() + ) + .is_ok() + ); } fn test_auth(slot: u64, signature: BlsSignature) -> SignedRequestAuth { diff --git a/tests/src/utils.rs b/tests/src/utils.rs index 000b1a69d..b15f959df 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -197,6 +197,8 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { validator_registration_batch_size: None, mux_registry_refresh_interval_seconds: 5, advertised_urls: vec![], + gloas_fork_version: None, + genesis_validators_root: None, } } diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index 9d1614a4b..cc50b73a7 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -78,6 +78,8 @@ async fn test_cfg_file_update() -> Result<()> { validator_registration_batch_size: None, mux_registry_refresh_interval_seconds: 384, advertised_urls: vec![], + gloas_fork_version: None, + genesis_validators_root: None, }; let cb_config = CommitBoostConfig { chain, From b7c3fbc048436710379a592fc8c998bc8e52545f Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 13:22:58 -0700 Subject: [PATCH 29/80] feat(km-tool): key-level p2p projection fields Add projection-only PbsConfig fields min_bid_p2p_eth and builder_boost_factor_p2p (consumed by KM tooling, not the PBS runtime; INFO-logged when set). When set, the projected KM doc's KEY-LEVEL min_bid/builder_boost_factor come from them: projected entries always carry explicit per-entry values, so the key level only governs p2p bids and entries that omit their own. Unset keeps the uniform projection (key level = mux/global values) so existing docs are unchanged. --- config.example.toml | 11 +++++ crates/common/src/config/pbs.rs | 49 ++++++++++++++++++++- crates/common/src/config/signer.rs | 2 + crates/km-tool/src/project.rs | 68 +++++++++++++++++++++++++++++- tests/src/utils.rs | 2 + tests/tests/pbs_cfg_file_update.rs | 2 + 6 files changed, 130 insertions(+), 4 deletions(-) diff --git a/config.example.toml b/config.example.toml index 86a6028d7..1f211adeb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -52,6 +52,17 @@ skip_sigverify = false # Can be specified as a float or a string for extra precision (e.g. "0.01") # OPTIONAL, DEFAULT: 0.0 min_bid_eth = 0.0 +# Projection-only: consumed by KM tooling, not read by the PBS runtime. +# The ePBS KEY-LEVEL minimum total payment in ETH: it governs p2p bids and builder entries that +# omit their own min_bid (projected entries always carry explicit per-entry values sourced from +# the mux/global min_bid). Float or string +# OPTIONAL, DEFAULT: unset (key level = mux/global min_bid) +# min_bid_p2p_eth = "0.2" +# Projection-only: consumed by KM tooling, not read by the PBS runtime. +# The ePBS KEY-LEVEL builder_boost_factor: it governs p2p bids and builder entries that omit +# their own (entry values stay mux-sourced) +# OPTIONAL, DEFAULT: unset +# builder_boost_factor_p2p = 100 # Execution-payment cap in Gwei used when ranking ePBS bids: a bid ranks at # `value + min(execution_payment, cap)`, matching how the beacon node values it (the BN clamps the # trusted payment at the cap instead of rejecting the bid). This does not accept or reject bids. diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 5dc1428d8..d64304295 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -14,6 +14,7 @@ use alloy::{ use docker_image::DockerImage; use eyre::{Result, ensure}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use tracing::info; use url::Url; use super::{ @@ -34,8 +35,8 @@ use crate::{ }, types::{BlsPublicKey, Chain, Jwt, ModuleId}, utils::{ - WEI_PER_ETH, as_eth_str, default_bool, default_host, default_u16, default_u32, default_u64, - default_u256, + WEI_PER_ETH, as_eth_str, as_opt_eth_str, default_bool, default_host, default_u16, + default_u32, default_u64, default_u256, }, }; @@ -200,6 +201,22 @@ pub struct PbsConfig { /// schedule. Unset = the built-in constant (behavior unchanged) #[serde(default)] pub genesis_validators_root: Option, + /// Projection-only: consumed by KM tooling, not read by the PBS runtime. + /// The ePBS KEY-LEVEL minimum total payment: it governs p2p bids and + /// builder entries that omit their own min_bid (projected entries always + /// carry explicit per-entry values sourced from the mux/global min_bid) + #[serde( + rename = "min_bid_p2p_eth", + with = "as_opt_eth_str", + default, + skip_serializing_if = "Option::is_none" + )] + pub min_bid_p2p_wei: Option, + /// Projection-only: consumed by KM tooling, not read by the PBS runtime. + /// The ePBS KEY-LEVEL builder_boost_factor: it governs p2p bids and + /// builder entries that omit their own (entry values stay mux-sourced) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_boost_factor_p2p: Option, } impl PbsConfig { @@ -226,6 +243,15 @@ impl PbsConfig { ); ensure!(self.late_in_slot_time_ms > 0, "late_in_slot_time_ms must be greater than 0"); + if self.min_bid_p2p_wei.is_some() { + info!("field min_bid_p2p_eth is applied via KM tooling, not by the PBS runtime"); + } + if self.builder_boost_factor_p2p.is_some() { + info!( + "field builder_boost_factor_p2p is applied via KM tooling, not by the PBS runtime" + ); + } + ensure!( self.timeout_get_header_ms < self.late_in_slot_time_ms, "timeout_get_header_ms must be less than late_in_slot_time_ms" @@ -536,6 +562,25 @@ mod tests { assert_eq!(cfg.bid_genesis_validators_root(), B256::from(GENESIS_VALIDATORS_ROOT)); } + // Projection-only p2p fields: parsed for KM tooling, absent = None so + // existing configs project exactly as before. + #[test] + fn p2p_projection_fields_parse_and_default() { + let cfg: PbsConfig = toml::from_str("").unwrap(); + assert_eq!(cfg.min_bid_p2p_wei, None); + assert_eq!(cfg.builder_boost_factor_p2p, None); + + let cfg: PbsConfig = toml::from_str( + r#" + min_bid_p2p_eth = "0.2" + builder_boost_factor_p2p = 0 + "#, + ) + .unwrap(); + assert_eq!(cfg.min_bid_p2p_wei, Some(U256::from(200_000_000_000_000_000u64))); + assert_eq!(cfg.builder_boost_factor_p2p, Some(0)); + } + // Configured overrides are what the bid domain sees, parsed from 0x-hex. #[test] fn bid_domain_params_use_configured_overrides() { diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index ba68c45bf..73e47fdef 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -490,6 +490,8 @@ mod tests { advertised_urls: vec![], gloas_fork_version: None, genesis_validators_root: None, + min_bid_p2p_wei: None, + builder_boost_factor_p2p: None, }, with_signer: true, }, diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index 5c93d268b..a1929ab68 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -381,11 +381,27 @@ fn project_mux( ensure_no_lax_ambiguity(&mux.id, &classes)?; + // Entry values are mux/global-sourced; the KEY-LEVEL values come from the + // global projection-only p2p fields when set. Rationale: projected entries + // always carry explicit per-entry values, so the key level only governs + // p2p bids and entries that omit their own. Unset p2p fields fall back to + // the entry values (uniform doc, today's behavior). let min_bid = resolve_min_bid(input, overlay, mux, warnings)?; + let key_min_bid = match input.cfg.pbs.pbs_config.min_bid_p2p_wei { + Some(wei) => wei_to_gwei_floor(&mux.id, wei, warnings)?.to_string(), + None => min_bid.clone(), + }; let boost = mux .projected_boost_factor() .or_else(|| overlay.per_mux.get(&mux.id).and_then(|m| m.builder_boost_factor)) .map(|b| b.to_string()); + let key_boost = input + .cfg + .pbs + .pbs_config + .builder_boost_factor_p2p + .map(|b| b.to_string()) + .or_else(|| boost.clone()); // `(url, auth_data-bytes)` uniqueness: classes are keyed by bytes and all // entries share the advertised URL, so uniqueness holds by construction; @@ -416,8 +432,8 @@ fn project_mux( } Ok(BuilderConfigDoc { - min_bid: Some(min_bid), - builder_boost_factor: boost, + min_bid: Some(key_min_bid), + builder_boost_factor: key_boost, builders: Some(entries), }) } @@ -688,6 +704,54 @@ min_bid_gwei = 12345 assert_eq!(entry.builder_boost_factor, Some("90".to_string())); } + // The p2p fields split the doc: KEY-LEVEL min_bid/boost come from the + // global projection-only p2p fields (they govern p2p bids and entries + // omitting their own), while ENTRIES keep the mux/global-sourced values. + #[test] + fn p2p_fields_differentiate_key_level_from_entries() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +min_bid_p2p_eth = "0.2" +builder_boost_factor_p2p = 0 +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +min_bid_eth = "0.000001" +builder_boost_factor = 100 +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + + // key level: 0.2 ETH = 200000000 Gwei floor, boost 0 + assert_eq!(doc.min_bid, Some("200000000".to_string())); + assert_eq!(doc.builder_boost_factor, Some("0".to_string())); + + // entry level: mux min_bid 0.000001 ETH = 1000 Gwei, mux boost 100 + let entry = &doc.builders.as_ref().unwrap()[0]; + assert_eq!(entry.min_bid, Some("1000".to_string())); + assert_eq!(entry.builder_boost_factor, Some("100".to_string())); + } + + // Unset p2p fields keep today's uniform projection (key = entry values). + #[test] + fn p2p_fields_unset_keep_uniform_projection() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + let entry = &doc.builders.as_ref().unwrap()[0]; + assert_eq!(doc.min_bid, entry.min_bid); + assert_eq!(doc.builder_boost_factor, None); + assert_eq!(entry.builder_boost_factor, None); + } + #[test] fn boost_omitted_without_source() { let key = random_key_hex(); diff --git a/tests/src/utils.rs b/tests/src/utils.rs index b15f959df..da9c3d7d2 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -199,6 +199,8 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { advertised_urls: vec![], gloas_fork_version: None, genesis_validators_root: None, + min_bid_p2p_wei: None, + builder_boost_factor_p2p: None, } } diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index cc50b73a7..fd752c7ad 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -80,6 +80,8 @@ async fn test_cfg_file_update() -> Result<()> { advertised_urls: vec![], gloas_fork_version: None, genesis_validators_root: None, + min_bid_p2p_wei: None, + builder_boost_factor_p2p: None, }; let cb_config = CommitBoostConfig { chain, From ade56d47042606ee56a65a622d6b5ef640274783 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 15:30:54 -0700 Subject: [PATCH 30/80] chore(deps): bump lighthouse to unstable for progressive gloas SSZ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the four lh_* deps to rev 31d8cfd (sigp/lighthouse unstable) so gloas containers hash as EIP-7495 ProgressiveContainers with an EIP-7916 progressive blob_kzg_commitments list — matching the consensus spec and every other client. CB's prior v8.2.2 pin hashed classic containers, giving a divergent bid signing root that made ePBS bid signature verification impossible. Mirror lighthouse's own [patch.crates.io] (ssz_types, milhouse, ethereum_ssz(+derive), tree_hash(+derive) at sigp git revs) into [patch.crates-io] — a consumer does not inherit a dependency's patch table, and these carry the progressive-container types. Adapt to the unstable gloas SSZ API: ForkName::Heze arm (post-gloas, unsupported on these endpoints, mirrors Fulu), ExecutionRequests offset (superstruct enum no longer impls Decode; the field is variable-length → 4-byte offset), get_header_ws fork_from_wire maps the new Heze wire byte, and Default::default() for the now-non-Default enum. --- Cargo.lock | 350 +++++++++++++++++++--- Cargo.toml | 16 +- crates/common/src/signature.rs | 4 +- crates/common/src/ssz.rs | 17 +- crates/common/src/wire.rs | 3 +- crates/pbs/src/mev_boost/get_header.rs | 2 +- crates/pbs/src/mev_boost/get_header_ws.rs | 1 + tests/src/mock_relay.rs | 4 +- 8 files changed, 334 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1787da4f4..34a377adb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -911,7 +911,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -922,7 +922,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1632,7 +1632,7 @@ dependencies = [ [[package]] name = "bls" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "alloy-primitives 1.6.1", "arbitrary", @@ -1765,6 +1765,25 @@ dependencies = [ "serde_core", ] +[[package]] +name = "builder_types" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" +dependencies = [ + "bls", + "context_deserialize", + "ethereum_serde_utils 0.8.1", + "ethereum_ssz", + "ethereum_ssz_derive", + "sensitive_url", + "serde", + "ssz_types", + "tree_hash", + "tree_hash_derive", + "typenum", + "types", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -1807,6 +1826,38 @@ dependencies = [ "serde", ] +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver 1.0.28", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "cast" version = "0.3.0" @@ -1940,7 +1991,7 @@ dependencies = [ "axum 0.8.9", "cb-common", "eyre", - "prometheus", + "prometheus 0.14.0", "thiserror 2.0.20", "tokio", "tracing", @@ -1964,7 +2015,7 @@ dependencies = [ "lazy_static", "notify", "parking_lot", - "prometheus", + "prometheus 0.14.0", "reqwest 0.13.4", "rustls", "serde", @@ -2000,7 +2051,7 @@ dependencies = [ "jsonwebtoken", "lazy_static", "parking_lot", - "prometheus", + "prometheus 0.14.0", "prost", "rand 0.9.5", "rustls", @@ -2086,8 +2137,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -2400,6 +2453,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -2480,7 +2542,7 @@ dependencies = [ "crossterm_winapi", "document-features", "parking_lot", - "rustix", + "rustix 1.1.4", "winapi", ] @@ -2548,7 +2610,7 @@ dependencies = [ "commit-boost", "eyre", "lazy_static", - "prometheus", + "prometheus 0.14.0", "serde", "serde_json", "tokio", @@ -3072,20 +3134,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "eth2" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "bls", + "builder_types", "context_deserialize", "educe", "ethereum_serde_utils 0.8.1", "ethereum_ssz", "ethereum_ssz_derive", + "fork_choice", "futures", "futures-util", "mediatype 0.19.20", @@ -3103,7 +3167,7 @@ dependencies = [ [[package]] name = "eth2_interop_keypairs" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "bls", "ethereum_hashing", @@ -3116,7 +3180,7 @@ dependencies = [ [[package]] name = "eth2_key_derivation" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "bls", "num-bigint-dig", @@ -3128,7 +3192,7 @@ dependencies = [ [[package]] name = "eth2_keystore" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "aes", "bls", @@ -3189,8 +3253,7 @@ dependencies = [ [[package]] name = "ethereum_ssz" version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e462875ad8693755ea8913d6e905715c76ea4836e2254e18c9cf0f7a8f8c2a13" +source = "git+https://github.com/sigp/ethereum_ssz?rev=2059c21ba52cd3a7e39a8ad537012b761812a393#2059c21ba52cd3a7e39a8ad537012b761812a393" dependencies = [ "alloy-primitives 1.6.1", "arbitrary", @@ -3206,8 +3269,7 @@ dependencies = [ [[package]] name = "ethereum_ssz_derive" version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf022360bdbe9456eda5f35718a50476d5b2a0d51a97ed4eae27420737a6fba" +source = "git+https://github.com/sigp/ethereum_ssz?rev=2059c21ba52cd3a7e39a8ad537012b761812a393#2059c21ba52cd3a7e39a8ad537012b761812a393" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -3307,7 +3369,7 @@ dependencies = [ [[package]] name = "fixed_bytes" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "alloy-primitives 1.6.1", "safe_arith", @@ -3319,6 +3381,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -3346,6 +3418,23 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fork_choice" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" +dependencies = [ + "ethereum_ssz", + "ethereum_ssz_derive", + "fixed_bytes", + "logging", + "metrics", + "proto_array", + "state_processing", + "superstruct", + "tracing", + "types", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3865,7 +3954,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4089,11 +4178,20 @@ dependencies = [ [[package]] name = "int_to_bytes" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "bytes", ] +[[package]] +name = "integer-sqrt" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770" +dependencies = [ + "num-traits", +] + [[package]] name = "interprocess" version = "2.4.3" @@ -4123,7 +4221,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4382,7 +4480,7 @@ dependencies = [ [[package]] name = "kzg" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "arbitrary", "educe", @@ -4426,6 +4524,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4459,6 +4563,37 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logging" +version = "0.2.0" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" +dependencies = [ + "chrono", + "logroller", + "metrics", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-appender", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "workspace_members", +] + +[[package]] +name = "logroller" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7525242cbd0624fe9b76dfebc1923a2d20c003e8c5d1a7eab1324bd8c0f615ac" +dependencies = [ + "chrono", + "flate2", + "regex", + "thiserror 1.0.69", +] + [[package]] name = "lru" version = "0.16.4" @@ -4533,7 +4668,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "merkle_proof" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "alloy-primitives 1.6.1", "ethereum_hashing", @@ -4576,11 +4711,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "metrics" +version = "0.2.0" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" +dependencies = [ + "prometheus 0.13.4", +] + [[package]] name = "milhouse" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259dd9da2ae5e0278b95da0b7ecef9c18c309d0a2d9e6db57ed33b9e8910c5e7" +source = "git+https://github.com/sigp/milhouse?rev=c70f128976ac0d60ea65a978dabd117a921c36ee#c70f128976ac0d60ea65a978dabd117a921c36ee" dependencies = [ "alloy-primitives 1.6.1", "arbitrary", @@ -4619,6 +4761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -4688,7 +4831,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5125,7 +5268,7 @@ dependencies = [ [[package]] name = "pretty_reqwest_error" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "reqwest 0.12.28", "sensitive_url", @@ -5192,6 +5335,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "procfs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "731e0d9356b0c25f16f33b5be79b1c57b562f141ebfcdb0ad8ac2c13a24293b4" +dependencies = [ + "bitflags 2.13.1", + "hex", + "lazy_static", + "procfs-core", + "rustix 0.38.44", +] + +[[package]] +name = "procfs-core" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +dependencies = [ + "bitflags 2.13.1", + "hex", +] + +[[package]] +name = "prometheus" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "libc", + "memchr", + "parking_lot", + "procfs", + "thiserror 1.0.69", +] + [[package]] name = "prometheus" version = "0.14.0" @@ -5289,6 +5471,23 @@ dependencies = [ "prost", ] +[[package]] +name = "proto_array" +version = "0.2.0" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" +dependencies = [ + "ethereum_ssz", + "ethereum_ssz_derive", + "fixed_bytes", + "safe_arith", + "serde", + "smallvec", + "superstruct", + "typenum", + "types", + "yaml_serde", +] + [[package]] name = "protobuf" version = "3.7.2" @@ -5328,7 +5527,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.20", "tokio", "tracing", @@ -5367,9 +5566,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5844,6 +6043,19 @@ dependencies = [ "semver 1.0.28", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5853,8 +6065,8 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.60.2", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -5922,7 +6134,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6134,6 +6346,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "semver-parser" @@ -6398,6 +6614,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -6452,7 +6674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6474,8 +6696,7 @@ dependencies = [ [[package]] name = "ssz_types" version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d625e4de8e0057eefe7e0b1510ba1dd7adf10cd375fad6cc7fcceac7c39623c9" +source = "git+https://github.com/sigp/ssz_types?rev=9203d56ad2d7bc5f12133d043e085843f81edcd6#9203d56ad2d7bc5f12133d043e085843f81edcd6" dependencies = [ "arbitrary", "context_deserialize", @@ -6496,6 +6717,34 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "state_processing" +version = "0.2.0" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" +dependencies = [ + "bls", + "educe", + "ethereum_hashing", + "ethereum_ssz", + "ethereum_ssz_derive", + "fixed_bytes", + "int_to_bytes", + "integer-sqrt", + "itertools 0.14.0", + "merkle_proof", + "metrics", + "milhouse", + "rand 0.9.5", + "rayon", + "safe_arith", + "smallvec", + "ssz_types", + "tracing", + "tree_hash", + "typenum", + "types", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -6512,7 +6761,7 @@ dependencies = [ "commit-boost", "eyre", "lazy_static", - "prometheus", + "prometheus 0.14.0", "reqwest 0.13.4", "serde", "tokio", @@ -6569,7 +6818,7 @@ dependencies = [ [[package]] name = "swap_or_not_shuffle" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "alloy-primitives 1.6.1", "ethereum_hashing", @@ -6681,10 +6930,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix", - "windows-sys 0.60.2", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -7231,8 +7480,7 @@ dependencies = [ [[package]] name = "tree_hash" version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7fd51aa83d2eb83b04570808430808b5d24fdbf479a4d5ac5dee4a2e2dd2be4" +source = "git+https://github.com/sigp/tree_hash?rev=03d9fa474586c125f306dd7a00cf46284575a01f#03d9fa474586c125f306dd7a00cf46284575a01f" dependencies = [ "alloy-primitives 1.6.1", "ethereum_hashing", @@ -7244,8 +7492,7 @@ dependencies = [ [[package]] name = "tree_hash_derive" version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8840ad4d852e325d3afa7fde8a50b2412f89dce47d7eb291c0cc7f87cd040f38" +source = "git+https://github.com/sigp/tree_hash?rev=03d9fa474586c125f306dd7a00cf46284575a01f#03d9fa474586c125f306dd7a00cf46284575a01f" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -7297,7 +7544,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "types" version = "0.2.1" -source = "git+https://github.com/sigp/lighthouse?tag=v8.2.2#e423a66763bb1bd780492d635123f208d80c3538" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "alloy-primitives 1.6.1", "alloy-rlp", @@ -7708,7 +7955,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7967,6 +8214,15 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "workspace_members" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" +dependencies = [ + "cargo_metadata", + "quote", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index 7eecdc451..818d64ecf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,10 +54,10 @@ indexmap = "2.2.6" jsonwebtoken = { version = "9.3.1", default-features = false } lazy_static = "1.5.0" mediatype = "0.20.0" -lh_eth2 = { package = "eth2", git = "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/sigp/lighthouse", tag = "v8.2.2", features = ["events"] } -lh_eth2_keystore = { package = "eth2_keystore", git = "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/sigp/lighthouse", tag = "v8.2.2" } -lh_bls = { package = "bls", git = "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/sigp/lighthouse", tag = "v8.2.2", features = ["arbitrary"] } -lh_types = { package = "types", git = "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/sigp/lighthouse", tag = "v8.2.2", features = ["arbitrary"] } +lh_eth2 = { package = "eth2", git = "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/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058", features = ["events"] } +lh_eth2_keystore = { package = "eth2_keystore", git = "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/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058" } +lh_bls = { package = "bls", git = "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/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058", features = ["arbitrary"] } +lh_types = { package = "types", git = "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/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058", features = ["arbitrary"] } notify = "8.2.0" parking_lot = "0.12.3" pbkdf2 = "0.12.2" @@ -97,3 +97,11 @@ webpki-roots = "1.0" [patch.crates-io] blstrs_plus = { git = "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/Commit-Boost/blstrs" } +# Progressive-container ssz stack required by lighthouse-unstable (EIP-7495/7916). +# A consumer does NOT inherit a dependency's [patch]; mirror lighthouse's here. +ssz_types = { git = "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/sigp/ssz_types", rev = "9203d56ad2d7bc5f12133d043e085843f81edcd6" } +milhouse = { git = "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/sigp/milhouse", rev = "c70f128976ac0d60ea65a978dabd117a921c36ee" } +ethereum_ssz = { git = "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/sigp/ethereum_ssz", rev = "2059c21ba52cd3a7e39a8ad537012b761812a393" } +ethereum_ssz_derive = { git = "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/sigp/ethereum_ssz", rev = "2059c21ba52cd3a7e39a8ad537012b761812a393" } +tree_hash = { git = "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/sigp/tree_hash", rev = "03d9fa474586c125f306dd7a00cf46284575a01f" } +tree_hash_derive = { git = "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/sigp/tree_hash", rev = "03d9fa474586c125f306dd7a00cf46284575a01f" } diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index 56bd0b272..c2bfc08e1 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -258,7 +258,7 @@ mod tests { constants::APPLICATION_BUILDER_DOMAIN, pbs::{ BlindedBeaconBlockElectra, BuilderBid, BuilderBidElectra, - ExecutionPayloadHeaderElectra, ExecutionRequests, + ExecutionPayloadHeaderElectra, }, types::{BlsSecretKey, Chain}, utils::TestRandomSeed, @@ -281,7 +281,7 @@ mod tests { let message = BuilderBid::Electra(BuilderBidElectra { header: ExecutionPayloadHeaderElectra::test_random(), blob_kzg_commitments: Default::default(), - execution_requests: ExecutionRequests::default(), + execution_requests: Default::default(), value: U256::from(10), pubkey: pubkey.clone().into(), }); diff --git a/crates/common/src/ssz.rs b/crates/common/src/ssz.rs index a62ac1993..30680142f 100644 --- a/crates/common/src/ssz.rs +++ b/crates/common/src/ssz.rs @@ -4,8 +4,7 @@ use lh_types::ForkName; use ssz::BYTES_PER_LENGTH_OFFSET; use crate::pbs::{ - BuilderBidFulu, ExecutionPayloadHeaderFulu, ExecutionRequests, KzgCommitments, - error::SszValueError, + BuilderBidFulu, ExecutionPayloadHeaderFulu, KzgCommitments, error::SszValueError, }; // Get the offset of the message in a SignedBuilderBid SSZ structure @@ -14,10 +13,16 @@ fn get_ssz_value_offset_for_fork(fork: ForkName) -> Result ForkName::Fulu => { // Message goes header -> blob_kzg_commitments -> execution_requests -> value -> // pubkey + // `execution_requests` (ExecutionRequestsElectra) is variable-length, + // so in the container's fixed section it is a 4-byte offset pointer, + // not its serialized body. Since lighthouse-unstable turned + // ExecutionRequests into a superstruct enum that no longer implements + // `ssz::Decode`, spell the offset width out as the SSZ constant rather + // than `<_ as Decode>::ssz_fixed_len()`. Ok(get_message_offset::() + ::ssz_fixed_len() + ::ssz_fixed_len() + - ::ssz_fixed_len()) + BYTES_PER_LENGTH_OFFSET) } _ => Err(SszValueError::UnsupportedFork { name: fork }), @@ -76,8 +81,8 @@ mod test { use super::get_bid_value_from_signed_builder_bid_ssz; use crate::{ pbs::{ - BuilderBid, BuilderBidFulu, ExecutionPayloadHeaderFulu, ExecutionRequests, - SignedBuilderBid, error::SszValueError, + BuilderBid, BuilderBidFulu, ExecutionPayloadHeaderFulu, SignedBuilderBid, + error::SszValueError, }, types::{BlsPublicKeyBytes, BlsSignature}, utils::TestRandomSeed, @@ -116,7 +121,7 @@ mod test { let message = BuilderBid::Fulu(BuilderBidFulu { header: ExecutionPayloadHeaderFulu::test_random(), blob_kzg_commitments: Default::default(), - execution_requests: ExecutionRequests::default(), + execution_requests: Default::default(), value: known_value, pubkey, }); diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index 79e3819da..d46f0f0ab 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -370,7 +370,8 @@ pub fn require_consensus_version_header( ForkName::Capella | ForkName::Deneb | ForkName::Electra | - ForkName::Fulu => Err(unsupported()), + ForkName::Fulu | + ForkName::Heze => Err(unsupported()), } } diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index de4fdf0d2..00c9a3da5 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -858,7 +858,7 @@ mod tests { ForkName::Fulu => {} // Skip future forks - ForkName::Gloas => continue, + ForkName::Gloas | ForkName::Heze => continue, } // Load get_header JSON from test data diff --git a/crates/pbs/src/mev_boost/get_header_ws.rs b/crates/pbs/src/mev_boost/get_header_ws.rs index afebec179..152dde577 100644 --- a/crates/pbs/src/mev_boost/get_header_ws.rs +++ b/crates/pbs/src/mev_boost/get_header_ws.rs @@ -66,6 +66,7 @@ fn fork_from_wire(byte: u8) -> Option { 5 => ForkName::Electra, 6 => ForkName::Fulu, 7 => ForkName::Gloas, + 8 => ForkName::Heze, _ => return None, }) } diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index a72416b67..998ee4542 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -25,7 +25,7 @@ use cb_common::{ pbs::{ BUILDER_V1_API_PATH, BUILDER_V2_API_PATH, BlobsBundle, BuilderBid, BuilderBidFulu, BuilderPreferencesRequest, ExecutionPayloadBid, ExecutionPayloadElectra, - ExecutionPayloadHeaderFulu, ExecutionRequests, ForkName, ForkVersionDecode, + ExecutionPayloadHeaderFulu, ForkName, ForkVersionDecode, GET_EXECUTION_PAYLOAD_BID_PATH, GET_HEADER_PATH, GET_STATUS_PATH, GetExecutionPayloadBidResponse, GetHeaderParams, GetHeaderResponse, GetPayloadInfo, HEADER_TIMEOUT_MS, PayloadAndBlobs, REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, @@ -465,7 +465,7 @@ pub fn mock_signed_builder_bid( let message = BuilderBid::Fulu(BuilderBidFulu { header, blob_kzg_commitments: Default::default(), - execution_requests: ExecutionRequests::default(), + execution_requests: Default::default(), value, pubkey: signer.public_key().into(), }); From 5e5123e621883d60031456552f62b0c648da109d Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 14:55:33 -0700 Subject: [PATCH 31/80] test(common): pin gloas ExecutionPayloadBid progressive tree-hash root Guard the EIP-7495 progressive-container hashing unlocked by the lighthouse bump: a known gloas ExecutionPayloadBid must tree-hash to the go-eth2-client ground-truth root 0x04f8e548...d268. This root is the object the builder signs, so a silent progressive-hashing regression would forge a different signing root and break every bid signature check. Fixture captured from a devnet builder bid (slot 297); the same root is what verified a real on-chain builder signature. --- crates/common/src/pbs/types/mod.rs | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index 70d396445..82b5c1fc4 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -327,4 +327,41 @@ mod tests { ]); assert_eq!(decoded.auth.signature.serialize().to_vec(), infinity_sig); } + + /// Regression guard for the lighthouse-unstable bump that unlocks real ePBS + /// bid signature verification. Under EIP-7495 progressive containers the + /// gloas `ExecutionPayloadBid` must tree-hash to the go-eth2-client ground + /// truth root `0x04f8e548…d268` (fixture captured from a devnet builder bid, + /// slot 297). This root is the object the builder signs; a silent + /// progressive-hashing regression here would forge a different signing root + /// and break every bid signature check, so pin it byte-for-byte. + #[test] + fn test_gloas_execution_payload_bid_progressive_tree_hash_root() { + use tree_hash::TreeHash; + + let bid_json = r#"{ + "parent_block_hash": "0x8f44cac724ae314ba6e846c745d7c2749985e5ef5ca5ce64c3edeaa05eab022a", + "parent_block_root": "0xfa8193d2de4cdf9fa3a038ab6fe5e451fe50efa8c143d957aee7a778a32e948a", + "block_hash": "0xc2687f3daaaa8e1db9335a33659cb79cc693c8b4f98ba2ad44b3c5deeedcff9c", + "prev_randao": "0x4af131519d8829635bba95750c1b6276d602c9370785091eaeb2906dd6e86258", + "fee_recipient": "0x8943545177806ED17B9F23F0a21ee5948eCaa776", + "gas_limit": "200000000", + "builder_index": "0", + "slot": "297", + "value": "101000000", + "execution_payment": "0", + "blob_kzg_commitments": [], + "execution_requests_root": "0x87b69a306c8e430d0857f7c4ac5e27cecffa1108d43c2e5df7388056fea7a423" + }"#; + + let bid: ExecutionPayloadBid = + serde_json::from_str(bid_json).expect("deserialize gloas ExecutionPayloadBid"); + let root = bid.tree_hash_root(); + + assert_eq!( + root.to_string(), + "0x04f8e548db621e7908410cde0fe878d29e3d9778c63cd3c71eaca336d8f7d268", + "progressive-container tree-hash root regressed" + ); + } } From 42e724b6e02c9344fd6b7bf51b014b182ce2543c Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 15:34:53 -0700 Subject: [PATCH 32/80] feat(common): per-mux p2p projection override fields Add MuxConfig.builder_boost_factor_p2p and min_bid_p2p (serde min_bid_p2p_eth). Projection-only, consumed by KM tooling and not read by the PBS runtime; INFO-logged when set, added to KNOWN_MUX_FIELDS so they do not warn as unknown. --- crates/common/src/config/mux.rs | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index a626b8ccd..a671de3f4 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -112,6 +112,18 @@ impl PbsMuxes { mux.id ); } + if mux.builder_boost_factor_p2p.is_some() { + info!( + "field builder_boost_factor_p2p on mux {} is applied via KM tooling, not by the PBS runtime", + mux.id + ); + } + if mux.min_bid_p2p_wei.is_some() { + info!( + "field min_bid_p2p_eth on mux {} is applied via KM tooling, not by the PBS runtime", + mux.id + ); + } let mut relay_clients = Vec::with_capacity(mux.relays.len()); for config in mux.relays.into_iter() { @@ -182,6 +194,21 @@ pub struct MuxConfig { skip_serializing_if = "Option::is_none" )] pub min_bid_wei: Option, + /// Projection-only: consumed by KM tooling, not read by the PBS runtime. + /// The ePBS KEY-LEVEL builder_boost_factor governing p2p bids for this + /// mux's keys. Overrides the global `[pbs] builder_boost_factor_p2p`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_boost_factor_p2p: Option, + /// Projection-only: consumed by KM tooling, not read by the PBS runtime. + /// The ePBS KEY-LEVEL minimum total payment governing p2p bids for this + /// mux's keys. Overrides the global `[pbs] min_bid_p2p_eth`. + #[serde( + rename = "min_bid_p2p_eth", + with = "as_opt_eth_str", + default, + skip_serializing_if = "Option::is_none" + )] + pub min_bid_p2p_wei: Option, } impl MuxConfig { @@ -347,6 +374,8 @@ const KNOWN_MUX_FIELDS: &[&str] = &[ "fee_recipient", "builder_boost_factor", "min_bid_eth", + "builder_boost_factor_p2p", + "min_bid_p2p_eth", ]; /// Unknown keys on the `[[mux]]` tables of a raw config document, as @@ -645,6 +674,19 @@ mod tests { assert_eq!(mux.builder_boost_factor, Some(120)); assert_eq!(mux.min_bid_wei, Some(U256::from(500_000_000_000_000_000u64))); + // p2p projection fields parse the same way as their non-p2p siblings + let mux: MuxConfig = toml::from_str( + r#" + id = "test" + relays = [] + builder_boost_factor_p2p = 90 + min_bid_p2p_eth = "0.2" + "#, + ) + .unwrap(); + assert_eq!(mux.builder_boost_factor_p2p, Some(90)); + assert_eq!(mux.min_bid_p2p_wei, Some(U256::from(200_000_000_000_000_000u64))); + // Float form, matching the global min_bid_eth let mux: MuxConfig = toml::from_str( r#" @@ -666,6 +708,8 @@ mod tests { .unwrap(); assert_eq!(mux.builder_boost_factor, None); assert_eq!(mux.min_bid_wei, None); + assert_eq!(mux.builder_boost_factor_p2p, None); + assert_eq!(mux.min_bid_p2p_wei, None); } #[test] @@ -685,10 +729,13 @@ mod tests { let serialized = toml::to_string(&mux).unwrap(); assert!(!serialized.contains("builder_boost_factor")); assert!(!serialized.contains("min_bid_eth")); + assert!(!serialized.contains("min_bid_p2p_eth")); let roundtripped: MuxConfig = toml::from_str(&serialized).unwrap(); assert_eq!(roundtripped.builder_boost_factor, None); assert_eq!(roundtripped.min_bid_wei, None); + assert_eq!(roundtripped.builder_boost_factor_p2p, None); + assert_eq!(roundtripped.min_bid_p2p_wei, None); } #[test] @@ -699,6 +746,8 @@ mod tests { relays = [] builder_boost_factor = 100 min_bid_eth = "0.1" + builder_boost_factor_p2p = 100 + min_bid_p2p_eth = "0.1" bulder_boost_factor = 100 [[mux]] From 491fbf0a7d41e557221103fc3b57e585ca2a59d0 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 15:36:20 -0700 Subject: [PATCH 33/80] feat(km-tool): per-mux p2p override in key-level projection MuxProjectionFields gains projected_min_bid_p2p_wei/ projected_boost_factor_p2p reading the new MuxConfig fields. The projected KEY-LEVEL min_bid/builder_boost_factor now resolve MUX p2p > global [pbs] p2p > (unset: uniform, key = entry). Entry values stay mux/global-sourced. --- crates/km-tool/src/mux_ext.rs | 22 ++++-- crates/km-tool/src/project.rs | 126 +++++++++++++++++++++++++++++++--- 2 files changed, 132 insertions(+), 16 deletions(-) diff --git a/crates/km-tool/src/mux_ext.rs b/crates/km-tool/src/mux_ext.rs index 2821dd695..85ef905c1 100644 --- a/crates/km-tool/src/mux_ext.rs +++ b/crates/km-tool/src/mux_ext.rs @@ -1,16 +1,18 @@ -//! Seam for MuxConfig fields the projection reads but this cb-common revision -//! does not carry yet (`builder_boost_factor`, `min_bid`). Once cb-common -//! gains them, implement these accessors to read the config fields; the -//! overlay's per-mux map stays as the fallback source below them. +//! Accessors for the projection-only MuxConfig fields. The overlay's per-mux +//! map stays the fallback source for the non-p2p fields below them. use alloy_primitives::U256; use cb_common::config::MuxConfig; pub trait MuxProjectionFields { - /// Per-mux builder boost factor, when the config schema carries it. + /// Per-mux builder boost factor for entries and the key level. fn projected_boost_factor(&self) -> Option; - /// Per-mux minimum bid in wei, when the config schema carries it. + /// Per-mux minimum bid in wei for entries and the key level. fn projected_min_bid_wei(&self) -> Option; + /// Per-mux KEY-LEVEL builder boost factor governing p2p bids. + fn projected_boost_factor_p2p(&self) -> Option; + /// Per-mux KEY-LEVEL minimum bid in wei governing p2p bids. + fn projected_min_bid_p2p_wei(&self) -> Option; } impl MuxProjectionFields for MuxConfig { @@ -21,4 +23,12 @@ impl MuxProjectionFields for MuxConfig { fn projected_min_bid_wei(&self) -> Option { self.min_bid_wei } + + fn projected_boost_factor_p2p(&self) -> Option { + self.builder_boost_factor_p2p + } + + fn projected_min_bid_p2p_wei(&self) -> Option { + self.min_bid_p2p_wei + } } diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index a1929ab68..6856118bc 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -382,12 +382,16 @@ fn project_mux( ensure_no_lax_ambiguity(&mux.id, &classes)?; // Entry values are mux/global-sourced; the KEY-LEVEL values come from the - // global projection-only p2p fields when set. Rationale: projected entries - // always carry explicit per-entry values, so the key level only governs - // p2p bids and entries that omit their own. Unset p2p fields fall back to - // the entry values (uniform doc, today's behavior). + // projection-only p2p fields when set, resolving MUX p2p > global [pbs] + // p2p. Rationale: projected entries always carry explicit per-entry + // values, so the key level only governs p2p bids and entries that omit + // their own. Unset p2p fields fall back to the entry values (uniform doc, + // today's behavior). let min_bid = resolve_min_bid(input, overlay, mux, warnings)?; - let key_min_bid = match input.cfg.pbs.pbs_config.min_bid_p2p_wei { + let key_min_bid = match mux + .projected_min_bid_p2p_wei() + .or(input.cfg.pbs.pbs_config.min_bid_p2p_wei) + { Some(wei) => wei_to_gwei_floor(&mux.id, wei, warnings)?.to_string(), None => min_bid.clone(), }; @@ -395,11 +399,9 @@ fn project_mux( .projected_boost_factor() .or_else(|| overlay.per_mux.get(&mux.id).and_then(|m| m.builder_boost_factor)) .map(|b| b.to_string()); - let key_boost = input - .cfg - .pbs - .pbs_config - .builder_boost_factor_p2p + let key_boost = mux + .projected_boost_factor_p2p() + .or(input.cfg.pbs.pbs_config.builder_boost_factor_p2p) .map(|b| b.to_string()) .or_else(|| boost.clone()); @@ -739,6 +741,110 @@ url = "https://{RELAY_PK_A}@relay-a.example.com" assert_eq!(entry.builder_boost_factor, Some("100".to_string())); } + // (a) A mux p2p field wins over a different global p2p field at the key level. + #[test] + fn mux_p2p_override_wins_over_global() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +min_bid_p2p_eth = "0.2" +builder_boost_factor_p2p = 50 +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +min_bid_p2p_eth = "0.7" +builder_boost_factor_p2p = 130 +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + // key level uses the MUX p2p values, not the global ones + assert_eq!(doc.min_bid, Some("700000000".to_string())); + assert_eq!(doc.builder_boost_factor, Some("130".to_string())); + } + + // (b) Only the global p2p fields are set: every mux uses them at the key level. + #[test] + fn global_p2p_applies_to_all_muxes() { + let key_a = random_key_hex(); + let key_b = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +min_bid_p2p_eth = "0.2" +builder_boost_factor_p2p = 50 +[[mux]] +id = "ma" +validator_pubkeys = ["{key_a}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +[[mux]] +id = "mb" +validator_pubkeys = ["{key_b}"] +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + assert_eq!(projection.docs.len(), 2); + for doc in projection.docs.values() { + assert_eq!(doc.min_bid, Some("200000000".to_string())); + assert_eq!(doc.builder_boost_factor, Some("50".to_string())); + } + } + + // (d) Mix: mux A overrides the global p2p, mux B inherits it. + #[test] + fn mux_p2p_override_and_inherit_mix() { + let key_a = random_key_hex(); + let key_b = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +min_bid_p2p_eth = "0.2" +builder_boost_factor_p2p = 50 +[[mux]] +id = "ma" +validator_pubkeys = ["{key_a}"] +min_bid_p2p_eth = "0.7" +builder_boost_factor_p2p = 130 +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +[[mux]] +id = "mb" +validator_pubkeys = ["{key_b}"] +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let by_key = |k: &str| { + projection + .docs + .iter() + .find(|(pk, _)| pk.to_string() == k) + .map(|(_, doc)| doc) + .unwrap() + }; + // mux A: its own p2p values + let doc_a = by_key(&key_a); + assert_eq!(doc_a.min_bid, Some("700000000".to_string())); + assert_eq!(doc_a.builder_boost_factor, Some("130".to_string())); + // mux B: the global p2p values + let doc_b = by_key(&key_b); + assert_eq!(doc_b.min_bid, Some("200000000".to_string())); + assert_eq!(doc_b.builder_boost_factor, Some("50".to_string())); + } + // Unset p2p fields keep today's uniform projection (key = entry values). #[test] fn p2p_fields_unset_keep_uniform_projection() { From f94bd87eca6b23f7cbb6534fc8bbb6c265d4b3d4 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 15:37:18 -0700 Subject: [PATCH 34/80] perf(pbs): reuse a process-lifetime pipe-relay placeholder pubkey The transient pipe relay's placeholder pubkey is never read (bid sigverify is skipped for the pipe), yet it was regenerated per unmatched request. Build it once in a OnceLock instead. --- crates/pbs/src/utils.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 8159de3b0..a189cda03 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -1,4 +1,7 @@ -use std::time::{Duration, Instant}; +use std::{ + sync::OnceLock, + time::{Duration, Instant}, +}; use cb_common::{ config::{GetHeaderTransport, RelayConfig}, @@ -226,6 +229,16 @@ pub(crate) fn decode_auth_data_url(data: &[u8]) -> Option { /// empty `advertised_urls`, which cannot rule that out - is an /// `AuthDataMismatch`, never a self-dial. Data carrying no URL at all names /// no builder and mismatches as before. +/// A process-lifetime placeholder pubkey for pipe relays. Bid sigverify is +/// skipped for the pipe (bid trust is the VC's job via KM builder_pubkeys, and +/// CB cannot know a pipe builder's key: bids carry builder_index, not a +/// pubkey), so this value is never read. A single lazily-built valid BLS point +/// avoids a keygen on every unmatched pipe request. +fn pipe_relay_placeholder_pubkey() -> BlsPublicKey { + static PLACEHOLDER: OnceLock = OnceLock::new(); + PLACEHOLDER.get_or_init(|| BlsSecretKey::random().public_key()).clone() +} + pub(crate) fn transient_pipe_relay( received_data: &[u8], advertised_urls: &[Url], @@ -240,11 +253,7 @@ pub(crate) fn transient_pipe_relay( let id = url.host_str().map(str::to_owned).unwrap_or_else(|| url.to_string()); let config = RelayConfig { - // The placeholder pubkey is never used: bid sigverify is skipped for - // the pipe relay because bid trust is the VC's job via KM - // builder_pubkeys and CB cannot know a pipe builder's key (bids carry - // builder_index, not a pubkey) - entry: RelayEntry { id, pubkey: BlsSecretKey::random().public_key(), url }, + entry: RelayEntry { id, pubkey: pipe_relay_placeholder_pubkey(), url }, id: None, headers: None, get_params: None, @@ -479,6 +488,13 @@ mod tests { assert!(!relay.config.enable_timing_games); } + // The placeholder pubkey is stable across pipe requests: one process-wide + // point rather than a fresh keygen per unmatched request. + #[test] + fn pipe_relay_placeholder_pubkey_is_stable() { + assert_eq!(pipe_relay_placeholder_pubkey(), pipe_relay_placeholder_pubkey()); + } + #[test] fn url_matches_ignores_userinfo_and_default_port() { let u = |s: &str| Url::parse(s).unwrap(); From b08dce9d463f14a6ebadd28a2168645016a3c605 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 15:37:55 -0700 Subject: [PATCH 35/80] feat(km-tool): apply --preserve-entries merges third-party builder entries POST replaces the builder_config doc in full (the KM spec forbids the server merging), so a plain apply erases any builder entry a third party pinned that our projection does not manage. --preserve-entries GETs each key's stored doc first and folds those entries back in: identity is (url, decoded auth_data); entries our projection also produces are ours (a resolved GET fills VC defaults on them, not third-party data) and win on collision; every other stored entry is appended after ours. Key-level min_bid/boost stay ours (p2p policy we own). The KM caps are re-checked on the merged set and the apply fails loudly rather than silently dropping a pinned entry. Default off; today's full-replace behavior is unchanged. --- crates/km-tool/src/apply.rs | 191 +++++++++++++++++++++++++++++++++++- crates/km-tool/src/main.rs | 15 ++- 2 files changed, 200 insertions(+), 6 deletions(-) diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index 0fd989a9f..5e196af5f 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -2,7 +2,7 @@ //! key was accepted by exactly one of them. use std::{ - collections::{BTreeMap, BTreeSet}, + collections::{BTreeMap, BTreeSet, HashSet}, path::{Path, PathBuf}, }; @@ -11,9 +11,12 @@ use tracing::{info, warn}; use crate::{ client::{GetConfigOutcome, KmClient, PostOutcome, read_token}, - doc::BuilderConfigDoc, + doc::{BuilderConfigDoc, BuilderEntryDoc}, overlay::Overlay, - project::{Projection, ProjectionInput, project, project_with_url}, + project::{ + MAX_BUILDER_ENTRIES, MAX_BUILDER_PUBKEYS, Projection, ProjectionInput, project, + project_with_url, + }, }; #[derive(Debug, Default, Clone)] @@ -21,6 +24,10 @@ pub struct ApplyOptions { pub dry_run: bool, pub emit_dir: Option, pub prune: bool, + /// GET each key's stored doc before POST and preserve builder entries no + /// other writer's identity our projection produces (POST replaces in full, + /// so a plain apply erases third-party-pinned entries). + pub preserve_entries: bool, } #[derive(Debug, Default)] @@ -142,6 +149,40 @@ pub async fn run_apply( let vc_projection = project_with_url(input, overlay, overlay.advertised_url_for(vc))?; for (key, doc) in &vc_projection.docs { let key = key.to_string(); + + // --preserve-entries: fold any third-party builder entries the VC + // already stores back into our doc so the full-replace POST does + // not erase them. + let merged; + let doc = if opts.preserve_entries { + match client.get_builder_config(&key).await { + Ok(GetConfigOutcome::Ok(stored)) => { + match merge_preserved_entries(&key, doc, &stored) { + Ok(m) => { + merged = m; + &merged + } + Err(err) => { + report.error(format!( + "{vc_name}: preserve-entries merge for {key} failed: {err}" + )); + continue; + } + } + } + // nothing stored (or route absent for this key): POST ours + Ok(GetConfigOutcome::NotFound) => doc, + Err(err) => { + report.error(format!( + "{vc_name}: preserve-entries GET for {key} failed: {err}" + )); + continue; + } + } + } else { + doc + }; + match client.post_builder_config(&key, doc).await { Ok(PostOutcome::Accepted) => { report.accepted.entry(key).or_default().push(vc_name.clone()); @@ -196,6 +237,72 @@ pub async fn run_apply( Ok(report) } +/// An entry's identity for the preserve merge: its URL and DECODED auth_data +/// bytes (hex case is not identity). Two entries collide iff both agree. +fn entry_identity(entry: &BuilderEntryDoc) -> Result<(String, Option>)> { + let auth = match &entry.auth_data { + Some(hex) => Some(crate::doc::decode_auth_data(hex)?), + None => None, + }; + Ok((entry.url.clone(), auth)) +} + +/// Folds third-party builder entries from a VC's stored (resolved) doc into our +/// projected doc. Identity is `(url, decoded auth_data)`. Every stored entry +/// whose identity our projection ALSO produces is OURS: a GET returns the doc +/// fully resolved, so field values on our own entries are VC defaults, not +/// third-party data, and ours win on collision. Every other stored entry was +/// pinned by someone else and is preserved, appended after ours. Key-level +/// fields (min_bid / boost) are p2p policy we own and stay ours. The KM entry +/// caps are re-checked on the MERGED set: a merge that would break the spec +/// (e.g. >64 combined entries) fails loudly rather than silently dropping. +fn merge_preserved_entries( + key: &str, + projected: &BuilderConfigDoc, + stored: &BuilderConfigDoc, +) -> Result { + let our_entries = projected.builders.clone().unwrap_or_default(); + let stored_entries = stored.builders.as_deref().unwrap_or_default(); + + let mut seen: HashSet<(String, Option>)> = HashSet::new(); + for entry in &our_entries { + seen.insert(entry_identity(entry)?); + } + + let mut merged = our_entries.clone(); + for entry in stored_entries { + let id = entry_identity(entry)?; + // ours win on identity collision; dedup a stored doc's own repeats + if seen.insert(id) { + merged.push(entry.clone()); + } + } + + ensure!( + merged.len() <= MAX_BUILDER_ENTRIES, + "{key}: --preserve-entries would keep {} builder entries, exceeding the KM maximum of \ + {MAX_BUILDER_ENTRIES}; refusing to POST rather than silently drop a pinned entry", + merged.len() + ); + for entry in &merged { + if let Some(pubkeys) = &entry.builder_pubkeys { + ensure!( + pubkeys.len() <= MAX_BUILDER_PUBKEYS, + "{key}: --preserve-entries merged entry {} has {} builder_pubkeys, exceeding the \ + KM maximum of {MAX_BUILDER_PUBKEYS}", + entry.url, + pubkeys.len() + ); + } + } + + Ok(BuilderConfigDoc { + min_bid: projected.min_bid.clone(), + builder_boost_factor: projected.builder_boost_factor.clone(), + builders: Some(merged), + }) +} + /// Writes per-key JSON docs plus a manifest instead of POSTing (GitOps / /// orchestrator-consumable). fn emit(dir: &Path, projection: &Projection) -> Result<()> { @@ -212,3 +319,81 @@ fn emit(dir: &Path, projection: &Projection) -> Result<()> { )?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::doc::encode_auth_data; + + fn entry(url: &str, auth: &[u8], pubkeys: &[&str]) -> BuilderEntryDoc { + BuilderEntryDoc { + url: url.to_string(), + auth_data: Some(encode_auth_data(auth)), + builder_pubkeys: Some(pubkeys.iter().map(|s| s.to_string()).collect()), + max_execution_payment: None, + min_bid: None, + builder_boost_factor: None, + } + } + + fn ours() -> BuilderConfigDoc { + BuilderConfigDoc { + min_bid: Some("500000000".into()), + builder_boost_factor: None, + builders: Some(vec![entry("https://cb.example.com", b"https://relay-a", &["0xaa"])]), + } + } + + #[test] + fn third_party_entry_is_preserved_after_ours() { + let stored = BuilderConfigDoc { + builders: Some(vec![entry("https://other.example.com", b"https://relay-x", &["0xff"])]), + ..Default::default() + }; + let merged = merge_preserved_entries("k", &ours(), &stored).unwrap(); + let entries = merged.builders.unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].url, "https://cb.example.com"); + assert_eq!(entries[1].url, "https://other.example.com"); + // key-level fields stay ours + assert_eq!(merged.min_bid, Some("500000000".into())); + } + + #[test] + fn our_identity_wins_over_stored_resolved_version() { + // stored holds OUR identity (same url + auth_data) but with VC-resolved + // fields and a different pubkey set: ours must win, no duplicate + let stored = BuilderConfigDoc { + builders: Some(vec![BuilderEntryDoc { + min_bid: Some("999".into()), + ..entry("https://cb.example.com", b"https://relay-a", &["0xbb"]) + }]), + ..Default::default() + }; + let merged = merge_preserved_entries("k", &ours(), &stored).unwrap(); + let entries = merged.builders.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].builder_pubkeys, Some(vec!["0xaa".to_string()])); + assert_eq!(entries[0].min_bid, None); + } + + #[test] + fn identity_ignores_hex_case() { + let mut theirs = entry("https://cb.example.com", b"https://relay-a", &["0xcc"]); + theirs.auth_data = Some(theirs.auth_data.unwrap().to_uppercase().replacen("0X", "0x", 1)); + let stored = BuilderConfigDoc { builders: Some(vec![theirs]), ..Default::default() }; + // same identity as ours despite uppercase hex -> collapses to ours + let merged = merge_preserved_entries("k", &ours(), &stored).unwrap(); + assert_eq!(merged.builders.unwrap().len(), 1); + } + + #[test] + fn merge_exceeding_max_entries_fails() { + let extras: Vec<_> = (0..MAX_BUILDER_ENTRIES) + .map(|i| entry("https://other.example.com", format!("relay-{i}").as_bytes(), &["0xff"])) + .collect(); + let stored = BuilderConfigDoc { builders: Some(extras), ..Default::default() }; + let err = merge_preserved_entries("k", &ours(), &stored).unwrap_err(); + assert!(err.to_string().contains("exceeding the KM maximum"), "{err}"); + } +} diff --git a/crates/km-tool/src/main.rs b/crates/km-tool/src/main.rs index 0cad19c04..0ac307966 100644 --- a/crates/km-tool/src/main.rs +++ b/crates/km-tool/src/main.rs @@ -14,7 +14,9 @@ use eyre::Result; about = "Project a Commit-Boost mux config into keymanager builder_config docs", after_help = "WARNING: check-green does not mean apply-is-a-no-op: GET returns resolved docs, \ so third-party-pinned values for fields the projection omits (boost, cap) are \ - invisible to check and will be ERASED by apply (POST replaces in full)." + invisible to check and will be ERASED by apply (POST replaces in full). Pass \ + `apply --preserve-entries` to fold any builder entry another writer pinned back \ + into the POST instead of erasing it." )] struct Cli { #[command(subcommand)] @@ -47,6 +49,13 @@ enum Command { /// POST {} for stored-but-unprojected enumerated keys #[arg(long)] prune: bool, + /// GET each key first and keep any builder entry pinned by another + /// writer (identity = url + auth_data) that our projection does not + /// produce, so the full-replace POST does not erase it. Off by default + /// (today's exact-projection replace). Fails loudly if the merge would + /// break a KM cap (e.g. >64 entries) rather than dropping an entry. + #[arg(long)] + preserve_entries: bool, }, /// Compare the stored VC docs against the projection (read-only) Check { @@ -85,9 +94,9 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Command::Apply { common, dry_run, emit, prune } => { + Command::Apply { common, dry_run, emit, prune, preserve_entries } => { let (input, overlay) = load(&common)?; - let opts = ApplyOptions { dry_run, emit_dir: emit, prune }; + let opts = ApplyOptions { dry_run, emit_dir: emit, prune, preserve_entries }; let report = run_apply(&input, &overlay, &opts).await?; for msg in &report.info { println!("{msg}"); From d559ae4412b774bc9b3759d521059b9bdaa519d9 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 15:37:55 -0700 Subject: [PATCH 36/80] docs(config): per-mux p2p overrides + advertised_urls form guidance Document the per-mux builder_boost_factor_p2p / min_bid_p2p_eth overrides, and note that advertised_urls must list every scheme/host/IP form CB is reachable by since the self-URL guard matches scheme+host+port exactly. --- config.example.toml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/config.example.toml b/config.example.toml index 1f211adeb..9d8007535 100644 --- a/config.example.toml +++ b/config.example.toml @@ -118,6 +118,9 @@ mux_registry_refresh_interval_seconds = 384 # pipe first checks the decoded URL against this list and rejects a match instead of dialing itself. # The guard fails closed: while this list is empty, unmatched auth data is always rejected and the # pipe never dials, so set it to enable piping to builders outside the [[relays]] below. +# List EVERY scheme/host/IP form CB is reachable by: the self-URL guard matches on scheme+host+port +# exactly, so an equivalent form you omit (e.g. an IP instead of a hostname, or http vs https) is not +# recognized as CB's own and the pipe could self-dial. # OPTIONAL, DEFAULT: [] # advertised_urls = ["http://cb.example.com:18550"] @@ -213,6 +216,12 @@ validator_pubkeys = [ # as the PBS-level `min_bid_eth`). Consumed by KM tooling, not read by the PBS runtime # OPTIONAL, DEFAULT: unset # min_bid_eth = 0.0 +# Projection-only: the KEY-LEVEL p2p overrides for this mux's keys. Consumed by KM tooling, not read +# by the PBS runtime. When set they take precedence over the PBS-level `builder_boost_factor_p2p` / +# `min_bid_p2p_eth`; unset inherits the PBS-level value +# OPTIONAL, DEFAULT: unset (inherit the PBS-level p2p value) +# builder_boost_factor_p2p = 100 +# min_bid_p2p_eth = "0.2" # Loader for validator pubkeys. Three types of loaders are supported: # - File: path to a file containing a list of validator pubkeys in JSON format # - URL: URL to an HTTP endpoint returning a list of validator pubkeys in JSON format From 29bd848404f04a668d8205877af92e5cdb200c35 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 15:37:55 -0700 Subject: [PATCH 37/80] test(km-tool): cover apply --preserve-entries merge Flag off: a stored third-party entry is ignored, POST body is our projection exactly. Flag on: a distinct third-party entry survives; a stored entry sharing our identity is replaced not duplicated; a merge past the 64-entry cap fails with no POST. --- crates/km-tool/tests/mock_km.rs | 149 ++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/crates/km-tool/tests/mock_km.rs b/crates/km-tool/tests/mock_km.rs index 682172258..2c03fc107 100644 --- a/crates/km-tool/tests/mock_km.rs +++ b/crates/km-tool/tests/mock_km.rs @@ -372,6 +372,155 @@ async fn dry_run_and_emit_post_nothing() { assert_eq!(manifest["keys"][0]["pubkey"], serde_json::json!(key)); } +/// Our projected doc for `key`, as the JSON value a VC would store. +fn projected_value(env: &TestEnv, key: &str) -> serde_json::Value { + serde_json::from_str(&projected_string(env, key)).unwrap() +} + +/// Our projected doc for `key`, serialized exactly as the tool POSTs it. +fn projected_string(env: &TestEnv, key: &str) -> String { + let projection = project(&env.input, &env.overlay).unwrap(); + let doc = projection.docs.iter().find(|(pk, _)| pk.to_string() == key).map(|(_, d)| d).unwrap(); + serde_json::to_string(doc).unwrap() +} + +fn third_party_entry(url: &str, auth_hex: &str) -> serde_json::Value { + serde_json::json!({ "url": url, "auth_data": auth_hex, "builder_pubkeys": [RELAY_PK_B] }) +} + +/// Collects the (url, auth_data) pairs in a POSTed builder_config body. +fn posted_entries(body: &str) -> Vec<(String, String)> { + let doc: serde_json::Value = serde_json::from_str(body).unwrap(); + doc["builders"] + .as_array() + .unwrap() + .iter() + .map(|e| { + (e["url"].as_str().unwrap().to_string(), e["auth_data"].as_str().unwrap().to_string()) + }) + .collect() +} + +// (a) Without the flag, a stored third-party entry is IGNORED: the POST body is +// our projection exactly, unchanged from today's full-replace behavior. +#[tokio::test] +async fn without_flag_post_body_is_projection_and_ignores_stored() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + let env = env_for(std::slice::from_ref(&key), &[]); + let mut stored = projected_value(&env, &key); + stored["builders"].as_array_mut().unwrap().push(third_party_entry( + "https://third-party.example.com", + "0xc0ffee", + )); + vc.stored.insert(key.clone(), stored); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(report.ok(), "{:?}", report.errors); + + let posts = vc.posts(); + let posted = posts.iter().find(|(pk, _)| pk == &key).unwrap(); + assert_eq!(posted.1, projected_string(&env, &key)); +} + +// (b) With the flag, a pre-existing third-party entry survives the apply: the +// POSTed body contains BOTH our projected entries and theirs. +#[tokio::test] +async fn preserve_entries_keeps_third_party_entry() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + let env = env_for(std::slice::from_ref(&key), &[]); + let mut stored = projected_value(&env, &key); + stored["builders"].as_array_mut().unwrap().push(third_party_entry( + "https://third-party.example.com", + "0xc0ffee", + )); + vc.stored.insert(key.clone(), stored); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let opts = ApplyOptions { preserve_entries: true, ..Default::default() }; + let report = run_apply(&env.input, &env.overlay, &opts).await.unwrap(); + assert!(report.ok(), "{:?}", report.errors); + + let posts = vc.posts(); + let posted = posts.iter().find(|(pk, _)| pk == &key).unwrap(); + let entries = posted_entries(&posted.1); + // our two projected entries plus the third party's, no more + assert_eq!(entries.len(), 3, "{entries:?}"); + assert!( + entries.iter().any(|(u, a)| u == "https://third-party.example.com" && a == "0xc0ffee"), + "{entries:?}" + ); + // both of ours (advertised URL) still present + assert_eq!(entries.iter().filter(|(u, _)| u == "https://cb.example.com").count(), 2); +} + +// (c) A stored third-party entry sharing OUR identity (url + auth_data) is +// REPLACED by ours, not duplicated -> no (url, auth_data) collision (no 400). +#[tokio::test] +async fn preserve_entries_collision_is_replaced_not_duplicated() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + let env = env_for(std::slice::from_ref(&key), &[]); + // stored = a doc whose entries share our identity but carry a foreign + // pubkey and a VC-resolved boost (simulating a resolved GET of our doc) + let mut stored = projected_value(&env, &key); + for entry in stored["builders"].as_array_mut().unwrap() { + entry["builder_pubkeys"] = serde_json::json!([RELAY_PK_A]); + entry["builder_boost_factor"] = serde_json::json!("100"); + } + vc.stored.insert(key.clone(), stored); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let opts = ApplyOptions { preserve_entries: true, ..Default::default() }; + let report = run_apply(&env.input, &env.overlay, &opts).await.unwrap(); + assert!(report.ok(), "{:?}", report.errors); + + let posts = vc.posts(); + let posted = posts.iter().find(|(pk, _)| pk == &key).unwrap(); + let entries = posted_entries(&posted.1); + // no identity duplicated: exactly our two projected entries + assert_eq!(entries.len(), 2, "{entries:?}"); + // ours win: the POST body equals our pure projection (resolved defaults dropped) + assert_eq!(posted.1, projected_string(&env, &key)); +} + +// (d) A merge that would exceed the KM 64-entry cap fails loudly with no POST. +#[tokio::test] +async fn preserve_entries_over_cap_fails_without_posting() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + let env = env_for(std::slice::from_ref(&key), &[]); + let mut stored = projected_value(&env, &key); + let builders = stored["builders"].as_array_mut().unwrap(); + // our 2 entries + 63 distinct third-party entries = 65 > 64 + for i in 0..63 { + builders.push(third_party_entry( + &format!("https://third-{i}.example.com"), + "0xabcdef", + )); + } + vc.stored.insert(key.clone(), stored); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let opts = ApplyOptions { preserve_entries: true, ..Default::default() }; + let report = run_apply(&env.input, &env.overlay, &opts).await.unwrap(); + + assert!(!report.ok()); + assert!( + report.errors.iter().any(|e| e.contains("exceeding the KM maximum")), + "{:?}", + report.errors + ); + // the merge aborts BEFORE any POST for this key + assert!(!vc.posts().iter().any(|(pk, _)| pk == &key), "{:?}", vc.posts()); +} + #[tokio::test] async fn check_reordered_uppercase_stored_doc_is_not_drift() { let key = random_key(); From f6d4bccbad6628efafa64e9c0d4cc2101a6fe7e6 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 15:47:58 -0700 Subject: [PATCH 38/80] test(km-tool): assert per-mux min_bid_p2p and boost_p2p are independent --- crates/km-tool/src/project.rs | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index 6856118bc..9a8cb44d8 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -845,6 +845,42 @@ url = "https://{RELAY_PK_B}@relay-b.example.com" assert_eq!(doc_b.builder_boost_factor, Some("50".to_string())); } + // Asymmetric: a mux sets ONLY min_bid_p2p (not builder_boost_factor_p2p). + // The key-level min_bid must take the mux p2p value, while the key-level + // boost must NOT be dragged along with it: boost independently follows the + // global/uniform p2p fallback, never the mux. Guards the two p2p fields + // against a coupling regression. + #[test] + fn mux_min_bid_p2p_does_not_drag_boost_p2p() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +builder_boost_factor_p2p = 50 +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +min_bid_p2p_eth = "0.7" +builder_boost_factor = 100 +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + + // key-level min_bid: the MUX p2p value (0.7 ETH = 700000000 Gwei floor) + assert_eq!(doc.min_bid, Some("700000000".to_string())); + // key-level boost: the GLOBAL p2p value, NOT the mux's own boost (100) + assert_eq!(doc.builder_boost_factor, Some("50".to_string())); + + // entry-level boost is still the mux's own value, confirming the split + let entry = &doc.builders.as_ref().unwrap()[0]; + assert_eq!(entry.builder_boost_factor, Some("100".to_string())); + } + // Unset p2p fields keep today's uniform projection (key = entry values). #[test] fn p2p_fields_unset_keep_uniform_projection() { From 135a42a66515e911bde580f4e3c196fa1a3db957 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 15:48:42 -0700 Subject: [PATCH 39/80] docs(km-tool): clarify --preserve-entries semantics and concurrency caveat --- crates/km-tool/src/apply.rs | 10 +++++++--- crates/km-tool/src/main.rs | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index 5e196af5f..14fae0751 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -24,9 +24,11 @@ pub struct ApplyOptions { pub dry_run: bool, pub emit_dir: Option, pub prune: bool, - /// GET each key's stored doc before POST and preserve builder entries no - /// other writer's identity our projection produces (POST replaces in full, - /// so a plain apply erases third-party-pinned entries). + /// When true, GET each key's stored doc and keep builder entries whose + /// identity (url, decoded auth_data) our projection does NOT produce -- i.e. + /// entries pinned by another writer -- appending them after ours. Client-side + /// read-modify-write: NOT atomic against a concurrent third-party write + /// between the GET and POST. pub preserve_entries: bool, } @@ -242,6 +244,8 @@ pub async fn run_apply( fn entry_identity(entry: &BuilderEntryDoc) -> Result<(String, Option>)> { let auth = match &entry.auth_data { Some(hex) => Some(crate::doc::decode_auth_data(hex)?), + // Dead in practice: a resolved KM GET always populates auth_data and our + // projection always sets Some, so identities collide correctly. None => None, }; Ok((entry.url.clone(), auth)) diff --git a/crates/km-tool/src/main.rs b/crates/km-tool/src/main.rs index 0ac307966..635c28ccb 100644 --- a/crates/km-tool/src/main.rs +++ b/crates/km-tool/src/main.rs @@ -54,6 +54,8 @@ enum Command { /// produce, so the full-replace POST does not erase it. Off by default /// (today's exact-projection replace). Fails loudly if the merge would /// break a KM cap (e.g. >64 entries) rather than dropping an entry. + /// (read-modify-write; not atomic vs a concurrent writer -- the + /// entry-level PATCH endpoint is the real fix). #[arg(long)] preserve_entries: bool, }, From 33eda8f043ee5e7e55f415efce48dfdb230e3dfb Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 16:05:40 -0700 Subject: [PATCH 40/80] test(pbs): carry the per-mux p2p fields through the remaining mux test literals The per-mux p2p merge added builder_boost_factor_p2p/min_bid_p2p_wei to MuxConfig; pbs_mux and pbs_mux_refresh construct MuxConfig literals that were not updated (their branch verification did not build these targets), breaking the workspace compile after merge. --- tests/tests/pbs_mux.rs | 6 ++++++ tests/tests/pbs_mux_refresh.rs | 2 ++ 2 files changed, 8 insertions(+) diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index f0703f365..b0cd348a0 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -380,6 +380,8 @@ async fn test_ssv_multi_with_node() -> Result<()> { fee_recipient: None, builder_boost_factor: None, min_bid_wei: None, + builder_boost_factor_p2p: None, + min_bid_p2p_wei: None, }], }; @@ -490,6 +492,8 @@ async fn test_ssv_multi_with_public() -> Result<()> { fee_recipient: None, builder_boost_factor: None, min_bid_wei: None, + builder_boost_factor_p2p: None, + min_bid_p2p_wei: None, }], }; @@ -550,6 +554,8 @@ async fn test_mux_fee_recipient_resolution() -> Result<()> { fee_recipient: Some(expected), builder_boost_factor: None, min_bid_wei: None, + builder_boost_factor_p2p: None, + min_bid_p2p_wei: None, }], }; diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index 8bcce13f2..859a7b011 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -96,6 +96,8 @@ async fn test_auto_refresh() -> Result<()> { fee_recipient: None, builder_boost_factor: None, min_bid_wei: None, + builder_boost_factor_p2p: None, + min_bid_p2p_wei: None, }], }; From ed884b0f3b72cd435bd0492d6fe1c861bc350a13 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 16:20:13 -0700 Subject: [PATCH 41/80] sec(deps): pin blstrs_plus git patch to an explicit rev The BLS backend patch tracked the Commit-Boost/blstrs default branch with no rev while every sibling patch pins a SHA, so a cargo update could pull an unreviewed change into the crypto path. Pin the already-locked commit. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34a377adb..68beef68e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1707,7 +1707,7 @@ dependencies = [ [[package]] name = "blstrs_plus" version = "0.8.18" -source = "git+https://github.com/Commit-Boost/blstrs#c4ea6b21193886ee9849867397a62e9c243c1fbb" +source = "git+https://github.com/Commit-Boost/blstrs?rev=c4ea6b21193886ee9849867397a62e9c243c1fbb#c4ea6b21193886ee9849867397a62e9c243c1fbb" dependencies = [ "arrayref", "blst", diff --git a/Cargo.toml b/Cargo.toml index 818d64ecf..76f1b5e05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,7 +96,7 @@ uuid = { version = "1.8.0", features = ["fast-rng", "serde", "v4"] } webpki-roots = "1.0" [patch.crates-io] -blstrs_plus = { git = "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/Commit-Boost/blstrs" } +blstrs_plus = { git = "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/Commit-Boost/blstrs", rev = "c4ea6b21193886ee9849867397a62e9c243c1fbb" } # Progressive-container ssz stack required by lighthouse-unstable (EIP-7495/7916). # A consumer does NOT inherit a dependency's [patch]; mirror lighthouse's here. ssz_types = { git = "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/sigp/ssz_types", rev = "9203d56ad2d7bc5f12133d043e085843f81edcd6" } From f623f1526d7370298d439c3347e32b4d594f2bc0 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 16:20:18 -0700 Subject: [PATCH 42/80] sec(km-tool): stop the KM client from following redirects A compromised VC must not be able to 3xx the tool into POSTing builder configs elsewhere. TLS and auth-strip-on-redirect already protect the token; refusing redirects outright is defense in depth. --- crates/km-tool/src/client.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/km-tool/src/client.rs b/crates/km-tool/src/client.rs index 106ff0c81..79cfb876e 100644 --- a/crates/km-tool/src/client.rs +++ b/crates/km-tool/src/client.rs @@ -80,7 +80,10 @@ pub struct KmClient { impl KmClient { pub fn new(base: Url, token: String) -> Result { - let http = reqwest::Client::builder().timeout(HTTP_TIMEOUT).build()?; + let http = reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build()?; Ok(Self { http, base, token }) } From a0f3d484288da643c04f0db89f557c6ac98a8ce5 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 16:20:30 -0700 Subject: [PATCH 43/80] sec(km-tool): restrict emit perms and redact secret auth_data in dry-run Emitted per-key JSON and its dir can carry bilateral-secret auth_data, so chmod them to owner-only (0600 files, 0700 dir). The dry-run print now replaces any auth_data that is not a valid UTF-8 URL with a length-only placeholder; URL-form auth_data is public and printed as-is. --- crates/km-tool/src/apply.rs | 56 ++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index 14fae0751..9074f898d 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -99,7 +99,8 @@ pub async fn run_apply( if opts.dry_run { for (key, doc) in &global.docs { - report.note(format!("would apply {key}: {}", serde_json::to_string(doc)?)); + let doc = redact_secrets_for_display(doc); + report.note(format!("would apply {key}: {}", serde_json::to_string(&doc)?)); } report.note(format!("dry-run: {} keys projected, nothing sent", global.docs.len())); return Ok(report); @@ -308,22 +309,69 @@ fn merge_preserved_entries( } /// Writes per-key JSON docs plus a manifest instead of POSTing (GitOps / -/// orchestrator-consumable). +/// orchestrator-consumable). Emitted files can carry bilateral-secret +/// auth_data, so the dir and every file are restricted to the owner. fn emit(dir: &Path, projection: &Projection) -> Result<()> { std::fs::create_dir_all(dir).wrap_err_with(|| format!("cannot create emit dir {dir:?}"))?; + restrict_to_owner(dir, 0o700)?; let mut manifest = Vec::new(); for (key, doc) in &projection.docs { let file = format!("{key}.json"); - std::fs::write(dir.join(&file), serde_json::to_string_pretty(doc)?)?; + let path = dir.join(&file); + std::fs::write(&path, serde_json::to_string_pretty(doc)?)?; + restrict_to_owner(&path, 0o600)?; manifest.push(serde_json::json!({ "pubkey": key.to_string(), "file": file })); } + let manifest_path = dir.join("manifest.json"); std::fs::write( - dir.join("manifest.json"), + &manifest_path, serde_json::to_string_pretty(&serde_json::json!({ "keys": manifest }))?, )?; + restrict_to_owner(&manifest_path, 0o600)?; Ok(()) } +/// Restricts a path to the owner. `mode` is 0o600 for files, 0o700 for the dir +/// (a directory needs its execute bit to stay traversable). No-op off Unix. +#[cfg(unix)] +fn restrict_to_owner(path: &Path, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .wrap_err_with(|| format!("cannot restrict permissions on {path:?}")) +} + +#[cfg(not(unix))] +fn restrict_to_owner(_path: &Path, _mode: u32) -> Result<()> { + Ok(()) +} + +/// For dry-run display: replace any bilateral-secret auth_data (bytes that are +/// not a valid UTF-8 URL) with a length-only placeholder. URL-form auth_data is +/// public and printed as-is. +fn redact_secrets_for_display(doc: &BuilderConfigDoc) -> BuilderConfigDoc { + let mut doc = doc.clone(); + if let Some(builders) = &mut doc.builders { + for entry in builders { + let Some(hex) = &entry.auth_data else { continue }; + if auth_data_is_url(hex) { + continue; + } + let len = crate::doc::decode_auth_data(hex).map(|b| b.len()).unwrap_or(0); + entry.auth_data = Some(format!("{len} bytes (secret)")); + } + } + doc +} + +/// Whether hex-encoded auth_data decodes to a valid UTF-8 URL (the public form). +fn auth_data_is_url(hex: &str) -> bool { + crate::doc::decode_auth_data(hex) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|s| url::Url::parse(&s).ok()) + .is_some() +} + #[cfg(test)] mod tests { use super::*; From f5c3373a00308e786c3dd2ab1a3011fcdf76ada1 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 16:20:50 -0700 Subject: [PATCH 44/80] refactor(km-tool): drop vestigial mux projection seams MuxConfig now carries builder_boost_factor, min_bid_wei, and the two p2p fields directly, so the MuxProjectionFields trait was pure indirection: inline the four reads and delete the trait. The overlay per_mux map was a second source for the same boost/min_bid values; MuxConfig is now the single source, so drop the per_mux struct, field, fallback reads, and unknown-key warning. Overlay keeps only apply-side config (advertised_url, vcs, token_path). Per-mux>global>uniform precedence is unchanged. --- crates/km-tool/src/apply.rs | 2 +- crates/km-tool/src/check.rs | 2 +- crates/km-tool/src/lib.rs | 1 - crates/km-tool/src/main.rs | 2 +- crates/km-tool/src/mux_ext.rs | 34 --------------- crates/km-tool/src/overlay.rs | 24 ++-------- crates/km-tool/src/project.rs | 82 +++++------------------------------ 7 files changed, 16 insertions(+), 131 deletions(-) delete mode 100644 crates/km-tool/src/mux_ext.rs diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index 9074f898d..d9f4d3699 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -149,7 +149,7 @@ pub async fn run_apply( } } - let vc_projection = project_with_url(input, overlay, overlay.advertised_url_for(vc))?; + let vc_projection = project_with_url(input, overlay.advertised_url_for(vc))?; for (key, doc) in &vc_projection.docs { let key = key.to_string(); diff --git a/crates/km-tool/src/check.rs b/crates/km-tool/src/check.rs index 81766f6c1..850f4658f 100644 --- a/crates/km-tool/src/check.rs +++ b/crates/km-tool/src/check.rs @@ -96,7 +96,7 @@ pub async fn run_check(input: &ProjectionInput, overlay: &Overlay) -> Result = vc_projection.docs.iter().map(|(k, d)| (k.to_string(), d)).collect(); diff --git a/crates/km-tool/src/lib.rs b/crates/km-tool/src/lib.rs index 685c54404..1a03b3284 100644 --- a/crates/km-tool/src/lib.rs +++ b/crates/km-tool/src/lib.rs @@ -7,7 +7,6 @@ pub mod apply; pub mod check; pub mod client; pub mod doc; -pub mod mux_ext; pub mod overlay; pub mod project; diff --git a/crates/km-tool/src/main.rs b/crates/km-tool/src/main.rs index 635c28ccb..fd10d150f 100644 --- a/crates/km-tool/src/main.rs +++ b/crates/km-tool/src/main.rs @@ -28,7 +28,7 @@ struct CommonArgs { /// Commit-Boost config TOML #[arg(long, default_value = "config.toml")] config: PathBuf, - /// Operational overlay TOML (advertised_url, vcs, per_mux) + /// Operational overlay TOML (advertised_url, vcs) #[arg(long, default_value = "km-overlay.toml")] overlay: PathBuf, } diff --git a/crates/km-tool/src/mux_ext.rs b/crates/km-tool/src/mux_ext.rs deleted file mode 100644 index 85ef905c1..000000000 --- a/crates/km-tool/src/mux_ext.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Accessors for the projection-only MuxConfig fields. The overlay's per-mux -//! map stays the fallback source for the non-p2p fields below them. - -use alloy_primitives::U256; -use cb_common::config::MuxConfig; - -pub trait MuxProjectionFields { - /// Per-mux builder boost factor for entries and the key level. - fn projected_boost_factor(&self) -> Option; - /// Per-mux minimum bid in wei for entries and the key level. - fn projected_min_bid_wei(&self) -> Option; - /// Per-mux KEY-LEVEL builder boost factor governing p2p bids. - fn projected_boost_factor_p2p(&self) -> Option; - /// Per-mux KEY-LEVEL minimum bid in wei governing p2p bids. - fn projected_min_bid_p2p_wei(&self) -> Option; -} - -impl MuxProjectionFields for MuxConfig { - fn projected_boost_factor(&self) -> Option { - self.builder_boost_factor - } - - fn projected_min_bid_wei(&self) -> Option { - self.min_bid_wei - } - - fn projected_boost_factor_p2p(&self) -> Option { - self.builder_boost_factor_p2p - } - - fn projected_min_bid_p2p_wei(&self) -> Option { - self.min_bid_p2p_wei - } -} diff --git a/crates/km-tool/src/overlay.rs b/crates/km-tool/src/overlay.rs index 94d35f66d..956f20539 100644 --- a/crates/km-tool/src/overlay.rs +++ b/crates/km-tool/src/overlay.rs @@ -1,9 +1,8 @@ //! The operational overlay: where/how to apply, kept out of the CB config. -//! Fleet-describing fields stay in the CB mux config; this file carries only -//! the advertised sidecar URL, the VC endpoints, and the per-mux fallbacks for -//! MuxConfig fields this cb-common revision does not carry yet (see mux_ext). +//! Fleet-describing fields (boost, min_bid) live in the CB mux config; this +//! file carries only the advertised sidecar URL and the VC endpoints. -use std::{collections::BTreeMap, path::Path}; +use std::path::Path; use eyre::{Context, Result, ensure}; use serde::Deserialize; @@ -19,8 +18,6 @@ pub struct Overlay { pub advertised_url: String, #[serde(default)] pub vcs: Vec, - #[serde(default)] - pub per_mux: BTreeMap, } #[derive(Debug, Clone, Deserialize)] @@ -32,13 +29,6 @@ pub struct VcConfig { pub advertised_url: Option, } -#[derive(Debug, Clone, Copy, Default, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PerMuxOverlay { - pub builder_boost_factor: Option, - pub min_bid_gwei: Option, -} - impl Overlay { pub fn parse_str(s: &str) -> Result { let overlay: Self = toml::from_str(s).wrap_err("could not parse overlay TOML")?; @@ -76,7 +66,6 @@ mod tests { let overlay = Overlay::parse_str(r#"advertised_url = "https://cb.example.com""#).unwrap(); assert_eq!(overlay.advertised_url, "https://cb.example.com"); assert!(overlay.vcs.is_empty()); - assert!(overlay.per_mux.is_empty()); } #[test] @@ -93,19 +82,12 @@ mod tests { url = "http://vc2:7500" token_path = "/tmp/token2" advertised_url = "https://cb2.example.com" - - [per_mux.mux1] - builder_boost_factor = 90 - min_bid_gwei = 10000000 "#, ) .unwrap(); assert_eq!(overlay.vcs.len(), 2); assert_eq!(overlay.advertised_url_for(&overlay.vcs[0]), "https://cb.example.com"); assert_eq!(overlay.advertised_url_for(&overlay.vcs[1]), "https://cb2.example.com"); - let per_mux = overlay.per_mux.get("mux1").unwrap(); - assert_eq!(per_mux.builder_boost_factor, Some(90)); - assert_eq!(per_mux.min_bid_gwei, Some(10_000_000)); } #[test] diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index 9a8cb44d8..788c6fd77 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -28,7 +28,6 @@ use tracing::warn; use crate::{ doc::{BuilderConfigDoc, BuilderEntryDoc, encode_auth_data}, - mux_ext::MuxProjectionFields, overlay::Overlay, }; @@ -183,35 +182,20 @@ pub struct Projection { /// Projects per-key KM docs with the overlay's global advertised URL. pub fn project(input: &ProjectionInput, overlay: &Overlay) -> Result { - project_with_url(input, overlay, &overlay.advertised_url) + project_with_url(input, &overlay.advertised_url) } /// Projects with an explicit advertised URL (per-VC overrides). -pub fn project_with_url( - input: &ProjectionInput, - overlay: &Overlay, - advertised_url: &str, -) -> Result { +pub fn project_with_url(input: &ProjectionInput, advertised_url: &str) -> Result { let mut warnings = Vec::new(); let mut docs = BTreeMap::new(); let mut relay_candidates = Vec::new(); let mut seen_keys: HashSet> = HashSet::new(); - for id in overlay.per_mux.keys() { - let known = input - .cfg - .muxes - .as_ref() - .is_some_and(|muxes| muxes.muxes.iter().any(|mux| &mux.id == id)); - if !known { - push_warn(&mut warnings, format!("overlay per_mux entry {id:?} matches no mux")); - } - } - if let Some(muxes) = &input.cfg.muxes { for (mux, raw_urls) in muxes.muxes.iter().zip(&input.mux_relay_urls) { let keys = resolve_mux_keys(mux, &mut warnings)?; - let doc = project_mux(input, overlay, mux, raw_urls, advertised_url, &mut warnings)?; + let doc = project_mux(input, mux, raw_urls, advertised_url, &mut warnings)?; for relay in mux.relays.iter().zip(raw_urls) { relay_candidates.push(RelayAuthCandidate { @@ -334,7 +318,6 @@ fn ensure_no_lax_ambiguity(mux_id: &str, classes: &BTreeMap, AuthClass>) fn project_mux( input: &ProjectionInput, - overlay: &Overlay, mux: &MuxConfig, raw_urls: &[String], advertised_url: &str, @@ -387,20 +370,17 @@ fn project_mux( // values, so the key level only governs p2p bids and entries that omit // their own. Unset p2p fields fall back to the entry values (uniform doc, // today's behavior). - let min_bid = resolve_min_bid(input, overlay, mux, warnings)?; + let min_bid = resolve_min_bid(input, mux, warnings)?; let key_min_bid = match mux - .projected_min_bid_p2p_wei() + .min_bid_p2p_wei .or(input.cfg.pbs.pbs_config.min_bid_p2p_wei) { Some(wei) => wei_to_gwei_floor(&mux.id, wei, warnings)?.to_string(), None => min_bid.clone(), }; - let boost = mux - .projected_boost_factor() - .or_else(|| overlay.per_mux.get(&mux.id).and_then(|m| m.builder_boost_factor)) - .map(|b| b.to_string()); + let boost = mux.builder_boost_factor.map(|b| b.to_string()); let key_boost = mux - .projected_boost_factor_p2p() + .builder_boost_factor_p2p .or(input.cfg.pbs.pbs_config.builder_boost_factor_p2p) .map(|b| b.to_string()) .or_else(|| boost.clone()); @@ -440,20 +420,16 @@ fn project_mux( }) } -/// Per-mux min_bid in Gwei: the MuxConfig field when the schema has it (wei), -/// else the overlay per-mux value (already Gwei), else the global -/// `min_bid_wei`. Wei sources floor-divide with a warning on a sub-Gwei +/// Per-mux min_bid in Gwei: the MuxConfig `min_bid_wei` when set, else the +/// global `min_bid_wei`. Wei sources floor-divide with a warning on a sub-Gwei /// remainder. fn resolve_min_bid( input: &ProjectionInput, - overlay: &Overlay, mux: &MuxConfig, warnings: &mut Vec, ) -> Result { - let gwei = if let Some(wei) = mux.projected_min_bid_wei() { + let gwei = if let Some(wei) = mux.min_bid_wei { wei_to_gwei_floor(&mux.id, wei, warnings)? - } else if let Some(gwei) = overlay.per_mux.get(&mux.id).and_then(|m| m.min_bid_gwei) { - gwei } else { wei_to_gwei_floor(&mux.id, input.cfg.pbs.pbs_config.min_bid_wei, warnings)? }; @@ -684,28 +660,6 @@ url = "https://{RELAY_PK_A}@relay-a.example.com" ); } - #[test] - fn overlay_per_mux_supplies_min_bid_and_boost() { - let key = random_key_hex(); - let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); - let overlay = Overlay::parse_str( - r#" -advertised_url = "https://cb.example.com" -[per_mux.mux1] -builder_boost_factor = 90 -min_bid_gwei = 12345 -"#, - ) - .unwrap(); - let projection = project(&input, &overlay).unwrap(); - let doc = projection.docs.values().next().unwrap(); - assert_eq!(doc.min_bid, Some("12345".to_string())); - assert_eq!(doc.builder_boost_factor, Some("90".to_string())); - let entry = &doc.builders.as_ref().unwrap()[0]; - assert_eq!(entry.min_bid, Some("12345".to_string())); - assert_eq!(entry.builder_boost_factor, Some("90".to_string())); - } - // The p2p fields split the doc: KEY-LEVEL min_bid/boost come from the // global projection-only p2p fields (they govern p2p bids and entries // omitting their own), while ENTRIES keep the mux/global-sourced values. @@ -904,22 +858,6 @@ url = "https://{RELAY_PK_A}@relay-a.example.com" assert_eq!(doc.builders.as_ref().unwrap()[0].builder_boost_factor, None); } - #[test] - fn unknown_per_mux_overlay_warns() { - let key = random_key_hex(); - let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); - let overlay = Overlay::parse_str( - r#" -advertised_url = "https://cb.example.com" -[per_mux.no_such_mux] -builder_boost_factor = 90 -"#, - ) - .unwrap(); - let projection = project(&input, &overlay).unwrap(); - assert!(projection.warnings.iter().any(|w| w.contains("no_such_mux"))); - } - #[test] fn determinism_under_toml_permutation() { let key_a = random_key_hex(); From cba085317ef4d249ec81649f5c21c5306ec281d8 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 16:20:56 -0700 Subject: [PATCH 45/80] docs(km-tool): trim triplicated preserve_entries field doc The clap --help and after_help already carry the full explanation; the struct field only needs a one-line pointer. --- crates/km-tool/src/apply.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index d9f4d3699..f34811b5c 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -24,11 +24,7 @@ pub struct ApplyOptions { pub dry_run: bool, pub emit_dir: Option, pub prune: bool, - /// When true, GET each key's stored doc and keep builder entries whose - /// identity (url, decoded auth_data) our projection does NOT produce -- i.e. - /// entries pinned by another writer -- appending them after ours. Client-side - /// read-modify-write: NOT atomic against a concurrent third-party write - /// between the GET and POST. + /// Preserve third-party builder entries a VC already stores (see --help). pub preserve_entries: bool, } From 1017b5473864d46be028a3c115c7c1dd2f03e27b Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 16:21:12 -0700 Subject: [PATCH 46/80] docs(pbs): reattach transient_pipe_relay doc and dedup skip-sigverify note The transient_pipe_relay doc block was mis-attached to pipe_relay_placeholder_pubkey (no blank line between them), leaving the public fn undocumented. Move it onto transient_pipe_relay and keep only the placeholder paragraph on the placeholder fn, trimming its duplicate skip-sigverify rationale to a pointer at the call site. --- crates/pbs/src/utils.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index a189cda03..d44a7f8c8 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -219,6 +219,15 @@ pub(crate) fn decode_auth_data_url(data: &[u8]) -> Option { std::str::from_utf8(url_bytes).ok().and_then(|s| Url::parse(s).ok()) } +/// A process-lifetime placeholder pubkey for pipe relays. This value is never +/// read: bid sigverify is skipped for the pipe (see the rationale at the call +/// site in `execution_payload_bid.rs`). A single lazily-built valid BLS point +/// avoids a keygen on every unmatched pipe request. +fn pipe_relay_placeholder_pubkey() -> BlsPublicKey { + static PLACEHOLDER: OnceLock = OnceLock::new(); + PLACEHOLDER.get_or_init(|| BlsSecretKey::random().public_key()).clone() +} + /// Builds the transient client the ePBS pipe dials when `auth.message.data` /// names a builder URL no configured relay serves: CB is a pure pipe and /// routes the request to the builder the proposer's signed auth data names @@ -229,16 +238,6 @@ pub(crate) fn decode_auth_data_url(data: &[u8]) -> Option { /// empty `advertised_urls`, which cannot rule that out - is an /// `AuthDataMismatch`, never a self-dial. Data carrying no URL at all names /// no builder and mismatches as before. -/// A process-lifetime placeholder pubkey for pipe relays. Bid sigverify is -/// skipped for the pipe (bid trust is the VC's job via KM builder_pubkeys, and -/// CB cannot know a pipe builder's key: bids carry builder_index, not a -/// pubkey), so this value is never read. A single lazily-built valid BLS point -/// avoids a keygen on every unmatched pipe request. -fn pipe_relay_placeholder_pubkey() -> BlsPublicKey { - static PLACEHOLDER: OnceLock = OnceLock::new(); - PLACEHOLDER.get_or_init(|| BlsSecretKey::random().public_key()).clone() -} - pub(crate) fn transient_pipe_relay( received_data: &[u8], advertised_urls: &[Url], From 71e46de34cb7462610b45caa6b47d765b782195b Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 16:21:19 -0700 Subject: [PATCH 47/80] docs(common): use plain hyphens and cite ticket id in pbs config docs Replace the two em dashes (house style) and swap the vague ticket reference for its id e14e42d5. --- crates/common/src/config/pbs.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index d64304295..35244b347 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -191,14 +191,14 @@ pub struct PbsConfig { pub advertised_urls: Vec, /// Gloas fork version (0x-hex, 4 bytes) used in the ePBS bid signing /// domain. Override for devnets/networks whose gloas fork version differs - /// from the built-in constant — see the ticket on deriving these from the - /// fork schedule. Unset = the built-in constant (behavior unchanged) + /// from the built-in constant -- see ticket e14e42d5 on deriving these from + /// the fork schedule. Unset = the built-in constant (behavior unchanged) #[serde(default)] pub gloas_fork_version: Option, /// Genesis validators root (0x-hex, 32 bytes) used in the ePBS bid signing /// domain. Override for devnets/networks whose genesis root differs from - /// the built-in constant — see the ticket on deriving these from the fork - /// schedule. Unset = the built-in constant (behavior unchanged) + /// the built-in constant -- see ticket e14e42d5 on deriving these from the + /// fork schedule. Unset = the built-in constant (behavior unchanged) #[serde(default)] pub genesis_validators_root: Option, /// Projection-only: consumed by KM tooling, not read by the PBS runtime. From 2e1338dafa78da12ed3c0c2b22685d87fea1bc09 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Fri, 21 Aug 2026 16:21:19 -0700 Subject: [PATCH 48/80] docs(pbs): fix mislabeled metric in relay-invalid-response test doc The doc was copy-pasted from the sibling BEACON_NODE_STATUS test and described the wrong metric (two labels). Keep only the correct three-label paragraph for RELAY_INVALID_RESPONSE. --- crates/pbs/src/utils.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index d44a7f8c8..e6e3cfe61 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -532,11 +532,6 @@ mod tests { assert!(decode_auth_data_url(b"not a url").is_none()); } - /// Pins that the helper lands in `BEACON_NODE_STATUS` with the declared - /// label ORDER (status, endpoint). Both labels are &str, so a swapped - /// order compiles and silently writes a different series - this read-back - /// with the correct order is the only thing that catches it. The endpoint - /// tag is unique to this test, so parallel tests cannot race it. /// Same label-order pin as the beacon-node counter: all three labels are /// &str, so a permuted order compiles and silently writes another series. #[test] From ce8176c18e49443788acb721de9e05eed9c603ac Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Sun, 23 Aug 2026 20:44:07 -0700 Subject: [PATCH 49/80] refactor(pbs): make the ePBS bid path a blind pipe Remove bid signature verification and its domain apparatus from the ePBS path. The beacon node already verifies a bid's builder_index against the on-chain builder registry and collateral, so CB's weaker configured-pubkey check was redundant and could only drop otherwise-valid bids on a domain or pubkey misconfiguration. Drops skip_sigverify from the ePBS ValidationContext, the gloas_fork_version and genesis_validators_root config plus their bid_* helpers, validate_signature, and verify_execution_payload_bid_signature. Keeps validate_header_data, auth_data routing, the self-URL guard, and the legacy get_header path (zero-diff), which retains its own skip_sigverify. The invalid-signature integration test now asserts the bad-sig bid is forwarded, not dropped. --- config.example.toml | 8 - crates/common/src/config/pbs.rs | 59 +------ crates/common/src/config/signer.rs | 2 - crates/common/src/signature.rs | 15 -- .../pbs/src/routes/execution_payload_bid.rs | 160 +----------------- tests/src/utils.rs | 2 - tests/tests/pbs_cfg_file_update.rs | 2 - tests/tests/pbs_get_execution_payload_bid.rs | 64 ++++--- 8 files changed, 50 insertions(+), 262 deletions(-) diff --git a/config.example.toml b/config.example.toml index 9d8007535..841de9a40 100644 --- a/config.example.toml +++ b/config.example.toml @@ -40,14 +40,6 @@ timeout_register_validator_ms = 3000 # Whether to skip signature verification of headers and pubkey matching against the relay pubkey # OPTIONAL, DEFAULT: false skip_sigverify = false -# Gloas fork version (0x-hex, 4 bytes) used in the ePBS bid signing domain. Override for -# devnets/networks whose gloas fork version differs from the built-in constant -# OPTIONAL, DEFAULT: unset (built-in constant) -# gloas_fork_version = "0x80435048" -# Genesis validators root (0x-hex, 32 bytes) used in the ePBS bid signing domain. Override for -# devnets/networks whose genesis root differs from the built-in constant -# OPTIONAL, DEFAULT: unset (built-in constant) -# genesis_validators_root = "0x0000000000000000000000000000000000000000000000000000000000000000" # Minimum bid in ETH that will be accepted from `get_header` # Can be specified as a float or a string for extra precision (e.g. "0.01") # OPTIONAL, DEFAULT: 0.0 diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 35244b347..6b8bb2c68 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -8,7 +8,7 @@ use std::{ }; use alloy::{ - primitives::{Address, B256, Bytes, U256, aliases::B32, utils::format_ether}, + primitives::{Address, Bytes, U256, utils::format_ether}, providers::{Provider, ProviderBuilder}, }; use docker_image::DockerImage; @@ -28,7 +28,6 @@ use crate::{ PbsMuxes, SIGNER_TLS_CERTIFICATE_NAME, SIGNER_TLS_CERTIFICATES_PATH_ENV, SIGNER_URL_ENV, SignerConfig, TlsMode, load_env_var, load_file_from_env, }, - constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{ DEFAULT_PBS_PORT, DEFAULT_REGISTRY_REFRESH_SECONDS, DefaultTimeout, LATE_IN_SLOT_TIME_MS, REGISTER_VALIDATOR_RETRY_LIMIT, RelayClient, RelayEntry, @@ -189,18 +188,6 @@ pub struct PbsConfig { /// CB's externally-reachable URLs; used by the ePBS pipe self-URL guard #[serde(default)] pub advertised_urls: Vec, - /// Gloas fork version (0x-hex, 4 bytes) used in the ePBS bid signing - /// domain. Override for devnets/networks whose gloas fork version differs - /// from the built-in constant -- see ticket e14e42d5 on deriving these from - /// the fork schedule. Unset = the built-in constant (behavior unchanged) - #[serde(default)] - pub gloas_fork_version: Option, - /// Genesis validators root (0x-hex, 32 bytes) used in the ePBS bid signing - /// domain. Override for devnets/networks whose genesis root differs from - /// the built-in constant -- see ticket e14e42d5 on deriving these from the - /// fork schedule. Unset = the built-in constant (behavior unchanged) - #[serde(default)] - pub genesis_validators_root: Option, /// Projection-only: consumed by KM tooling, not read by the PBS runtime. /// The ePBS KEY-LEVEL minimum total payment: it governs p2p bids and /// builder entries that omit their own min_bid (projected entries always @@ -220,18 +207,6 @@ pub struct PbsConfig { } impl PbsConfig { - /// Gloas fork version for the ePBS bid signing domain: the configured - /// override, else the built-in constant. - pub fn bid_fork_version(&self) -> [u8; 4] { - self.gloas_fork_version.map(|v| v.0).unwrap_or(GLOAS_FORK_VERSION) - } - - /// Genesis validators root for the ePBS bid signing domain: the configured - /// override, else the built-in constant. - pub fn bid_genesis_validators_root(&self) -> B256 { - self.genesis_validators_root.unwrap_or_else(|| B256::from(GENESIS_VALIDATORS_ROOT)) - } - /// Validate PBS config parameters pub async fn validate(&self, chain: Chain) -> Result<()> { // timeouts must be positive @@ -546,21 +521,7 @@ fn default_public_ssv_api_url() -> Url { #[cfg(test)] mod tests { - use alloy::primitives::{B256, aliases::B32, b256}; - use super::*; - use crate::constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}; - - // Absent overrides fall back to the built-in constants: existing configs - // keep verifying bids exactly as before. - #[test] - fn bid_domain_params_default_to_constants() { - let cfg: PbsConfig = toml::from_str("").unwrap(); - assert_eq!(cfg.gloas_fork_version, None); - assert_eq!(cfg.genesis_validators_root, None); - assert_eq!(cfg.bid_fork_version(), GLOAS_FORK_VERSION); - assert_eq!(cfg.bid_genesis_validators_root(), B256::from(GENESIS_VALIDATORS_ROOT)); - } // Projection-only p2p fields: parsed for KM tooling, absent = None so // existing configs project exactly as before. @@ -580,22 +541,4 @@ mod tests { assert_eq!(cfg.min_bid_p2p_wei, Some(U256::from(200_000_000_000_000_000u64))); assert_eq!(cfg.builder_boost_factor_p2p, Some(0)); } - - // Configured overrides are what the bid domain sees, parsed from 0x-hex. - #[test] - fn bid_domain_params_use_configured_overrides() { - let cfg: PbsConfig = toml::from_str( - r#" - gloas_fork_version = "0x80000038" - genesis_validators_root = "0x6c74d2e46eee2b5ec9b3512975fda3e1e97cee8cf2be0f2f6bcbf36002d17f3c" - "#, - ) - .unwrap(); - assert_eq!(cfg.gloas_fork_version, Some(B32::new([0x80, 0x00, 0x00, 0x38]))); - assert_eq!(cfg.bid_fork_version(), [0x80, 0x00, 0x00, 0x38]); - assert_eq!( - cfg.bid_genesis_validators_root(), - b256!("6c74d2e46eee2b5ec9b3512975fda3e1e97cee8cf2be0f2f6bcbf36002d17f3c") - ); - } } diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 73e47fdef..5106c0c62 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -488,8 +488,6 @@ mod tests { ssv_node_api_url: Url::parse("https://example.net").unwrap(), ssv_public_api_url: Url::parse("https://example.net").unwrap(), advertised_urls: vec![], - gloas_fork_version: None, - genesis_validators_root: None, min_bid_p2p_wei: None, builder_boost_factor_p2p: None, }, diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index c2bfc08e1..67fb23915 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -166,21 +166,6 @@ pub fn sign_execution_payload_bid_root( sign_message(secret_key, signing_data.tree_hash_root()) } -/// Verifies an ePBS execution payload bid signature under the bid domain. -pub fn verify_execution_payload_bid_signature( - pubkey: &BlsPublicKey, - msg: &T, - signature: &BlsSignature, - fork_version: [u8; 4], - genesis_validators_root: B256, -) -> bool { - let signing_data = types::SigningData { - object_root: msg.tree_hash_root(), - signing_domain: execution_payload_bid_domain(fork_version, genesis_validators_root), - }; - verify_bls_signature(pubkey, signing_data.tree_hash_root(), signature) -} - pub fn sign_commit_boost_root( chain: Chain, secret_key: &BlsSecretKey, diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 9133e3862..a697fd557 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -20,8 +20,7 @@ use cb_common::{ HEADER_TIMEOUT_MS, RelayClient, SignedExecutionPayloadBid, SignedRequestAuth, error::{PbsError, ValidationError}, }, - signature::verify_execution_payload_bid_signature, - types::{BlsPublicKey, BlsSignature, Chain}, + types::Chain, utils::{ms_into_slot, utcnow_ms}, wire::{ AcceptedEncodings, AcceptedEncodingsError, CONSENSUS_VERSION_HEADER, EncodingType, @@ -38,7 +37,6 @@ use reqwest::{ use ssz::{Decode, Encode}; use tokio::time::sleep; use tracing::{Instrument, debug, error, info, warn}; -use tree_hash::TreeHash; use url::Url; use crate::{ @@ -252,15 +250,9 @@ pub async fn get_execution_payload_bid( max_timeout_ms, ranking_cap_gwei(relay, pbs_config), ValidationContext { - // Pipe bids skip sigverify: bid trust is the VC's job via - // KM builder_pubkeys, and CB cannot know a pipe builder's - // key (bids carry builder_index, not a pubkey) - skip_sigverify: pbs_config.skip_sigverify || is_pipe, expected_fee_recipient: pbs_config.fee_recipient, extra_validation_enabled: state.extra_validation_enabled(), parent_block: parent_block.clone(), - fork_version: pbs_config.bid_fork_version(), - genesis_validators_root: pbs_config.bid_genesis_validators_root(), }, ) .in_current_span(), @@ -610,14 +602,9 @@ struct RequestContext { #[derive(Clone)] struct ValidationContext { - skip_sigverify: bool, expected_fee_recipient: Option
, extra_validation_enabled: bool, parent_block: Arc>>, - /// Bid signing-domain parameters: config overrides when set (devnets whose - /// gloas fork version / genesis root differ), else the built-in constants - fork_version: [u8; 4], - genesis_validators_root: B256, } async fn send_one_get_execution_payload_bid( @@ -744,16 +731,6 @@ async fn send_one_get_execution_payload_bid( validate_header_data(&header_info, ¶ms, validation.expected_fee_recipient)?; - if !validation.skip_sigverify { - validate_signature( - relay.pubkey(), - &get_header_response.data.message, - &get_header_response.data.signature, - validation.fork_version, - validation.genesis_validators_root, - )?; - } - if validation.extra_validation_enabled { let parent_block = validation.parent_block.read(); if let Some(parent_block) = parent_block.as_ref() { @@ -825,26 +802,6 @@ fn validate_header_data( Ok(()) } -fn validate_signature( - expected_pubkey: &BlsPublicKey, - message: &T, - signature: &BlsSignature, - fork_version: [u8; 4], - genesis_validators_root: B256, -) -> Result<(), ValidationError> { - if !verify_execution_payload_bid_signature( - expected_pubkey, - &message, - signature, - fork_version, - genesis_validators_root, - ) { - return Err(ValidationError::Sigverify); - } - - Ok(()) -} - fn extra_validation( parent_block: &Block, header_info: &HeaderInfo, @@ -890,13 +847,14 @@ mod tests { pbs::{RequestAuth, error::ValidationError}, signature::{ compute_domain, compute_domain_with_fork_version, request_auth_domain, - sign_builder_message, sign_execution_payload_bid_root, sign_request_auth_root, + sign_execution_payload_bid_root, sign_request_auth_root, }, - types::{BlsSecretKey, Chain}, + types::{BlsSecretKey, BlsSignature, Chain}, utils::TestRandomSeed, wire::BodyDeserializeError, }; use lh_types::Slot; + use tree_hash::TreeHash; use super::{validate_header_data, *}; @@ -974,116 +932,6 @@ mod tests { .unwrap(); } - #[test] - fn test_validate_signature() { - let secret_key = BlsSecretKey::random(); - let pubkey = secret_key.public_key(); - let wrong_signature = BlsSignature::test_random(); - - let message = B256::random(); - - // A legacy builder-domain signature must be rejected: bids use the - // gloas bid domain (DOMAIN_BEACON_BUILDER), not APPLICATION_BUILDER_DOMAIN. - let builder_domain_sig = sign_builder_message(Chain::Holesky, &secret_key, &message); - let bid_domain_sig = sign_execution_payload_bid_root( - &secret_key, - &message.tree_hash_root(), - GLOAS_FORK_VERSION, - GENESIS_VALIDATORS_ROOT.into(), - ); - - assert!(matches!( - validate_signature( - &pubkey, - &message, - &wrong_signature, - GLOAS_FORK_VERSION, - GENESIS_VALIDATORS_ROOT.into() - ), - Err(ValidationError::Sigverify) - )); - assert!(matches!( - validate_signature( - &pubkey, - &message, - &builder_domain_sig, - GLOAS_FORK_VERSION, - GENESIS_VALIDATORS_ROOT.into() - ), - Err(ValidationError::Sigverify) - )); - assert!( - validate_signature( - &pubkey, - &message, - &bid_domain_sig, - GLOAS_FORK_VERSION, - GENESIS_VALIDATORS_ROOT.into() - ) - .is_ok() - ); - } - - // A devnet whose fork version / genesis root differ from the built-in - // constants must verify bids under ITS domain (config override), and a - // signature made under the built-in domain must then be rejected. - #[test] - fn test_validate_signature_uses_configured_domain_params() { - let secret_key = BlsSecretKey::random(); - let pubkey = secret_key.public_key(); - let message = B256::random(); - - let devnet_fork: [u8; 4] = [0x80, 0x00, 0x00, 0x38]; - let devnet_root = B256::from([0x6c; 32]); - assert_ne!(devnet_fork, GLOAS_FORK_VERSION); - - let devnet_sig = sign_execution_payload_bid_root( - &secret_key, - &message.tree_hash_root(), - devnet_fork, - devnet_root, - ); - - // Verifies under the devnet domain - assert!( - validate_signature(&pubkey, &message, &devnet_sig, devnet_fork, devnet_root).is_ok() - ); - // The built-in domain must reject the devnet signature (and vice versa) - assert!(matches!( - validate_signature( - &pubkey, - &message, - &devnet_sig, - GLOAS_FORK_VERSION, - GENESIS_VALIDATORS_ROOT.into() - ), - Err(ValidationError::Sigverify) - )); - - // The config helpers are what the route threads through: default = - // constants, override = the devnet values - let default_cfg: PbsConfig = toml::from_str("").unwrap(); - assert_eq!(default_cfg.bid_fork_version(), GLOAS_FORK_VERSION); - assert_eq!(default_cfg.bid_genesis_validators_root(), B256::from(GENESIS_VALIDATORS_ROOT)); - let override_cfg: PbsConfig = toml::from_str( - r#" - gloas_fork_version = "0x80000038" - genesis_validators_root = "0x6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c" - "#, - ) - .unwrap(); - assert!( - validate_signature( - &pubkey, - &message, - &devnet_sig, - override_cfg.bid_fork_version(), - override_cfg.bid_genesis_validators_root() - ) - .is_ok() - ); - } - fn test_auth(slot: u64, signature: BlsSignature) -> SignedRequestAuth { SignedRequestAuth { // Non-empty so it clears the empty-data guard; the value itself is the diff --git a/tests/src/utils.rs b/tests/src/utils.rs index da9c3d7d2..2701f65ea 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -197,8 +197,6 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { validator_registration_batch_size: None, mux_registry_refresh_interval_seconds: 5, advertised_urls: vec![], - gloas_fork_version: None, - genesis_validators_root: None, min_bid_p2p_wei: None, builder_boost_factor_p2p: None, } diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index fd752c7ad..191dd8f29 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -78,8 +78,6 @@ async fn test_cfg_file_update() -> Result<()> { validator_registration_batch_size: None, mux_registry_refresh_interval_seconds: 384, advertised_urls: vec![], - gloas_fork_version: None, - genesis_validators_root: None, min_bid_p2p_wei: None, builder_boost_factor_p2p: None, }; diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 2d195c3bf..062d7fd61 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -59,15 +59,19 @@ async fn test_get_execution_payload_bid_no_bid() -> Result<()> { .await } -/// Test that a bid signed with the wrong key is dropped +/// A bid signed with the wrong key is forwarded unchanged, not dropped: the +/// ePBS bid path is a blind pipe. The beacon node verifies the bid's +/// builder_index against the on-chain builder registry and collateral, so CB +/// does not verify the signature. #[tokio::test] -async fn test_get_execution_payload_bid_invalid_signature() -> Result<()> { - test_get_execution_payload_bid_impl( +async fn test_get_execution_payload_bid_invalid_signature_is_forwarded() -> Result<()> { + test_get_execution_payload_bid_impl_opts( vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_epbs_invalid_signature()], - StatusCode::NO_CONTENT, + StatusCode::OK, &[1], - None, + Some(10), 0, + false, ) .await } @@ -408,9 +412,8 @@ async fn test_get_execution_payload_bid_demux_by_url_bytes() -> Result<()> { /// PIPE: auth data naming a builder URL outside CB's config dials it via a /// transient client and returns its bid. The bid is signed by the builder's -/// own key, which no configured relay entry carries, so the 200 also proves -/// bid sigverify is skipped for the pipe relay (per-context, not globally: -/// `skip_sigverify` stays false here). +/// own key, which no configured relay entry carries, and CB forwards it +/// unchanged (blind pipe: the ePBS path does not verify the bid signature). #[tokio::test] async fn test_get_execution_payload_bid_pipe_dials_unconfigured_builder() -> Result<()> { setup_test_env(); @@ -2034,6 +2037,25 @@ async fn test_get_execution_payload_bid_impl( expected_relay_counts: &[u64], expected_value: Option, max_execution_payment_gwei: u64, +) -> Result<()> { + test_get_execution_payload_bid_impl_opts( + relay_states, + expected_code, + expected_relay_counts, + expected_value, + max_execution_payment_gwei, + true, + ) + .await +} + +async fn test_get_execution_payload_bid_impl_opts( + relay_states: Vec, + expected_code: StatusCode, + expected_relay_counts: &[u64], + expected_value: Option, + max_execution_payment_gwei: u64, + require_relay_signature: bool, ) -> Result<()> { // Setup test environment setup_test_env(); @@ -2098,16 +2120,20 @@ async fn test_get_execution_payload_bid_impl( if let Some(expected_value) = expected_value { assert_eq!(res.value(), expected_value); } - // The winning bid must be signed by one of the configured relays - let object_root = res.data.message.tree_hash_root(); - assert!( - states.iter().any(|s| sign_execution_payload_bid_root( - &s.signer, - &object_root, - GLOAS_FORK_VERSION, - GENESIS_VALIDATORS_ROOT.into(), - ) == res.data.signature), - "bid signature does not match any configured relay" - ); + // The winning bid must be signed by one of the configured relays, unless the + // case deliberately forwards a mis-signed bid (blind pipe: CB does not verify + // the bid signature; the beacon node does). + if require_relay_signature { + let object_root = res.data.message.tree_hash_root(); + assert!( + states.iter().any(|s| sign_execution_payload_bid_root( + &s.signer, + &object_root, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ) == res.data.signature), + "bid signature does not match any configured relay" + ); + } Ok(()) } From 54d29c605c2a0e2edb22e610663e376b4e6a7853 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Sun, 23 Aug 2026 20:59:23 -0700 Subject: [PATCH 50/80] refactor(pbs): rename request-auth symbols to builder-request-auth Mirror builder-specs PR #165 (commit 4ad99382), which renamed the out-of-protocol builder request-auth types, constants, and helpers for clarity. Naming only: no behavior change. DOMAIN stays 0x0B000001 and the auth-data cap stays 4096. - RequestAuth -> BuilderRequestAuth - SignedRequestAuth -> SignedBuilderRequestAuth - DOMAIN_REQUEST_AUTH -> DOMAIN_BUILDER_REQUEST_AUTH - MAX_DATA_SIZE / MAX_AUTH_DATA_SIZE -> MAX_BUILDER_AUTH_DATA_SIZE - sign_request_auth_root -> sign_builder_request_auth_root - request_auth_domain -> builder_request_auth_domain - validate_request_auth -> validate_builder_request_auth - verify_request_auth_signature -> verify_builder_request_auth_signature - config field verify_request_auth -> verify_builder_request_auth The spec file path types/gloas/request_auth.yaml was not renamed, so comments citing it are unchanged. --- config.example.toml | 4 +- crates/common/src/config/pbs.rs | 4 +- crates/common/src/config/signer.rs | 2 +- crates/common/src/constants.rs | 4 +- crates/common/src/pbs/types/mod.rs | 30 +++---- crates/common/src/signature.rs | 20 ++--- crates/common/src/wire.rs | 2 +- crates/km-tool/src/project.rs | 6 +- crates/pbs/src/error.rs | 10 +-- crates/pbs/src/routes/builder_preferences.rs | 26 +++--- .../pbs/src/routes/execution_payload_bid.rs | 80 +++++++++---------- crates/pbs/src/utils.rs | 16 ++-- tests/src/mock_relay.rs | 18 ++--- tests/src/mock_validator.rs | 6 +- tests/src/utils.rs | 24 +++--- tests/tests/pbs_cfg_file_update.rs | 2 +- tests/tests/pbs_get_execution_payload_bid.rs | 16 ++-- tests/tests/pbs_submit_builder_preferences.rs | 16 ++-- 18 files changed, 143 insertions(+), 143 deletions(-) diff --git a/config.example.toml b/config.example.toml index 841de9a40..203189c77 100644 --- a/config.example.toml +++ b/config.example.toml @@ -64,11 +64,11 @@ min_bid_eth = 0.0 # Expected fee recipient in ePBS bids. When set, bids whose fee_recipient differs are rejected # OPTIONAL, DEFAULT: unset (no check) # fee_recipient = "0x1234567890123456789012345678901234567890" -# Whether to verify the BLS signature of the `SignedRequestAuth` on an ePBS request against the +# Whether to verify the BLS signature of the `SignedBuilderRequestAuth` on an ePBS request against the # proposer pubkey in the request path, rejecting a bad signature with 401. CB forwards by default # because the downstream builder must re-verify anyway; operators terminating trust at CB set it true # OPTIONAL, DEFAULT: false -verify_request_auth = false +verify_builder_request_auth = false # How late in milliseconds in the slot is "late". This impacts the `get_header` requests, by shortening timeouts for `get_header` calls to # relays and make sure a header is returned within this deadline. If the request from the CL comes later in the slot, then fetching headers is skipped # to force local building and miniminzing the risk of missed slots. See also the timing games section below diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 6b8bb2c68..43a689407 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -148,11 +148,11 @@ pub struct PbsConfig { #[serde(default = "default_u64::<{ u64::MAX }>")] pub max_execution_payment_gwei: u64, /// When enabled, the BLS signature of an ePBS request's - /// `SignedRequestAuth` is verified against the proposer pubkey. False by + /// `SignedBuilderRequestAuth` is verified against the proposer pubkey. False by /// default: CB forwards because the downstream builder must re-verify /// anyway; operators terminating trust at CB set it true #[serde(default = "default_bool::")] - pub verify_request_auth: bool, + pub verify_builder_request_auth: bool, /// Expected fee recipient in ePBS bids; when set, bids with a different /// fee_recipient are rejected pub fee_recipient: Option
, diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 5106c0c62..72fed24be 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -476,7 +476,7 @@ mod tests { skip_sigverify: false, min_bid_wei: Uint::<256, 4>::from(0), max_execution_payment_gwei: 0, - verify_request_auth: false, + verify_builder_request_auth: false, fee_recipient: None, late_in_slot_time_ms: 0, extra_validation_enabled: false, diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs index 952c2ccd0..14e07a2b6 100644 --- a/crates/common/src/constants.rs +++ b/crates/common/src/constants.rs @@ -3,8 +3,8 @@ pub const APPLICATION_BUILDER_DOMAIN: [u8; 4] = [0, 0, 0, 1]; // legacy builder domain 0x00000001 nor the request-auth domain 0x0B000001. pub const DOMAIN_BEACON_BUILDER: [u8; 4] = [0x0B, 0x00, 0x00, 0x00]; // Out-of-protocol Builder API request-auth domain (builder-specs -// DOMAIN_REQUEST_AUTH), for `RequestAuth` only. -pub const DOMAIN_REQUEST_AUTH: [u8; 4] = [0x0B, 0x00, 0x00, 0x01]; +// DOMAIN_BUILDER_REQUEST_AUTH), for `BuilderRequestAuth` only. +pub const DOMAIN_BUILDER_REQUEST_AUTH: [u8; 4] = [0x0B, 0x00, 0x00, 0x01]; // TODO placeholders: gloas devnet fork version pub const GLOAS_FORK_VERSION: [u8; 4] = [0x80, 0x43, 0x50, 0x48]; pub const GENESIS_VALIDATORS_ROOT: [u8; 32] = [0; 32]; diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index 82b5c1fc4..0a81b25b0 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -193,23 +193,23 @@ impl GetPayloadInfo for SignedBlindedBeaconBlock { } #[allow(non_camel_case_types)] -pub type MAX_DATA_SIZE = typenum::U4096; +pub type MAX_BUILDER_AUTH_DATA_SIZE = typenum::U4096; -// `RequestAuth` is used to authenticate requests to a builder. This is useful +// `BuilderRequestAuth` is used to authenticate requests to a builder. This is useful // so that other builders do not DDOS or run replay attacks on the builder. #[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone, TreeHash)] -pub struct RequestAuth { +pub struct BuilderRequestAuth { /// Opaque authentication data agreed with the builder out of band; hex /// string on the JSON wire #[serde(with = "ssz_types::serde_utils::hex_var_list")] - pub data: VariableList, + pub data: VariableList, pub slot: Slot, } -// `SignedRequestAuth` +// `SignedBuilderRequestAuth` #[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] -pub struct SignedRequestAuth { - pub message: RequestAuth, +pub struct SignedBuilderRequestAuth { + pub message: BuilderRequestAuth, pub signature: BlsSignature, } @@ -228,7 +228,7 @@ pub struct BuilderPreferences { #[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] pub struct BuilderPreferencesRequest { pub preferences: BuilderPreferences, - pub auth: SignedRequestAuth, + pub auth: SignedBuilderRequestAuth, } /// Path params for `POST /eth/v1/builder/builder_preferences/{proposer_pubkey}` @@ -244,9 +244,9 @@ mod tests { /// `data` is an opaque hex STRING on the wire #[test] - fn test_request_auth_data_serializes_as_hex() { - let auth = SignedRequestAuth { - message: RequestAuth { + fn test_builder_request_auth_data_serializes_as_hex() { + let auth = SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: VariableList::new(vec![0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]) .unwrap(), slot: Slot::new(100), @@ -260,7 +260,7 @@ mod tests { /// Round-trip the spec's wire shape back into the struct #[test] - fn test_request_auth_deserializes_spec_json() { + fn test_builder_request_auth_deserializes_spec_json() { let json = r#"{ "message": { "data": "0x1234567890abcdef", @@ -268,7 +268,7 @@ mod tests { }, "signature": "0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" }"#; - let auth: SignedRequestAuth = serde_json::from_str(json).unwrap(); + let auth: SignedBuilderRequestAuth = serde_json::from_str(json).unwrap(); assert_eq!(auth.message.data.to_vec(), vec![ 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef ]); @@ -288,8 +288,8 @@ mod tests { let mut infinity_sig = vec![0u8; 96]; infinity_sig[0] = 0xc0; - let auth = SignedRequestAuth { - message: RequestAuth { + let auth = SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: VariableList::new(vec![0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]) .unwrap(), slot: Slot::new(1234), diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index 67fb23915..28aa92f5e 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -4,7 +4,7 @@ use tree_hash_derive::TreeHash; use crate::{ constants::{ - COMMIT_BOOST_DOMAIN, DOMAIN_BEACON_BUILDER, DOMAIN_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, + COMMIT_BOOST_DOMAIN, DOMAIN_BEACON_BUILDER, DOMAIN_BUILDER_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, }, signer::{EcdsaSignature, verify_bls_signature, verify_ecdsa_signature}, types::{self, BlsPublicKey, BlsSecretKey, BlsSignature, Chain, SignatureRequestInfo}, @@ -81,27 +81,27 @@ pub fn execution_payload_bid_domain(fork_version: [u8; 4], genesis_validators_ro /// Builder API request-auth signing domain. The request WIRE type is /// fork-versioned per builder-specs, but the signing domain is not: the spec's -/// `compute_domain(DOMAIN_REQUEST_AUTH)` takes the genesis fork version and a +/// `compute_domain(DOMAIN_BUILDER_REQUEST_AUTH)` takes the genesis fork version and a /// zero root, exactly like the validator registrations it replaces. -pub fn request_auth_domain(chain: Chain) -> B256 { - compute_domain(chain, &B32::from(DOMAIN_REQUEST_AUTH)) +pub fn builder_request_auth_domain(chain: Chain) -> B256 { + compute_domain(chain, &B32::from(DOMAIN_BUILDER_REQUEST_AUTH)) } -/// Signs a `RequestAuth` message root under the request-auth domain. -pub fn sign_request_auth_root( +/// Signs a `BuilderRequestAuth` message root under the request-auth domain. +pub fn sign_builder_request_auth_root( secret_key: &BlsSecretKey, object_root: &B256, chain: Chain, ) -> BlsSignature { let signing_data = types::SigningData { object_root: *object_root, - signing_domain: request_auth_domain(chain), + signing_domain: builder_request_auth_domain(chain), }; sign_message(secret_key, signing_data.tree_hash_root()) } -/// Verifies a `SignedRequestAuth` signature under the request-auth domain. -pub fn verify_request_auth_signature( +/// Verifies a `SignedBuilderRequestAuth` signature under the request-auth domain. +pub fn verify_builder_request_auth_signature( pubkey: &BlsPublicKey, msg: &T, signature: &BlsSignature, @@ -109,7 +109,7 @@ pub fn verify_request_auth_signature( ) -> bool { let signing_data = types::SigningData { object_root: msg.tree_hash_root(), - signing_domain: request_auth_domain(chain), + signing_domain: builder_request_auth_domain(chain), }; verify_bls_signature(pubkey, signing_data.tree_hash_root(), signature) } diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index d46f0f0ab..2a7d5f357 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -548,7 +548,7 @@ pub fn deserialize_body( } /// Decode a fork-versioned ePBS request body (builder-specs fork-versions -/// `SignedRequestAuth` and `BuilderPreferencesRequest`) as JSON or SSZ, +/// `SignedBuilderRequestAuth` and `BuilderPreferencesRequest`) as JSON or SSZ, /// defaulting to SSZ when no `Content-Type` is set. An empty body is rejected /// first so a missing body reads as `MissingBody`. `Eth-Consensus-Version` is /// required for BOTH encodings and its value must name a fork this build diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index 788c6fd77..9bece281e 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -34,7 +34,7 @@ use crate::{ /// KM spec limits (builder_entry.yaml) pub const MAX_BUILDER_ENTRIES: usize = 64; pub const MAX_BUILDER_PUBKEYS: usize = 64; -pub const MAX_AUTH_DATA_SIZE: usize = 4096; +pub const MAX_BUILDER_AUTH_DATA_SIZE: usize = 4096; const WEI_PER_GWEI: u64 = 1_000_000_000; @@ -330,8 +330,8 @@ fn project_mux( for (relay, raw_url) in mux.relays.iter().zip(raw_urls) { let bytes = candidate_auth_data(relay, raw_url); ensure!( - !bytes.is_empty() && bytes.len() <= MAX_AUTH_DATA_SIZE, - "mux {} relay {}: auth_data must be 1..={MAX_AUTH_DATA_SIZE} bytes, got {}", + !bytes.is_empty() && bytes.len() <= MAX_BUILDER_AUTH_DATA_SIZE, + "mux {} relay {}: auth_data must be 1..={MAX_BUILDER_AUTH_DATA_SIZE} bytes, got {}", mux.id, relay.id(), bytes.len() diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index b4be01cd9..9b0555654 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -89,19 +89,19 @@ impl IntoResponse for PbsClientError { PbsClientError::NoResponse => "no response from relays".to_string(), PbsClientError::NoBuilderResponse => "no builder accepted the submission".to_string(), PbsClientError::AuthDataMismatch => { - "Invalid SignedRequestAuth: auth.message.data does not match the value agreed with this builder".to_string() + "Invalid SignedBuilderRequestAuth: auth.message.data does not match the value agreed with this builder".to_string() } PbsClientError::EmptyAuthData => { - "Invalid SignedRequestAuth: auth.message.data must not be empty".to_string() + "Invalid SignedBuilderRequestAuth: auth.message.data must not be empty".to_string() } PbsClientError::MissingTimingHeader => { "Invalid request: Date-Milliseconds and X-Timeout-Ms headers are required".to_string() } PbsClientError::AuthSlotMismatch => { - "Invalid SignedRequestAuth: auth.message.slot does not match the proposal slot in the request path".to_string() + "Invalid SignedBuilderRequestAuth: auth.message.slot does not match the proposal slot in the request path".to_string() } PbsClientError::AuthSlotPassed => { - "Invalid SignedRequestAuth: auth.message.slot has already passed".to_string() + "Invalid SignedBuilderRequestAuth: auth.message.slot has already passed".to_string() } // The builder's own body is never forwarded: it is untrusted and may be // arbitrarily large @@ -109,7 +109,7 @@ impl IntoResponse for PbsClientError { format!("The addressed builder rejected the submission with status {code}") } PbsClientError::AuthSigVerify => { - "Invalid SignedRequestAuth: signature verification failed".to_string() + "Invalid SignedBuilderRequestAuth: signature verification failed".to_string() } PbsClientError::NotGloasBlock => { "Invalid signed beacon block: only Gloas blocks are supported".to_string() diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index 477b2d4e9..641e98dec 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -8,7 +8,7 @@ use axum::{ }; use cb_common::{ pbs::{ - BuilderPreferencesRequest, RelayClient, SignedRequestAuth, SubmitBuilderPreferencesParams, + BuilderPreferencesRequest, RelayClient, SignedBuilderRequestAuth, SubmitBuilderPreferencesParams, error::PbsError, }, types::Chain, @@ -98,7 +98,7 @@ pub async fn submit_builder_preferences( &request.auth, ¶ms, state.config.chain, - pbs_config.verify_request_auth, + pbs_config.verify_builder_request_auth, )?; let matched = match_relays_by_auth_data(relays, request.auth.message.data.as_ref()); @@ -174,14 +174,14 @@ pub async fn submit_builder_preferences( Ok(()) } -/// Validates the caller's `SignedRequestAuth`. There is no slot in the +/// Validates the caller's `SignedBuilderRequestAuth`. There is no slot in the /// request path here, so instead of matching one we reject a slot that has /// already ended: preferences are submitted an epoch ahead, and a replayed /// submission must not be able to roll a proposer's preferences back to a stale /// value. `auth.message.data` must be non-empty; which builder it addresses is /// the demux's job (`match_relays_by_auth_data`). fn validate_preferences_auth( - auth: &SignedRequestAuth, + auth: &SignedBuilderRequestAuth, params: &SubmitBuilderPreferencesParams, chain: Chain, verify_signature: bool, @@ -239,7 +239,7 @@ async fn send_one_submit_builder_preferences( #[cfg(test)] mod tests { use cb_common::{ - pbs::{BuilderPreferences, RequestAuth}, + pbs::{BuilderPreferences, BuilderRequestAuth}, types::BlsSignature, utils::{timestamp_of_slot_start_sec, utcnow_ms, utcnow_sec}, wire::{BodyDeserializeError, CONSENSUS_VERSION_HEADER}, @@ -290,8 +290,8 @@ mod tests { let chain = Chain::Hoodi; let params = SubmitBuilderPreferencesParams { proposer_pubkey: BlsSecretKey::random().public_key() }; - let empty = SignedRequestAuth { - message: RequestAuth { + let empty = SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: Default::default(), slot: lh_types::Slot::new(current_slot(chain)), }, @@ -320,8 +320,8 @@ mod tests { #[test] fn decode_defaults_to_ssz_without_a_content_type() { let request = BuilderPreferencesRequest { - auth: SignedRequestAuth { - message: RequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, + auth: SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, signature: BlsSignature::empty(), }, preferences: BuilderPreferences { max_execution_payment: 7 }, @@ -374,8 +374,8 @@ mod tests { #[test] fn decode_rejects_json_without_the_version_header() { let request = BuilderPreferencesRequest { - auth: SignedRequestAuth { - message: RequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, + auth: SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, signature: BlsSignature::empty(), }, preferences: BuilderPreferences { max_execution_payment: 7 }, @@ -401,8 +401,8 @@ mod tests { #[test] fn decode_rejects_an_unrecognized_fork_value() { let request = BuilderPreferencesRequest { - auth: SignedRequestAuth { - message: RequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, + auth: SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, signature: BlsSignature::empty(), }, preferences: BuilderPreferences { max_execution_payment: 7 }, diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index a697fd557..6abe02f50 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -17,7 +17,7 @@ use cb_common::{ pbs::{ DEFAULT_BID_POLL_TIMEOUT_MS, ForkName, GetExecutionPayloadBidInfo, GetExecutionPayloadBidParams, GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, - HEADER_TIMEOUT_MS, RelayClient, SignedExecutionPayloadBid, SignedRequestAuth, + HEADER_TIMEOUT_MS, RelayClient, SignedExecutionPayloadBid, SignedBuilderRequestAuth, error::{PbsError, ValidationError}, }, types::Chain, @@ -53,7 +53,7 @@ use crate::{ }, }; -/// The body is the required `SignedRequestAuth`; builder-specs fork-versions +/// The body is the required `SignedBuilderRequestAuth`; builder-specs fork-versions /// the request wire type, and `Eth-Consensus-Version` is required for JSON and /// SSZ alike (builder-specs #165). pub async fn handle_get_execution_payload_bid( @@ -65,7 +65,7 @@ pub async fn handle_get_execution_payload_bid( // Count decode rejections: a client broken by the strict header rule must // show up as a 400 spike on this endpoint, not vanish from the counter let body = Arc::new( - decode_versioned_request_body::(&req_headers, &body) + decode_versioned_request_body::(&req_headers, &body) .map_err(|err| record_client_error(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG))?, ); tracing::Span::current().record("slot", params.slot); @@ -162,7 +162,7 @@ pub async fn handle_get_execution_payload_bid( /// with Internal (-> 500). pub async fn get_execution_payload_bid( params: GetExecutionPayloadBidParams, - body: Arc, + body: Arc, req_headers: HeaderMap, state: PbsState, ) -> Result, PbsClientError> { @@ -177,7 +177,7 @@ pub async fn get_execution_payload_bid( } // Validate before any outbound work so a rejected request costs nothing - validate_request_auth(&body, ¶ms, state.config.chain, pbs_config.verify_request_auth)?; + validate_builder_request_auth(&body, ¶ms, state.config.chain, pbs_config.verify_builder_request_auth)?; let parent_block = Arc::new(RwLock::new(None)); if state.extra_validation_enabled() && @@ -360,14 +360,14 @@ fn request_budget_ms(req_headers: &HeaderMap, now_ms: u64) -> Result, + body: Arc, relay: RelayClient, headers: HeaderMap, ms_into_slot: u64, @@ -609,7 +609,7 @@ struct ValidationContext { async fn send_one_get_execution_payload_bid( params: GetExecutionPayloadBidParams, - body: Arc, + body: Arc, relay: RelayClient, mut req_config: RequestContext, validation: ValidationContext, @@ -843,11 +843,11 @@ mod tests { use alloy::primitives::{B256, aliases::B32}; use cb_common::{ - constants::{DOMAIN_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, - pbs::{RequestAuth, error::ValidationError}, + constants::{DOMAIN_BUILDER_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, + pbs::{BuilderRequestAuth, error::ValidationError}, signature::{ - compute_domain, compute_domain_with_fork_version, request_auth_domain, - sign_execution_payload_bid_root, sign_request_auth_root, + compute_domain, compute_domain_with_fork_version, builder_request_auth_domain, + sign_execution_payload_bid_root, sign_builder_request_auth_root, }, types::{BlsSecretKey, BlsSignature, Chain}, utils::TestRandomSeed, @@ -932,11 +932,11 @@ mod tests { .unwrap(); } - fn test_auth(slot: u64, signature: BlsSignature) -> SignedRequestAuth { - SignedRequestAuth { + fn test_auth(slot: u64, signature: BlsSignature) -> SignedBuilderRequestAuth { + SignedBuilderRequestAuth { // Non-empty so it clears the empty-data guard; the value itself is the // demux's input, exercised elsewhere, not this validator's slot/sig path - message: RequestAuth { data: vec![0x01].try_into().unwrap(), slot: Slot::new(slot) }, + message: BuilderRequestAuth { data: vec![0x01].try_into().unwrap(), slot: Slot::new(slot) }, signature, } } @@ -945,7 +945,7 @@ mod tests { // cannot slip through a catch-all relay match. Guards the wiring of the // shared `validate_auth_data` into this endpoint. #[test] - fn validate_request_auth_rejects_empty_data() { + fn validate_builder_request_auth_rejects_empty_data() { let chain = Chain::Hoodi; let slot = 5; let params = GetExecutionPayloadBidParams { @@ -954,13 +954,13 @@ mod tests { parent_root: B256::ZERO, proposer_pubkey: BlsSecretKey::random().public_key(), }; - let empty = SignedRequestAuth { - message: RequestAuth { data: Default::default(), slot: Slot::new(slot) }, + let empty = SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: Default::default(), slot: Slot::new(slot) }, signature: BlsSignature::empty(), }; for verify in [false, true] { assert!(matches!( - validate_request_auth(&empty, ¶ms, chain, verify), + validate_builder_request_auth(&empty, ¶ms, chain, verify), Err(PbsClientError::EmptyAuthData) )); } @@ -968,9 +968,9 @@ mod tests { // An empty body is as invalid as a malformed one: the spec requires the auth #[test] - fn test_decode_request_auth_rejects_empty_body() { + fn test_decode_builder_request_auth_rejects_empty_body() { assert!(matches!( - decode_versioned_request_body::(&HeaderMap::new(), &Bytes::new()), + decode_versioned_request_body::(&HeaderMap::new(), &Bytes::new()), Err(BodyDeserializeError::MissingBody) )); } @@ -1078,31 +1078,31 @@ mod tests { } // The auth domain is NOT fork-versioned: it must equal the spec's - // compute_domain(DOMAIN_REQUEST_AUTH), i.e. genesis fork version and a zero + // compute_domain(DOMAIN_BUILDER_REQUEST_AUTH), i.e. genesis fork version and a zero // root. A sign/verify round trip cannot catch a wrong domain, so pin it. #[test] - fn test_request_auth_domain_is_not_fork_versioned() { + fn test_builder_request_auth_domain_is_not_fork_versioned() { for chain in [Chain::Mainnet, Chain::Hoodi, Chain::Holesky] { assert_eq!( - request_auth_domain(chain), - compute_domain(chain, &B32::from(DOMAIN_REQUEST_AUTH)), + builder_request_auth_domain(chain), + compute_domain(chain, &B32::from(DOMAIN_BUILDER_REQUEST_AUTH)), ); // A fork-versioned domain would differ; that is the bug this guards assert_ne!( - request_auth_domain(chain), + builder_request_auth_domain(chain), compute_domain_with_fork_version( GLOAS_FORK_VERSION, GENESIS_VALIDATORS_ROOT.into(), - &B32::from(DOMAIN_REQUEST_AUTH), + &B32::from(DOMAIN_BUILDER_REQUEST_AUTH), ), ); } // Chains are separated by their genesis fork version - assert_ne!(request_auth_domain(Chain::Mainnet), request_auth_domain(Chain::Hoodi)); + assert_ne!(builder_request_auth_domain(Chain::Mainnet), builder_request_auth_domain(Chain::Hoodi)); } #[test] - fn test_validate_request_auth() { + fn test_validate_builder_request_auth() { let chain = Chain::Hoodi; let secret_key = BlsSecretKey::random(); let pubkey = secret_key.public_key(); @@ -1117,7 +1117,7 @@ mod tests { // Slot mismatch is a 400 whether or not sigverify is on for verify in [false, true] { assert!(matches!( - validate_request_auth( + validate_builder_request_auth( &test_auth(slot + 1, BlsSignature::empty()), ¶ms, chain, @@ -1129,9 +1129,9 @@ mod tests { // With verification off a bad signature passes through to the builder let bad = test_auth(slot, BlsSignature::test_random()); - validate_request_auth(&bad, ¶ms, chain, false).unwrap(); + validate_builder_request_auth(&bad, ¶ms, chain, false).unwrap(); assert!(matches!( - validate_request_auth(&bad, ¶ms, chain, true), + validate_builder_request_auth(&bad, ¶ms, chain, true), Err(PbsClientError::AuthSigVerify) )); @@ -1144,20 +1144,20 @@ mod tests { GENESIS_VALIDATORS_ROOT.into(), ); assert!(matches!( - validate_request_auth(&test_auth(slot, bid_domain_sig), ¶ms, chain, true), + validate_builder_request_auth(&test_auth(slot, bid_domain_sig), ¶ms, chain, true), Err(PbsClientError::AuthSigVerify) )); // A signature made for another chain must not verify here let other_chain_sig = - sign_request_auth_root(&secret_key, &message.tree_hash_root(), Chain::Mainnet); + sign_builder_request_auth_root(&secret_key, &message.tree_hash_root(), Chain::Mainnet); assert!(matches!( - validate_request_auth(&test_auth(slot, other_chain_sig), ¶ms, chain, true), + validate_builder_request_auth(&test_auth(slot, other_chain_sig), ¶ms, chain, true), Err(PbsClientError::AuthSigVerify) )); - let good_sig = sign_request_auth_root(&secret_key, &message.tree_hash_root(), chain); - validate_request_auth(&test_auth(slot, good_sig), ¶ms, chain, true).unwrap(); + let good_sig = sign_builder_request_auth_root(&secret_key, &message.tree_hash_root(), chain); + validate_builder_request_auth(&test_auth(slot, good_sig), ¶ms, chain, true).unwrap(); } struct MockBid { diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index e6e3cfe61..39560f7de 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -5,8 +5,8 @@ use std::{ use cb_common::{ config::{GetHeaderTransport, RelayConfig}, - pbs::{ForkName, RelayClient, RelayEntry, SignedRequestAuth, error::PbsError}, - signature::verify_request_auth_signature, + pbs::{ForkName, RelayClient, RelayEntry, SignedBuilderRequestAuth, error::PbsError}, + signature::verify_builder_request_auth_signature, types::{BlsPublicKey, BlsSecretKey, Chain}, wire::{CONSENSUS_VERSION_HEADER, get_user_agent_with_version}, }; @@ -138,7 +138,7 @@ pub(crate) fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { /// is invalid"). It addresses no builder, so it must be rejected up front /// rather than slip through a catch-all relay match in /// [`match_relays_by_auth_data`]. Shared by both ePBS request-auth validators. -pub(crate) fn validate_auth_data(auth: &SignedRequestAuth) -> Result<(), PbsClientError> { +pub(crate) fn validate_auth_data(auth: &SignedBuilderRequestAuth) -> Result<(), PbsClientError> { if auth.message.data.is_empty() { warn!("auth data is empty"); return Err(PbsClientError::EmptyAuthData); @@ -153,12 +153,12 @@ pub(crate) fn validate_auth_data(auth: &SignedRequestAuth) -> Result<(), PbsClie /// slot rule differs between them and stays with each caller. pub(crate) fn verify_auth_signature( pubkey: &BlsPublicKey, - auth: &SignedRequestAuth, + auth: &SignedBuilderRequestAuth, chain: Chain, verify_signature: bool, ) -> Result<(), PbsClientError> { if verify_signature && - !verify_request_auth_signature(pubkey, &auth.message, &auth.signature, chain) + !verify_builder_request_auth_signature(pubkey, &auth.message, &auth.signature, chain) { warn!(pubkey = %pubkey, "auth signature verification failed"); return Err(PbsClientError::AuthSigVerify); @@ -323,11 +323,11 @@ mod tests { #[test] fn validate_auth_data_requires_nonempty_data() { - use cb_common::{pbs::RequestAuth, types::BlsSignature}; + use cb_common::{pbs::BuilderRequestAuth, types::BlsSignature}; use lh_types::Slot; - let with_data = |data: Vec| SignedRequestAuth { - message: RequestAuth { data: data.try_into().unwrap(), slot: Slot::new(1) }, + let with_data = |data: Vec| SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: data.try_into().unwrap(), slot: Slot::new(1) }, signature: BlsSignature::empty(), }; diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index 998ee4542..b5cba4dac 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -30,7 +30,7 @@ use cb_common::{ GetExecutionPayloadBidResponse, GetHeaderParams, GetHeaderResponse, GetPayloadInfo, HEADER_TIMEOUT_MS, PayloadAndBlobs, REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SUBMIT_BUILDER_PREFERENCES_PATH, SUBMIT_SIGNED_BEACON_BLOCK_PATH, SignedBeaconBlock, - SignedBuilderBid, SignedExecutionPayloadBid, SignedRequestAuth, SubmitBlindedBlockResponse, + SignedBuilderBid, SignedExecutionPayloadBid, SignedBuilderRequestAuth, SubmitBlindedBlockResponse, }, signature::{sign_builder_root, sign_execution_payload_bid_root}, signer::random_secret, @@ -132,9 +132,9 @@ pub struct MockRelayState { /// Hold every bid request this long before answering, simulating a builder /// that sits on a request instead of answering promptly bid_delay_ms: Option, - /// `data` bytes of the last `SignedRequestAuth` forwarded on a bid + /// `data` bytes of the last `SignedBuilderRequestAuth` forwarded on a bid /// request - received_auth: RwLock>, + received_auth: RwLock>, response_override: RwLock>, bid_value: RwLock, /// The raw `Accept` header PBS sent on the most recent get_header request, @@ -206,8 +206,8 @@ impl MockRelayState { .map(|r| r.preferences.max_execution_payment) } - /// The `SignedRequestAuth` carried by the last submitted preferences - pub fn received_preferences_auth(&self) -> Option { + /// The `SignedBuilderRequestAuth` carried by the last submitted preferences + pub fn received_preferences_auth(&self) -> Option { self.received_preferences.read().unwrap().as_ref().map(|r| r.auth.clone()) } @@ -231,9 +231,9 @@ impl MockRelayState { self.received_auth.read().unwrap().as_ref().map(|a| a.message.data.to_vec()) } - /// The full `SignedRequestAuth` the relay saw, so a test can assert the + /// The full `SignedBuilderRequestAuth` the relay saw, so a test can assert the /// signature was forwarded byte-for-byte. - pub fn received_auth(&self) -> Option { + pub fn received_auth(&self) -> Option { self.received_auth.read().unwrap().clone() } pub fn large_body(&self) -> bool { @@ -503,9 +503,9 @@ async fn handle_get_execution_payload_bid( ) .into_response(); } - SignedRequestAuth::from_ssz_bytes(&body).ok() + SignedBuilderRequestAuth::from_ssz_bytes(&body).ok() } - EncodingType::Json => serde_json::from_slice::(&body).ok(), + EncodingType::Json => serde_json::from_slice::(&body).ok(), }; if let Some(auth) = auth { *state.received_auth.write().unwrap() = Some(auth); diff --git a/tests/src/mock_validator.rs b/tests/src/mock_validator.rs index 16eaa2bcd..8dee79dce 100644 --- a/tests/src/mock_validator.rs +++ b/tests/src/mock_validator.rs @@ -2,7 +2,7 @@ use alloy::{primitives::B256, rpc::types::beacon::relay::ValidatorRegistration}; use cb_common::{ pbs::{ BuilderApiVersion, BuilderPreferencesRequest, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, - RelayClient, SignedBeaconBlock, SignedBlindedBeaconBlock, SignedRequestAuth, + RelayClient, SignedBeaconBlock, SignedBlindedBeaconBlock, SignedBuilderRequestAuth, }, types::{BlsPublicKey, KnownChain}, utils::{bls_pubkey_from_hex, utcnow_ms}, @@ -134,7 +134,7 @@ impl MockValidator { parent_hash: B256, parent_root: B256, pubkey: Option, - auth: Option<&SignedRequestAuth>, + auth: Option<&SignedBuilderRequestAuth>, accept: Vec, ) -> eyre::Result { self.do_get_execution_payload_bid_with_timeout( @@ -158,7 +158,7 @@ impl MockValidator { parent_hash: B256, parent_root: B256, pubkey: Option, - auth: Option<&SignedRequestAuth>, + auth: Option<&SignedBuilderRequestAuth>, accept: Vec, timeout_ms: u64, ) -> eyre::Result { diff --git a/tests/src/utils.rs b/tests/src/utils.rs index 2701f65ea..ab64d55ae 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -15,8 +15,8 @@ use cb_common::{ SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, SIGNER_PORT_DEFAULT, SignerConfig, SignerType, StartSignerConfig, StaticModuleConfig, StaticPbsConfig, TlsMode, }, - pbs::{RelayClient, RelayEntry, RequestAuth, SignedRequestAuth}, - signature::sign_request_auth_root, + pbs::{RelayClient, RelayEntry, BuilderRequestAuth, SignedBuilderRequestAuth}, + signature::sign_builder_request_auth_root, signer::{SignerLoader, random_secret}, types::{BlsPublicKey, BlsSecretKey, BlsSignature, Chain, ModuleId}, utils::{bls_pubkey_from_hex, default_host}, @@ -187,7 +187,7 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { fee_recipient: None, late_in_slot_time_ms: u64::MAX, extra_validation_enabled: false, - verify_request_auth: false, + verify_builder_request_auth: false, ssv_node_api_url: Url::parse("http://localhost:0").unwrap(), ssv_public_api_url: Url::parse("http://localhost:0").unwrap(), @@ -302,28 +302,28 @@ pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { bls_pubkey_from_hex(hex).unwrap() } -/// Build a `SignedRequestAuth` carrying opaque `data`. CB forwards it -/// unmodified; the signature is only verified when `verify_request_auth` is on, +/// Build a `SignedBuilderRequestAuth` carrying opaque `data`. CB forwards it +/// unmodified; the signature is only verified when `verify_builder_request_auth` is on, /// so an empty one suffices elsewhere. -pub fn opaque_auth(data: &[u8], slot: u64) -> SignedRequestAuth { - SignedRequestAuth { - message: RequestAuth { - data: ssz_types::VariableList::new(data.to_vec()).expect("data fits in MAX_DATA_SIZE"), +pub fn opaque_auth(data: &[u8], slot: u64) -> SignedBuilderRequestAuth { + SignedBuilderRequestAuth { + message: BuilderRequestAuth { + data: ssz_types::VariableList::new(data.to_vec()).expect("data fits in MAX_BUILDER_AUTH_DATA_SIZE"), slot: Slot::new(slot), }, signature: BlsSignature::empty(), } } -/// Same, but signed under the spec's `DOMAIN_REQUEST_AUTH` by `secret_key`. +/// Same, but signed under the spec's `DOMAIN_BUILDER_REQUEST_AUTH` by `secret_key`. pub fn signed_auth( secret_key: &BlsSecretKey, data: &[u8], slot: u64, chain: Chain, -) -> SignedRequestAuth { +) -> SignedBuilderRequestAuth { let mut auth = opaque_auth(data, slot); - auth.signature = sign_request_auth_root(secret_key, &auth.message.tree_hash_root(), chain); + auth.signature = sign_builder_request_auth_root(secret_key, &auth.message.tree_hash_root(), chain); auth } diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index 191dd8f29..facc5f3e9 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -65,7 +65,7 @@ async fn test_cfg_file_update() -> Result<()> { skip_sigverify: true, min_bid_wei: U256::ZERO, max_execution_payment_gwei: 0, - verify_request_auth: false, + verify_builder_request_auth: false, fee_recipient: None, late_in_slot_time_ms: u64::MAX / 2, /* serde gets very upset about serializing u64::MAX * or anything close to it */ diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 062d7fd61..61e671666 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -586,7 +586,7 @@ async fn test_get_execution_payload_bid_demux_no_match_400() -> Result<()> { assert_eq!(body["code"], 400); assert_eq!( body["message"], - "Invalid SignedRequestAuth: auth.message.data does not match the value agreed with this builder" + "Invalid SignedBuilderRequestAuth: auth.message.data does not match the value agreed with this builder" ); Ok(()) } @@ -627,7 +627,7 @@ async fn test_get_execution_payload_bid_unmatched_opaque_auth_400() -> Result<() assert_eq!(body["code"], 400); assert_eq!( body["message"], - "Invalid SignedRequestAuth: auth.message.data does not match the value agreed with this builder" + "Invalid SignedBuilderRequestAuth: auth.message.data does not match the value agreed with this builder" ); Ok(()) } @@ -713,22 +713,22 @@ async fn test_get_execution_payload_bid_auth_slot_mismatch_400() -> Result<()> { assert_eq!(body["code"], 400); assert_eq!( body["message"], - "Invalid SignedRequestAuth: auth.message.slot does not match the proposal slot in the request path" + "Invalid SignedBuilderRequestAuth: auth.message.slot does not match the proposal slot in the request path" ); Ok(()) } -/// With `verify_request_auth` on, a bad auth signature is a 401 and a good one +/// With `verify_builder_request_auth` on, a bad auth signature is a 401 and a good one /// passes through to the relay. #[tokio::test] -async fn test_get_execution_payload_bid_verify_request_auth_enabled() -> Result<()> { +async fn test_get_execution_payload_bid_verify_builder_request_auth_enabled() -> Result<()> { let secret_key = random_secret(); let proposer_pubkey = secret_key.public_key(); let (mock_validator, mock_state) = - setup_relay(Chain::Hoodi, |cfg| cfg.verify_request_auth = true, generate_mock_relay) + setup_relay(Chain::Hoodi, |cfg| cfg.verify_builder_request_auth = true, generate_mock_relay) .await?; - // An empty signature never verifies under DOMAIN_REQUEST_AUTH + // An empty signature never verifies under DOMAIN_BUILDER_REQUEST_AUTH let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); let res = mock_validator .do_get_execution_payload_bid( @@ -744,7 +744,7 @@ async fn test_get_execution_payload_bid_verify_request_auth_enabled() -> Result< assert_eq!(mock_state.received_execution_payload_bid(), 0, "bad auth precedes relay calls"); let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; assert_eq!(body["code"], 401); - assert_eq!(body["message"], "Invalid SignedRequestAuth: signature verification failed"); + assert_eq!(body["message"], "Invalid SignedBuilderRequestAuth: signature verification failed"); let auth = signed_auth(&secret_key, &[0xde, 0xad], TEST_SLOT, Chain::Hoodi); let res = mock_validator diff --git a/tests/tests/pbs_submit_builder_preferences.rs b/tests/tests/pbs_submit_builder_preferences.rs index f2d4f233a..b8c61ede5 100644 --- a/tests/tests/pbs_submit_builder_preferences.rs +++ b/tests/tests/pbs_submit_builder_preferences.rs @@ -1,5 +1,5 @@ use cb_common::{ - pbs::{BuilderPreferences, BuilderPreferencesRequest, SignedRequestAuth}, + pbs::{BuilderPreferences, BuilderPreferencesRequest, SignedBuilderRequestAuth}, signer::random_secret, types::Chain, utils::utcnow_ms, @@ -38,7 +38,7 @@ fn past_slot(chain: Chain) -> u64 { ((now_sec.saturating_sub(chain.genesis_time_sec())) / chain.slot_time_sec()).saturating_sub(10) } -fn preferences(auth: SignedRequestAuth, max_execution_payment: u64) -> BuilderPreferencesRequest { +fn preferences(auth: SignedBuilderRequestAuth, max_execution_payment: u64) -> BuilderPreferencesRequest { BuilderPreferencesRequest { auth, preferences: BuilderPreferences { max_execution_payment } } } @@ -172,7 +172,7 @@ async fn test_submit_builder_preferences_signature_bound_to_path_pubkey() -> Res let signer = random_secret(); let other_pubkey = random_secret().public_key(); let (mock_validator, mock_state) = - setup_relay(chain, |config| config.verify_request_auth = true, generate_mock_relay).await?; + setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay).await?; // Genuinely signed, just not by the proposer named in the path let auth = signed_auth(&signer, TEST_AUTH_DATA, future_slot(chain), chain); @@ -349,7 +349,7 @@ async fn test_submit_builder_preferences_slot_passed_400() -> Result<()> { assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; assert_eq!(body["code"], 400); - assert_eq!(body["message"], "Invalid SignedRequestAuth: auth.message.slot has already passed"); + assert_eq!(body["message"], "Invalid SignedBuilderRequestAuth: auth.message.slot has already passed"); assert_eq!(mock_state.received_builder_preferences(), 0, "no builder should be contacted"); Ok(()) } @@ -521,7 +521,7 @@ async fn test_submit_builder_preferences_unmatched_opaque_auth_400() -> Result<( assert_eq!(body["code"], 400); assert_eq!( body["message"], - "Invalid SignedRequestAuth: auth.message.data does not match the value agreed with this builder" + "Invalid SignedBuilderRequestAuth: auth.message.data does not match the value agreed with this builder" ); Ok(()) } @@ -553,7 +553,7 @@ async fn test_submit_builder_preferences_auth_data_match() -> Result<()> { async fn test_submit_builder_preferences_bad_signature_401() -> Result<()> { let chain = Chain::Hoodi; let (mock_validator, mock_state) = - setup_relay(chain, |config| config.verify_request_auth = true, generate_mock_relay).await?; + setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay).await?; let request = preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); @@ -562,7 +562,7 @@ async fn test_submit_builder_preferences_bad_signature_401() -> Result<()> { assert_eq!(res.status(), StatusCode::UNAUTHORIZED); let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; - assert_eq!(body["message"], "Invalid SignedRequestAuth: signature verification failed"); + assert_eq!(body["message"], "Invalid SignedBuilderRequestAuth: signature verification failed"); assert_eq!(mock_state.received_builder_preferences(), 0); Ok(()) } @@ -574,7 +574,7 @@ async fn test_submit_builder_preferences_valid_signature() -> Result<()> { let secret = random_secret(); let pubkey = secret.public_key(); let (mock_validator, mock_state) = - setup_relay(chain, |config| config.verify_request_auth = true, generate_mock_relay).await?; + setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay).await?; let auth = signed_auth(&secret, TEST_AUTH_DATA, future_slot(chain), chain); let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT); From 3b02575ed07a839ad224840614e58e6e9de3233e Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Sun, 23 Aug 2026 21:57:03 -0700 Subject: [PATCH 51/80] docs(pbs): strip restate comments and dedup rationale in the ePBS surface Remove comments that restate the code, move duplicated rationale to a single home with back-references, fix a few stale/wrong comments, and fold in the behavior-identical rust-idiom cleanups (map_err over nested match, as_deref over clone, tuple destructuring in the zip loop, err binding, extend over a push loop, alloy hex encode). --- crates/common/src/config/mux.rs | 6 +-- crates/common/src/config/pbs.rs | 7 ++-- crates/common/src/pbs/types/mod.rs | 10 ++--- crates/common/src/signature.rs | 3 -- crates/km-tool/src/apply.rs | 4 +- crates/km-tool/src/check.rs | 13 ++++--- crates/km-tool/src/doc.rs | 7 +--- crates/km-tool/src/project.rs | 20 +++++----- crates/pbs/src/error.rs | 4 +- crates/pbs/src/routes/builder_preferences.rs | 5 +-- .../pbs/src/routes/execution_payload_bid.rs | 38 +++++++------------ crates/pbs/src/utils.rs | 23 ++++------- 12 files changed, 53 insertions(+), 87 deletions(-) diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index a671de3f4..d87e348fa 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -181,11 +181,11 @@ pub struct MuxConfig { pub late_in_slot_time_ms: Option, /// Expected fee recipient in ePBS bids for this mux's validators pub fee_recipient: Option
, - /// Projection-only: consumed by KM tooling, not read by the PBS runtime. + // The projection-only fields below are consumed by KM tooling, not read by + // the PBS runtime. /// The ePBS builder_boost_factor for this mux's keys #[serde(skip_serializing_if = "Option::is_none")] pub builder_boost_factor: Option, - /// Projection-only: consumed by KM tooling, not read by the PBS runtime. /// The ePBS per-key-group minimum total payment for this mux's keys #[serde( rename = "min_bid_eth", @@ -194,12 +194,10 @@ pub struct MuxConfig { skip_serializing_if = "Option::is_none" )] pub min_bid_wei: Option, - /// Projection-only: consumed by KM tooling, not read by the PBS runtime. /// The ePBS KEY-LEVEL builder_boost_factor governing p2p bids for this /// mux's keys. Overrides the global `[pbs] builder_boost_factor_p2p`. #[serde(default, skip_serializing_if = "Option::is_none")] pub builder_boost_factor_p2p: Option, - /// Projection-only: consumed by KM tooling, not read by the PBS runtime. /// The ePBS KEY-LEVEL minimum total payment governing p2p bids for this /// mux's keys. Overrides the global `[pbs] min_bid_p2p_eth`. #[serde( diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 43a689407..8096a3562 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -39,6 +39,8 @@ use crate::{ }, }; +/// How CB fetches bids from a relay: `Http` = the classic get_header request, +/// `Stream` = the ePBS bid stream (polling/SSE). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum GetHeaderTransport { @@ -59,7 +61,6 @@ pub struct RelayConfig { pub headers: Option>, /// Optional GET parameters to add to each request pub get_params: Option>, - /// How to fetch headers from this relay #[serde(default)] pub get_header: GetHeaderTransport, /// Whether to enable timing games @@ -188,7 +189,8 @@ pub struct PbsConfig { /// CB's externally-reachable URLs; used by the ePBS pipe self-URL guard #[serde(default)] pub advertised_urls: Vec, - /// Projection-only: consumed by KM tooling, not read by the PBS runtime. + // The p2p projection-only fields below are consumed by KM tooling, not read + // by the PBS runtime. /// The ePBS KEY-LEVEL minimum total payment: it governs p2p bids and /// builder entries that omit their own min_bid (projected entries always /// carry explicit per-entry values sourced from the mux/global min_bid) @@ -199,7 +201,6 @@ pub struct PbsConfig { skip_serializing_if = "Option::is_none" )] pub min_bid_p2p_wei: Option, - /// Projection-only: consumed by KM tooling, not read by the PBS runtime. /// The ePBS KEY-LEVEL builder_boost_factor: it governs p2p bids and /// builder entries that omit their own (entry values stay mux-sourced) #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index 0a81b25b0..897835871 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -136,13 +136,11 @@ impl GetExecutionPayloadBidInfo for GetExecutionPayloadBidResponse { /// {proposer_pubkey}` #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GetExecutionPayloadBidParams { - /// The slot for which the block should be proposed. pub slot: u64, /// The hash of the execution layer block the proposer will build on. pub parent_hash: B256, /// The root of the beacon block the proposer will build on. pub parent_root: B256, - /// The public key of the proposer pub proposer_pubkey: BlsPublicKey, } @@ -206,7 +204,6 @@ pub struct BuilderRequestAuth { pub slot: Slot, } -// `SignedBuilderRequestAuth` #[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] pub struct SignedBuilderRequestAuth { pub message: BuilderRequestAuth, @@ -275,10 +272,9 @@ mod tests { assert_eq!(auth.message.slot, Slot::new(100)); } - /// Spec vector for the SSZ layout of `BuilderPreferencesRequest`: - /// `(preferences, auth)` per builder-specs - /// `types/gloas/builder_preferences.yaml`. The order-determining fixed - /// part is cross-checked byte-for-byte against the canonical example + /// Spec vector for the SSZ layout of `BuilderPreferencesRequest` (field + /// order documented on the struct). The order-determining fixed part is + /// cross-checked byte-for-byte against the canonical example /// `examples/gloas/builder_preferences_request.ssz`. #[test] fn test_builder_preferences_request_ssz_spec_vector() { diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index 28aa92f5e..f810f1756 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -87,7 +87,6 @@ pub fn builder_request_auth_domain(chain: Chain) -> B256 { compute_domain(chain, &B32::from(DOMAIN_BUILDER_REQUEST_AUTH)) } -/// Signs a `BuilderRequestAuth` message root under the request-auth domain. pub fn sign_builder_request_auth_root( secret_key: &BlsSecretKey, object_root: &B256, @@ -100,7 +99,6 @@ pub fn sign_builder_request_auth_root( sign_message(secret_key, signing_data.tree_hash_root()) } -/// Verifies a `SignedBuilderRequestAuth` signature under the request-auth domain. pub fn verify_builder_request_auth_signature( pubkey: &BlsPublicKey, msg: &T, @@ -152,7 +150,6 @@ pub fn sign_builder_root( sign_message(secret_key, signing_root) } -/// Signs a message root under the ePBS execution payload bid domain. pub fn sign_execution_payload_bid_root( secret_key: &BlsSecretKey, object_root: &B256, diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index f34811b5c..4caf9b955 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -88,9 +88,7 @@ pub async fn run_apply( ) -> Result { let mut report = ApplyReport::default(); let global = project(input, overlay)?; - for w in &global.warnings { - report.warnings.push(w.clone()); - } + report.warnings.extend(global.warnings.iter().cloned()); let projected_keys: BTreeSet = global.docs.keys().map(|k| k.to_string()).collect(); if opts.dry_run { diff --git a/crates/km-tool/src/check.rs b/crates/km-tool/src/check.rs index 850f4658f..eaad79669 100644 --- a/crates/km-tool/src/check.rs +++ b/crates/km-tool/src/check.rs @@ -1,8 +1,8 @@ //! `cb-km check`: read-only comparison of stored VC docs against the -//! projection. Comparison is CANONICAL, never a byte-diff: the spec promises -//! neither entry order nor hex case, and a GET returns the doc fully -//! RESOLVED, so fields the projection intentionally omits (they resolve to -//! the VC's own config) are skipped rather than reported as drift. +//! projection. Comparison is canonical (see `doc::CanonicalDoc`), never a +//! byte-diff: a GET returns the doc fully RESOLVED, so fields the projection +//! intentionally omits (they resolve to the VC's own config) are skipped +//! rather than reported as drift. use std::{ collections::{BTreeMap, BTreeSet}, @@ -185,7 +185,8 @@ pub async fn run_check(input: &ProjectionInput, overlay: &Overlay) -> Result Vec { let mut lines = Vec::new(); compare_field(&mut lines, "min_bid", &projected.min_bid, &stored.min_bid); @@ -199,7 +200,7 @@ fn drift_lines(projected: &CanonicalDoc, stored: &CanonicalDoc) -> Vec { let Some(projected_entries) = &projected.builders else { return lines; }; - let stored_entries = stored.builders.clone().unwrap_or_default(); + let stored_entries = stored.builders.as_deref().unwrap_or_default(); let stored_by_key: BTreeMap<(String, Option>), &CanonicalEntry> = stored_entries .iter() .map(|entry| ((entry.url.clone(), entry.auth_data.clone()), entry)) diff --git a/crates/km-tool/src/doc.rs b/crates/km-tool/src/doc.rs index 274c872d2..37dc2bb46 100644 --- a/crates/km-tool/src/doc.rs +++ b/crates/km-tool/src/doc.rs @@ -34,12 +34,7 @@ pub struct BuilderEntryDoc { /// Encodes bytes as the KM `auth_data` wire form: 0x-prefixed lowercase hex. pub fn encode_auth_data(bytes: &[u8]) -> String { - let mut out = String::with_capacity(2 + bytes.len() * 2); - out.push_str("0x"); - for b in bytes { - out.push_str(&format!("{b:02x}")); - } - out + format!("0x{}", alloy_primitives::hex::encode(bytes)) } /// Decodes a 0x-prefixed hex `auth_data`, accepting either hex case. diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index 9bece281e..623fba945 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -62,10 +62,9 @@ impl std::fmt::Display for OrderedPubkey { } /// The parsed CB config plus the RAW relay URL strings from the same TOML -/// text. The raw strings matter: `RelayEntry` holds a parsed `Url`, and `Url` -/// serialization normalizes (adds the trailing slash to an empty path, drops -/// default ports), while the auth_data convention wants the URL bytes exactly -/// as configured. +/// text. The auth_data convention wants the URL bytes exactly as configured, +/// but `RelayEntry` holds a parsed `Url` whose serialization normalizes them +/// (see `Overlay::advertised_url`), so the raw strings are kept alongside. pub struct ProjectionInput { pub cfg: CommitBoostConfig, mux_relay_urls: Vec>, @@ -197,11 +196,11 @@ pub fn project_with_url(input: &ProjectionInput, advertised_url: &str) -> Result let keys = resolve_mux_keys(mux, &mut warnings)?; let doc = project_mux(input, mux, raw_urls, advertised_url, &mut warnings)?; - for relay in mux.relays.iter().zip(raw_urls) { + for (relay, raw_url) in mux.relays.iter().zip(raw_urls) { relay_candidates.push(RelayAuthCandidate { source: mux.id.clone(), - relay_id: relay.0.id().to_string(), - bytes: candidate_auth_data(relay.0, relay.1), + relay_id: relay.id().to_string(), + bytes: candidate_auth_data(relay, raw_url), projected: !keys.is_empty(), }); } @@ -325,7 +324,6 @@ fn project_mux( ) -> Result { ensure!(!mux.relays.is_empty(), "mux {} has no relays", mux.id); - // group relays into auth_data equivalence classes by identical bytes let mut classes: BTreeMap, AuthClass> = BTreeMap::new(); for (relay, raw_url) in mux.relays.iter().zip(raw_urls) { let bytes = candidate_auth_data(relay, raw_url); @@ -695,7 +693,7 @@ url = "https://{RELAY_PK_A}@relay-a.example.com" assert_eq!(entry.builder_boost_factor, Some("100".to_string())); } - // (a) A mux p2p field wins over a different global p2p field at the key level. + // A mux p2p field wins over a different global p2p field at the key level. #[test] fn mux_p2p_override_wins_over_global() { let key = random_key_hex(); @@ -722,7 +720,7 @@ url = "https://{RELAY_PK_A}@relay-a.example.com" assert_eq!(doc.builder_boost_factor, Some("130".to_string())); } - // (b) Only the global p2p fields are set: every mux uses them at the key level. + // Only the global p2p fields are set: every mux uses them at the key level. #[test] fn global_p2p_applies_to_all_muxes() { let key_a = random_key_hex(); @@ -754,7 +752,7 @@ url = "https://{RELAY_PK_B}@relay-b.example.com" } } - // (d) Mix: mux A overrides the global p2p, mux B inherits it. + // Mix: mux A overrides the global p2p, mux B inherits it. #[test] fn mux_p2p_override_and_inherit_mix() { let key_a = random_key_hex(); diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index 9b0555654..0fcd7a110 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -116,8 +116,8 @@ impl IntoResponse for PbsClientError { } PbsClientError::NoPayload => "no payload from relays".to_string(), PbsClientError::Internal => "internal server error".to_string(), - PbsClientError::DecodeError(e) => format!("error decoding request: {e}"), - PbsClientError::HeaderError(e) => format!("header error: {e}"), + PbsClientError::DecodeError(err) => format!("error decoding request: {err}"), + PbsClientError::HeaderError(err) => format!("header error: {err}"), }; // Return the spec's JSON `ErrorMessage` rather than plain text so clients diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index 641e98dec..2e4a2ef2c 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -133,9 +133,8 @@ pub async fn submit_builder_preferences( ) .in_current_span(), ) - .map(|join_result| match join_result { - Ok(res) => res, - Err(err) => Err(PbsError::TokioJoinError(err)), + .map(|join_result| { + join_result.unwrap_or_else(|err| Err(PbsError::TokioJoinError(err))) }), ); } diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 6abe02f50..23479d5db 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -169,7 +169,6 @@ pub async fn get_execution_payload_bid( let ms_into_slot = ms_into_slot(params.slot, state.config.chain); let (pbs_config, relays, maybe_mux_id) = state.mux_config_and_relays(¶ms.proposer_pubkey); - // All acceptable builders this pubkey can talk to if let Some(mux_id) = maybe_mux_id { debug!(mux_id, relays = relays.len(), pubkey = %params.proposer_pubkey, "using mux config"); } else { @@ -220,7 +219,7 @@ pub async fn get_execution_payload_bid( } let max_timeout_ms = max_timeout_ms.min(budget_ms); - // prepare headers, except for start time which is set in `send_one_get_header` + // prepare headers, except for start time which is set in `send_one_get_execution_payload_bid` let mut send_headers = epbs_base_send_headers(&req_headers)?; // Forward the caller's Accept preference to the relay so it returns the @@ -498,11 +497,8 @@ async fn send_timed_get_execution_payload_bid( "TG: sending multiple header requests" ); - // Every poll shares the proposer's deadline, so granting each one all - // the time left would let a builder hold them all until that instant - // and leave nothing in hand if it is missed. Bound the early polls so - // they land as a floor of progressively better bids; only the last - // poll holds for the full remainder. + // Bounded early polls land a floor of progressively better bids (see + // poll_call_timeout_ms). let poll_timeout_ms = relay.config.bid_poll_timeout_ms.unwrap_or(DEFAULT_BID_POLL_TIMEOUT_MS); @@ -582,7 +578,6 @@ async fn send_timed_get_execution_payload_bid( } } - // if no timing games or no repeated send, just send one request send_one_get_execution_payload_bid( params, body, @@ -614,7 +609,6 @@ async fn send_one_get_execution_payload_bid( mut req_config: RequestContext, validation: ValidationContext, ) -> Result<(u64, Option), PbsError> { - // the timestamp in the header is the consensus block time which is fixed, // request send time, forwarded to the relay in HEADER_START_TIME_UNIX_MS let start_request_time = utcnow_ms(); req_config.headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); @@ -664,17 +658,13 @@ async fn send_one_get_execution_payload_bid( } let get_header_response = match content_type { - EncodingType::Json => { - match serde_json::from_slice::(&response_bytes) { - Ok(parsed) => parsed, - Err(err) => { - return Err(PbsError::JsonDecode { - err, - raw: String::from_utf8_lossy(&response_bytes).into_owned(), - }); - } - } - } + EncodingType::Json => serde_json::from_slice::( + &response_bytes, + ) + .map_err(|err| PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(&response_bytes).into_owned(), + })?, EncodingType::Ssz => { // SSZ requires the fork from Eth-Consensus-Version; its absence is a // relay protocol violation. @@ -683,8 +673,8 @@ async fn send_one_get_execution_payload_bid( .to_string(), code: code.as_u16(), })?; - let data = SignedExecutionPayloadBid::from_ssz_bytes(&response_bytes).map_err(|e| { - PbsError::SSZDecode { err: format!("error decoding relay payload: {e:?}"), fork } + let data = SignedExecutionPayloadBid::from_ssz_bytes(&response_bytes).map_err(|err| { + PbsError::SSZDecode { err: format!("error decoding relay payload: {err:?}"), fork } })?; GetExecutionPayloadBidResponse { version: fork, data, metadata: Default::default() } } @@ -868,8 +858,8 @@ mod tests { let mock_params = GetExecutionPayloadBidParams { slot, - parent_hash: parent_hash.clone(), - parent_root: parent_root.clone(), + parent_hash, + parent_root, proposer_pubkey: pubkey, }; diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 39560f7de..2ea42f574 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -23,14 +23,11 @@ use crate::{ metrics::{RELAY_LATENCY, RELAY_STATUS_CODE}, }; -/// Sends one already-built relay request and records the per-relay metrics -/// shared by all three ePBS endpoints: a send failure bumps `RELAY_STATUS_CODE` -/// at `TIMEOUT_ERROR_CODE_STR` and returns the error; otherwise the latency is -/// observed and the response status recorded. Returns the response and its -/// latency so the caller can read/decode the body itself. `tag` is the -/// per-endpoint metric label. Callers build their own `RequestBuilder` because -/// the requests legitimately differ (bid sets a per-call timeout and timing -/// headers). +/// Sends one already-built relay request, recording the per-relay metrics +/// shared by all three ePBS endpoints, and returns the response and its latency +/// so the caller can read/decode the body itself. `tag` is the per-endpoint +/// metric label. Callers build their own `RequestBuilder` because the requests +/// legitimately differ (bid sets a per-call timeout and timing headers). pub(crate) async fn send_to_relay( req: reqwest::RequestBuilder, relay: &RelayClient, @@ -70,10 +67,9 @@ pub(crate) fn record_client_error( err } -/// Count a relay response that CB rejected during validation. The relay's HTTP -/// status was already recorded when the response arrived, so without this a -/// relay serving invalid bids every slot is indistinguishable in metrics from -/// an honest empty auction. +/// Count a relay response that CB rejected during validation, by reason (see +/// `RELAY_INVALID_RESPONSE` for why this is a separate signal from the relay's +/// HTTP status). pub(crate) fn record_invalid_relay_response(reason: &str, endpoint: &str, relay_id: &str) { crate::metrics::RELAY_INVALID_RESPONSE.with_label_values(&[reason, endpoint, relay_id]).inc(); } @@ -336,7 +332,6 @@ mod tests { validate_auth_data(&with_data(vec![])), Err(PbsClientError::EmptyAuthData) )); - // A single byte clears the guard assert!(validate_auth_data(&with_data(vec![0xaa])).is_ok()); } @@ -346,7 +341,6 @@ mod tests { test_relay("http://a.example.com", Some(&[0xaa])), test_relay("http://b.example.com", Some(&[0xbb])), ]; - // Exact-bytes match selects exactly one relay let matched = match_relays_by_auth_data(&relays, &[0xbb]); assert_eq!(matched.len(), 1); assert_eq!(matched[0].config.entry.url.host_str(), Some("b.example.com")); @@ -366,7 +360,6 @@ mod tests { let relays = vec![test_relay("https://0xdeadbeef@builder.example.com", None)]; assert_eq!(match_relays_by_auth_data(&relays, b"https://builder.example.com").len(), 1); assert_eq!(match_relays_by_auth_data(&relays, b"https://builder.example.com:443").len(), 1); - // A non-default port must not match assert!(match_relays_by_auth_data(&relays, b"https://builder.example.com:8443").is_empty()); } From ffe058749d0dd528d91cb0130d15a3b555835048 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Sun, 23 Aug 2026 22:07:14 -0700 Subject: [PATCH 52/80] refactor(pbs): extract shared ePBS route helpers and decompose long fns Add shared utils for the ePBS routes so the bid and write endpoints cannot diverge: post_ssz_expect_accepted (the 202-only SSZ write), resolve_addressed_ relays (demux + pipe fallback), record_beacon_status, and log_mux_selection. Extract encode_bid_response and build_auth_classes, and lift the per-VC apply body into apply_to_vc. Table-drive the mux projection-only info! logs. All behavior-preserving pure code motion. --- crates/common/src/config/mux.rs | 36 ++- crates/km-tool/src/apply.rs | 220 ++++++++++-------- crates/km-tool/src/project.rs | 26 ++- crates/pbs/src/routes/builder_preferences.rs | 72 ++---- .../pbs/src/routes/execution_payload_bid.rs | 134 +++++------ .../src/routes/submit_signed_beacon_block.rs | 50 ++-- crates/pbs/src/utils.rs | 71 +++++- 7 files changed, 312 insertions(+), 297 deletions(-) diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index d87e348fa..6bbd316f2 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -100,29 +100,19 @@ impl PbsMuxes { "using mux" ); - if mux.builder_boost_factor.is_some() { - info!( - "field builder_boost_factor on mux {} is applied via KM tooling, not by the PBS runtime", - mux.id - ); - } - if mux.min_bid_wei.is_some() { - info!( - "field min_bid_eth on mux {} is applied via KM tooling, not by the PBS runtime", - mux.id - ); - } - if mux.builder_boost_factor_p2p.is_some() { - info!( - "field builder_boost_factor_p2p on mux {} is applied via KM tooling, not by the PBS runtime", - mux.id - ); - } - if mux.min_bid_p2p_wei.is_some() { - info!( - "field min_bid_p2p_eth on mux {} is applied via KM tooling, not by the PBS runtime", - mux.id - ); + // Serde-renamed names, so the message column is explicit + for (present, name) in [ + (mux.builder_boost_factor.is_some(), "builder_boost_factor"), + (mux.min_bid_wei.is_some(), "min_bid_eth"), + (mux.builder_boost_factor_p2p.is_some(), "builder_boost_factor_p2p"), + (mux.min_bid_p2p_wei.is_some(), "min_bid_p2p_eth"), + ] { + if present { + info!( + "field {name} on mux {} is applied via KM tooling, not by the PBS runtime", + mux.id + ); + } } let mut relay_clients = Vec::with_capacity(mux.relays.len()); diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index 4caf9b955..c3e493e9e 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -12,7 +12,7 @@ use tracing::{info, warn}; use crate::{ client::{GetConfigOutcome, KmClient, PostOutcome, read_token}, doc::{BuilderConfigDoc, BuilderEntryDoc}, - overlay::Overlay, + overlay::{Overlay, VcConfig}, project::{ MAX_BUILDER_ENTRIES, MAX_BUILDER_PUBKEYS, Projection, ProjectionInput, project, project_with_url, @@ -111,106 +111,8 @@ pub async fn run_apply( let mut all_enumerated: BTreeSet = BTreeSet::new(); for vc in &overlay.vcs { - let vc_name = vc.url.to_string(); - let token = read_token(Path::new(&vc.token_path))?; - let client = KmClient::new(vc.url.clone(), token)?; - - let enumerated = match client.list_keystores().await { - Ok(keys) => keys, - Err(err) => { - report.error(format!("{vc_name}: keystores preflight failed: {err}")); - continue; - } - }; - all_enumerated.extend(enumerated.iter().cloned()); - - match preflight_builder_config(&client, &enumerated).await { - Ok(Preflight::Supported) => {} - Ok(Preflight::Unsupported) => { - report.error(format!( - "{vc_name}: no builder_config support (keymanager-APIs #88); skipping" - )); - continue; - } - Ok(Preflight::Unknown) => { - report.warn(format!( - "{vc_name}: no keys to probe for builder_config support; POSTing anyway" - )); - } - Err(err) => { - report.error(format!("{vc_name}: builder_config probe failed: {err}")); - continue; - } - } - - let vc_projection = project_with_url(input, overlay.advertised_url_for(vc))?; - for (key, doc) in &vc_projection.docs { - let key = key.to_string(); - - // --preserve-entries: fold any third-party builder entries the VC - // already stores back into our doc so the full-replace POST does - // not erase them. - let merged; - let doc = if opts.preserve_entries { - match client.get_builder_config(&key).await { - Ok(GetConfigOutcome::Ok(stored)) => { - match merge_preserved_entries(&key, doc, &stored) { - Ok(m) => { - merged = m; - &merged - } - Err(err) => { - report.error(format!( - "{vc_name}: preserve-entries merge for {key} failed: {err}" - )); - continue; - } - } - } - // nothing stored (or route absent for this key): POST ours - Ok(GetConfigOutcome::NotFound) => doc, - Err(err) => { - report.error(format!( - "{vc_name}: preserve-entries GET for {key} failed: {err}" - )); - continue; - } - } - } else { - doc - }; - - match client.post_builder_config(&key, doc).await { - Ok(PostOutcome::Accepted) => { - report.accepted.entry(key).or_default().push(vc_name.clone()); - } - Ok(PostOutcome::KeyNotFound) => {} - Ok(PostOutcome::ConfigFileManaged) => { - report - .warn(format!("{vc_name}: {key} is config-file-managed, cannot override")); - } - Err(err) => report.error(format!("{vc_name}: POST {key} failed: {err}")), - } - } - - if opts.prune { - for key in &enumerated { - if !projected_keys.contains(key) { - // POST {} is spec-equal to DELETE and a no-op when nothing - // is stored - match client.post_builder_config(key, &BuilderConfigDoc::default()).await { - Ok(PostOutcome::Accepted) => { - report.pruned.push((vc_name.clone(), key.clone())); - } - Ok(other) => report - .warn(format!("{vc_name}: prune of {key} not accepted: {other:?}")), - Err(err) => { - report.error(format!("{vc_name}: prune of {key} failed: {err}")) - } - } - } - } - } + apply_to_vc(vc, input, overlay, opts, &projected_keys, &mut all_enumerated, &mut report) + .await?; } // coverage warning (default on): enumerated keys outside the projection @@ -234,6 +136,122 @@ pub async fn run_apply( Ok(report) } +/// Applies the projection to one VC: enumerate its keys, confirm #88 support, +/// POST each projected doc (folding in preserved entries when asked), and prune +/// unprojected keys. A per-VC failure is recorded on the report and returns +/// early rather than aborting the whole run; only a transport/setup error +/// propagates. +async fn apply_to_vc( + vc: &VcConfig, + input: &ProjectionInput, + overlay: &Overlay, + opts: &ApplyOptions, + projected_keys: &BTreeSet, + all_enumerated: &mut BTreeSet, + report: &mut ApplyReport, +) -> Result<()> { + let vc_name = vc.url.to_string(); + let token = read_token(Path::new(&vc.token_path))?; + let client = KmClient::new(vc.url.clone(), token)?; + + let enumerated = match client.list_keystores().await { + Ok(keys) => keys, + Err(err) => { + report.error(format!("{vc_name}: keystores preflight failed: {err}")); + return Ok(()); + } + }; + all_enumerated.extend(enumerated.iter().cloned()); + + match preflight_builder_config(&client, &enumerated).await { + Ok(Preflight::Supported) => {} + Ok(Preflight::Unsupported) => { + report.error(format!( + "{vc_name}: no builder_config support (keymanager-APIs #88); skipping" + )); + return Ok(()); + } + Ok(Preflight::Unknown) => { + report.warn(format!( + "{vc_name}: no keys to probe for builder_config support; POSTing anyway" + )); + } + Err(err) => { + report.error(format!("{vc_name}: builder_config probe failed: {err}")); + return Ok(()); + } + } + + let vc_projection = project_with_url(input, overlay.advertised_url_for(vc))?; + for (key, doc) in &vc_projection.docs { + let key = key.to_string(); + + // --preserve-entries: fold any third-party builder entries the VC + // already stores back into our doc so the full-replace POST does + // not erase them. + let merged; + let doc = if opts.preserve_entries { + match client.get_builder_config(&key).await { + Ok(GetConfigOutcome::Ok(stored)) => { + match merge_preserved_entries(&key, doc, &stored) { + Ok(m) => { + merged = m; + &merged + } + Err(err) => { + report.error(format!( + "{vc_name}: preserve-entries merge for {key} failed: {err}" + )); + continue; + } + } + } + // nothing stored (or route absent for this key): POST ours + Ok(GetConfigOutcome::NotFound) => doc, + Err(err) => { + report.error(format!( + "{vc_name}: preserve-entries GET for {key} failed: {err}" + )); + continue; + } + } + } else { + doc + }; + + match client.post_builder_config(&key, doc).await { + Ok(PostOutcome::Accepted) => { + report.accepted.entry(key).or_default().push(vc_name.clone()); + } + Ok(PostOutcome::KeyNotFound) => {} + Ok(PostOutcome::ConfigFileManaged) => { + report.warn(format!("{vc_name}: {key} is config-file-managed, cannot override")); + } + Err(err) => report.error(format!("{vc_name}: POST {key} failed: {err}")), + } + } + + if opts.prune { + for key in &enumerated { + if !projected_keys.contains(key) { + // POST {} is spec-equal to DELETE and a no-op when nothing + // is stored + match client.post_builder_config(key, &BuilderConfigDoc::default()).await { + Ok(PostOutcome::Accepted) => { + report.pruned.push((vc_name.clone(), key.clone())); + } + Ok(other) => { + report.warn(format!("{vc_name}: prune of {key} not accepted: {other:?}")) + } + Err(err) => report.error(format!("{vc_name}: prune of {key} failed: {err}")), + } + } + } + } + + Ok(()) +} + /// An entry's identity for the preserve merge: its URL and DECODED auth_data /// bytes (hex case is not identity). Two entries collide iff both agree. fn entry_identity(entry: &BuilderEntryDoc) -> Result<(String, Option>)> { diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index 623fba945..dd170e8f7 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -315,15 +315,10 @@ fn ensure_no_lax_ambiguity(mux_id: &str, classes: &BTreeMap, AuthClass>) Ok(()) } -fn project_mux( - input: &ProjectionInput, - mux: &MuxConfig, - raw_urls: &[String], - advertised_url: &str, - warnings: &mut Vec, -) -> Result { - ensure!(!mux.relays.is_empty(), "mux {} has no relays", mux.id); - +/// Groups a mux's relays into auth_data equivalence classes keyed by identical +/// candidate bytes, unioning each class's builder pubkeys, requiring one shared +/// execution-payment cap per class, and enforcing the KM entry-count limit. +fn build_auth_classes(mux: &MuxConfig, raw_urls: &[String]) -> Result, AuthClass>> { let mut classes: BTreeMap, AuthClass> = BTreeMap::new(); for (relay, raw_url) in mux.relays.iter().zip(raw_urls) { let bytes = candidate_auth_data(relay, raw_url); @@ -360,6 +355,19 @@ fn project_mux( classes.len() ); + Ok(classes) +} + +fn project_mux( + input: &ProjectionInput, + mux: &MuxConfig, + raw_urls: &[String], + advertised_url: &str, + warnings: &mut Vec, +) -> Result { + ensure!(!mux.relays.is_empty(), "mux {} has no relays", mux.id); + + let classes = build_auth_classes(mux, raw_urls)?; ensure_no_lax_ambiguity(&mux.id, &classes)?; // Entry values are mux/global-sourced; the KEY-LEVEL values come from the diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index 2e4a2ef2c..d89818991 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - use axum::{ body::Bytes, extract::{Path, State}, @@ -13,22 +11,21 @@ use cb_common::{ }, types::Chain, utils::ms_into_slot, - wire::{EncodingType, decode_versioned_request_body, get_user_agent, safe_read_http_response}, + wire::{decode_versioned_request_body, get_user_agent}, }; use futures::{FutureExt, future::join_all}; -use reqwest::{StatusCode, header::CONTENT_TYPE}; +use reqwest::StatusCode; use ssz::Encode; use tracing::{Instrument, debug, error, info, warn}; use crate::{ PbsStateGuard, - constants::{MAX_SIZE_DEFAULT, SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG}, + constants::SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, error::PbsClientError, - metrics::BEACON_NODE_STATUS, state::{BuilderApiState, PbsState}, utils::{ - epbs_base_send_headers, expect_status, match_relays_by_auth_data, record_client_error, - send_to_relay, transient_pipe_relay, validate_auth_data, verify_auth_signature, + epbs_base_send_headers, log_mux_selection, post_ssz_expect_accepted, record_beacon_status, + record_client_error, resolve_addressed_relays, validate_auth_data, verify_auth_signature, }, }; @@ -58,20 +55,12 @@ pub async fn handle_submit_builder_preferences( match submit_builder_preferences(params, request, req_headers, state).await { Ok(()) => { - BEACON_NODE_STATUS - .with_label_values(&["202", SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG]) - .inc(); + record_beacon_status("202", SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG); Ok(StatusCode::ACCEPTED.into_response()) } Err(err) => { error!(%err, "submit_builder_preferences failed"); - - BEACON_NODE_STATUS - .with_label_values(&[ - err.status_code().as_str(), - SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, - ]) - .inc(); + record_beacon_status(err.status_code().as_str(), SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG); Err(err) } } @@ -87,11 +76,7 @@ pub async fn submit_builder_preferences( ) -> Result<(), PbsClientError> { let (pbs_config, relays, maybe_mux_id) = state.mux_config_and_relays(¶ms.proposer_pubkey); - if let Some(mux_id) = maybe_mux_id { - debug!(mux_id, relays = relays.len(), pubkey = %params.proposer_pubkey, "using mux config"); - } else { - debug!(relays = relays.len(), pubkey = %params.proposer_pubkey, "using default config"); - } + log_mux_selection(maybe_mux_id, relays.len(), ¶ms.proposer_pubkey); // Validate before any outbound work so a rejected request costs nothing validate_preferences_auth( @@ -101,15 +86,11 @@ pub async fn submit_builder_preferences( pbs_config.verify_builder_request_auth, )?; - let matched = match_relays_by_auth_data(relays, request.auth.message.data.as_ref()); - let relays: Vec = if matched.is_empty() { - // No configured relay serves this auth data: pipe the preferences to - // the builder URL the proposer's signed auth data names (self-URL - // guarded), mirroring the bid endpoint's demux semantics - vec![transient_pipe_relay(request.auth.message.data.as_ref(), &pbs_config.advertised_urls)?] - } else { - matched.into_iter().cloned().collect() - }; + let relays = resolve_addressed_relays( + relays, + request.auth.message.data.as_ref(), + &pbs_config.advertised_urls, + )?; let send_headers = epbs_base_send_headers(&req_headers)?; @@ -212,24 +193,15 @@ async fn send_one_submit_builder_preferences( // The builder decodes what the proposer signed either way, and SSZ is the // faster wire format on the relay hop - let req = relay - .client - .post(url) - .timeout(Duration::from_millis(timeout_ms)) - .headers(headers) - .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) - .body(request.as_ssz_bytes()); - let (res, request_latency) = - send_to_relay(req, &relay, SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG).await?; - let code = res.status(); - - // Cap the read like every other relay call: a builder is untrusted and must - // not be able to stream an unbounded error body into memory and the logs - safe_read_http_response(res, MAX_SIZE_DEFAULT).await?; - - // The spec makes 202 the only success: another 2xx means the builder did not - // commit to storing these preferences - expect_status(code, StatusCode::ACCEPTED)?; + let request_latency = post_ssz_expect_accepted( + &relay, + url, + request.as_ssz_bytes(), + headers, + timeout_ms, + SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, + ) + .await?; debug!(relay_id = relay.id.as_ref(), latency = ?request_latency, "preferences accepted"); Ok(()) diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 23479d5db..5e7ee5435 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -10,7 +10,7 @@ use axum::{ body::Bytes, extract::{Path, State}, http::{HeaderMap, HeaderValue}, - response::IntoResponse, + response::{IntoResponse, Response}, }; use cb_common::{ config::PbsConfig, @@ -45,11 +45,12 @@ use crate::{ GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, MAX_SIZE_GET_HEADER_RESPONSE, TIMEOUT_ERROR_CODE, }, error::PbsClientError, - metrics::{BEACON_NODE_STATUS, RELAY_HEADER_VALUE, RELAY_LAST_SLOT}, + metrics::{RELAY_HEADER_VALUE, RELAY_LAST_SLOT}, state::{BuilderApiState, PbsState}, utils::{ - check_gas_limit, epbs_base_send_headers, match_relays_by_auth_data, record_client_error, - send_to_relay, transient_pipe_relay, validate_auth_data, verify_auth_signature, + check_gas_limit, epbs_base_send_headers, log_mux_selection, record_beacon_status, + record_client_error, resolve_addressed_relays, send_to_relay, validate_auth_data, + verify_auth_signature, }, }; @@ -92,71 +93,62 @@ pub async fn handle_get_execution_payload_bid( info!(ua, ms_into_slot, "new request"); match get_execution_payload_bid(params, body, req_headers, state).await { - Ok(res) => { - if let Some(max_bid) = res { - info!(trustless_bid_eth = format_ether(max_bid.value()), execution_payment_eth = format_ether(max_bid.execution_payment()), block_hash =% max_bid.block_hash(), builder_index = max_bid.builder_index(), "received header"); - - // Eth-Consensus-Version is required on the 200 for both encodings - let consensus_version_header = HeaderValue::from_str(&max_bid.version.to_string()) - .expect("fork name is always a valid header value"); - - match response_encoding { - // Unreachable in practice: get_accept_types errors (-> 406 - // above) when the caller offers nothing we support. Counted - // here, NOT above, so a request emits exactly one label. - None => { - BEACON_NODE_STATUS - .with_label_values(&["406", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG]) - .inc(); - Err(PbsClientError::HeaderError( - AcceptedEncodingsError::UnsupportedAcceptType, - )) - } - Some(EncodingType::Ssz) => { - BEACON_NODE_STATUS - .with_label_values(&["200", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG]) - .inc(); - let mut res = max_bid.data.as_ssz_bytes().into_response(); - res.headers_mut() - .insert(CONSENSUS_VERSION_HEADER, consensus_version_header); - res.headers_mut() - .insert(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()); - Ok(res) - } - Some(EncodingType::Json) => { - BEACON_NODE_STATUS - .with_label_values(&["200", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG]) - .inc(); - let mut res = axum::Json(max_bid).into_response(); - res.headers_mut() - .insert(CONSENSUS_VERSION_HEADER, consensus_version_header); - Ok(res) - } - } - } else { - // spec: return 204 if request is valid but no bid available - info!("no header available for slot"); - - BEACON_NODE_STATUS - .with_label_values(&["204", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG]) - .inc(); - Ok(StatusCode::NO_CONTENT.into_response()) - } + Ok(Some(max_bid)) => { + encode_bid_response(max_bid, response_encoding, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG) + } + Ok(None) => { + // spec: return 204 if request is valid but no bid available + info!("no header available for slot"); + record_beacon_status("204", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG); + Ok(StatusCode::NO_CONTENT.into_response()) } Err(err) => { error!(%err, "get_execution_payload_bid failed"); - - BEACON_NODE_STATUS - .with_label_values(&[ - err.status_code().as_str(), - GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, - ]) - .inc(); + record_beacon_status(err.status_code().as_str(), GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG); Err(err) } } } +/// Encodes a winning bid into the 200 response for the caller's negotiated +/// encoding, stamping the required `Eth-Consensus-Version` header and counting +/// the returned status. The `None` (no supported encoding) arm is unreachable +/// in practice - `get_accept_types` already 406s an unsupported Accept, and it +/// is counted here so a request emits exactly one label - but is kept as a +/// defensive 406. +fn encode_bid_response( + max_bid: GetExecutionPayloadBidResponse, + response_encoding: Option, + endpoint: &str, +) -> Result { + info!(trustless_bid_eth = format_ether(max_bid.value()), execution_payment_eth = format_ether(max_bid.execution_payment()), block_hash =% max_bid.block_hash(), builder_index = max_bid.builder_index(), "received header"); + + // Eth-Consensus-Version is required on the 200 for both encodings + let consensus_version_header = HeaderValue::from_str(&max_bid.version.to_string()) + .expect("fork name is always a valid header value"); + + match response_encoding { + None => { + record_beacon_status("406", endpoint); + Err(PbsClientError::HeaderError(AcceptedEncodingsError::UnsupportedAcceptType)) + } + Some(EncodingType::Ssz) => { + record_beacon_status("200", endpoint); + let mut res = max_bid.data.as_ssz_bytes().into_response(); + res.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + res.headers_mut() + .insert(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()); + Ok(res) + } + Some(EncodingType::Json) => { + record_beacon_status("200", endpoint); + let mut res = axum::Json(max_bid).into_response(); + res.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + Ok(res) + } + } +} + /// Implements https://ethereum.github.io/builder-specs/?urls.primaryName=dev#/Builder/getExecutionPayloadBid /// Some(bid) if a relay serves one (-> 200), None if none do (-> 204); errors /// with Internal (-> 500). @@ -169,11 +161,7 @@ pub async fn get_execution_payload_bid( let ms_into_slot = ms_into_slot(params.slot, state.config.chain); let (pbs_config, relays, maybe_mux_id) = state.mux_config_and_relays(¶ms.proposer_pubkey); - if let Some(mux_id) = maybe_mux_id { - debug!(mux_id, relays = relays.len(), pubkey = %params.proposer_pubkey, "using mux config"); - } else { - debug!(relays = relays.len(), pubkey = %params.proposer_pubkey, "using default config"); - } + log_mux_selection(maybe_mux_id, relays.len(), ¶ms.proposer_pubkey); // Validate before any outbound work so a rejected request costs nothing validate_builder_request_auth(&body, ¶ms, state.config.chain, pbs_config.verify_builder_request_auth)?; @@ -187,15 +175,11 @@ pub async fn get_execution_payload_bid( ); } - let matched = match_relays_by_auth_data(relays, body.message.data.as_ref()); - let is_pipe = matched.is_empty(); - let relays: Vec = if is_pipe { - // No configured relay serves this auth data: pipe the request to the - // builder URL the proposer's signed auth data names (self-URL guarded) - vec![transient_pipe_relay(body.message.data.as_ref(), &pbs_config.advertised_urls)?] - } else { - matched.into_iter().cloned().collect() - }; + let relays = resolve_addressed_relays( + relays, + body.message.data.as_ref(), + &pbs_config.advertised_urls, + )?; let max_timeout_ms = pbs_config .timeout_get_header_ms diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs index 6d7fc6596..92b9a06d8 100644 --- a/crates/pbs/src/routes/submit_signed_beacon_block.rs +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -1,22 +1,19 @@ -use std::time::Duration; - use axum::{body::Bytes, extract::State, http::HeaderMap, response::IntoResponse}; use cb_common::{ pbs::{RelayClient, SignedBeaconBlock, error::PbsError, is_gloas}, - wire::{EncodingType, decode_signed_beacon_block, get_user_agent, safe_read_http_response}, + wire::{decode_signed_beacon_block, get_user_agent}, }; use futures::future::join_all; -use reqwest::{StatusCode, header::CONTENT_TYPE}; +use reqwest::StatusCode; use ssz::Encode; use tracing::{Instrument, error, info}; use crate::{ PbsStateGuard, - constants::{MAX_SIZE_DEFAULT, SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG}, + constants::SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, error::PbsClientError, - metrics::BEACON_NODE_STATUS, state::{BuilderApiState, PbsState}, - utils::{epbs_base_send_headers, expect_status, record_client_error, send_to_relay}, + utils::{epbs_base_send_headers, post_ssz_expect_accepted, record_beacon_status, record_client_error}, }; /// The body is the required `SignedBeaconBlock`. `Eth-Consensus-Version` is @@ -38,20 +35,12 @@ pub async fn handle_submit_signed_beacon_block( match submit_signed_beacon_block(block, req_headers, state).await { Ok(()) => { - BEACON_NODE_STATUS - .with_label_values(&["202", SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG]) - .inc(); + record_beacon_status("202", SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG); Ok(StatusCode::ACCEPTED.into_response()) } Err(err) => { error!(%err, "submit_signed_beacon_block failed"); - - BEACON_NODE_STATUS - .with_label_values(&[ - err.status_code().as_str(), - SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, - ]) - .inc(); + record_beacon_status(err.status_code().as_str(), SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG); Err(err) } } @@ -124,23 +113,14 @@ async fn send_one_submit_signed_beacon_block( // Every builder implements SSZ for this new endpoint, so the block is // forwarded in SSZ (the fork travels in Eth-Consensus-Version). - let req = relay - .client - .post(url) - .timeout(Duration::from_millis(timeout_ms)) - .headers(headers) - .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) - .body(body); - let (res, _latency) = - send_to_relay(req, &relay, SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG).await?; - let code = res.status(); - - // Cap the read: a builder is untrusted and must not stream an unbounded - // error body into memory and the logs - safe_read_http_response(res, MAX_SIZE_DEFAULT).await?; - - // 202 is the spec's only success; the builder publishes the payload envelope - expect_status(code, StatusCode::ACCEPTED)?; - + post_ssz_expect_accepted( + &relay, + url, + body, + headers, + timeout_ms, + SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, + ) + .await?; Ok(()) } diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 2ea42f574..d354eaa9e 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -8,17 +8,17 @@ use cb_common::{ pbs::{ForkName, RelayClient, RelayEntry, SignedBuilderRequestAuth, error::PbsError}, signature::verify_builder_request_auth_signature, types::{BlsPublicKey, BlsSecretKey, Chain}, - wire::{CONSENSUS_VERSION_HEADER, get_user_agent_with_version}, + wire::{CONSENSUS_VERSION_HEADER, EncodingType, get_user_agent_with_version, safe_read_http_response}, }; use reqwest::{ StatusCode, - header::{HeaderMap, HeaderValue, USER_AGENT}, + header::{CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT}, }; -use tracing::warn; +use tracing::{debug, warn}; use url::Url; use crate::{ - constants::TIMEOUT_ERROR_CODE_STR, + constants::{MAX_SIZE_DEFAULT, TIMEOUT_ERROR_CODE_STR}, error::PbsClientError, metrics::{RELAY_LATENCY, RELAY_STATUS_CODE}, }; @@ -67,6 +67,24 @@ pub(crate) fn record_client_error( err } +/// Records the HTTP status CB returned to the beacon node for one request on an +/// ePBS endpoint. One home for the `(status, endpoint)` label pair the three +/// handlers all bump. +pub(crate) fn record_beacon_status(code: &str, endpoint: &str) { + crate::metrics::BEACON_NODE_STATUS.with_label_values(&[code, endpoint]).inc(); +} + +/// Logs which relay set an ePBS demux request resolved to (a mux's relays or +/// the default set), shared by the bid and preferences endpoints. +pub(crate) fn log_mux_selection(maybe_mux_id: Option<&str>, relay_count: usize, pubkey: &BlsPublicKey) { + match maybe_mux_id { + Some(mux_id) => { + debug!(mux_id, relays = relay_count, pubkey = %pubkey, "using mux config") + } + None => debug!(relays = relay_count, pubkey = %pubkey, "using default config"), + } +} + /// Count a relay response that CB rejected during validation, by reason (see /// `RELAY_INVALID_RESPONSE` for why this is a separate signal from the relay's /// HTTP status). @@ -87,6 +105,33 @@ pub(crate) fn expect_status(code: StatusCode, expected: StatusCode) -> Result<() Ok(()) } +/// POSTs an SSZ body to a builder and enforces the ePBS write-endpoint +/// contract: 202 Accepted is the only success. The response body is read (and +/// capped) then discarded - a builder is untrusted and must not stream an +/// unbounded error body into memory or the logs. Returns the request latency. +/// Shared by `submitBuilderPreferences` and `submitSignedBeaconBlock`. +pub(crate) async fn post_ssz_expect_accepted( + relay: &RelayClient, + url: Url, + body: impl Into, + headers: HeaderMap, + timeout_ms: u64, + tag: &str, +) -> Result { + let req = relay + .client + .post(url) + .timeout(Duration::from_millis(timeout_ms)) + .headers(headers) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(body); + let (res, latency) = send_to_relay(req, relay, tag).await?; + let code = res.status(); + safe_read_http_response(res, MAX_SIZE_DEFAULT).await?; + expect_status(code, StatusCode::ACCEPTED)?; + Ok(latency) +} + /// Base outbound headers shared by the ePBS endpoints: the versioned /// `User-Agent` and `Eth-Consensus-Version`. All three relay hops send SSZ /// bodies of fork-versioned wire types, so the builder needs the fork header; @@ -201,6 +246,24 @@ pub(crate) fn match_relays_by_auth_data<'a>( .collect() } +/// Resolves the relays an ePBS demux request is sent to: the configured relays +/// whose auth data matches (see [`match_relays_by_auth_data`]), or, when none +/// match, a single transient pipe relay dialing the builder URL the auth data +/// names (self-URL guarded, see [`transient_pipe_relay`]). Shared by the bid +/// and preferences endpoints so their demux cannot diverge. +pub(crate) fn resolve_addressed_relays( + relays: &[RelayClient], + auth_data: &[u8], + advertised_urls: &[Url], +) -> Result, PbsClientError> { + let matched = match_relays_by_auth_data(relays, auth_data); + if matched.is_empty() { + Ok(vec![transient_pipe_relay(auth_data, advertised_urls)?]) + } else { + Ok(matched.into_iter().cloned().collect()) + } +} + /// Extracts a builder URL from `auth.message.data` using Commit-Boost's /// purely additive convention: the UTF-8 bytes of the builder's URL, optionally /// followed by a NUL byte and opaque extra bytes. Data without extra bytes is From 1c83ebb462b96034cfdf51b8e1f43097037903af Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Sun, 23 Aug 2026 22:15:54 -0700 Subject: [PATCH 53/80] fix(pbs): correct gwei/eth units in ePBS metrics and logs The RELAY_HEADER_VALUE gauge is labelled gwei and the bid value() is already denominated in gwei, so the /1e9 scaling drove the metric 1e9x low; set it unscaled. The _eth log fields ran format_ether (which expects wei) on gwei values, also 1e9x low; scale gwei to wei before formatting via a shared helper. The ranking path is untouched: it is already gwei-consistent. --- .../pbs/src/routes/execution_payload_bid.rs | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 5e7ee5435..06d4bdf87 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -121,7 +121,7 @@ fn encode_bid_response( response_encoding: Option, endpoint: &str, ) -> Result { - info!(trustless_bid_eth = format_ether(max_bid.value()), execution_payment_eth = format_ether(max_bid.execution_payment()), block_hash =% max_bid.block_hash(), builder_index = max_bid.builder_index(), "received header"); + info!(trustless_bid_eth = format_gwei_as_eth(max_bid.value()), execution_payment_eth = format_gwei_as_eth(max_bid.execution_payment()), block_hash =% max_bid.block_hash(), builder_index = max_bid.builder_index(), "received header"); // Eth-Consensus-Version is required on the 200 for both encodings let consensus_version_header = HeaderValue::from_str(&max_bid.version.to_string()) @@ -250,10 +250,8 @@ pub async fn get_execution_payload_bid( match res { Ok(Some(res)) => { RELAY_LAST_SLOT.with_label_values(&[relay_id]).set(params.slot as i64); - let value_gwei = (U256::from(res.value()) / U256::from(1_000_000_000)) - .try_into() - .unwrap_or_default(); - RELAY_HEADER_VALUE.with_label_values(&[relay_id]).set(value_gwei); + // value() is already gwei (the gauge is labelled gwei), so it is set unscaled + RELAY_HEADER_VALUE.with_label_values(&[relay_id]).set(res.value() as i64); relay_bids.push((relay_id, res, ranking_cap_gwei(relay, pbs_config))) } @@ -268,9 +266,9 @@ pub async fn get_execution_payload_bid( if let Some((winning_relay_id, ref bid)) = max_bid { info!( relay_id = winning_relay_id, - bid_eth = format_ether(total_payment(bid)), - trustless_bid_eth = format_ether(bid.value()), - execution_payment_eth = format_ether(bid.execution_payment()), + bid_eth = format_gwei_as_eth(total_payment(bid)), + trustless_bid_eth = format_gwei_as_eth(bid.value()), + execution_payment_eth = format_gwei_as_eth(bid.execution_payment()), block_hash = %bid.block_hash(), "auction winner" ); @@ -369,6 +367,12 @@ fn total_payment(bid: &impl GetExecutionPayloadBidInfo) -> u64 { bid.value().saturating_add(bid.execution_payment()) } +/// Bid amounts are denominated in gwei, but `format_ether` expects wei; scale up +/// before formatting so the human-readable `_eth` log fields are correct. +fn format_gwei_as_eth(gwei: u64) -> String { + format_ether(U256::from(gwei) * U256::from(1_000_000_000u64)) +} + /// The execution-payment cap used when ranking a relay's bids: the per-relay /// override, else the global config value (default u64::MAX = unclamped). fn ranking_cap_gwei(relay: &RelayClient, pbs_config: &PbsConfig) -> u64 { @@ -687,9 +691,9 @@ async fn send_one_get_execution_payload_bid( header_size_bytes, latency = ?request_latency, version =? get_header_response.version, - bid_eth = format_ether(get_header_response.data.message.value + get_header_response.data.message.execution_payment), - trustless_bid_eth = format_ether(get_header_response.data.message.value), - execution_payment_eth = format_ether(get_header_response.data.message.execution_payment), + bid_eth = format_gwei_as_eth(get_header_response.data.message.value + get_header_response.data.message.execution_payment), + trustless_bid_eth = format_gwei_as_eth(get_header_response.data.message.value), + execution_payment_eth = format_gwei_as_eth(get_header_response.data.message.execution_payment), block_hash = %get_header_response.data.message.block_hash, "received new header" ); From cd1d15c84c45a78174ff58116bf87e122b10b100 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Sun, 23 Aug 2026 22:17:56 -0700 Subject: [PATCH 54/80] fix(pbs): logging levels and metric coverage on ePBS paths Handler errors on all three ePBS endpoints now log at error! only for 5xx and warn! for 4xx, so a client-side rejection no longer reads as a CB fault. A non-winning builder's rejection of a signed block is warn!, not error!, since only the auction winner accepts. The per-poll header log drops to debug!, leaving the per-request info! summary. Header and extra-validation rejections now bump RELAY_INVALID_RESPONSE with distinct reasons, matching the wrong-fork branch. A parent-block fetch failure is a warn! naming the parent hash. cb-km respects RUST_LOG, defaulting to info. --- crates/km-tool/src/apply.rs | 4 +-- crates/km-tool/src/main.rs | 7 ++++- crates/pbs/src/routes/builder_preferences.rs | 7 ++++- .../pbs/src/routes/execution_payload_bid.rs | 29 +++++++++++++++---- .../src/routes/submit_signed_beacon_block.rs | 13 +++++++-- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index c3e493e9e..739999d3e 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -7,7 +7,7 @@ use std::{ }; use eyre::{Context, Result, ensure}; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use crate::{ client::{GetConfigOutcome, KmClient, PostOutcome, read_token}, @@ -45,7 +45,7 @@ impl ApplyReport { } fn error(&mut self, msg: String) { - warn!("{msg}"); + error!("{msg}"); self.errors.push(msg); } diff --git a/crates/km-tool/src/main.rs b/crates/km-tool/src/main.rs index fd10d150f..ca4cb6952 100644 --- a/crates/km-tool/src/main.rs +++ b/crates/km-tool/src/main.rs @@ -92,7 +92,12 @@ fn load(common: &CommonArgs) -> Result<(ProjectionInput, Overlay)> { #[tokio::main] async fn main() -> Result<()> { - tracing_subscriber::fmt().with_env_filter("info").init(); + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); let cli = Cli::parse(); match cli.command { diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index d89818991..409db19e5 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -59,7 +59,12 @@ pub async fn handle_submit_builder_preferences( Ok(StatusCode::ACCEPTED.into_response()) } Err(err) => { - error!(%err, "submit_builder_preferences failed"); + // A 4xx is the caller's fault, not CB's: only a 5xx is an error! + if err.status_code().is_server_error() { + error!(%err, "submit_builder_preferences failed"); + } else { + warn!(%err, "submit_builder_preferences failed"); + } record_beacon_status(err.status_code().as_str(), SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG); Err(err) } diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 06d4bdf87..14f1a4c8c 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -103,7 +103,12 @@ pub async fn handle_get_execution_payload_bid( Ok(StatusCode::NO_CONTENT.into_response()) } Err(err) => { - error!(%err, "get_execution_payload_bid failed"); + // A 4xx is the caller's fault, not CB's: only a 5xx is an error! + if err.status_code().is_server_error() { + error!(%err, "get_execution_payload_bid failed"); + } else { + warn!(%err, "get_execution_payload_bid failed"); + } record_beacon_status(err.status_code().as_str(), GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG); Err(err) } @@ -417,7 +422,7 @@ async fn fetch_parent_block( *guard = maybe_block; } Err(err) => { - error!(%err, "fetch failed"); + warn!(%err, %parent_hash, "failed to fetch parent block, skipping extra validation"); } } } @@ -686,7 +691,7 @@ async fn send_one_get_execution_payload_bid( }); } - info!( + debug!( relay_id = relay.id.as_ref(), header_size_bytes, latency = ?request_latency, @@ -707,12 +712,26 @@ async fn send_one_get_execution_payload_bid( gas_limit: get_header_response.gas_limit(), }; - validate_header_data(&header_info, ¶ms, validation.expected_fee_recipient)?; + validate_header_data(&header_info, ¶ms, validation.expected_fee_recipient).inspect_err( + |_| { + crate::utils::record_invalid_relay_response( + "header_validation", + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, + &relay.id, + ); + }, + )?; if validation.extra_validation_enabled { let parent_block = validation.parent_block.read(); if let Some(parent_block) = parent_block.as_ref() { - extra_validation(parent_block, &header_info, ¶ms)?; + extra_validation(parent_block, &header_info, ¶ms).inspect_err(|_| { + crate::utils::record_invalid_relay_response( + "extra_validation", + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, + &relay.id, + ); + })?; } else { warn!( relay_id = relay.id.as_ref(), diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs index 92b9a06d8..b5abbd77d 100644 --- a/crates/pbs/src/routes/submit_signed_beacon_block.rs +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -6,7 +6,7 @@ use cb_common::{ use futures::future::join_all; use reqwest::StatusCode; use ssz::Encode; -use tracing::{Instrument, error, info}; +use tracing::{Instrument, error, info, warn}; use crate::{ PbsStateGuard, @@ -39,7 +39,12 @@ pub async fn handle_submit_signed_beacon_block( Ok(StatusCode::ACCEPTED.into_response()) } Err(err) => { - error!(%err, "submit_signed_beacon_block failed"); + // A 4xx is the caller's fault, not CB's: only a 5xx is an error! + if err.status_code().is_server_error() { + error!(%err, "submit_signed_beacon_block failed"); + } else { + warn!(%err, "submit_signed_beacon_block failed"); + } record_beacon_status(err.status_code().as_str(), SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG); Err(err) } @@ -89,7 +94,9 @@ pub async fn submit_signed_beacon_block( .filter(|(res, relay)| match res { Ok(()) => true, Err(err) => { - error!(relay_id = relay.id.as_ref(), %err, "builder did not accept the block"); + // Non-winning builders reject by design; only the auction winner + // accepts, so a rejection here may be expected + warn!(relay_id = relay.id.as_ref(), %err, "builder did not accept the block; may be expected, only the winner accepts"); false } }) From 17805ab2f9b2b0ea7c9bf9c8a0beaa06d33367e2 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Sun, 23 Aug 2026 22:20:46 -0700 Subject: [PATCH 55/80] fix(pbs): ePBS hardening across the bid, block and km-tool paths - Drop the auth-data and auth-signature span records on the bid path: they leak a bilateral secret if the field is ever declared and are no-ops today. The non-secret parent_root gains a router span field so its existing record surfaces. - url_matches canonicalizes a trailing-dot host so a fully-qualified name cannot slip the self-URL pipe guard. - check_gas_limit uses saturating_add so a near-max parent gas limit cannot overflow the adjustment check. - KmClient warns when a VC keymanager URL is plain HTTP to a non-loopback host, where the bearer token travels in cleartext. - cb-km check redacts secret-form (non-URL) auth_data to length-only in its findings, matching the apply-path redaction. - cb-km check diffs stored and projected entry sets both ways, itemizing each surplus stored entry instead of only reporting a count. - The signed-block fan-out spawns each send so a BN disconnect cannot cancel in-flight broadcasts, preserving the winner-accepts count. --- crates/km-tool/src/check.rs | 41 ++++++++++++++----- crates/km-tool/src/client.rs | 16 ++++++++ .../pbs/src/routes/execution_payload_bid.rs | 3 -- crates/pbs/src/routes/router.rs | 1 + .../src/routes/submit_signed_beacon_block.rs | 20 +++++---- crates/pbs/src/utils.rs | 14 ++++++- 6 files changed, 73 insertions(+), 22 deletions(-) diff --git a/crates/km-tool/src/check.rs b/crates/km-tool/src/check.rs index eaad79669..95d37a0a0 100644 --- a/crates/km-tool/src/check.rs +++ b/crates/km-tool/src/check.rs @@ -133,7 +133,7 @@ pub async fn run_check(input: &ProjectionInput, overlay: &Overlay) -> Result Result Vec { let mut lines = Vec::new(); compare_field(&mut lines, "min_bid", &projected.min_bid, &stored.min_bid); @@ -212,7 +213,7 @@ fn drift_lines(projected: &CanonicalDoc, stored: &CanonicalDoc) -> Vec { lines.push(format!( "entry ({}, {}) is missing", entry.url, - entry.auth_data.as_deref().map(crate::doc::encode_auth_data).unwrap_or_default() + entry.auth_data.as_deref().map(display_auth_data).unwrap_or_default() )); continue; }; @@ -234,16 +235,36 @@ fn drift_lines(projected: &CanonicalDoc, stored: &CanonicalDoc) -> Vec { ); } - if stored_entries.len() > projected_entries.len() { - lines.push(format!( - "stored doc has {} entries, projection has {}", - stored_entries.len(), - projected_entries.len() - )); + let projected_by_key: BTreeSet<(String, Option>)> = projected_entries + .iter() + .map(|entry| (entry.url.clone(), entry.auth_data.clone())) + .collect(); + for entry in stored_entries { + let key = (entry.url.clone(), entry.auth_data.clone()); + if !projected_by_key.contains(&key) { + lines.push(format!( + "entry ({}, {}) is stored but not projected", + entry.url, + entry.auth_data.as_deref().map(display_auth_data).unwrap_or_default() + )); + } } lines } +/// Renders decoded auth_data for a finding: a public builder URL is shown +/// as-is, but a bilateral secret (non-URL bytes) is reduced to a length-only +/// placeholder so a check report never prints the secret. Mirrors apply.rs +/// `redact_secrets_for_display`. +fn display_auth_data(bytes: &[u8]) -> String { + let is_url = std::str::from_utf8(bytes).ok().and_then(|s| url::Url::parse(s).ok()).is_some(); + if is_url { + crate::doc::encode_auth_data(bytes) + } else { + format!("{} bytes (secret)", bytes.len()) + } +} + fn compare_field( lines: &mut Vec, name: &str, diff --git a/crates/km-tool/src/client.rs b/crates/km-tool/src/client.rs index 79cfb876e..832a0ecc6 100644 --- a/crates/km-tool/src/client.rs +++ b/crates/km-tool/src/client.rs @@ -78,8 +78,24 @@ pub struct KmClient { token: String, } +/// Whether a URL's host is a loopback address (or `localhost`), where sending +/// the bearer token over plain HTTP does not expose it on the wire. +fn host_is_loopback(url: &Url) -> bool { + match url.host() { + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + Some(url::Host::Domain(host)) => host == "localhost", + None => false, + } +} + impl KmClient { pub fn new(base: Url, token: String) -> Result { + if base.scheme() != "https" && !host_is_loopback(&base) { + warn!( + "VC keymanager URL {base} is not HTTPS and not loopback; the bearer token is sent in cleartext" + ); + } let http = reqwest::Client::builder() .timeout(HTTP_TIMEOUT) .redirect(reqwest::redirect::Policy::none()) diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 14f1a4c8c..7a226645e 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -73,9 +73,6 @@ pub async fn handle_get_execution_payload_bid( tracing::Span::current().record("parent_hash", tracing::field::debug(params.parent_hash)); tracing::Span::current().record("parent_root", tracing::field::debug(params.parent_root)); tracing::Span::current().record("validator", tracing::field::debug(¶ms.proposer_pubkey)); - tracing::Span::current() - .record("auth data", tracing::field::debug(&body.message.data.to_vec())); - tracing::Span::current().record("auth signature", tracing::field::debug(&body.signature)); let state = state.read().clone(); diff --git a/crates/pbs/src/routes/router.rs b/crates/pbs/src/routes/router.rs index a1091e2f0..54118d8cf 100644 --- a/crates/pbs/src/routes/router.rs +++ b/crates/pbs/src/routes/router.rs @@ -89,6 +89,7 @@ pub fn create_app_router>(state: PbsStateGu block_hash = tracing::field::Empty, block_number = tracing::field::Empty, parent_hash = tracing::field::Empty, + parent_root = tracing::field::Empty, validator = tracing::field::Empty, ), )] diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs index b5abbd77d..3caa8ea5e 100644 --- a/crates/pbs/src/routes/submit_signed_beacon_block.rs +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -3,7 +3,7 @@ use cb_common::{ pbs::{RelayClient, SignedBeaconBlock, error::PbsError, is_gloas}, wire::{decode_signed_beacon_block, get_user_agent}, }; -use futures::future::join_all; +use futures::{FutureExt, future::join_all}; use reqwest::StatusCode; use ssz::Encode; use tracing::{Instrument, error, info, warn}; @@ -74,16 +74,22 @@ pub async fn submit_signed_beacon_block( let body = Bytes::from(block.as_ssz_bytes()); let relays = state.all_relays(); + // Spawned like builder_preferences' sends: a BN disconnect must not cancel + // in-flight block broadcasts mid-fan-out, leaving some builders with the + // block and others without let mut handles = Vec::with_capacity(relays.len()); for relay in relays.iter() { handles.push( - send_one_submit_signed_beacon_block( - relay.clone(), - body.clone(), - send_headers.clone(), - timeout_ms, + tokio::spawn( + send_one_submit_signed_beacon_block( + relay.clone(), + body.clone(), + send_headers.clone(), + timeout_ms, + ) + .in_current_span(), ) - .in_current_span(), + .map(|join_result| join_result.unwrap_or_else(|err| Err(PbsError::TokioJoinError(err)))), ); } diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index d354eaa9e..269766b29 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -159,7 +159,7 @@ const GAS_LIMIT_MINIMUM: u64 = 5_000; /// execution spec https://github.com/ethereum/execution-specs/blob/98d6ddaaa709a2b7d0cd642f4cfcdadc8c0808e1/src/ethereum/cancun/fork.py#L1118-L1154 pub(crate) fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { let max_adjustment_delta = parent_gas_limit / GAS_LIMIT_ADJUSTMENT_FACTOR; - if gas_limit >= parent_gas_limit + max_adjustment_delta { + if gas_limit >= parent_gas_limit.saturating_add(max_adjustment_delta) { return false; } @@ -334,8 +334,14 @@ pub(crate) fn transient_pipe_relay( /// entry URL embeds the relay pubkey as userinfo, so full equality would never /// match a bare builder URL. pub(crate) fn url_matches(a: &Url, b: &Url) -> bool { + // A trailing dot marks a fully-qualified host that resolves to the same + // host as its dotless form; canonicalize so it cannot slip the self-URL + // guard in `transient_pipe_relay`. + fn host_canonical(url: &Url) -> Option<&str> { + url.host_str().map(|host| host.strip_suffix('.').unwrap_or(host)) + } a.scheme() == b.scheme() && - a.host_str() == b.host_str() && + host_canonical(a) == host_canonical(b) && a.port_or_known_default() == b.port_or_known_default() } @@ -566,6 +572,10 @@ mod tests { assert!(!url_matches(&u("http://a.com"), &u("https://a.com"))); assert!(!url_matches(&u("https://a.com"), &u("https://b.com"))); assert!(!url_matches(&u("http://a.com:8001"), &u("http://a.com:8002"))); + // A fully-qualified trailing-dot host matches its dotless form, so it + // cannot be used to slip the self-URL guard. + assert!(url_matches(&u("https://cb.example.com."), &u("https://cb.example.com"))); + assert!(url_matches(&u("https://cb.example.com"), &u("https://cb.example.com."))); } #[test] From 7798fec0ba8c869fbaf64a5402a90b364270fea4 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Mon, 24 Aug 2026 14:09:27 -0700 Subject: [PATCH 56/80] fix(km-tool): stop populating builder_config builder_pubkeys cb-km inserted the relay URL's userinfo pubkey into each builder_config entry's builder_pubkeys. That pubkey is the relay's identity, not the builder's bid-signing key. Lodestar rejects any builder-API bid whose signing pubkey is not in builder_pubkeys (the check is skipped when the array is empty), so binding the relay identity silently rejected every bid. cb-km has no reliable way to learn the builder's actual bid-signing key from config, so emit an empty array (accept any builder for the key) until real support can supply that pubkey. Removes the now-dead AuthClass.builder_pubkeys field, its union collection, and the per-entry MAX_BUILDER_PUBKEYS cap (the const stays, still used by the --preserve-entries merge path). The auth_data and url were already userinfo-stripped, so no pubkey credential leaks there. --- crates/km-tool/src/apply.rs | 9 ++++- crates/km-tool/src/project.rs | 69 ++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index 739999d3e..3b2061cf5 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -400,11 +400,14 @@ mod tests { } } + // Our projected doc: builder_pubkeys is emitted empty (the projection no + // longer unions in relay-identity pubkeys). Preserved third-party entries + // may still carry their own pubkeys, so `entry()` keeps the parameter. fn ours() -> BuilderConfigDoc { BuilderConfigDoc { min_bid: Some("500000000".into()), builder_boost_factor: None, - builders: Some(vec![entry("https://cb.example.com", b"https://relay-a", &["0xaa"])]), + builders: Some(vec![entry("https://cb.example.com", b"https://relay-a", &[])]), } } @@ -437,7 +440,9 @@ mod tests { let merged = merge_preserved_entries("k", &ours(), &stored).unwrap(); let entries = merged.builders.unwrap(); assert_eq!(entries.len(), 1); - assert_eq!(entries[0].builder_pubkeys, Some(vec!["0xaa".to_string()])); + // ours won on identity: empty builder_pubkeys and ours' (None) min_bid, + // not the stored 0xbb / 999 + assert_eq!(entries[0].builder_pubkeys, Some(vec![])); assert_eq!(entries[0].min_bid, None); } diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index dd170e8f7..86cdbb860 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -1,10 +1,14 @@ //! Pure projection: CB mux config + overlay -> per-key KM builder_config docs. //! //! One KM entry per auth_data EQUIVALENCE CLASS: relays whose candidate -//! auth_data byte-strings are identical share one entry whose -//! `builder_pubkeys` is the union of the class's relay pubkeys. The candidate -//! is `expected_auth_data` when set, else the UTF-8 bytes of the relay URL as -//! configured with the userinfo stripped. Grouping-by-identical-bytes is a +//! auth_data byte-strings are identical share one entry. Its `builder_pubkeys` +//! is emitted EMPTY (accept any builder for the key): the only pubkey cb-km can +//! see is the relay URL's userinfo pubkey, which is the relay's identity, not +//! the builder's bid-signing key, and binding the wrong key rejects every bid +//! (see `project_mux`). The candidate is `expected_auth_data` when set, else the +//! UTF-8 bytes of the relay URL as configured with the userinfo stripped +//! (userinfo, and thus any pubkey credential, is intentionally NOT part of the +//! emitted url or auth_data). Grouping-by-identical-bytes is a //! reimplementation of the demux contract of cb-pbs's //! `match_relays_by_auth_data` (pub(crate) there); the CB-side contract tests //! pin those semantics. CAVEAT (also in the plan): CB's own matching is LAXER @@ -14,7 +18,7 @@ //! the builder's advertised URL. use std::{ - collections::{BTreeMap, BTreeSet, HashSet}, + collections::{BTreeMap, HashSet}, path::Path, }; @@ -274,7 +278,6 @@ fn resolve_mux_keys(mux: &MuxConfig, warnings: &mut Vec) -> Result, - builder_pubkeys: BTreeSet, max_execution_payment_gwei: Option, } @@ -331,7 +334,6 @@ fn build_auth_classes(mux: &MuxConfig, raw_urls: &[String]) -> Result Result one entry, two pubkeys + // both relays carry the same expected_auth_data -> one entry let toml_text = format!( r#" chain = "Holesky" @@ -580,9 +582,7 @@ expected_auth_data = "0xaabb" let doc = projection.docs.values().next().unwrap(); let entries = doc.builders.as_ref().unwrap(); assert_eq!(entries.len(), 1); - let mut expected = vec![RELAY_PK_A.to_string(), RELAY_PK_B.to_string()]; - expected.sort(); - assert_eq!(entries[0].builder_pubkeys.as_ref().unwrap(), &expected); + assert_eq!(entries[0].builder_pubkeys.as_ref().unwrap(), &Vec::::new()); } #[test] @@ -1081,7 +1081,8 @@ url = "https://{RELAY_PK_B}@relay.example.com" let doc = projection.docs.values().next().unwrap(); let entries = doc.builders.as_ref().unwrap(); assert_eq!(entries.len(), 1); - assert_eq!(entries[0].builder_pubkeys.as_ref().unwrap().len(), 2); + // grouped into one class; builder_pubkeys is emitted empty + assert_eq!(entries[0].builder_pubkeys.as_ref().unwrap().len(), 0); } #[test] From 97de482b3749a51e4c1ab980dc68217b4a404501 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 12:30:25 -0700 Subject: [PATCH 57/80] fix(pbs): blind-pipe builder preferences, drop the slot_has_passed gate CB is a blind pipe for builder preferences: rejecting a stale or replayed submission is the builder's call, not the relay's. slot_has_passed rejected every preference whose slot had ended by the time CB validated it. On a live devnet the proposer submits an epoch ahead but the auth reaches CB after its slot, so the check rejected the whole preference stream: CB then served no header and the builder's bids only reached the proposer over p2p. Forward regardless of slot age; the builder rejects what it does not want. Removes the now-dead AuthSlotPassed error and the slot_has_passed unit tests. --- crates/pbs/src/error.rs | 6 -- crates/pbs/src/routes/builder_preferences.rs | 62 ++++---------------- 2 files changed, 11 insertions(+), 57 deletions(-) diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index 0fcd7a110..a70748db9 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -35,8 +35,6 @@ pub enum PbsClientError { MissingTimingHeader, #[error("auth slot does not match the request path")] AuthSlotMismatch, - #[error("auth slot has already passed")] - AuthSlotPassed, #[error("the addressed builder rejected the request with {code}")] BuilderRejected { code: u16 }, #[error("auth signature verification failed")] @@ -62,7 +60,6 @@ impl PbsClientError { PbsClientError::EmptyAuthData => StatusCode::BAD_REQUEST, PbsClientError::MissingTimingHeader => StatusCode::BAD_REQUEST, PbsClientError::AuthSlotMismatch => StatusCode::BAD_REQUEST, - PbsClientError::AuthSlotPassed => StatusCode::BAD_REQUEST, // A lone addressed builder's own 400/401 from the preferences // endpoint is propagated (the sole constructor guards to those two // codes, so the 502 fallback below is currently dead). @@ -100,9 +97,6 @@ impl IntoResponse for PbsClientError { PbsClientError::AuthSlotMismatch => { "Invalid SignedBuilderRequestAuth: auth.message.slot does not match the proposal slot in the request path".to_string() } - PbsClientError::AuthSlotPassed => { - "Invalid SignedBuilderRequestAuth: auth.message.slot has already passed".to_string() - } // The builder's own body is never forwarded: it is untrusted and may be // arbitrarily large PbsClientError::BuilderRejected { code } => { diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index 409db19e5..c72fbe219 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -10,7 +10,6 @@ use cb_common::{ error::PbsError, }, types::Chain, - utils::ms_into_slot, wire::{decode_versioned_request_body, get_user_agent}, }; use futures::{FutureExt, future::join_all}; @@ -159,12 +158,13 @@ pub async fn submit_builder_preferences( Ok(()) } -/// Validates the caller's `SignedBuilderRequestAuth`. There is no slot in the -/// request path here, so instead of matching one we reject a slot that has -/// already ended: preferences are submitted an epoch ahead, and a replayed -/// submission must not be able to roll a proposer's preferences back to a stale -/// value. `auth.message.data` must be non-empty; which builder it addresses is -/// the demux's job (`match_relays_by_auth_data`). +/// Validates the caller's `SignedBuilderRequestAuth`. CB is a blind pipe for +/// preferences: it does not gate on the auth slot. Freshness (rejecting a stale +/// or replayed submission that would roll a proposer's preferences back) is the +/// builder's call, not the relay's, so CB forwards regardless of slot age. All +/// that is required here is `auth.message.data` (non-empty; which builder it +/// addresses is the demux's job, `match_relays_by_auth_data`) and, when enabled, +/// the request-auth signature. fn validate_preferences_auth( auth: &SignedBuilderRequestAuth, params: &SubmitBuilderPreferencesParams, @@ -173,20 +173,9 @@ fn validate_preferences_auth( ) -> Result<(), PbsClientError> { validate_auth_data(auth)?; - if slot_has_passed(auth.message.slot.as_u64(), chain) { - warn!(auth_slot = %auth.message.slot, "auth slot already passed"); - return Err(PbsClientError::AuthSlotPassed); - } - verify_auth_signature(¶ms.proposer_pubkey, auth, chain, verify_signature) } -/// `ms_into_slot` saturates at 0 for a future slot, so a full slot's worth of -/// elapsed time means the slot is over. -fn slot_has_passed(slot: u64, chain: Chain) -> bool { - ms_into_slot(slot, chain) >= chain.slot_time_sec() * 1000 -} - async fn send_one_submit_builder_preferences( proposer_pubkey: cb_common::types::BlsPublicKey, request: BuilderPreferencesRequest, @@ -217,7 +206,7 @@ mod tests { use cb_common::{ pbs::{BuilderPreferences, BuilderRequestAuth}, types::BlsSignature, - utils::{timestamp_of_slot_start_sec, utcnow_ms, utcnow_sec}, + utils::utcnow_sec, wire::{BodyDeserializeError, CONSENSUS_VERSION_HEADER}, }; @@ -227,38 +216,9 @@ mod tests { (utcnow_sec() - chain.genesis_time_sec()) / chain.slot_time_sec() } - /// The boundary is the slot's END, not its start: a proposer legitimately - /// submits for a slot that is still in progress. - #[test] - fn slot_has_passed_is_exclusive_of_the_current_slot() { - let chain = Chain::Hoodi; - let now = current_slot(chain); - - assert!(!slot_has_passed(now, chain), "the in-progress slot has not passed"); - assert!(!slot_has_passed(now + 1, chain), "the next slot has not passed"); - assert!(!slot_has_passed(now + 1_000, chain), "a far future slot has not passed"); - assert!(slot_has_passed(now - 1, chain), "the previous slot has passed"); - assert!(slot_has_passed(0, chain), "slot 0 has long passed"); - } - - /// A slot is over exactly one slot-duration after it began, so the check - /// must not fire a millisecond early or a millisecond late. - #[test] - fn slot_has_passed_flips_one_slot_after_the_start() { - let chain = Chain::Hoodi; - let now = current_slot(chain); - let elapsed_ms = utcnow_ms() - timestamp_of_slot_start_sec(now, chain) * 1_000; - - // Whatever point of the slot the test runs at, exactly one slot's worth - // of elapsed time separates "not passed" from "passed" - assert!(elapsed_ms < chain.slot_time_sec() * 1_000); - assert!(!slot_has_passed(now, chain)); - assert!(slot_has_passed(now - 1, chain)); - } - - // Empty `auth.message.data` is rejected before slot_has_passed / sigverify, - // so it cannot slip through a catch-all relay match. Guards the wiring of - // the shared `validate_auth_data` into this endpoint. + // Empty `auth.message.data` is rejected before sigverify, so it cannot slip + // through a catch-all relay match. Guards the wiring of the shared + // `validate_auth_data` into this endpoint. #[test] fn validate_preferences_auth_rejects_empty_data() { use cb_common::types::BlsSecretKey; From 33ea7d7b6c246f174f875bc78284bf70a5cd9f60 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 15:22:02 -0700 Subject: [PATCH 58/80] fix(km-tool): drop the lax-URL-ambiguity projection gate ensure_no_lax_ambiguity aborted the whole cb-km projection when two builders on one host differed only by implicit-vs-explicit port, host case, or a supplied URL. Its premise -- that a lax URL collision would split builder_pubkeys across KM entries and make the VC reject winning bids -- is void: project_mux now emits builder_pubkeys empty unconditionally, so every entry accepts any builder and there is nothing to split. The two byte-distinct URLs simply project as two entries. Reintroduce only if real per-builder pubkeys are ever projected. --- crates/km-tool/src/project.rs | 61 ++++++++--------------------------- 1 file changed, 14 insertions(+), 47 deletions(-) diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index 86cdbb860..bfb6378ef 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -281,43 +281,6 @@ struct AuthClass { max_execution_payment_gwei: Option, } -/// The key cb-pbs's `url_matches` compares by: scheme, host (lowercased for -/// good measure; `Url` already lowercases domains) and effective port. -/// Userinfo never enters the key. `None` for bytes that are not a URL with a -/// host (e.g. an opaque `expected_auth_data`). -fn lax_url_key(bytes: &[u8]) -> Option<(String, String, Option)> { - let text = std::str::from_utf8(bytes).ok()?; - let url = url::Url::parse(text).ok()?; - let host = url.host_str()?.to_lowercase(); - Some((url.scheme().to_string(), host, url.port_or_known_default())) -} - -/// Two byte-distinct auth_data classes whose bytes parse to lax-equivalent -/// URLs would be matched as ONE relay by CB (`url_matches` ignores userinfo, -/// host case and the default port), so the matched set could include a relay -/// whose pubkey is missing from the entry's builder_pubkeys and the VC would -/// silently reject its winning bids. This mirrors cb-pbs `url_matches` -/// semantics conservatively; the CB-side contract tests pin the real matcher. -fn ensure_no_lax_ambiguity(mux_id: &str, classes: &BTreeMap, AuthClass>) -> Result<()> { - let mut by_lax: BTreeMap<(String, String, Option), &[u8]> = BTreeMap::new(); - for bytes in classes.keys() { - let Some(lax) = lax_url_key(bytes) else { - continue; - }; - if let Some(first) = by_lax.get(&lax) { - bail!( - "mux {mux_id}: auth_data {:?} and {:?} differ in bytes but CB's lax URL matching \ - would treat them as the same relay, splitting builder_pubkeys across KM entries; \ - make the relay URLs byte-identical or set explicit expected_auth_data", - String::from_utf8_lossy(first), - String::from_utf8_lossy(bytes) - ); - } - by_lax.insert(lax, bytes); - } - Ok(()) -} - /// Groups a mux's relays into auth_data equivalence classes keyed by identical /// candidate bytes, unioning each class's builder pubkeys, requiring one shared /// execution-payment cap per class, and enforcing the KM entry-count limit. @@ -369,7 +332,6 @@ fn project_mux( ensure!(!mux.relays.is_empty(), "mux {} has no relays", mux.id); let classes = build_auth_classes(mux, raw_urls)?; - ensure_no_lax_ambiguity(&mux.id, &classes)?; // Entry values are mux/global-sourced; the KEY-LEVEL values come from the // projection-only p2p fields when set, resolving MUX p2p > global [pbs] @@ -1001,10 +963,12 @@ url = "https://{RELAY_PK_A}@relay-a.example.com" } #[test] - fn lax_equivalent_urls_across_classes_error() { + fn lax_equivalent_urls_project_as_separate_entries() { let key = random_key_hex(); - // byte-distinct candidates, but CB's url_matches sees one relay: - // https://h vs https://h:443 (default port) + // byte-distinct candidates that CB's url_matches would see as one relay + // (https://h vs https://h:443) project as two byte-distinct entries. + // builder_pubkeys is empty, so both accept any builder and there is + // nothing to split. let toml_text = format!( r#" chain = "Holesky" @@ -1019,16 +983,18 @@ url = "https://{RELAY_PK_B}@relay.example.com:443" "# ); let input = ProjectionInput::parse_str(&toml_text).unwrap(); - let err = project(&input, &overlay()).unwrap_err(); - assert!(err.to_string().contains("byte-identical"), "{err}"); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.builders.as_ref().unwrap().len(), 2); } #[test] - fn lax_collision_via_expected_auth_data_url_errors() { + fn lax_collision_via_expected_auth_data_url_projects_two_entries() { let key = random_key_hex(); // expected_auth_data holds URL bytes lax-equivalent to the other // relay's URL-derived candidate: hex of "HTTPS://RELAY.EXAMPLE.COM" - // is byte-distinct but host-case-insensitively the same relay + // is byte-distinct but host-case-insensitively the same relay. Both + // project as separate entries with empty builder_pubkeys. let upper_hex = crate::doc::encode_auth_data(b"https://RELAY.EXAMPLE.COM"); let toml_text = format!( r#" @@ -1045,8 +1011,9 @@ expected_auth_data = "{upper_hex}" "# ); let input = ProjectionInput::parse_str(&toml_text).unwrap(); - let err = project(&input, &overlay()).unwrap_err(); - assert!(err.to_string().contains("byte-identical"), "{err}"); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.builders.as_ref().unwrap().len(), 2); } #[test] From 3a83b07776f252c7314f9c3aa653047030f1dfe5 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 15:27:28 -0700 Subject: [PATCH 59/80] fix(pbs): drop the fee_recipient equality check from the ePBS bid path validate_header_data rejected any bid whose header fee_recipient differed from the operator-configured pbs.fee_recipient. On the gloas path this is wrong: the execution block's fee recipient is the builder's, and the proposer is paid via value + execution_payment, so header.fee_recipient is not expected to equal the proposer's configured recipient. An operator carrying a legacy fee_recipient would drop every legitimate builder bid. Bid validity is the builder's and the BN's job. Removes the check, its plumbing, and the now-dead FeeRecipientMismatch. --- crates/common/src/pbs/error.rs | 5 +-- .../pbs/src/routes/execution_payload_bid.rs | 43 ++++--------------- 2 files changed, 9 insertions(+), 39 deletions(-) diff --git a/crates/common/src/pbs/error.rs b/crates/common/src/pbs/error.rs index 61457770d..b2d1c01d6 100644 --- a/crates/common/src/pbs/error.rs +++ b/crates/common/src/pbs/error.rs @@ -1,4 +1,4 @@ -use alloy::primitives::{Address, B256, U256}; +use alloy::primitives::{B256, U256}; use lh_types::ForkName; use thiserror::Error; @@ -115,9 +115,6 @@ pub enum ValidationError { #[error("bid below minimum: min: {min} got {got}")] BidTooLow { min: U256, got: U256 }, - #[error("fee recipient mismatch: expected {expected} got {got}")] - FeeRecipientMismatch { expected: Address, got: Address }, - #[error("empty parent root")] EmptyParentRoot, diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 7a226645e..d8bada9c2 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -2,7 +2,7 @@ use std::{sync::Arc, time::Duration}; use alloy::{ consensus::BlockHeader, - primitives::{Address, B256, U256, utils::format_ether}, + primitives::{B256, U256, utils::format_ether}, providers::Provider, rpc::types::Block, }; @@ -235,7 +235,6 @@ pub async fn get_execution_payload_bid( max_timeout_ms, ranking_cap_gwei(relay, pbs_config), ValidationContext { - expected_fee_recipient: pbs_config.fee_recipient, extra_validation_enabled: state.extra_validation_enabled(), parent_block: parent_block.clone(), }, @@ -587,7 +586,6 @@ struct RequestContext { #[derive(Clone)] struct ValidationContext { - expected_fee_recipient: Option
, extra_validation_enabled: bool, parent_block: Arc>>, } @@ -705,11 +703,10 @@ async fn send_one_get_execution_payload_bid( parent_hash: get_header_response.parent_hash(), parent_root: get_header_response.parent_root(), slot: get_header_response.slot(), - fee_recipient: get_header_response.fee_recipient(), gas_limit: get_header_response.gas_limit(), }; - validate_header_data(&header_info, ¶ms, validation.expected_fee_recipient).inspect_err( + validate_header_data(&header_info, ¶ms).inspect_err( |_| { crate::utils::record_invalid_relay_response( "header_validation", @@ -745,7 +742,6 @@ struct HeaderInfo { parent_hash: B256, parent_root: B256, slot: u64, - fee_recipient: Address, gas_limit: u64, } @@ -757,7 +753,6 @@ struct HeaderInfo { fn validate_header_data( header_info: &HeaderInfo, params: &GetExecutionPayloadBidParams, - expected_fee_recipient: Option
, ) -> Result<(), ValidationError> { if header_info.block_hash == B256::ZERO { return Err(ValidationError::EmptyBlockhash); @@ -784,15 +779,6 @@ fn validate_header_data( }); } - if let Some(expected) = expected_fee_recipient && - header_info.fee_recipient != expected - { - return Err(ValidationError::FeeRecipientMismatch { - expected, - got: header_info.fee_recipient, - }); - } - Ok(()) } @@ -872,19 +858,18 @@ mod tests { parent_hash: B256::default(), parent_root: B256::default(), slot: 0, - fee_recipient: Address::ZERO, gas_limit: 0, }; assert_eq!( - validate_header_data(&mock_header_data, &mock_params, None), + validate_header_data(&mock_header_data, &mock_params), Err(ValidationError::EmptyBlockhash) ); mock_header_data.block_hash.0[1] = 1; assert_eq!( - validate_header_data(&mock_header_data, &mock_params, None), + validate_header_data(&mock_header_data, &mock_params), Err(ValidationError::ParentHashMismatch { expected: mock_params.parent_hash, got: B256::default() @@ -894,7 +879,7 @@ mod tests { mock_header_data.parent_hash = parent_hash; assert_eq!( - validate_header_data(&mock_header_data, &mock_params, None), + validate_header_data(&mock_header_data, &mock_params), Err(ValidationError::ParentRootMismatch { expected: mock_params.parent_root, got: B256::default() @@ -904,26 +889,14 @@ mod tests { mock_header_data.parent_root = parent_root; assert_eq!( - validate_header_data(&mock_header_data, &mock_params, None), + validate_header_data(&mock_header_data, &mock_params), Err(ValidationError::SlotNumberMismatch { expected: slot, got: 0 }) ); mock_header_data.slot = slot; - let expected_fee_recipient = Address::from([1; 20]); - - assert_eq!( - validate_header_data(&mock_header_data, &mock_params, Some(expected_fee_recipient)), - Err(ValidationError::FeeRecipientMismatch { - expected: expected_fee_recipient, - got: Address::ZERO, - }) - ); - - mock_header_data.fee_recipient = expected_fee_recipient; - - validate_header_data(&mock_header_data, &mock_params, Some(expected_fee_recipient)) - .unwrap(); + // All request-derived fields now agree, so the header validates. + validate_header_data(&mock_header_data, &mock_params).unwrap(); } fn test_auth(slot: u64, signature: BlsSignature) -> SignedBuilderRequestAuth { From fea1e497944c51ea7cdba6febae3c8817a234936 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 15:43:05 -0700 Subject: [PATCH 60/80] fix(pbs): make /beacon_blocks a blind pipe, decode only under strict opt-in Block validity is the builder's job (builder-specs: an invalid block MUST be rejected by the builder). By default CB now forwards the reveal bytes to every builder without decoding them, so its decode strictness (SSZ over-read, JSON deny_unknown_fields) no longer 400s a block the builder would accept. The new strict_block_decode flag (default false) restores CB-side decode + a non-gloas 400 for operators who want it. Two decode-path corrections that apply under strict mode and to the version gate used in both modes: - absent Content-Type now defaults to JSON, per the builder-specs preamble ("all requests by default send and receive JSON"); octet-stream is SSZ-only. - Eth-Consensus-Version accepts gloas-or-later (the endpoint is "Gloas onwards") and the outbound header carries the block's actual fork instead of a hard-coded gloas; still exhaustive so a new fork forces an explicit add. --- crates/common/src/config/pbs.rs | 7 ++ crates/common/src/wire.rs | 18 ++-- .../src/routes/submit_signed_beacon_block.rs | 94 +++++++++++++------ 3 files changed, 82 insertions(+), 37 deletions(-) diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 8096a3562..491ec690e 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -163,6 +163,13 @@ pub struct PbsConfig { /// Enable extra validation of get_header responses #[serde(default = "default_bool::")] pub extra_validation_enabled: bool, + /// Opt-in strict decoding of the reveal at POST /eth/v1/builder/beacon_blocks. + /// Default (false): CB is a blind pipe, forwarding the block bytes to the + /// builder without parsing them (the builder validates and rejects, per + /// builder-specs). When true: CB decodes the SignedBeaconBlock, rejects a + /// non-gloas or undecodable body with 400, and re-encodes it outbound. + #[serde(default = "default_bool::")] + pub strict_block_decode: bool, /// Execution Layer RPC url to use for extra validation pub rpc_url: Option, /// URL for the user's own SSV node API endpoint diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index 2a7d5f357..c83d0571c 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -359,19 +359,20 @@ pub fn require_consensus_version_header( // Echoed into the 400 body, so bound attacker-controlled length let unsupported = || BodyDeserializeError::InvalidVersionHeader(value.chars().take(64).collect()); - // Exhaustive on purpose, no wildcard: when lighthouse adds a post-Gloas - // fork this match stops compiling, forcing an explicit decision about the - // window instead of silently 400ing the new fork's clients + // The endpoint is defined from the Gloas fork ONWARDS, so a gloas-or-later + // version is accepted and returned as-is (the caller uses it to select the + // SSZ variant). Still exhaustive, no wildcard: when lighthouse adds a fork + // after the current tip this match stops compiling, forcing an explicit + // decision to add it to the accepted set rather than silently 400ing it. match ForkName::from_str(value).map_err(|_| unsupported())? { - ForkName::Gloas => Ok(ForkName::Gloas), + fork @ (ForkName::Gloas | ForkName::Heze) => Ok(fork), ForkName::Base | ForkName::Altair | ForkName::Bellatrix | ForkName::Capella | ForkName::Deneb | ForkName::Electra | - ForkName::Fulu | - ForkName::Heze => Err(unsupported()), + ForkName::Fulu => Err(unsupported()), } } @@ -594,7 +595,10 @@ pub fn decode_signed_beacon_block( // request. SSZ uses the fork to select the variant; JSON // self-describes via `deny_unknown_fields`, so a recognized-but-mismatched // value is ignored rather than second-guessing a decodable body. - let encoding = content_type_encoding_with_default(headers, EncodingType::Ssz)?; + // Absent Content-Type defaults to JSON, per the builder-specs preamble + // ("all requests by default send and receive JSON"); octet-stream is only + // for bodies that carry SSZ. + let encoding = content_type_encoding_with_default(headers, EncodingType::Json)?; let fork = require_consensus_version_header(headers)?; match encoding { EncodingType::Json => serde_json::from_slice::(body.as_ref()) diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs index 3caa8ea5e..065acedd0 100644 --- a/crates/pbs/src/routes/submit_signed_beacon_block.rs +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -1,7 +1,15 @@ -use axum::{body::Bytes, extract::State, http::HeaderMap, response::IntoResponse}; +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, HeaderValue}, + response::IntoResponse, +}; use cb_common::{ - pbs::{RelayClient, SignedBeaconBlock, error::PbsError, is_gloas}, - wire::{decode_signed_beacon_block, get_user_agent}, + pbs::{RelayClient, error::PbsError, is_gloas}, + wire::{ + BodyDeserializeError, CONSENSUS_VERSION_HEADER, decode_signed_beacon_block, get_user_agent, + require_consensus_version_header, + }, }; use futures::{FutureExt, future::join_all}; use reqwest::StatusCode; @@ -13,27 +21,24 @@ use crate::{ constants::SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, error::PbsClientError, state::{BuilderApiState, PbsState}, - utils::{epbs_base_send_headers, post_ssz_expect_accepted, record_beacon_status, record_client_error}, + utils::{epbs_base_send_headers, post_ssz_expect_accepted, record_beacon_status}, }; -/// The body is the required `SignedBeaconBlock`. `Eth-Consensus-Version` is -/// required for JSON and SSZ alike and must name a known fork (spec PR #165); -/// the SSZ form additionally uses it to select the variant +/// POST /eth/v1/builder/beacon_blocks (submitSignedBeaconBlock). +/// `Eth-Consensus-Version` is required (spec PR #165) and names the block's +/// fork. By default CB is a blind pipe: it forwards the block bytes to every +/// builder WITHOUT decoding them, because block validity is the builder's job +/// (builder-specs: an invalid block MUST be rejected by the builder). Set +/// `strict_block_decode` to have CB decode the block and 400 a non-gloas or +/// undecodable reveal itself. pub async fn handle_submit_signed_beacon_block( State(state): State>, req_headers: HeaderMap, body: Bytes, ) -> Result { - let block = decode_signed_beacon_block(&req_headers, &body) - .map_err(|err| record_client_error(err, SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG))?; - let slot = block.slot().as_u64(); - tracing::Span::current().record("slot", slot); - let state = state.read().clone(); - let ua = get_user_agent(&req_headers); - info!(ua, slot, "new request"); - match submit_signed_beacon_block(block, req_headers, state).await { + match submit_signed_beacon_block(body, req_headers, state).await { Ok(()) => { record_beacon_status("202", SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG); Ok(StatusCode::ACCEPTED.into_response()) @@ -51,28 +56,57 @@ pub async fn handle_submit_signed_beacon_block( } } -/// Broadcasts a `SignedBeaconBlock` to every configured builder. CB is -/// stateless here: it keeps no record of the auction winner, so it forwards the -/// block to all relays to improve inclusion guarantees, -/// additive to the beacon node's own p2p gossip. -/// Ok(()) means at least one builder accepted with a 202. +/// Decides the SSZ body + fork to forward (blind by default, decoding only +/// under `strict_block_decode`) and broadcasts to every configured builder. CB +/// keeps no auction state, so it forwards to all relays to improve inclusion, +/// additive to the beacon node's own p2p gossip. Ok(()) means at least one +/// builder accepted with a 202. pub async fn submit_signed_beacon_block( - block: SignedBeaconBlock, + body: Bytes, req_headers: HeaderMap, state: PbsState, ) -> Result<(), PbsClientError> { - // Gloas-only endpoint per spec; earlier forks carry no execution payload bid - if !is_gloas(&block) { - return Err(PbsClientError::NotGloasBlock); + let strict = state.pbs_config().strict_block_decode; + let ua = get_user_agent(&req_headers); + + // Eth-Consensus-Version is spec-required here and names the block's fork: it + // labels the outbound SSZ and, under strict decode, selects the variant. + let fork = require_consensus_version_header(&req_headers)?; + + let (out_body, slot) = if strict { + // Strict: CB decodes and rejects a malformed or non-gloas reveal itself. + let block = decode_signed_beacon_block(&req_headers, &body)?; + if !is_gloas(&block) { + return Err(PbsClientError::NotGloasBlock); + } + let slot = block.slot().as_u64(); + (Bytes::from(block.as_ssz_bytes()), Some(slot)) + } else { + // Blind pipe: forward the bytes without parsing; block validity is the + // builder's job. The outbound is always SSZ, so the reveal is expected + // in SSZ (strict mode is for operators who want CB to decode). + if body.is_empty() { + return Err(BodyDeserializeError::MissingBody.into()); + } + (body, None) + }; + + if let Some(slot) = slot { + tracing::Span::current().record("slot", slot); } + info!(ua, ?slot, strict, "new request"); - // Base headers carry Eth-Consensus-Version: gloas, which the builder needs - // to decode the SSZ block - let send_headers = epbs_base_send_headers(&req_headers)?; + // Base headers, then stamp the block's ACTUAL fork (gloas or later) as the + // outbound Eth-Consensus-Version rather than a hard-coded gloas, so a + // post-gloas reveal is labeled correctly. + let mut send_headers = epbs_base_send_headers(&req_headers)?; + send_headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&fork.to_string()) + .expect("fork name is always a valid header value"), + ); let timeout_ms = state.pbs_config().timeout_get_payload_ms; - - let body = Bytes::from(block.as_ssz_bytes()); let relays = state.all_relays(); // Spawned like builder_preferences' sends: a BN disconnect must not cancel // in-flight block broadcasts mid-fan-out, leaving some builders with the @@ -83,7 +117,7 @@ pub async fn submit_signed_beacon_block( tokio::spawn( send_one_submit_signed_beacon_block( relay.clone(), - body.clone(), + out_body.clone(), send_headers.clone(), timeout_ms, ) From ade046f0cfad24a77f766796ebef671edd25c0e9 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 16:11:54 -0700 Subject: [PATCH 61/80] fix(pbs): derive the ePBS bid timeout from the proposer deadline + a buffer The old ePBS bid timeout was min(timeout_get_header_ms, late_in_slot - ms_into_slot), which returned 204 without contacting the builder once the request arrived past late_in_slot_time_ms (default 2s into the slot) -- a legacy mev-boost timing knob with no basis in the getExecutionPayloadBid spec, discarding bids the proposer would still accept. The beacon node sends its own deadline on every request (Date-Milliseconds + X-Timeout-Ms), so CB derives its timeout from that live value: it reserves proposer_deadline_buffer_ms (default 50) for the winning bid's return trip to the BN and the BN's own selection, and asks the builder for the rest. This replaces the drift-prone "configure deadline-minus-margin as an absolute" pattern with "configure the margin"; the BN's timeout can change and CB tracks it. On the ePBS path timeout_get_header_ms and late_in_slot_time_ms are no longer consulted (they remain load-bearing for the legacy get_header path, which carries no X-Timeout-Ms). --- config.example.toml | 10 +++++- crates/common/src/config/pbs.rs | 13 +++++-- crates/common/src/pbs/constants.rs | 6 ++++ .../pbs/src/routes/execution_payload_bid.rs | 36 +++++++++---------- 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/config.example.toml b/config.example.toml index 203189c77..d88df151f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -28,9 +28,17 @@ relay_check = true # OPTIONAL, DEFAULT: true wait_all_registrations = true # Timeout in milliseconds for the `get_header` call to relays. Note that the CL has also a timeout (e.g. 1 second) so -# this should be lower than that, leaving some margin for overhead +# this should be lower than that, leaving some margin for overhead. +# LEGACY get_header path only: the ePBS bid path derives its timeout from the beacon node's own +# X-Timeout-Ms header instead (see proposer_deadline_buffer_ms below). # OPTIONAL, DEFAULT: 950 timeout_get_header_ms = 950 +# ePBS bid path only. The beacon node sends CB its own deadline (X-Timeout-Ms) on every bid request, +# so CB no longer needs a static timeout: it asks the builder for `deadline - proposer_deadline_buffer_ms`, +# reserving this many ms for the winning bid's return trip to the beacon node and the node's own +# bid-selection/assembly. Configure the margin directly instead of a deadline-minus-margin absolute. +# OPTIONAL, DEFAULT: 50 +proposer_deadline_buffer_ms = 50 # Timeout in milliseconds for the `submit_blinded_block` call to relays. # OPTIONAL, DEFAULT: 4000 timeout_get_payload_ms = 4000 diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 8096a3562..2b4eaa747 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -30,7 +30,7 @@ use crate::{ }, pbs::{ DEFAULT_PBS_PORT, DEFAULT_REGISTRY_REFRESH_SECONDS, DefaultTimeout, LATE_IN_SLOT_TIME_MS, - REGISTER_VALIDATOR_RETRY_LIMIT, RelayClient, RelayEntry, + PROPOSER_DEADLINE_BUFFER_MS, REGISTER_VALIDATOR_RETRY_LIMIT, RelayClient, RelayEntry, }, types::{BlsPublicKey, Chain, Jwt, ModuleId}, utils::{ @@ -157,9 +157,18 @@ pub struct PbsConfig { /// Expected fee recipient in ePBS bids; when set, bids with a different /// fee_recipient are rejected pub fee_recipient: Option
, - /// How late in the slot we consider to be "late" + /// How late in the slot we consider to be "late" (legacy get_header path) #[serde(default = "default_u64::")] pub late_in_slot_time_ms: u64, + /// ePBS bid path only: ms reserved before the proposer's declared deadline + /// (Date-Milliseconds + X-Timeout-Ms) for the winning bid's return trip to + /// the beacon node and the beacon node's own selection/assembly. CB asks the + /// builder for `deadline - this`, deriving its timeout from the BN's live + /// X-Timeout-Ms instead of a static config; timeout_get_header_ms and + /// late_in_slot_time_ms (legacy get_header knobs, which carry no X-Timeout-Ms) + /// are not consulted on the ePBS bid path. + #[serde(default = "default_u64::")] + pub proposer_deadline_buffer_ms: u64, /// Enable extra validation of get_header responses #[serde(default = "default_bool::")] pub extra_validation_enabled: bool, diff --git a/crates/common/src/pbs/constants.rs b/crates/common/src/pbs/constants.rs index 2e2818c50..474f309bb 100644 --- a/crates/common/src/pbs/constants.rs +++ b/crates/common/src/pbs/constants.rs @@ -41,6 +41,12 @@ impl DefaultTimeout { pub const LATE_IN_SLOT_TIME_MS: u64 = 2000; +/// ePBS bid path: ms reserved before the proposer's own deadline +/// (Date-Milliseconds + X-Timeout-Ms) for the winning bid's return trip to the +/// beacon node and the beacon node's own selection/assembly. CB asks the builder +/// for `proposer_deadline − this`. 50ms covers a same-host CB<->BN comfortably. +pub const PROPOSER_DEADLINE_BUFFER_MS: u64 = 50; + /// How long each ePBS bid poll may take before the next one supersedes it. Set /// generously: a proposer far from its builders needs more than the poll /// cadence to land any bid at all, and a value below the round trip would time diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 7a226645e..7a3bec1ab 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -183,27 +183,23 @@ pub async fn get_execution_payload_bid( &pbs_config.advertised_urls, )?; - let max_timeout_ms = pbs_config - .timeout_get_header_ms - .min(pbs_config.late_in_slot_time_ms.saturating_sub(ms_into_slot)); - - if max_timeout_ms == 0 { - warn!( - ms_into_slot, - threshold = pbs_config.late_in_slot_time_ms, - "late in slot, skipping relay requests" - ); - - return Ok(None); - } - - // The proposer's deadline bounds everything below it + // The proposer's own deadline (Date-Milliseconds + X-Timeout-Ms) tells CB + // exactly when the beacon node will stop waiting, so CB derives its timeout + // from that live value rather than a static, drift-prone config. It reserves + // proposer_deadline_buffer_ms for the winning bid's return trip to the BN and + // the BN's own selection/assembly, and asks the builder for the rest. Legacy + // timeout_get_header_ms and late_in_slot_time_ms are NOT consulted here: they + // exist for the get_header path, which carries no X-Timeout-Ms. saturating_sub + // yields 0 when the deadline is already inside the buffer (a natural no-bid, + // never a preemptive skip of a request the proposer might still accept). let budget_ms = request_budget_ms(&req_headers, utcnow_ms())?; - if budget_ms == 0 { - warn!("proposer deadline already passed, skipping relay requests"); - return Ok(None); - } - let max_timeout_ms = max_timeout_ms.min(budget_ms); + let max_timeout_ms = budget_ms.saturating_sub(pbs_config.proposer_deadline_buffer_ms); + debug!( + budget_ms, + buffer_ms = pbs_config.proposer_deadline_buffer_ms, + max_timeout_ms, + "ePBS bid request budget" + ); // prepare headers, except for start time which is set in `send_one_get_execution_payload_bid` let mut send_headers = epbs_base_send_headers(&req_headers)?; From bfb6055651e118cb8506922d379747f7f959421f Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 17:39:37 -0700 Subject: [PATCH 62/80] fix(pbs): make the fail-closed ePBS transient pipe loud, not silent The transient pipe forwards a bid/preferences request to the builder URL the proposer's signed auth_data names when no configured relay matches. It fails closed without advertised_urls: CB cannot tell an unconfigured key's self-URL default (which points at CB itself) from an external builder, so it will not dial -- a deliberate, SSRF-conscious opt-in. The defect was that it did so SILENTLY, rejecting with a bare 400 and no hint that one config field enables it. Now CB warns once at startup (when muxes are configured but advertised_urls is empty) and logs an actionable one-time hint on the first rejected pipe request. No behavior change: the pipe stays opt-in via advertised_urls. --- crates/common/src/config/pbs.rs | 30 +++++++++++++++++++++++++++++- crates/pbs/src/utils.rs | 21 ++++++++++++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 175ad052b..094fff9fa 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -14,7 +14,7 @@ use alloy::{ use docker_image::DockerImage; use eyre::{Result, ensure}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use tracing::info; +use tracing::{info, warn}; use url::Url; use super::{ @@ -381,6 +381,20 @@ pub async fn load_pbs_config(config_path: Option) -> Result<(PbsModuleC None => (None, None), }; + // The ePBS transient pipe (forwarding a bid/preferences request to a + // proposer-addressed builder that is not in the relay config) is fail-closed + // without advertised_urls: CB cannot tell an unconfigured key's self-URL + // default from an external builder, so it will not dial. Warn once at startup + // so this reads as a deliberate opt-in, not a silent 400 at request time. + if mux_lookup.is_some() && config.pbs.pbs_config.advertised_urls.is_empty() { + warn!( + "advertised_urls is unset: the ePBS transient pipe is disabled, so a bid or \ + preferences request addressed to a builder not in your relay config is rejected \ + with 400. Set advertised_urls to CB's advertised URL(s) to enable forwarding to \ + proposer-addressed builders." + ); + } + // Build the list of all relays, starting with muxes if let Some(muxes) = &mux_lookup { for (_, mux) in muxes.iter() { @@ -465,6 +479,20 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC None => (None, None), }; + // The ePBS transient pipe (forwarding a bid/preferences request to a + // proposer-addressed builder that is not in the relay config) is fail-closed + // without advertised_urls: CB cannot tell an unconfigured key's self-URL + // default from an external builder, so it will not dial. Warn once at startup + // so this reads as a deliberate opt-in, not a silent 400 at request time. + if mux_lookup.is_some() && cb_config.pbs.static_config.pbs_config.advertised_urls.is_empty() { + warn!( + "advertised_urls is unset: the ePBS transient pipe is disabled, so a bid or \ + preferences request addressed to a builder not in your relay config is rejected \ + with 400. Set advertised_urls to CB's advertised URL(s) to enable forwarding to \ + proposer-addressed builders." + ); + } + // Build the list of all relays, starting with muxes if let Some(muxes) = &mux_lookup { for (_, mux) in muxes.iter() { diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 269766b29..e502fd431 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -1,5 +1,8 @@ use std::{ - sync::OnceLock, + sync::{ + OnceLock, + atomic::{AtomicBool, Ordering}, + }, time::{Duration, Instant}, }; @@ -304,8 +307,20 @@ pub(crate) fn transient_pipe_relay( let Some(url) = decode_auth_data_url(received_data) else { return Err(PbsClientError::AuthDataMismatch); }; - if advertised_urls.is_empty() || advertised_urls.iter().any(|own| url_matches(own, &url)) { - warn!(%url, "auth data URL is CB's own or advertised_urls is unset, not dialing"); + if advertised_urls.is_empty() { + // Fail closed, but tell the operator why (once, to avoid per-request + // spam): without advertised_urls CB cannot distinguish an unconfigured + // key's self-URL default from an external builder. Setting advertised_urls + // to CB's advertised URL(s) enables forwarding to proposer-addressed + // builders. The same condition is warned once at startup in load_pbs_config. + static WARNED: AtomicBool = AtomicBool::new(false); + if !WARNED.swap(true, Ordering::Relaxed) { + warn!(%url, "advertised_urls is unset: the ePBS transient pipe is disabled, not forwarding to this proposer-addressed builder; set advertised_urls to CB's advertised URL(s) to enable it"); + } + return Err(PbsClientError::AuthDataMismatch); + } + if advertised_urls.iter().any(|own| url_matches(own, &url)) { + warn!(%url, "auth data URL matches CB's own advertised URL, not self-dialing"); return Err(PbsClientError::AuthDataMismatch); } From dddfab2ec58854fe0647980a79ea29def2805db5 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 17:41:47 -0700 Subject: [PATCH 63/80] fix(pbs): restore Address import in the bid-path test module The fee_recipient removal dropped the top-level Address import, but a mock in the test module still uses it. cargo check (non-test) did not flag it, so the cb-pbs test build broke. Import Address in the test module. --- crates/pbs/src/routes/execution_payload_bid.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index d3b135f60..ca8f86ecf 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -817,7 +817,7 @@ fn extra_validation( #[cfg(test)] mod tests { - use alloy::primitives::{B256, aliases::B32}; + use alloy::primitives::{Address, B256, aliases::B32}; use cb_common::{ constants::{DOMAIN_BUILDER_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{BuilderRequestAuth, error::ValidationError}, From 5fa66d4466a7c8b39e08e8f6f8859c7454258b5b Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 17:45:26 -0700 Subject: [PATCH 64/80] test(pbs): add the new PbsConfig fields to test-only literals proposer_deadline_buffer_ms and strict_block_decode were added to PbsConfig but several test-only struct literals build it field-by-field, so the test build broke (cargo check does not compile the test cfg, so it was not caught earlier). Fill in the two fields in the signer, tests-utils, and cfg-file-update literals. --- crates/common/src/config/signer.rs | 2 ++ tests/src/utils.rs | 2 ++ tests/tests/pbs_cfg_file_update.rs | 2 ++ 3 files changed, 6 insertions(+) diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 72fed24be..123b86bc9 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -479,7 +479,9 @@ mod tests { verify_builder_request_auth: false, fee_recipient: None, late_in_slot_time_ms: 0, + proposer_deadline_buffer_ms: 0, extra_validation_enabled: false, + strict_block_decode: false, rpc_url: None, http_timeout_seconds: 30, register_validator_retry_limit: 3, diff --git a/tests/src/utils.rs b/tests/src/utils.rs index ab64d55ae..15bf70c96 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -186,7 +186,9 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { max_execution_payment_gwei: u64::MAX, fee_recipient: None, late_in_slot_time_ms: u64::MAX, + proposer_deadline_buffer_ms: 0, extra_validation_enabled: false, + strict_block_decode: false, verify_builder_request_auth: false, ssv_node_api_url: Url::parse("http://localhost:0").unwrap(), diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index facc5f3e9..9349836e3 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -69,7 +69,9 @@ async fn test_cfg_file_update() -> Result<()> { fee_recipient: None, late_in_slot_time_ms: u64::MAX / 2, /* serde gets very upset about serializing u64::MAX * or anything close to it */ + proposer_deadline_buffer_ms: 0, extra_validation_enabled: false, + strict_block_decode: false, rpc_url: None, ssv_node_api_url: Url::parse("http://example.com").unwrap(), ssv_public_api_url: Url::parse("http://example.com").unwrap(), From 701953b5dada7c73939d7194fce7e6f8c04f25bb Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 19:05:53 -0700 Subject: [PATCH 65/80] fix(pbs): return 204 on an expired proposer deadline; align fee_recipient tests Zero budget (the proposer's Date-Milliseconds + X-Timeout-Ms deadline, minus the buffer, has passed) means any bid would land too late for the beacon node to use, so return 204 without a doomed relay call. This honors the proposer's own deadline, not a CB late-in-slot cutoff, so it is not the preemptive skip the bid path used to do. Update the bid-path tests for the accepted behavior changes: fee_recipient is no longer enforced on the ePBS path (repurpose the wrong-fee-recipient test to assert the bid is served; drop the mux-fee-recipient test, which asserted the removed mux-level enforcement). --- .../pbs/src/routes/execution_payload_bid.rs | 14 +++- tests/tests/pbs_get_execution_payload_bid.rs | 73 ++----------------- 2 files changed, 19 insertions(+), 68 deletions(-) diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index ca8f86ecf..123692893 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -189,9 +189,7 @@ pub async fn get_execution_payload_bid( // proposer_deadline_buffer_ms for the winning bid's return trip to the BN and // the BN's own selection/assembly, and asks the builder for the rest. Legacy // timeout_get_header_ms and late_in_slot_time_ms are NOT consulted here: they - // exist for the get_header path, which carries no X-Timeout-Ms. saturating_sub - // yields 0 when the deadline is already inside the buffer (a natural no-bid, - // never a preemptive skip of a request the proposer might still accept). + // exist for the get_header path, which carries no X-Timeout-Ms. let budget_ms = request_budget_ms(&req_headers, utcnow_ms())?; let max_timeout_ms = budget_ms.saturating_sub(pbs_config.proposer_deadline_buffer_ms); debug!( @@ -201,6 +199,16 @@ pub async fn get_execution_payload_bid( "ePBS bid request budget" ); + // Zero budget = the proposer's own deadline (minus the buffer) has already + // passed, so any bid would land too late for the beacon node to use. Return + // 204 without a doomed relay call. This honors the PROPOSER's deadline, not a + // CB-imposed late-in-slot cutoff, so it is not the preemptive skip the bid + // path used to do. + if max_timeout_ms == 0 { + warn!(budget_ms, "proposer deadline reached, no time to solicit a bid"); + return Ok(None); + } + // prepare headers, except for start time which is set in `send_one_get_execution_payload_bid` let mut send_headers = epbs_base_send_headers(&req_headers)?; diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 61e671666..509f9c00a 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -1,8 +1,7 @@ -use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::Arc}; use alloy::primitives::{Address, B256, U256}; use cb_common::{ - config::RuntimeMuxConfig, constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{ DEFAULT_BID_POLL_TIMEOUT_MS, GetExecutionPayloadBidInfo, GetExecutionPayloadBidResponse, @@ -202,10 +201,13 @@ async fn test_get_execution_payload_bid_below_min_bid_passes() -> Result<()> { Ok(()) } -/// Test that a bid whose fee_recipient differs from the configured expected -/// value is dropped. The mock serves Address::ZERO as fee_recipient. +/// fee_recipient is NOT enforced on the ePBS bid path: a bid whose fee_recipient +/// differs from the config value is still served. The execution block's fee +/// recipient is the builder's and the proposer is paid via value + +/// execution_payment, so the BN (not CB) verifies it. The mock serves +/// Address::ZERO while the config sets a different value; the bid is returned. #[tokio::test] -async fn test_get_execution_payload_bid_wrong_fee_recipient_rejected() -> Result<()> { +async fn test_get_execution_payload_bid_fee_recipient_not_enforced() -> Result<()> { setup_test_env(); let chain = Chain::Hoodi; let pbs_listener = get_free_listener().await; @@ -227,72 +229,13 @@ async fn test_get_execution_payload_bid_wrong_fee_recipient_rejected() -> Result wait_for_ready(&mock_validator).await?; let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; - assert_eq!(res.status(), StatusCode::NO_CONTENT); - assert_eq!(mock_state.received_execution_payload_bid(), 1); - Ok(()) -} - -/// Test that a MUX-level fee_recipient reaches bid validation: the mux's -/// validator gets its bid (mock serves Address::ZERO) rejected, while the -/// default config (no expected fee_recipient) still accepts it. -#[tokio::test] -async fn test_get_execution_payload_bid_mux_fee_recipient() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - // Default config has no expected fee_recipient; only the mux does - let mut config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); - let mut mux_pbs_config = get_pbs_config(pbs_port); - mux_pbs_config.fee_recipient = Some(Address::from([1; 20])); - let mux = RuntimeMuxConfig { - id: String::from("fee-mux"), - config: Arc::new(mux_pbs_config), - relays: vec![mock_relay], - }; - let mux_pubkey = random_secret().public_key(); - config.mux_lookup = Some(HashMap::from([(mux_pubkey.clone(), mux)])); - - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; - - // The mux validator's bid fails the fee_recipient check - let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid( - TEST_SLOT, - B256::ZERO, - B256::ZERO, - Some(mux_pubkey), - Some(&auth), - vec![EncodingType::Json], - ) - .await?; - assert_eq!(res.status(), StatusCode::NO_CONTENT); - - // A non-mux validator uses the default config and gets the bid let res = mock_validator .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ EncodingType::Json, ]) .await?; assert_eq!(res.status(), StatusCode::OK); - assert_eq!(mock_state.received_execution_payload_bid(), 2); + assert_eq!(mock_state.received_execution_payload_bid(), 1); Ok(()) } From 74fb47603c4a42912ab956bda3af53119ecf9c09 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 19:23:29 -0700 Subject: [PATCH 66/80] test(pbs): align beacon-block and preferences tests with the new defaults /beacon_blocks decoding is now opt-in (strict_block_decode): the json-202, non-gloas-400, and unsupported-content-type-415 tests exercise the decode path, so enable strict mode in their configs. And builder preferences are no longer gated on slot age (blind pipe), so repurpose the slot-passed-400 test to assert a past-slot preference is forwarded and accepted. --- tests/tests/pbs_submit_builder_preferences.rs | 23 +++++++++++-------- tests/tests/pbs_submit_signed_beacon_block.rs | 13 ++++++++--- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/tests/pbs_submit_builder_preferences.rs b/tests/tests/pbs_submit_builder_preferences.rs index b8c61ede5..6b2dee0cb 100644 --- a/tests/tests/pbs_submit_builder_preferences.rs +++ b/tests/tests/pbs_submit_builder_preferences.rs @@ -333,24 +333,27 @@ async fn test_submit_builder_preferences_json_no_version_400() -> Result<()> { Ok(()) } -/// Preferences naming a slot that has already ended are rejected before any -/// builder is contacted: a replay must not roll preferences back to a stale -/// value. +/// CB does not gate preferences on slot age: freshness (rejecting a stale or +/// replayed submission) is the builder's call, not the relay's, so CB forwards +/// regardless. A preference naming a slot that has already ended still reaches +/// the builder and is accepted. #[tokio::test] -async fn test_submit_builder_preferences_slot_passed_400() -> Result<()> { +async fn test_submit_builder_preferences_past_slot_forwarded() -> Result<()> { let chain = Chain::Hoodi; - let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + let (mock_validator, mock_state) = setup_relay( + chain, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, TEST_AUTH_DATA), + ) + .await?; let request = preferences(opaque_auth(TEST_AUTH_DATA, past_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); let res = mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; - assert_eq!(res.status(), StatusCode::BAD_REQUEST); - let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; - assert_eq!(body["code"], 400); - assert_eq!(body["message"], "Invalid SignedBuilderRequestAuth: auth.message.slot has already passed"); - assert_eq!(mock_state.received_builder_preferences(), 0, "no builder should be contacted"); + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1, "past-slot preference is forwarded"); Ok(()) } diff --git a/tests/tests/pbs_submit_signed_beacon_block.rs b/tests/tests/pbs_submit_signed_beacon_block.rs index c199fa665..84f3d01ff 100644 --- a/tests/tests/pbs_submit_signed_beacon_block.rs +++ b/tests/tests/pbs_submit_signed_beacon_block.rs @@ -83,7 +83,9 @@ async fn test_submit_signed_beacon_block_ssz_202() -> Result<()> { #[tokio::test] async fn test_submit_signed_beacon_block_json_202() -> Result<()> { let chain = Chain::Hoodi; - let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + // Decoding (JSON->SSZ normalization) is opt-in via strict_block_decode. + let (mock_validator, state) = + setup_relay(chain, |config| config.strict_block_decode = true, generate_mock_relay).await?; let committed = B256::repeat_byte(0x5a); let block = gloas_block(TEST_SLOT, committed); @@ -124,7 +126,9 @@ async fn test_submit_signed_beacon_block_spec_url() -> Result<()> { #[tokio::test] async fn test_submit_signed_beacon_block_non_gloas_400() -> Result<()> { let chain = Chain::Hoodi; - let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + // The gloas-only 400 is part of strict decode; the default pipe forwards. + let (mock_validator, state) = + setup_relay(chain, |config| config.strict_block_decode = true, generate_mock_relay).await?; // gloas is the only header the wire layer admits, so a non-Gloas BLOCK can // only reach the route's Gloas-only check as JSON. The untagged decode @@ -296,7 +300,10 @@ async fn test_submit_signed_beacon_block_fulu_label_400() -> Result<()> { #[tokio::test] async fn test_submit_signed_beacon_block_unsupported_content_type_415() -> Result<()> { let chain = Chain::Hoodi; - let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + // Content-Type validation is part of strict decode; the default pipe + // forwards without inspecting it. + let (mock_validator, state) = + setup_relay(chain, |config| config.strict_block_decode = true, generate_mock_relay).await?; let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; let res = mock_validator From ab8b1902230a96d00a78b4e5286ef474397e4bd1 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 25 Aug 2026 19:25:06 -0700 Subject: [PATCH 67/80] chore(fmt): rustfmt under the pinned nightly-2026-01-01 Formats the ePBS bottleneck + transient-pipe work, and picks up five files with pre-existing drift under the pinned toolchain (types/mod.rs, signature.rs, apply.rs, mock_km.rs, mock_relay.rs). --- crates/common/src/config/pbs.rs | 28 ++--- crates/common/src/pbs/constants.rs | 5 +- crates/common/src/pbs/types/mod.rs | 9 +- crates/common/src/signature.rs | 10 +- crates/common/src/utils.rs | 6 +- crates/km-tool/src/apply.rs | 8 +- crates/km-tool/src/project.rs | 21 ++-- crates/km-tool/tests/mock_km.rs | 24 ++--- crates/pbs/src/routes/builder_preferences.rs | 28 +++-- .../pbs/src/routes/execution_payload_bid.rs | 100 +++++++++++------- .../src/routes/submit_signed_beacon_block.rs | 12 ++- crates/pbs/src/utils.rs | 11 +- tests/src/mock_relay.rs | 16 +-- tests/src/utils.rs | 15 +-- tests/tests/pbs_get_execution_payload_bid.rs | 19 ++-- tests/tests/pbs_submit_builder_preferences.rs | 27 +++-- 16 files changed, 194 insertions(+), 145 deletions(-) diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 094fff9fa..8e93a0e52 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -149,9 +149,9 @@ pub struct PbsConfig { #[serde(default = "default_u64::<{ u64::MAX }>")] pub max_execution_payment_gwei: u64, /// When enabled, the BLS signature of an ePBS request's - /// `SignedBuilderRequestAuth` is verified against the proposer pubkey. False by - /// default: CB forwards because the downstream builder must re-verify - /// anyway; operators terminating trust at CB set it true + /// `SignedBuilderRequestAuth` is verified against the proposer pubkey. + /// False by default: CB forwards because the downstream builder must + /// re-verify anyway; operators terminating trust at CB set it true #[serde(default = "default_bool::")] pub verify_builder_request_auth: bool, /// Expected fee recipient in ePBS bids; when set, bids with a different @@ -162,21 +162,23 @@ pub struct PbsConfig { pub late_in_slot_time_ms: u64, /// ePBS bid path only: ms reserved before the proposer's declared deadline /// (Date-Milliseconds + X-Timeout-Ms) for the winning bid's return trip to - /// the beacon node and the beacon node's own selection/assembly. CB asks the - /// builder for `deadline - this`, deriving its timeout from the BN's live - /// X-Timeout-Ms instead of a static config; timeout_get_header_ms and - /// late_in_slot_time_ms (legacy get_header knobs, which carry no X-Timeout-Ms) - /// are not consulted on the ePBS bid path. + /// the beacon node and the beacon node's own selection/assembly. CB asks + /// the builder for `deadline - this`, deriving its timeout from the + /// BN's live X-Timeout-Ms instead of a static config; + /// timeout_get_header_ms and late_in_slot_time_ms (legacy get_header + /// knobs, which carry no X-Timeout-Ms) are not consulted on the ePBS + /// bid path. #[serde(default = "default_u64::")] pub proposer_deadline_buffer_ms: u64, /// Enable extra validation of get_header responses #[serde(default = "default_bool::")] pub extra_validation_enabled: bool, - /// Opt-in strict decoding of the reveal at POST /eth/v1/builder/beacon_blocks. - /// Default (false): CB is a blind pipe, forwarding the block bytes to the - /// builder without parsing them (the builder validates and rejects, per - /// builder-specs). When true: CB decodes the SignedBeaconBlock, rejects a - /// non-gloas or undecodable body with 400, and re-encodes it outbound. + /// Opt-in strict decoding of the reveal at POST + /// /eth/v1/builder/beacon_blocks. Default (false): CB is a blind pipe, + /// forwarding the block bytes to the builder without parsing them (the + /// builder validates and rejects, per builder-specs). When true: CB + /// decodes the SignedBeaconBlock, rejects a non-gloas or undecodable + /// body with 400, and re-encodes it outbound. #[serde(default = "default_bool::")] pub strict_block_decode: bool, /// Execution Layer RPC url to use for extra validation diff --git a/crates/common/src/pbs/constants.rs b/crates/common/src/pbs/constants.rs index 474f309bb..6566295d3 100644 --- a/crates/common/src/pbs/constants.rs +++ b/crates/common/src/pbs/constants.rs @@ -43,8 +43,9 @@ pub const LATE_IN_SLOT_TIME_MS: u64 = 2000; /// ePBS bid path: ms reserved before the proposer's own deadline /// (Date-Milliseconds + X-Timeout-Ms) for the winning bid's return trip to the -/// beacon node and the beacon node's own selection/assembly. CB asks the builder -/// for `proposer_deadline − this`. 50ms covers a same-host CB<->BN comfortably. +/// beacon node and the beacon node's own selection/assembly. CB asks the +/// builder for `proposer_deadline − this`. 50ms covers a same-host CB<->BN +/// comfortably. pub const PROPOSER_DEADLINE_BUFFER_MS: u64 = 50; /// How long each ePBS bid poll may take before the next one supersedes it. Set diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index 897835871..dfc982766 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -193,8 +193,9 @@ impl GetPayloadInfo for SignedBlindedBeaconBlock { #[allow(non_camel_case_types)] pub type MAX_BUILDER_AUTH_DATA_SIZE = typenum::U4096; -// `BuilderRequestAuth` is used to authenticate requests to a builder. This is useful -// so that other builders do not DDOS or run replay attacks on the builder. +// `BuilderRequestAuth` is used to authenticate requests to a builder. This is +// useful so that other builders do not DDOS or run replay attacks on the +// builder. #[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone, TreeHash)] pub struct BuilderRequestAuth { /// Opaque authentication data agreed with the builder out of band; hex @@ -327,8 +328,8 @@ mod tests { /// Regression guard for the lighthouse-unstable bump that unlocks real ePBS /// bid signature verification. Under EIP-7495 progressive containers the /// gloas `ExecutionPayloadBid` must tree-hash to the go-eth2-client ground - /// truth root `0x04f8e548…d268` (fixture captured from a devnet builder bid, - /// slot 297). This root is the object the builder signs; a silent + /// truth root `0x04f8e548…d268` (fixture captured from a devnet builder + /// bid, slot 297). This root is the object the builder signs; a silent /// progressive-hashing regression here would forge a different signing root /// and break every bid signature check, so pin it byte-for-byte. #[test] diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index f810f1756..0770991ed 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -4,7 +4,8 @@ use tree_hash_derive::TreeHash; use crate::{ constants::{ - COMMIT_BOOST_DOMAIN, DOMAIN_BEACON_BUILDER, DOMAIN_BUILDER_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, + COMMIT_BOOST_DOMAIN, DOMAIN_BEACON_BUILDER, DOMAIN_BUILDER_REQUEST_AUTH, + GENESIS_VALIDATORS_ROOT, }, signer::{EcdsaSignature, verify_bls_signature, verify_ecdsa_signature}, types::{self, BlsPublicKey, BlsSecretKey, BlsSignature, Chain, SignatureRequestInfo}, @@ -81,8 +82,8 @@ pub fn execution_payload_bid_domain(fork_version: [u8; 4], genesis_validators_ro /// Builder API request-auth signing domain. The request WIRE type is /// fork-versioned per builder-specs, but the signing domain is not: the spec's -/// `compute_domain(DOMAIN_BUILDER_REQUEST_AUTH)` takes the genesis fork version and a -/// zero root, exactly like the validator registrations it replaces. +/// `compute_domain(DOMAIN_BUILDER_REQUEST_AUTH)` takes the genesis fork version +/// and a zero root, exactly like the validator registrations it replaces. pub fn builder_request_auth_domain(chain: Chain) -> B256 { compute_domain(chain, &B32::from(DOMAIN_BUILDER_REQUEST_AUTH)) } @@ -239,8 +240,7 @@ mod tests { use crate::{ constants::APPLICATION_BUILDER_DOMAIN, pbs::{ - BlindedBeaconBlockElectra, BuilderBid, BuilderBidElectra, - ExecutionPayloadHeaderElectra, + BlindedBeaconBlockElectra, BuilderBid, BuilderBidElectra, ExecutionPayloadHeaderElectra, }, types::{BlsSecretKey, Chain}, utils::TestRandomSeed, diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 50a3bb711..104b446fe 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -155,9 +155,9 @@ pub mod as_opt_eth_str { let value = Option::::deserialize(deserializer)?; Ok(match value { - Some(StringOrF64::Str(s)) => Some( - parse_ether(&s).map_err(|_| serde::de::Error::custom("invalid eth amount"))?, - ), + Some(StringOrF64::Str(s)) => { + Some(parse_ether(&s).map_err(|_| serde::de::Error::custom("invalid eth amount"))?) + } Some(StringOrF64::F64(f)) => Some(eth_to_wei(f)), None => None, }) diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs index 3b2061cf5..203549dd1 100644 --- a/crates/km-tool/src/apply.rs +++ b/crates/km-tool/src/apply.rs @@ -209,9 +209,8 @@ async fn apply_to_vc( // nothing stored (or route absent for this key): POST ours Ok(GetConfigOutcome::NotFound) => doc, Err(err) => { - report.error(format!( - "{vc_name}: preserve-entries GET for {key} failed: {err}" - )); + report + .error(format!("{vc_name}: preserve-entries GET for {key} failed: {err}")); continue; } } @@ -375,7 +374,8 @@ fn redact_secrets_for_display(doc: &BuilderConfigDoc) -> BuilderConfigDoc { doc } -/// Whether hex-encoded auth_data decodes to a valid UTF-8 URL (the public form). +/// Whether hex-encoded auth_data decodes to a valid UTF-8 URL (the public +/// form). fn auth_data_is_url(hex: &str) -> bool { crate::doc::decode_auth_data(hex) .ok() diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index bfb6378ef..40e183ad8 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -5,8 +5,8 @@ //! is emitted EMPTY (accept any builder for the key): the only pubkey cb-km can //! see is the relay URL's userinfo pubkey, which is the relay's identity, not //! the builder's bid-signing key, and binding the wrong key rejects every bid -//! (see `project_mux`). The candidate is `expected_auth_data` when set, else the -//! UTF-8 bytes of the relay URL as configured with the userinfo stripped +//! (see `project_mux`). The candidate is `expected_auth_data` when set, else +//! the UTF-8 bytes of the relay URL as configured with the userinfo stripped //! (userinfo, and thus any pubkey credential, is intentionally NOT part of the //! emitted url or auth_data). Grouping-by-identical-bytes is a //! reimplementation of the demux contract of cb-pbs's @@ -284,7 +284,10 @@ struct AuthClass { /// Groups a mux's relays into auth_data equivalence classes keyed by identical /// candidate bytes, unioning each class's builder pubkeys, requiring one shared /// execution-payment cap per class, and enforcing the KM entry-count limit. -fn build_auth_classes(mux: &MuxConfig, raw_urls: &[String]) -> Result, AuthClass>> { +fn build_auth_classes( + mux: &MuxConfig, + raw_urls: &[String], +) -> Result, AuthClass>> { let mut classes: BTreeMap, AuthClass> = BTreeMap::new(); for (relay, raw_url) in mux.relays.iter().zip(raw_urls) { let bytes = candidate_auth_data(relay, raw_url); @@ -340,10 +343,7 @@ fn project_mux( // their own. Unset p2p fields fall back to the entry values (uniform doc, // today's behavior). let min_bid = resolve_min_bid(input, mux, warnings)?; - let key_min_bid = match mux - .min_bid_p2p_wei - .or(input.cfg.pbs.pbs_config.min_bid_p2p_wei) - { + let key_min_bid = match mux.min_bid_p2p_wei.or(input.cfg.pbs.pbs_config.min_bid_p2p_wei) { Some(wei) => wei_to_gwei_floor(&mux.id, wei, warnings)?.to_string(), None => min_bid.clone(), }; @@ -750,12 +750,7 @@ url = "https://{RELAY_PK_B}@relay-b.example.com" let input = ProjectionInput::parse_str(&toml_text).unwrap(); let projection = project(&input, &overlay()).unwrap(); let by_key = |k: &str| { - projection - .docs - .iter() - .find(|(pk, _)| pk.to_string() == k) - .map(|(_, doc)| doc) - .unwrap() + projection.docs.iter().find(|(pk, _)| pk.to_string() == k).map(|(_, doc)| doc).unwrap() }; // mux A: its own p2p values let doc_a = by_key(&key_a); diff --git a/crates/km-tool/tests/mock_km.rs b/crates/km-tool/tests/mock_km.rs index 2c03fc107..5ac74fc40 100644 --- a/crates/km-tool/tests/mock_km.rs +++ b/crates/km-tool/tests/mock_km.rs @@ -409,10 +409,10 @@ async fn without_flag_post_body_is_projection_and_ignores_stored() { let mut vc = MockVc::holding(std::slice::from_ref(&key)); let env = env_for(std::slice::from_ref(&key), &[]); let mut stored = projected_value(&env, &key); - stored["builders"].as_array_mut().unwrap().push(third_party_entry( - "https://third-party.example.com", - "0xc0ffee", - )); + stored["builders"] + .as_array_mut() + .unwrap() + .push(third_party_entry("https://third-party.example.com", "0xc0ffee")); vc.stored.insert(key.clone(), stored); let url = serve(vc.clone()).await; @@ -433,10 +433,10 @@ async fn preserve_entries_keeps_third_party_entry() { let mut vc = MockVc::holding(std::slice::from_ref(&key)); let env = env_for(std::slice::from_ref(&key), &[]); let mut stored = projected_value(&env, &key); - stored["builders"].as_array_mut().unwrap().push(third_party_entry( - "https://third-party.example.com", - "0xc0ffee", - )); + stored["builders"] + .as_array_mut() + .unwrap() + .push(third_party_entry("https://third-party.example.com", "0xc0ffee")); vc.stored.insert(key.clone(), stored); let url = serve(vc.clone()).await; @@ -485,7 +485,8 @@ async fn preserve_entries_collision_is_replaced_not_duplicated() { let entries = posted_entries(&posted.1); // no identity duplicated: exactly our two projected entries assert_eq!(entries.len(), 2, "{entries:?}"); - // ours win: the POST body equals our pure projection (resolved defaults dropped) + // ours win: the POST body equals our pure projection (resolved defaults + // dropped) assert_eq!(posted.1, projected_string(&env, &key)); } @@ -499,10 +500,7 @@ async fn preserve_entries_over_cap_fails_without_posting() { let builders = stored["builders"].as_array_mut().unwrap(); // our 2 entries + 63 distinct third-party entries = 65 > 64 for i in 0..63 { - builders.push(third_party_entry( - &format!("https://third-{i}.example.com"), - "0xabcdef", - )); + builders.push(third_party_entry(&format!("https://third-{i}.example.com"), "0xabcdef")); } vc.stored.insert(key.clone(), stored); let url = serve(vc.clone()).await; diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index c72fbe219..fa1c6126d 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -6,8 +6,8 @@ use axum::{ }; use cb_common::{ pbs::{ - BuilderPreferencesRequest, RelayClient, SignedBuilderRequestAuth, SubmitBuilderPreferencesParams, - error::PbsError, + BuilderPreferencesRequest, RelayClient, SignedBuilderRequestAuth, + SubmitBuilderPreferencesParams, error::PbsError, }, types::Chain, wire::{decode_versioned_request_body, get_user_agent}, @@ -64,7 +64,10 @@ pub async fn handle_submit_builder_preferences( } else { warn!(%err, "submit_builder_preferences failed"); } - record_beacon_status(err.status_code().as_str(), SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG); + record_beacon_status( + err.status_code().as_str(), + SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, + ); Err(err) } } @@ -163,8 +166,8 @@ pub async fn submit_builder_preferences( /// or replayed submission that would roll a proposer's preferences back) is the /// builder's call, not the relay's, so CB forwards regardless of slot age. All /// that is required here is `auth.message.data` (non-empty; which builder it -/// addresses is the demux's job, `match_relays_by_auth_data`) and, when enabled, -/// the request-auth signature. +/// addresses is the demux's job, `match_relays_by_auth_data`) and, when +/// enabled, the request-auth signature. fn validate_preferences_auth( auth: &SignedBuilderRequestAuth, params: &SubmitBuilderPreferencesParams, @@ -257,7 +260,10 @@ mod tests { fn decode_defaults_to_ssz_without_a_content_type() { let request = BuilderPreferencesRequest { auth: SignedBuilderRequestAuth { - message: BuilderRequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, + message: BuilderRequestAuth { + data: Default::default(), + slot: lh_types::Slot::new(3), + }, signature: BlsSignature::empty(), }, preferences: BuilderPreferences { max_execution_payment: 7 }, @@ -311,7 +317,10 @@ mod tests { fn decode_rejects_json_without_the_version_header() { let request = BuilderPreferencesRequest { auth: SignedBuilderRequestAuth { - message: BuilderRequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, + message: BuilderRequestAuth { + data: Default::default(), + slot: lh_types::Slot::new(3), + }, signature: BlsSignature::empty(), }, preferences: BuilderPreferences { max_execution_payment: 7 }, @@ -338,7 +347,10 @@ mod tests { fn decode_rejects_an_unrecognized_fork_value() { let request = BuilderPreferencesRequest { auth: SignedBuilderRequestAuth { - message: BuilderRequestAuth { data: Default::default(), slot: lh_types::Slot::new(3) }, + message: BuilderRequestAuth { + data: Default::default(), + slot: lh_types::Slot::new(3), + }, signature: BlsSignature::empty(), }, preferences: BuilderPreferences { max_execution_payment: 7 }, diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 123692893..0242d130b 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -17,7 +17,7 @@ use cb_common::{ pbs::{ DEFAULT_BID_POLL_TIMEOUT_MS, ForkName, GetExecutionPayloadBidInfo, GetExecutionPayloadBidParams, GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, - HEADER_TIMEOUT_MS, RelayClient, SignedExecutionPayloadBid, SignedBuilderRequestAuth, + HEADER_TIMEOUT_MS, RelayClient, SignedBuilderRequestAuth, SignedExecutionPayloadBid, error::{PbsError, ValidationError}, }, types::Chain, @@ -54,9 +54,9 @@ use crate::{ }, }; -/// The body is the required `SignedBuilderRequestAuth`; builder-specs fork-versions -/// the request wire type, and `Eth-Consensus-Version` is required for JSON and -/// SSZ alike (builder-specs #165). +/// The body is the required `SignedBuilderRequestAuth`; builder-specs +/// fork-versions the request wire type, and `Eth-Consensus-Version` is required +/// for JSON and SSZ alike (builder-specs #165). pub async fn handle_get_execution_payload_bid( State(state): State>, req_headers: HeaderMap, @@ -106,7 +106,10 @@ pub async fn handle_get_execution_payload_bid( } else { warn!(%err, "get_execution_payload_bid failed"); } - record_beacon_status(err.status_code().as_str(), GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG); + record_beacon_status( + err.status_code().as_str(), + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, + ); Err(err) } } @@ -138,8 +141,7 @@ fn encode_bid_response( record_beacon_status("200", endpoint); let mut res = max_bid.data.as_ssz_bytes().into_response(); res.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); - res.headers_mut() - .insert(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()); + res.headers_mut().insert(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()); Ok(res) } Some(EncodingType::Json) => { @@ -166,7 +168,12 @@ pub async fn get_execution_payload_bid( log_mux_selection(maybe_mux_id, relays.len(), ¶ms.proposer_pubkey); // Validate before any outbound work so a rejected request costs nothing - validate_builder_request_auth(&body, ¶ms, state.config.chain, pbs_config.verify_builder_request_auth)?; + validate_builder_request_auth( + &body, + ¶ms, + state.config.chain, + pbs_config.verify_builder_request_auth, + )?; let parent_block = Arc::new(RwLock::new(None)); if state.extra_validation_enabled() && @@ -177,11 +184,8 @@ pub async fn get_execution_payload_bid( ); } - let relays = resolve_addressed_relays( - relays, - body.message.data.as_ref(), - &pbs_config.advertised_urls, - )?; + let relays = + resolve_addressed_relays(relays, body.message.data.as_ref(), &pbs_config.advertised_urls)?; // The proposer's own deadline (Date-Milliseconds + X-Timeout-Ms) tells CB // exactly when the beacon node will stop waiting, so CB derives its timeout @@ -209,7 +213,8 @@ pub async fn get_execution_payload_bid( return Ok(None); } - // prepare headers, except for start time which is set in `send_one_get_execution_payload_bid` + // prepare headers, except for start time which is set in + // `send_one_get_execution_payload_bid` let mut send_headers = epbs_base_send_headers(&req_headers)?; // Forward the caller's Accept preference to the relay so it returns the @@ -346,12 +351,12 @@ fn request_budget_ms(req_headers: &HeaderMap, now_ms: u64) -> Result u64 { bid.value().saturating_add(bid.execution_payment()) } -/// Bid amounts are denominated in gwei, but `format_ether` expects wei; scale up -/// before formatting so the human-readable `_eth` log fields are correct. +/// Bid amounts are denominated in gwei, but `format_ether` expects wei; scale +/// up before formatting so the human-readable `_eth` log fields are correct. fn format_gwei_as_eth(gwei: u64) -> String { format_ether(U256::from(gwei) * U256::from(1_000_000_000u64)) } @@ -665,9 +670,13 @@ async fn send_one_get_execution_payload_bid( .to_string(), code: code.as_u16(), })?; - let data = SignedExecutionPayloadBid::from_ssz_bytes(&response_bytes).map_err(|err| { - PbsError::SSZDecode { err: format!("error decoding relay payload: {err:?}"), fork } - })?; + let data = + SignedExecutionPayloadBid::from_ssz_bytes(&response_bytes).map_err(|err| { + PbsError::SSZDecode { + err: format!("error decoding relay payload: {err:?}"), + fork, + } + })?; GetExecutionPayloadBidResponse { version: fork, data, metadata: Default::default() } } }; @@ -710,15 +719,13 @@ async fn send_one_get_execution_payload_bid( gas_limit: get_header_response.gas_limit(), }; - validate_header_data(&header_info, ¶ms).inspect_err( - |_| { - crate::utils::record_invalid_relay_response( - "header_validation", - GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, - &relay.id, - ); - }, - )?; + validate_header_data(&header_info, ¶ms).inspect_err(|_| { + crate::utils::record_invalid_relay_response( + "header_validation", + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, + &relay.id, + ); + })?; if validation.extra_validation_enabled { let parent_block = validation.parent_block.read(); @@ -830,8 +837,8 @@ mod tests { constants::{DOMAIN_BUILDER_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{BuilderRequestAuth, error::ValidationError}, signature::{ - compute_domain, compute_domain_with_fork_version, builder_request_auth_domain, - sign_execution_payload_bid_root, sign_builder_request_auth_root, + builder_request_auth_domain, compute_domain, compute_domain_with_fork_version, + sign_builder_request_auth_root, sign_execution_payload_bid_root, }, types::{BlsSecretKey, BlsSignature, Chain}, utils::TestRandomSeed, @@ -907,7 +914,10 @@ mod tests { SignedBuilderRequestAuth { // Non-empty so it clears the empty-data guard; the value itself is the // demux's input, exercised elsewhere, not this validator's slot/sig path - message: BuilderRequestAuth { data: vec![0x01].try_into().unwrap(), slot: Slot::new(slot) }, + message: BuilderRequestAuth { + data: vec![0x01].try_into().unwrap(), + slot: Slot::new(slot), + }, signature, } } @@ -941,7 +951,10 @@ mod tests { #[test] fn test_decode_builder_request_auth_rejects_empty_body() { assert!(matches!( - decode_versioned_request_body::(&HeaderMap::new(), &Bytes::new()), + decode_versioned_request_body::( + &HeaderMap::new(), + &Bytes::new() + ), Err(BodyDeserializeError::MissingBody) )); } @@ -1049,8 +1062,9 @@ mod tests { } // The auth domain is NOT fork-versioned: it must equal the spec's - // compute_domain(DOMAIN_BUILDER_REQUEST_AUTH), i.e. genesis fork version and a zero - // root. A sign/verify round trip cannot catch a wrong domain, so pin it. + // compute_domain(DOMAIN_BUILDER_REQUEST_AUTH), i.e. genesis fork version and a + // zero root. A sign/verify round trip cannot catch a wrong domain, so pin + // it. #[test] fn test_builder_request_auth_domain_is_not_fork_versioned() { for chain in [Chain::Mainnet, Chain::Hoodi, Chain::Holesky] { @@ -1069,7 +1083,10 @@ mod tests { ); } // Chains are separated by their genesis fork version - assert_ne!(builder_request_auth_domain(Chain::Mainnet), builder_request_auth_domain(Chain::Hoodi)); + assert_ne!( + builder_request_auth_domain(Chain::Mainnet), + builder_request_auth_domain(Chain::Hoodi) + ); } #[test] @@ -1127,7 +1144,8 @@ mod tests { Err(PbsClientError::AuthSigVerify) )); - let good_sig = sign_builder_request_auth_root(&secret_key, &message.tree_hash_root(), chain); + let good_sig = + sign_builder_request_auth_root(&secret_key, &message.tree_hash_root(), chain); validate_builder_request_auth(&test_auth(slot, good_sig), ¶ms, chain, true).unwrap(); } diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs index 065acedd0..d61e08623 100644 --- a/crates/pbs/src/routes/submit_signed_beacon_block.rs +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -50,7 +50,10 @@ pub async fn handle_submit_signed_beacon_block( } else { warn!(%err, "submit_signed_beacon_block failed"); } - record_beacon_status(err.status_code().as_str(), SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG); + record_beacon_status( + err.status_code().as_str(), + SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, + ); Err(err) } } @@ -102,8 +105,7 @@ pub async fn submit_signed_beacon_block( let mut send_headers = epbs_base_send_headers(&req_headers)?; send_headers.insert( CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&fork.to_string()) - .expect("fork name is always a valid header value"), + HeaderValue::from_str(&fork.to_string()).expect("fork name is always a valid header value"), ); let timeout_ms = state.pbs_config().timeout_get_payload_ms; @@ -123,7 +125,9 @@ pub async fn submit_signed_beacon_block( ) .in_current_span(), ) - .map(|join_result| join_result.unwrap_or_else(|err| Err(PbsError::TokioJoinError(err)))), + .map(|join_result| { + join_result.unwrap_or_else(|err| Err(PbsError::TokioJoinError(err))) + }), ); } diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index e502fd431..1fa67db8f 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -11,7 +11,10 @@ use cb_common::{ pbs::{ForkName, RelayClient, RelayEntry, SignedBuilderRequestAuth, error::PbsError}, signature::verify_builder_request_auth_signature, types::{BlsPublicKey, BlsSecretKey, Chain}, - wire::{CONSENSUS_VERSION_HEADER, EncodingType, get_user_agent_with_version, safe_read_http_response}, + wire::{ + CONSENSUS_VERSION_HEADER, EncodingType, get_user_agent_with_version, + safe_read_http_response, + }, }; use reqwest::{ StatusCode, @@ -79,7 +82,11 @@ pub(crate) fn record_beacon_status(code: &str, endpoint: &str) { /// Logs which relay set an ePBS demux request resolved to (a mux's relays or /// the default set), shared by the bid and preferences endpoints. -pub(crate) fn log_mux_selection(maybe_mux_id: Option<&str>, relay_count: usize, pubkey: &BlsPublicKey) { +pub(crate) fn log_mux_selection( + maybe_mux_id: Option<&str>, + relay_count: usize, + pubkey: &BlsPublicKey, +) { match maybe_mux_id { Some(mux_id) => { debug!(mux_id, relays = relay_count, pubkey = %pubkey, "using mux config") diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index b5cba4dac..5bb70fddf 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -25,12 +25,12 @@ use cb_common::{ pbs::{ BUILDER_V1_API_PATH, BUILDER_V2_API_PATH, BlobsBundle, BuilderBid, BuilderBidFulu, BuilderPreferencesRequest, ExecutionPayloadBid, ExecutionPayloadElectra, - ExecutionPayloadHeaderFulu, ForkName, ForkVersionDecode, - GET_EXECUTION_PAYLOAD_BID_PATH, GET_HEADER_PATH, GET_STATUS_PATH, - GetExecutionPayloadBidResponse, GetHeaderParams, GetHeaderResponse, GetPayloadInfo, - HEADER_TIMEOUT_MS, PayloadAndBlobs, REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, - SUBMIT_BUILDER_PREFERENCES_PATH, SUBMIT_SIGNED_BEACON_BLOCK_PATH, SignedBeaconBlock, - SignedBuilderBid, SignedExecutionPayloadBid, SignedBuilderRequestAuth, SubmitBlindedBlockResponse, + ExecutionPayloadHeaderFulu, ForkName, ForkVersionDecode, GET_EXECUTION_PAYLOAD_BID_PATH, + GET_HEADER_PATH, GET_STATUS_PATH, GetExecutionPayloadBidResponse, GetHeaderParams, + GetHeaderResponse, GetPayloadInfo, HEADER_TIMEOUT_MS, PayloadAndBlobs, + REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SUBMIT_BUILDER_PREFERENCES_PATH, + SUBMIT_SIGNED_BEACON_BLOCK_PATH, SignedBeaconBlock, SignedBuilderBid, + SignedBuilderRequestAuth, SignedExecutionPayloadBid, SubmitBlindedBlockResponse, }, signature::{sign_builder_root, sign_execution_payload_bid_root}, signer::random_secret, @@ -231,8 +231,8 @@ impl MockRelayState { self.received_auth.read().unwrap().as_ref().map(|a| a.message.data.to_vec()) } - /// The full `SignedBuilderRequestAuth` the relay saw, so a test can assert the - /// signature was forwarded byte-for-byte. + /// The full `SignedBuilderRequestAuth` the relay saw, so a test can assert + /// the signature was forwarded byte-for-byte. pub fn received_auth(&self) -> Option { self.received_auth.read().unwrap().clone() } diff --git a/tests/src/utils.rs b/tests/src/utils.rs index 15bf70c96..7065431e6 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -15,7 +15,7 @@ use cb_common::{ SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, SIGNER_PORT_DEFAULT, SignerConfig, SignerType, StartSignerConfig, StaticModuleConfig, StaticPbsConfig, TlsMode, }, - pbs::{RelayClient, RelayEntry, BuilderRequestAuth, SignedBuilderRequestAuth}, + pbs::{BuilderRequestAuth, RelayClient, RelayEntry, SignedBuilderRequestAuth}, signature::sign_builder_request_auth_root, signer::{SignerLoader, random_secret}, types::{BlsPublicKey, BlsSecretKey, BlsSignature, Chain, ModuleId}, @@ -305,19 +305,21 @@ pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { } /// Build a `SignedBuilderRequestAuth` carrying opaque `data`. CB forwards it -/// unmodified; the signature is only verified when `verify_builder_request_auth` is on, -/// so an empty one suffices elsewhere. +/// unmodified; the signature is only verified when +/// `verify_builder_request_auth` is on, so an empty one suffices elsewhere. pub fn opaque_auth(data: &[u8], slot: u64) -> SignedBuilderRequestAuth { SignedBuilderRequestAuth { message: BuilderRequestAuth { - data: ssz_types::VariableList::new(data.to_vec()).expect("data fits in MAX_BUILDER_AUTH_DATA_SIZE"), + data: ssz_types::VariableList::new(data.to_vec()) + .expect("data fits in MAX_BUILDER_AUTH_DATA_SIZE"), slot: Slot::new(slot), }, signature: BlsSignature::empty(), } } -/// Same, but signed under the spec's `DOMAIN_BUILDER_REQUEST_AUTH` by `secret_key`. +/// Same, but signed under the spec's `DOMAIN_BUILDER_REQUEST_AUTH` by +/// `secret_key`. pub fn signed_auth( secret_key: &BlsSecretKey, data: &[u8], @@ -325,7 +327,8 @@ pub fn signed_auth( chain: Chain, ) -> SignedBuilderRequestAuth { let mut auth = opaque_auth(data, slot); - auth.signature = sign_builder_request_auth_root(secret_key, &auth.message.tree_hash_root(), chain); + auth.signature = + sign_builder_request_auth_root(secret_key, &auth.message.tree_hash_root(), chain); auth } diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 509f9c00a..66eb220c2 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -201,9 +201,9 @@ async fn test_get_execution_payload_bid_below_min_bid_passes() -> Result<()> { Ok(()) } -/// fee_recipient is NOT enforced on the ePBS bid path: a bid whose fee_recipient -/// differs from the config value is still served. The execution block's fee -/// recipient is the builder's and the proposer is paid via value + +/// fee_recipient is NOT enforced on the ePBS bid path: a bid whose +/// fee_recipient differs from the config value is still served. The execution +/// block's fee recipient is the builder's and the proposer is paid via value + /// execution_payment, so the BN (not CB) verifies it. The mock serves /// Address::ZERO while the config sets a different value; the bid is returned. #[tokio::test] @@ -661,15 +661,18 @@ async fn test_get_execution_payload_bid_auth_slot_mismatch_400() -> Result<()> { Ok(()) } -/// With `verify_builder_request_auth` on, a bad auth signature is a 401 and a good one -/// passes through to the relay. +/// With `verify_builder_request_auth` on, a bad auth signature is a 401 and a +/// good one passes through to the relay. #[tokio::test] async fn test_get_execution_payload_bid_verify_builder_request_auth_enabled() -> Result<()> { let secret_key = random_secret(); let proposer_pubkey = secret_key.public_key(); - let (mock_validator, mock_state) = - setup_relay(Chain::Hoodi, |cfg| cfg.verify_builder_request_auth = true, generate_mock_relay) - .await?; + let (mock_validator, mock_state) = setup_relay( + Chain::Hoodi, + |cfg| cfg.verify_builder_request_auth = true, + generate_mock_relay, + ) + .await?; // An empty signature never verifies under DOMAIN_BUILDER_REQUEST_AUTH let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); diff --git a/tests/tests/pbs_submit_builder_preferences.rs b/tests/tests/pbs_submit_builder_preferences.rs index 6b2dee0cb..a09ae27ba 100644 --- a/tests/tests/pbs_submit_builder_preferences.rs +++ b/tests/tests/pbs_submit_builder_preferences.rs @@ -1,3 +1,5 @@ +use std::{path::PathBuf, sync::Arc}; + use cb_common::{ pbs::{BuilderPreferences, BuilderPreferencesRequest, SignedBuilderRequestAuth}, signer::random_secret, @@ -5,8 +7,6 @@ use cb_common::{ utils::utcnow_ms, wire::{CONSENSUS_VERSION_HEADER, EncodingType}, }; -use std::{path::PathBuf, sync::Arc}; - use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, @@ -38,7 +38,10 @@ fn past_slot(chain: Chain) -> u64 { ((now_sec.saturating_sub(chain.genesis_time_sec())) / chain.slot_time_sec()).saturating_sub(10) } -fn preferences(auth: SignedBuilderRequestAuth, max_execution_payment: u64) -> BuilderPreferencesRequest { +fn preferences( + auth: SignedBuilderRequestAuth, + max_execution_payment: u64, +) -> BuilderPreferencesRequest { BuilderPreferencesRequest { auth, preferences: BuilderPreferences { max_execution_payment } } } @@ -172,7 +175,8 @@ async fn test_submit_builder_preferences_signature_bound_to_path_pubkey() -> Res let signer = random_secret(); let other_pubkey = random_secret().public_key(); let (mock_validator, mock_state) = - setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay).await?; + setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay) + .await?; // Genuinely signed, just not by the proposer named in the path let auth = signed_auth(&signer, TEST_AUTH_DATA, future_slot(chain), chain); @@ -447,11 +451,10 @@ async fn test_submit_builder_preferences_pipe_self_url_not_dialed() -> Result<() // The mock stands in for whatever answers at the named URL: anything // it receives means a dial went out let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = generate_mock_relay_with_auth_data( - relay_port, - mock_state.signer.public_key(), - &[0xaa], - )?; + let mock_relay = + generate_mock_relay_with_auth_data(relay_port, mock_state.signer.public_key(), &[ + 0xaa, + ])?; tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); let self_url = format!("http://0.0.0.0:{relay_port}/"); @@ -556,7 +559,8 @@ async fn test_submit_builder_preferences_auth_data_match() -> Result<()> { async fn test_submit_builder_preferences_bad_signature_401() -> Result<()> { let chain = Chain::Hoodi; let (mock_validator, mock_state) = - setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay).await?; + setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay) + .await?; let request = preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); @@ -577,7 +581,8 @@ async fn test_submit_builder_preferences_valid_signature() -> Result<()> { let secret = random_secret(); let pubkey = secret.public_key(); let (mock_validator, mock_state) = - setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay).await?; + setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay) + .await?; let auth = signed_auth(&secret, TEST_AUTH_DATA, future_slot(chain), chain); let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT); From 3a4ddb5d1c8bedd326358267eb647c4bd265b208 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 1 Sep 2026 16:26:18 -0700 Subject: [PATCH 68/80] refactor(pbs): harden ePBS config surface and drop the unused fee_recipient knob - max_execution_payment_gwei is now Option (None = unclamped), mirroring the relay-level override. The old u64::MAX default was documented as a literal that TOML cannot parse, so copying it from config.example.toml bricked startup. - Add an unknown-field scanner for the [pbs] table (KNOWN_PBS_FIELDS): PbsConfig is flattened into StaticPbsConfig and cannot use deny_unknown_fields, so a typo'd knob (e.g. a security flag) parsed clean and silently stayed default. It now warns, matching the [[mux]] discipline. Wired on the default and custom-module load paths. - RelayConfig::validate rejects a zero bid_poll_timeout_ms / frequency_get_header_ms (a stall, not "unset"); proposer_deadline_buffer_ms is capped at one slot. - Document strict_block_decode in config.example.toml. - Remove fee_recipient: the field was read nowhere (the BN filters bids by fee_recipient for ePBS), yet its doc claimed CB rejected mismatched bids. Drop the field, the mux override, the dead trait accessor, and the tests that pinned non-enforcement. --- config.example.toml | 20 +-- crates/common/src/config/mod.rs | 6 + crates/common/src/config/mux.rs | 5 +- crates/common/src/config/pbs.rs | 129 ++++++++++++++++-- crates/common/src/config/signer.rs | 3 +- crates/common/src/pbs/types/mod.rs | 7 +- .../pbs/src/routes/execution_payload_bid.rs | 11 +- tests/src/utils.rs | 5 +- tests/tests/pbs_cfg_file_update.rs | 3 +- tests/tests/pbs_get_execution_payload_bid.rs | 42 +----- tests/tests/pbs_mux.rs | 45 +----- tests/tests/pbs_mux_refresh.rs | 1 - 12 files changed, 148 insertions(+), 129 deletions(-) diff --git a/config.example.toml b/config.example.toml index 1acfa86d2..ea952aee9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -67,16 +67,19 @@ min_bid_eth = 0.0 # `value + min(execution_payment, cap)`, matching how the beacon node values it (the BN clamps the # trusted payment at the cap instead of rejecting the bid). This does not accept or reject bids. # Can be overridden per relay with `max_execution_payment_gwei` on the relay entry -# OPTIONAL, DEFAULT: 18446744073709551615 (u64::MAX, unclamped) -# max_execution_payment_gwei = 18446744073709551615 -# Expected fee recipient in ePBS bids. When set, bids whose fee_recipient differs are rejected -# OPTIONAL, DEFAULT: unset (no check) -# fee_recipient = "0x1234567890123456789012345678901234567890" +# OPTIONAL, DEFAULT: unset (unclamped) +# max_execution_payment_gwei = 1000000000 # Whether to verify the BLS signature of the `SignedBuilderRequestAuth` on an ePBS request against the # proposer pubkey in the request path, rejecting a bad signature with 401. CB forwards by default # because the downstream builder must re-verify anyway; operators terminating trust at CB set it true # OPTIONAL, DEFAULT: false verify_builder_request_auth = false +# Strict decoding of the reveal at POST /eth/v1/builder/beacon_blocks. Default (false): CB is a blind +# pipe, forwarding the reveal bytes to the builder without parsing them, so the reveal MUST be SSZ in +# blind mode (the builder validates and rejects a bad block per builder-specs). When true: CB decodes +# the gloas SignedBeaconBlock itself and rejects a non-gloas or otherwise undecodable body with a 400 +# OPTIONAL, DEFAULT: false +# strict_block_decode = false # How late in milliseconds in the slot is "late". This impacts the `get_header` requests, by shortening timeouts for `get_header` calls to # relays and make sure a header is returned within this deadline. If the request from the CL comes later in the slot, then fetching headers is skipped # to force local building and miniminzing the risk of missed slots. See also the timing games section below @@ -183,8 +186,8 @@ frequency_get_header_ms = 300 # bid_poll_timeout_ms = 500 # Per-relay override of the ePBS bid-ranking execution-payment cap in Gwei (see the PBS-level # `max_execution_payment_gwei`) -# OPTIONAL, DEFAULT: the PBS-level `max_execution_payment_gwei` -# max_execution_payment_gwei = 18446744073709551615 +# OPTIONAL, DEFAULT: the PBS-level `max_execution_payment_gwei` (unset = unclamped) +# max_execution_payment_gwei = 1000000000 # The ePBS auth data this relay serves: a bid request routes here only when its `auth.message.data` # equals this value. When unset, the relay is matched only by auth data carrying its URL; auth data # matching no configured relay is rejected with 400 @@ -205,9 +208,6 @@ validator_pubkeys = [ "0x80c7f782b2467c5898c5516a8b6595d75623960b4afc4f71ee07d40985d20e117ba35e7cd352a3e75fb85a8668a3b745", "0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09fe73ccd21f88eab31d6de16194d17782e", ] -# Expected fee recipient in ePBS bids for this mux's validators -# OPTIONAL, DEFAULT: the PBS-level `fee_recipient` -# fee_recipient = "0x1234567890123456789012345678901234567890" # Projection-only: consumed by KM tooling (builder_config projection for this mux's keys), not read # by the PBS runtime # OPTIONAL, DEFAULT: unset diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 558bbf29f..a21dd70ac 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -52,12 +52,17 @@ impl CommitBoostConfig { ) } + for relay in self.relays.iter() { + relay.validate()?; + } + Ok(()) } pub fn from_file(path: &PathBuf) -> Result { let (config, _): (Self, _) = load_from_file(path)?; warn_unknown_mux_fields(path); + warn_unknown_pbs_fields(path); Ok(config) } @@ -66,6 +71,7 @@ impl CommitBoostConfig { pub fn from_env_path() -> Result<(Self, PathBuf)> { let (config, config_path) = Self::from_env_path_silent()?; warn_unknown_mux_fields(&config_path); + warn_unknown_pbs_fields(&config_path); Ok((config, config_path)) } diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index b33e82049..7b0033c8d 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -117,6 +117,7 @@ impl PbsMuxes { let mut relay_clients = Vec::with_capacity(mux.relays.len()); for config in mux.relays.into_iter() { + config.validate()?; relay_clients.push(RelayClient::new(config)?); } @@ -127,7 +128,6 @@ impl PbsMuxes { late_in_slot_time_ms: mux .late_in_slot_time_ms .unwrap_or(default_pbs.late_in_slot_time_ms), - fee_recipient: mux.fee_recipient.or(default_pbs.fee_recipient), ..default_pbs.clone() }; config.validate(chain).await?; @@ -169,8 +169,6 @@ pub struct MuxConfig { pub loader: Option, pub timeout_get_header_ms: Option, pub late_in_slot_time_ms: Option, - /// Expected fee recipient in ePBS bids for this mux's validators - pub fee_recipient: Option
, // The projection-only fields below are consumed by KM tooling, not read by // the PBS runtime. /// The ePBS builder_boost_factor for this mux's keys @@ -359,7 +357,6 @@ const KNOWN_MUX_FIELDS: &[&str] = &[ "loader", "timeout_get_header_ms", "late_in_slot_time_ms", - "fee_recipient", "builder_boost_factor", "min_bid_eth", "builder_boost_factor_p2p", diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 8e93a0e52..961a1ecf2 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -3,12 +3,12 @@ use std::{ collections::HashMap, net::{Ipv4Addr, SocketAddr}, - path::PathBuf, + path::{Path, PathBuf}, sync::Arc, }; use alloy::{ - primitives::{Address, Bytes, U256, utils::format_ether}, + primitives::{Bytes, U256, utils::format_ether}, providers::{Provider, ProviderBuilder}, }; use docker_image::DockerImage; @@ -39,8 +39,8 @@ use crate::{ }, }; -/// How CB fetches bids from a relay: `Http` = the classic get_header request, -/// `Stream` = the ePBS bid stream (polling/SSE). +/// How CB fetches get_header bids from this relay: `Http` = request/response, +/// `Stream` = the relay's WebSocket bid stream. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum GetHeaderTransport { @@ -110,6 +110,19 @@ impl RelayConfig { pub fn id(&self) -> &str { self.id.as_deref().unwrap_or(self.entry.id.as_str()) } + + /// Validate relay-level knobs the PBS runtime reads directly. The timing + /// knobs are optional, but a zero would stall the ePBS bid poll / timing + /// games rather than mean "unset", so reject it explicitly. + pub fn validate(&self) -> Result<()> { + if let Some(ms) = self.bid_poll_timeout_ms { + ensure!(ms > 0, "bid_poll_timeout_ms must be greater than 0 when set"); + } + if let Some(ms) = self.frequency_get_header_ms { + ensure!(ms > 0, "frequency_get_header_ms must be greater than 0 when set"); + } + Ok(()) + } } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -145,18 +158,15 @@ pub struct PbsConfig { /// at `value + min(execution_payment, cap)`, mirroring the BN's valuation /// (beacon-APIs #630 clamps at `max_execution_payment` instead of /// rejecting). Not an accept/reject check; the BN enforces the cap. - /// Default u64::MAX = unclamped - #[serde(default = "default_u64::<{ u64::MAX }>")] - pub max_execution_payment_gwei: u64, + /// Default: unset (None) = unclamped + #[serde(default)] + pub max_execution_payment_gwei: Option, /// When enabled, the BLS signature of an ePBS request's /// `SignedBuilderRequestAuth` is verified against the proposer pubkey. /// False by default: CB forwards because the downstream builder must /// re-verify anyway; operators terminating trust at CB set it true #[serde(default = "default_bool::")] pub verify_builder_request_auth: bool, - /// Expected fee recipient in ePBS bids; when set, bids with a different - /// fee_recipient are rejected - pub fee_recipient: Option
, /// How late in the slot we consider to be "late" (legacy get_header path) #[serde(default = "default_u64::")] pub late_in_slot_time_ms: u64, @@ -237,6 +247,15 @@ impl PbsConfig { ); ensure!(self.late_in_slot_time_ms > 0, "late_in_slot_time_ms must be greater than 0"); + // The buffer is subtracted from the proposer's own deadline; a value at + // or above one slot would leave no time for the bid poll. 0 is allowed + // (no reserve). + const MAX_PROPOSER_DEADLINE_BUFFER_MS: u64 = 12_000; + ensure!( + self.proposer_deadline_buffer_ms < MAX_PROPOSER_DEADLINE_BUFFER_MS, + "proposer_deadline_buffer_ms must be less than one slot ({MAX_PROPOSER_DEADLINE_BUFFER_MS} ms)" + ); + if self.min_bid_p2p_wei.is_some() { info!("field min_bid_p2p_eth is applied via KM tooling, not by the PBS runtime"); } @@ -368,7 +387,6 @@ pub async fn load_pbs_config(config_path: Option) -> Result<(PbsModuleC SocketAddr::from((config.pbs.pbs_config.host, config.pbs.pbs_config.port)) }; - // Get the list of relays from the default config let relay_clients = config.relays.into_iter().map(RelayClient::new).collect::>>()?; let mut all_relays = HashMap::with_capacity(relay_clients.len()); @@ -453,6 +471,7 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC // load module config including the extra data (if any) let (cb_config, config_path): (StubConfig, _) = load_file_from_env(CONFIG_ENV)?; super::warn_unknown_mux_fields(&config_path); + warn_unknown_pbs_fields(&config_path); cb_config.pbs.static_config.validate(cb_config.chain).await?; // use endpoint from env if set, otherwise use default host and port @@ -465,7 +484,12 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC )) }; - // Get the list of relays from the default config + // Get the list of relays from the default config. Validate each first: the + // default binary validates top-level relays via `CommitBoostConfig::validate`, + // which this custom-module load path does not call. + for relay in cb_config.relays.iter() { + relay.validate()?; + } let relay_clients = cb_config.relays.into_iter().map(RelayClient::new).collect::>>()?; let mut all_relays = HashMap::with_capacity(relay_clients.len()); @@ -566,6 +590,64 @@ fn default_public_ssv_api_url() -> Url { Url::parse("https://api.ssv.network/api/v4/").expect("default URL is valid") } +/// The serde keys recognized on the `[pbs]` table: the `StaticPbsConfig` +/// wrapper (`docker_image`, `with_signer`) plus the flattened `PbsConfig` +/// fields in their serde-renamed form (e.g. `min_bid_eth`, not `min_bid_wei`). +/// Kept in lockstep with those two structs. +const KNOWN_PBS_FIELDS: &[&str] = &[ + // StaticPbsConfig wrapper + "docker_image", + "with_signer", + // PbsConfig (flattened) + "host", + "port", + "relay_check", + "wait_all_registrations", + "timeout_get_header_ms", + "timeout_get_payload_ms", + "timeout_register_validator_ms", + "skip_sigverify", + "min_bid_eth", + "max_execution_payment_gwei", + "verify_builder_request_auth", + "late_in_slot_time_ms", + "proposer_deadline_buffer_ms", + "extra_validation_enabled", + "strict_block_decode", + "rpc_url", + "ssv_node_api_url", + "ssv_public_api_url", + "http_timeout_seconds", + "register_validator_retry_limit", + "validator_registration_batch_size", + "mux_registry_refresh_interval_seconds", + "advertised_urls", + "min_bid_p2p_eth", + "builder_boost_factor_p2p", +]; + +/// Unknown keys on the `[pbs]` table of a raw config document. `PbsConfig` is +/// `#[serde(flatten)]`ed into `StaticPbsConfig`, and a flattened struct cannot +/// take `#[serde(deny_unknown_fields)]` (it would reject the outer struct's own +/// keys), so typo visibility comes from this extra pass over the raw TOML +/// instead (mirrors [`super::unknown_mux_fields`]). +pub fn unknown_pbs_fields(raw: &toml::Value) -> Vec { + let Some(table) = raw.get("pbs").and_then(|value| value.as_table()) else { + return Vec::new(); + }; + table.keys().filter(|key| !KNOWN_PBS_FIELDS.contains(&key.as_str())).cloned().collect() +} + +/// WARN-logs every unknown `[pbs]` key in the config file at `path`. +/// Best-effort: unreadable/unparseable input is serde's problem to report. +pub fn warn_unknown_pbs_fields(path: &Path) { + let Ok(raw) = std::fs::read_to_string(path) else { return }; + let Ok(value) = raw.parse::() else { return }; + for key in unknown_pbs_fields(&value) { + warn!("unknown field `{key}` on the [pbs] table is ignored by the PBS runtime"); + } +} + #[cfg(test)] mod tests { use super::*; @@ -588,4 +670,27 @@ mod tests { assert_eq!(cfg.min_bid_p2p_wei, Some(U256::from(200_000_000_000_000_000u64))); assert_eq!(cfg.builder_boost_factor_p2p, Some(0)); } + + // The false-positive trap: a renamed field (`min_bid_eth`) or a wrapper key + // (`docker_image`) must not be flagged as unknown. + #[test] + fn unknown_pbs_fields_flags_typos_only() { + let raw: toml::Value = r#" + [pbs] + docker_image = "x" + with_signer = false + min_bid_eth = 0.0 + max_execution_payment_gwei = 1000000000 + strict_block_decode = true + proposer_deadline_buffer_ms = 50 + skip_sigverify = false + bid_poll_timeout_ms = 500 + "# + .parse() + .unwrap(); + assert_eq!(unknown_pbs_fields(&raw), vec!["bid_poll_timeout_ms".to_string()]); + + let raw: toml::Value = "chain = \"Holesky\"".parse().unwrap(); + assert!(unknown_pbs_fields(&raw).is_empty()); + } } diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 123b86bc9..2f3e2f5fa 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -475,9 +475,8 @@ mod tests { timeout_register_validator_ms: 0, skip_sigverify: false, min_bid_wei: Uint::<256, 4>::from(0), - max_execution_payment_gwei: 0, + max_execution_payment_gwei: None, verify_builder_request_auth: false, - fee_recipient: None, late_in_slot_time_ms: 0, proposer_deadline_buffer_ms: 0, extra_validation_enabled: false, diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index dfc982766..1c968309f 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -1,4 +1,4 @@ -use alloy::primitives::{Address, B256, U256, b256}; +use alloy::primitives::{B256, U256, b256}; pub use lh_eth2::ForkVersionedResponse; pub use lh_types::ForkName; use lh_types::{BlindedPayload, ExecPayload, MainnetEthSpec, Slot}; @@ -87,7 +87,6 @@ pub trait GetExecutionPayloadBidInfo { fn parent_root(&self) -> B256; fn value(&self) -> u64; fn execution_payment(&self) -> u64; - fn fee_recipient(&self) -> Address; fn builder_index(&self) -> u64; fn slot(&self) -> u64; fn gas_limit(&self) -> u64; @@ -114,10 +113,6 @@ impl GetExecutionPayloadBidInfo for GetExecutionPayloadBidResponse { self.data.message.execution_payment } - fn fee_recipient(&self) -> Address { - self.data.message.fee_recipient - } - fn builder_index(&self) -> u64 { self.data.message.builder_index } diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 0242d130b..be4774591 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -386,7 +386,11 @@ fn format_gwei_as_eth(gwei: u64) -> String { /// The execution-payment cap used when ranking a relay's bids: the per-relay /// override, else the global config value (default u64::MAX = unclamped). fn ranking_cap_gwei(relay: &RelayClient, pbs_config: &PbsConfig) -> u64 { - relay.config.max_execution_payment_gwei.unwrap_or(pbs_config.max_execution_payment_gwei) + relay + .config + .max_execution_payment_gwei + .or(pbs_config.max_execution_payment_gwei) + .unwrap_or(u64::MAX) } /// A bid's ranking value per beacon-APIs #630: the BN values a bid at @@ -832,7 +836,7 @@ fn extra_validation( #[cfg(test)] mod tests { - use alloy::primitives::{Address, B256, aliases::B32}; + use alloy::primitives::{B256, aliases::B32}; use cb_common::{ constants::{DOMAIN_BUILDER_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{BuilderRequestAuth, error::ValidationError}, @@ -1170,9 +1174,6 @@ mod tests { fn execution_payment(&self) -> u64 { self.execution_payment } - fn fee_recipient(&self) -> Address { - Address::ZERO - } fn builder_index(&self) -> u64 { 0 } diff --git a/tests/src/utils.rs b/tests/src/utils.rs index 7065431e6..77f05dd5e 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -182,9 +182,8 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { timeout_register_validator_ms: u64::MAX, skip_sigverify: false, min_bid_wei: U256::ZERO, - // matches the config default: the cap is a ranking clamp, MAX = unclamped - max_execution_payment_gwei: u64::MAX, - fee_recipient: None, + // matches the config default: the cap is a ranking clamp, unset = unclamped + max_execution_payment_gwei: None, late_in_slot_time_ms: u64::MAX, proposer_deadline_buffer_ms: 0, extra_validation_enabled: false, diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index 9349836e3..b8858aeb9 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -64,9 +64,8 @@ async fn test_cfg_file_update() -> Result<()> { timeout_register_validator_ms: 3000, skip_sigverify: true, min_bid_wei: U256::ZERO, - max_execution_payment_gwei: 0, + max_execution_payment_gwei: None, verify_builder_request_auth: false, - fee_recipient: None, late_in_slot_time_ms: u64::MAX / 2, /* serde gets very upset about serializing u64::MAX * or anything close to it */ proposer_deadline_buffer_ms: 0, diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 66eb220c2..9e98506fe 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -1,6 +1,6 @@ use std::{path::PathBuf, sync::Arc}; -use alloy::primitives::{Address, B256, U256}; +use alloy::primitives::{B256, U256}; use cb_common::{ constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{ @@ -201,44 +201,6 @@ async fn test_get_execution_payload_bid_below_min_bid_passes() -> Result<()> { Ok(()) } -/// fee_recipient is NOT enforced on the ePBS bid path: a bid whose -/// fee_recipient differs from the config value is still served. The execution -/// block's fee recipient is the builder's and the proposer is paid via value + -/// execution_payment, so the BN (not CB) verifies it. The mock serves -/// Address::ZERO while the config sets a different value; the bid is returned. -#[tokio::test] -async fn test_get_execution_payload_bid_fee_recipient_not_enforced() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let mut pbs_config = get_pbs_config(pbs_port); - pbs_config.fee_recipient = Some(Address::from([1; 20])); - let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; - - let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; - assert_eq!(res.status(), StatusCode::OK); - assert_eq!(mock_state.received_execution_payload_bid(), 1); - Ok(()) -} - /// The caller's auth data designates the downstream: only the relay whose /// `expected_auth_data` matches is contacted, and its bid is returned. #[tokio::test] @@ -2024,7 +1986,7 @@ async fn test_get_execution_payload_bid_impl_opts( // Run the PBS service let mut pbs_config = get_pbs_config(pbs_port); - pbs_config.max_execution_payment_gwei = max_execution_payment_gwei; + pbs_config.max_execution_payment_gwei = Some(max_execution_payment_gwei); let config = to_pbs_config(chain, pbs_config, relays); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index b0cd348a0..dc84822f1 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; -use alloy::primitives::{Address, U256}; +use alloy::primitives::U256; use cb_common::{ config::{ HTTP_TIMEOUT_SECONDS_DEFAULT, MUXER_HTTP_MAX_LENGTH, MuxConfig, MuxKeysLoader, PbsMuxes, @@ -377,7 +377,6 @@ async fn test_ssv_multi_with_node() -> Result<()> { relays: vec![(*relay.config).clone()], timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], - fee_recipient: None, builder_boost_factor: None, min_bid_wei: None, builder_boost_factor_p2p: None, @@ -489,7 +488,6 @@ async fn test_ssv_multi_with_public() -> Result<()> { relays: vec![(*relay.config).clone()], timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], - fee_recipient: None, builder_boost_factor: None, min_bid_wei: None, builder_boost_factor_p2p: None, @@ -535,44 +533,3 @@ async fn test_ssv_multi_with_public() -> Result<()> { Ok(()) } - -/// Mux-level fee_recipient overrides the default config's for that mux's keys -#[tokio::test] -async fn test_mux_fee_recipient_resolution() -> Result<()> { - setup_test_env(); - let relay = generate_mock_relay(30100, random_secret().public_key())?; - let validator_pubkey = random_secret().public_key(); - let expected = Address::from([1; 20]); - let muxes = PbsMuxes { - muxes: vec![MuxConfig { - id: "fee-mux".to_string(), - loader: None, - late_in_slot_time_ms: None, - relays: vec![(*relay.config).clone()], - timeout_get_header_ms: Some(u64::MAX - 1), - validator_pubkeys: vec![validator_pubkey.clone()], - fee_recipient: Some(expected), - builder_boost_factor: None, - min_bid_wei: None, - builder_boost_factor_p2p: None, - min_bid_p2p_wei: None, - }], - }; - - let pbs_config = get_pbs_config(30101); - let (mux_lookup, _) = muxes.clone().validate_and_fill(Chain::Hoodi, &pbs_config).await?; - let mux = mux_lookup.get(&validator_pubkey).unwrap(); - assert_eq!(mux.config.fee_recipient, Some(expected)); - assert_eq!(pbs_config.fee_recipient, None); - - // The inherit direction: a mux without its own fee_recipient gets the default's - let mut muxes = muxes; - muxes.muxes[0].fee_recipient = None; - let mut pbs_config = pbs_config; - let default_recipient = Address::from([2; 20]); - pbs_config.fee_recipient = Some(default_recipient); - let (mux_lookup, _) = muxes.validate_and_fill(Chain::Hoodi, &pbs_config).await?; - let mux = mux_lookup.get(&validator_pubkey).unwrap(); - assert_eq!(mux.config.fee_recipient, Some(default_recipient)); - Ok(()) -} diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index 859a7b011..f087bb1da 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -93,7 +93,6 @@ async fn test_auto_refresh() -> Result<()> { relays: vec![(*mux_relay.config).clone()], timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], - fee_recipient: None, builder_boost_factor: None, min_bid_wei: None, builder_boost_factor_p2p: None, From 9c669e3e49707a04d59e93d51ef593469142df4e Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 1 Sep 2026 16:26:30 -0700 Subject: [PATCH 69/80] feat(pbs): reject a non-SSZ block reveal with 415 in the blind pipe In the default blind pipe CB forwards the reveal bytes unparsed, so the reveal must be SSZ. A JSON or otherwise non-SSZ reveal was relabeled octet-stream and failed opaquely at the builder (a 500 with no explanation). It is now rejected up front with 415. The Content-Type is resolved with the builder-specs default (JSON when the header is absent), so an unlabeled reveal is treated as JSON and rejected too, not assumed to be SSZ. --- .../src/routes/submit_signed_beacon_block.rs | 17 ++++-- tests/tests/pbs_submit_signed_beacon_block.rs | 59 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs index d61e08623..10c16c264 100644 --- a/crates/pbs/src/routes/submit_signed_beacon_block.rs +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -7,7 +7,8 @@ use axum::{ use cb_common::{ pbs::{RelayClient, error::PbsError, is_gloas}, wire::{ - BodyDeserializeError, CONSENSUS_VERSION_HEADER, decode_signed_beacon_block, get_user_agent, + BodyDeserializeError, CONSENSUS_VERSION_HEADER, EncodingType, + content_type_encoding_with_default, decode_signed_beacon_block, get_user_agent, require_consensus_version_header, }, }; @@ -85,12 +86,20 @@ pub async fn submit_signed_beacon_block( let slot = block.slot().as_u64(); (Bytes::from(block.as_ssz_bytes()), Some(slot)) } else { - // Blind pipe: forward the bytes without parsing; block validity is the - // builder's job. The outbound is always SSZ, so the reveal is expected - // in SSZ (strict mode is for operators who want CB to decode). + // Blind pipe: forward the bytes without parsing (the builder validates). + // The outbound is always SSZ, so the reveal must be SSZ; a JSON or + // otherwise non-SSZ reveal would be forwarded mislabeled as octet-stream + // and fail opaquely downstream, so reject it up front with 415. The + // Content-Type defaults to JSON when absent (builder-specs), so an + // unlabeled reveal is treated as JSON and rejected, not assumed SSZ. if body.is_empty() { return Err(BodyDeserializeError::MissingBody.into()); } + if content_type_encoding_with_default(&req_headers, EncodingType::Json)? != + EncodingType::Ssz + { + return Err(BodyDeserializeError::UnsupportedMediaType.into()); + } (body, None) }; diff --git a/tests/tests/pbs_submit_signed_beacon_block.rs b/tests/tests/pbs_submit_signed_beacon_block.rs index 84f3d01ff..7127127aa 100644 --- a/tests/tests/pbs_submit_signed_beacon_block.rs +++ b/tests/tests/pbs_submit_signed_beacon_block.rs @@ -325,6 +325,65 @@ async fn test_submit_signed_beacon_block_unsupported_content_type_415() -> Resul Ok(()) } +/// In the default blind pipe the reveal must be SSZ: a JSON `Content-Type` is +/// rejected up front with a 415 (and nothing is forwarded) rather than being +/// relabeled octet-stream and failing opaquely at the builder. +#[tokio::test] +async fn test_submit_signed_beacon_block_blind_json_415() -> Result<()> { + let chain = Chain::Hoodi; + // Default config: blind pipe, no strict decode. + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(serde_json::to_vec(&block)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!( + state.received_signed_beacon_block(), + 0, + "a JSON reveal must not be forwarded in blind mode" + ); + Ok(()) +} + +/// Blind mode requires an explicit SSZ Content-Type. An unlabeled reveal defaults +/// to JSON per builder-specs, so it is rejected with 415 rather than assumed to be +/// SSZ and forwarded mislabeled (which would 500 opaquely at the builder). +#[tokio::test] +async fn test_submit_signed_beacon_block_blind_absent_content_type_415() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + // SSZ bytes, but no Content-Type header at all + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(block.as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!( + state.received_signed_beacon_block(), + 0, + "an unlabeled reveal must not be forwarded in blind mode" + ); + Ok(()) +} + /// An SSZ submission missing `Eth-Consensus-Version` is a 400: the SSZ block is /// not self-describing, so the fork header is required to select the variant /// (and the spec mandates the header regardless of encoding). From c92e0d8263d82d8c7d155cbf37aa3fb96557e9e6 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 1 Sep 2026 16:26:44 -0700 Subject: [PATCH 70/80] refactor(pbs): accept Gloas only in the consensus-version header require_consensus_version_header accepted Gloas | Heze while its own doc said GLOAS ONLY. Narrow it to Gloas: a later fork's ePBS semantics are not yet validated here, so it is 400'd rather than silently handled as gloas. The match stays exhaustive with no wildcard, so a future lighthouse fork stops it compiling and forces an explicit decision to widen the set. --- crates/common/src/wire.rs | 13 ++++++------- crates/pbs/src/routes/submit_signed_beacon_block.rs | 7 ++++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index c83d0571c..47dce82b8 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -359,20 +359,19 @@ pub fn require_consensus_version_header( // Echoed into the 400 body, so bound attacker-controlled length let unsupported = || BodyDeserializeError::InvalidVersionHeader(value.chars().take(64).collect()); - // The endpoint is defined from the Gloas fork ONWARDS, so a gloas-or-later - // version is accepted and returned as-is (the caller uses it to select the - // SSZ variant). Still exhaustive, no wildcard: when lighthouse adds a fork - // after the current tip this match stops compiling, forcing an explicit - // decision to add it to the accepted set rather than silently 400ing it. + // The endpoint is defined for the Gloas fork, and only Gloas is accepted for + // now: a later fork's semantics are not yet validated here, so it is 400'd + // rather than silently handled as gloas. match ForkName::from_str(value).map_err(|_| unsupported())? { - fork @ (ForkName::Gloas | ForkName::Heze) => Ok(fork), + ForkName::Gloas => Ok(ForkName::Gloas), ForkName::Base | ForkName::Altair | ForkName::Bellatrix | ForkName::Capella | ForkName::Deneb | ForkName::Electra | - ForkName::Fulu => Err(unsupported()), + ForkName::Fulu | + ForkName::Heze => Err(unsupported()), } } diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs index 10c16c264..5f71856e3 100644 --- a/crates/pbs/src/routes/submit_signed_beacon_block.rs +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -108,9 +108,10 @@ pub async fn submit_signed_beacon_block( } info!(ua, ?slot, strict, "new request"); - // Base headers, then stamp the block's ACTUAL fork (gloas or later) as the - // outbound Eth-Consensus-Version rather than a hard-coded gloas, so a - // post-gloas reveal is labeled correctly. + // Base headers, then stamp the block's fork as the outbound + // Eth-Consensus-Version. The header validator accepts Gloas only, so `fork` + // is always Gloas today; it is passed through (not hard-coded) so widening + // the accepted set later needs no change here. let mut send_headers = epbs_base_send_headers(&req_headers)?; send_headers.insert( CONSENSUS_VERSION_HEADER, From 7711068e0b24aaecd02c2ec224e536bd656d6379 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 1 Sep 2026 16:27:04 -0700 Subject: [PATCH 71/80] chore(pbs): drop redundant comments and the dead content_type_encoding helper Remove comments that restate the adjacent, self-documenting code, correct two doc comments that no longer matched their code (the epbs_base_send_headers fork note and the mock received_auth field), fix a run-on test comment, and delete the unused content_type_encoding wrapper (only content_type_encoding_with_default is called). --- crates/common/src/config/mux.rs | 1 - crates/common/src/pbs/relay.rs | 10 ++++------ crates/common/src/wire.rs | 17 ++++------------- crates/pbs/src/routes/execution_payload_bid.rs | 3 --- crates/pbs/src/utils.rs | 7 ++++--- tests/src/mock_relay.rs | 3 +-- 6 files changed, 13 insertions(+), 28 deletions(-) diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index 7b0033c8d..632d2587a 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -342,7 +342,6 @@ impl MuxKeysLoader { }, }?; - // Remove duplicates let deduped_keys = remove_duplicate_keys(keys); Ok(deduped_keys) } diff --git a/crates/common/src/pbs/relay.rs b/crates/common/src/pbs/relay.rs index 978694266..0733bf091 100644 --- a/crates/common/src/pbs/relay.rs +++ b/crates/common/src/pbs/relay.rs @@ -307,9 +307,8 @@ mod tests { let validator_pubkey = bls_pubkey_from_hex_unchecked( "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", ); - // Note: HashMap iteration order is not guaranteed, so we can't predict the - // exact order of parameters Instead of hard-coding the order, we'll - // check that both parameters are present in the URL + // HashMap iteration order is not guaranteed, so assert both parameters are + // present rather than hard-coding their order in the URL. let url_prefix = format!( "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz/eth/v1/builder/header/{slot}/{parent_hash}/{validator_pubkey}?" ); @@ -489,9 +488,8 @@ mod tests { let validator_pubkey = bls_pubkey_from_hex_unchecked( "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", ); - // Note: HashMap iteration order is not guaranteed, so we can't predict the - // exact order of parameters Instead of hard-coding the order, we'll - // check that both parameters are present in the URL + // HashMap iteration order is not guaranteed, so assert both parameters are + // present rather than hard-coding their order in the URL. let url_prefix = format!( "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz/eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{validator_pubkey}?" ); diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index 47dce82b8..9fab4497d 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -76,7 +76,6 @@ pub async fn read_chunked_body_with_max( max_size: usize, request_url: &str, ) -> Result, ResponseReadError> { - // Get the content length from the response headers #[cfg(not(feature = "testing-flags"))] let content_length = res.content_length(); @@ -476,21 +475,13 @@ pub enum BodyDeserializeError { MissingBody, } -/// The request body encoding to decode with, from the Content-Type, using the -/// shared `NO_PREFERENCE_DEFAULT` (JSON) when no Content-Type is present. -pub fn content_type_encoding(headers: &HeaderMap) -> Result { - content_type_encoding_with_default(headers, NO_PREFERENCE_DEFAULT) -} - -/// Like `content_type_encoding`, but the caller chooses the encoding used when -/// the request has no Content-Type header. This is the Content-Type analogue of -/// [`get_accept_types_with_default`]. Precedence: +/// The request body encoding to decode with, from the Content-Type, letting the +/// caller choose the encoding used when the request has no Content-Type header. +/// This is the Content-Type analogue of [`get_accept_types_with_default`]. +/// Precedence: /// - Content-Type absent → `no_preference_default` /// - Content-Type recognized → use it /// - Content-Type present but unrecognized → UnsupportedMediaType -/// -/// Legacy callers use [`content_type_encoding`] (default JSON); SSZ-by-default -/// endpoints pass `EncodingType::Ssz`. pub fn content_type_encoding_with_default( headers: &HeaderMap, no_preference_default: EncodingType, diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index be4774591..eda8b02eb 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -461,8 +461,6 @@ async fn send_timed_get_execution_payload_bid( if relay.config.enable_timing_games { if let Some(target_ms) = relay.config.target_first_request_ms { - // sleep until target time in slot - let Some(delay) = target_first_request_delay_ms(target_ms, ms_into_slot, timeout_left_ms) else { @@ -610,7 +608,6 @@ async fn send_one_get_execution_payload_bid( mut req_config: RequestContext, validation: ValidationContext, ) -> Result<(u64, Option), PbsError> { - // request send time, forwarded to the relay in HEADER_START_TIME_UNIX_MS let start_request_time = utcnow_ms(); req_config.headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 1fa67db8f..a201ae4aa 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -145,9 +145,10 @@ pub(crate) async fn post_ssz_expect_accepted( /// Base outbound headers shared by the ePBS endpoints: the versioned /// `User-Agent` and `Eth-Consensus-Version`. All three relay hops send SSZ /// bodies of fork-versioned wire types, so the builder needs the fork header; -/// it is re-derived as Gloas (these are Gloas-only endpoints), never echoed -/// from the inbound request. Callers add their endpoint-specific headers (bid -/// adds `Accept` and the timing headers). +/// it defaults to Gloas here and callers may override it +/// (submit_signed_beacon_block overwrites it with the inbound request's fork). +/// Callers add their endpoint-specific headers (bid adds `Accept` and the +/// timing headers). pub(crate) fn epbs_base_send_headers(req_headers: &HeaderMap) -> Result { let mut headers = HeaderMap::new(); headers.insert( diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index 5bb70fddf..e3257a810 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -132,8 +132,7 @@ pub struct MockRelayState { /// Hold every bid request this long before answering, simulating a builder /// that sits on a request instead of answering promptly bid_delay_ms: Option, - /// `data` bytes of the last `SignedBuilderRequestAuth` forwarded on a bid - /// request + /// The last `SignedBuilderRequestAuth` forwarded on a bid request received_auth: RwLock>, response_override: RwLock>, bid_value: RwLock, From f4711c0e13d43acc6b46a3da5cd66b7a0a464ff5 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Wed, 2 Sep 2026 16:46:35 -0700 Subject: [PATCH 72/80] feat(pbs): harden the ePBS transient pipe (SSRF deny-list, sentinel relay_id, shared client) The transient pipe dials a builder URL taken straight from untrusted auth data, so before building the client it now resolves the target and refuses any address in loopback / private / link-local / CGNAT (100.64.0.0/10) / v4-mapped-internal space, failing closed on a resolution error (PbsClientError::PipeTargetBlocked -> 400). A DNS-rebind (TOCTOU) between the check and the dial is a disclosed v1 limitation. The check is compiled and active in every build; only the `testing-flags` feature can skip it (a thread-local a test sets) so the e2e suite can dial a local mock. The pipe relay id is now a fixed "pipe" sentinel instead of the attacker-supplied URL host, which was an unbounded Prometheus relay_id cardinality vector. The pipe reuses a single shared reqwest::Client (built once in PbsState) instead of constructing a fresh one per bid - matching how configured relays already reuse their client and keeping the connection pool warm across slots. Adds RelayClient::with_client for that; resolve_addressed_relays / transient_pipe_relay are now async for the resolution step. --- crates/common/src/pbs/relay.rs | 52 +++-- crates/pbs/Cargo.toml | 5 + crates/pbs/src/error.rs | 12 + crates/pbs/src/lib.rs | 3 + crates/pbs/src/routes/builder_preferences.rs | 4 +- .../pbs/src/routes/execution_payload_bid.rs | 9 +- crates/pbs/src/state.rs | 37 ++- crates/pbs/src/utils.rs | 215 ++++++++++++++++-- tests/Cargo.toml | 2 +- tests/tests/pbs_get_execution_payload_bid.rs | 3 + tests/tests/pbs_submit_builder_preferences.rs | 3 + 11 files changed, 298 insertions(+), 47 deletions(-) diff --git a/crates/common/src/pbs/relay.rs b/crates/common/src/pbs/relay.rs index 0733bf091..c69790889 100644 --- a/crates/common/src/pbs/relay.rs +++ b/crates/common/src/pbs/relay.rs @@ -93,35 +93,51 @@ pub struct RelayClient { pub config: Arc, } +/// The baseline outbound headers for a relay: the CommitBoost version header +/// plus any operator-configured custom headers. Shared by the client's default +/// headers and the get_header stream handshake so the two cannot diverge. +fn relay_headers(config: &RelayConfig) -> eyre::Result { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_VERSION_KEY, HeaderValue::from_static(HEADER_VERSION_VALUE)); + + if let Some(custom_headers) = &config.headers { + for (key, value) in custom_headers { + headers.insert( + HeaderName::from_str(key).wrap_err("{key} is an invalid header name")?, + HeaderValue::from_str(value).wrap_err("{key} has an invalid header value")?, + ); + } + } + + Ok(headers) +} + impl RelayClient { pub fn new(config: RelayConfig) -> eyre::Result { + let client = reqwest::Client::builder() + .default_headers(relay_headers(&config)?) + .timeout(DEFAULT_REQUEST_TIMEOUT) + .build()?; + + Self::with_client(config, client) + } + + /// Builds a relay client that reuses an existing `reqwest::Client` (its + /// connection pool and TLS config) instead of constructing a fresh one. + /// The ePBS transient pipe dials per request, so it reuses one process-wide + /// client rather than paying a cold client build on every dial. Otherwise + /// identical to [`RelayClient::new`]. + pub fn with_client(config: RelayConfig, client: reqwest::Client) -> eyre::Result { let stream_url = match config.get_header { GetHeaderTransport::Http => None, GetHeaderTransport::Stream => Some(stream_url(&config.entry.url)?), }; - let mut headers = HeaderMap::new(); - headers.insert(HEADER_VERSION_KEY, HeaderValue::from_static(HEADER_VERSION_VALUE)); - - if let Some(custom_headers) = &config.headers { - for (key, value) in custom_headers { - headers.insert( - HeaderName::from_str(key).wrap_err("{key} is an invalid header name")?, - HeaderValue::from_str(value).wrap_err("{key} has an invalid header value")?, - ); - } - } - - let client = reqwest::Client::builder() - .default_headers(headers.clone()) - .timeout(DEFAULT_REQUEST_TIMEOUT) - .build()?; - Ok(Self { id: Arc::new(config.id().to_owned()), client, stream_url, - stream_headers: Arc::new(headers), + stream_headers: Arc::new(relay_headers(&config)?), config: Arc::new(config), }) } diff --git a/crates/pbs/Cargo.toml b/crates/pbs/Cargo.toml index 7f591a283..92f53c063 100644 --- a/crates/pbs/Cargo.toml +++ b/crates/pbs/Cargo.toml @@ -5,6 +5,11 @@ publish = false rust-version.workspace = true version.workspace = true +[features] +# Test-only escape hatches (e.g. skipping the pipe SSRF target check so an e2e +# test can dial a local mock). Never enabled in a release build. +testing-flags = [] + [dependencies] alloy.workspace = true async-trait.workspace = true diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index a70748db9..27171dd66 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -29,6 +29,12 @@ pub enum PbsClientError { NoBuilderResponse, #[error("auth data does not match a configured builder")] AuthDataMismatch, + /// The transient pipe was pointed at a builder URL that resolves into + /// loopback/private/link-local space (or one that could not be resolved at + /// all): an SSRF attempt or a misconfiguration aiming CB at an internal + /// host, so it is a bad request, never a target to dial. + #[error("pipe target resolves to a disallowed address")] + PipeTargetBlocked, #[error("auth data is empty")] EmptyAuthData, #[error("missing or invalid timing headers")] @@ -57,6 +63,7 @@ impl PbsClientError { PbsClientError::NoResponse => StatusCode::BAD_GATEWAY, PbsClientError::NoBuilderResponse => StatusCode::INTERNAL_SERVER_ERROR, PbsClientError::AuthDataMismatch => StatusCode::BAD_REQUEST, + PbsClientError::PipeTargetBlocked => StatusCode::BAD_REQUEST, PbsClientError::EmptyAuthData => StatusCode::BAD_REQUEST, PbsClientError::MissingTimingHeader => StatusCode::BAD_REQUEST, PbsClientError::AuthSlotMismatch => StatusCode::BAD_REQUEST, @@ -88,6 +95,9 @@ impl IntoResponse for PbsClientError { PbsClientError::AuthDataMismatch => { "Invalid SignedBuilderRequestAuth: auth.message.data does not match the value agreed with this builder".to_string() } + PbsClientError::PipeTargetBlocked => { + "Invalid SignedBuilderRequestAuth: the addressed builder URL resolves to a disallowed address".to_string() + } PbsClientError::EmptyAuthData => { "Invalid SignedBuilderRequestAuth: auth.message.data must not be empty".to_string() } @@ -154,6 +164,8 @@ mod test { fn auth_errors_map_to_spec_status_codes() { assert_eq!(PbsClientError::AuthSlotMismatch.status_code(), StatusCode::BAD_REQUEST); assert_eq!(PbsClientError::AuthSigVerify.status_code(), StatusCode::UNAUTHORIZED); + // A pipe target pointed at an internal address is a bad request, not a 5xx + assert_eq!(PbsClientError::PipeTargetBlocked.status_code(), StatusCode::BAD_REQUEST); assert_eq!( PbsClientError::DecodeError(BodyDeserializeError::MissingBody).status_code(), StatusCode::BAD_REQUEST, diff --git a/crates/pbs/src/lib.rs b/crates/pbs/src/lib.rs index 8b4afdcfe..35be31879 100644 --- a/crates/pbs/src/lib.rs +++ b/crates/pbs/src/lib.rs @@ -13,3 +13,6 @@ pub use constants::*; pub use mev_boost::*; pub use service::PbsService; pub use state::{BuilderApiState, PbsState, PbsStateGuard}; +// Test-only: let the e2e suite skip the pipe SSRF target check (dial a local mock). +#[cfg(feature = "testing-flags")] +pub use utils::set_skip_pipe_target_check; diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index fa1c6126d..7f4274801 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -97,7 +97,9 @@ pub async fn submit_builder_preferences( relays, request.auth.message.data.as_ref(), &pbs_config.advertised_urls, - )?; + &state.pipe_client, + ) + .await?; let send_headers = epbs_base_send_headers(&req_headers)?; diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index eda8b02eb..8d42b81d9 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -184,8 +184,13 @@ pub async fn get_execution_payload_bid( ); } - let relays = - resolve_addressed_relays(relays, body.message.data.as_ref(), &pbs_config.advertised_urls)?; + let relays = resolve_addressed_relays( + relays, + body.message.data.as_ref(), + &pbs_config.advertised_urls, + &state.pipe_client, + ) + .await?; // The proposer's own deadline (Date-Milliseconds + X-Timeout-Ms) tells CB // exactly when the beacon node will stop waiting, so CB derives its timeout diff --git a/crates/pbs/src/state.rs b/crates/pbs/src/state.rs index bd683e5f4..cad88d290 100644 --- a/crates/pbs/src/state.rs +++ b/crates/pbs/src/state.rs @@ -1,11 +1,13 @@ use std::{path::PathBuf, sync::Arc}; use cb_common::{ + DEFAULT_REQUEST_TIMEOUT, config::{PbsConfig, PbsModuleConfig}, - pbs::RelayClient, + pbs::{HEADER_VERSION_KEY, HEADER_VERSION_VALUE, RelayClient}, types::BlsPublicKey, }; use parking_lot::RwLock; +use reqwest::header::{HeaderMap, HeaderValue}; pub trait BuilderApiState: Clone + Sync + Send + 'static {} impl BuilderApiState for () {} @@ -21,17 +23,46 @@ pub struct PbsState { pub config: Arc, /// Path of the config file, for watching changes pub config_path: Arc, + /// One process-wide HTTP client the ePBS transient pipe reuses across + /// dials. Configured relays build their client once at load; the pipe + /// would otherwise pay a cold client (pool + TLS) build on every + /// request, so it shares this one instead. Cloning a `reqwest::Client` + /// is a cheap Arc bump. + pub pipe_client: reqwest::Client, /// Opaque extra data for library use pub data: S, } +/// Builds the shared pipe client the same way [`RelayClient::new`] builds its +/// own: the CommitBoost version header as a default header and the shared +/// request timeout. +fn build_pipe_client() -> reqwest::Client { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_VERSION_KEY, HeaderValue::from_static(HEADER_VERSION_VALUE)); + reqwest::Client::builder() + .default_headers(headers) + .timeout(DEFAULT_REQUEST_TIMEOUT) + .build() + .expect("a static default header and timeout always build a valid reqwest client") +} + impl PbsState<()> { pub fn new(config: PbsModuleConfig, config_path: PathBuf) -> Self { - Self { config: Arc::new(config), config_path: Arc::new(config_path), data: () } + Self { + config: Arc::new(config), + config_path: Arc::new(config_path), + pipe_client: build_pipe_client(), + data: (), + } } pub fn with_data(self, data: S) -> PbsState { - PbsState { data, config: self.config, config_path: self.config_path } + PbsState { + data, + config: self.config, + config_path: self.config_path, + pipe_client: self.pipe_client, + } } } diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index a201ae4aa..fa33cf1aa 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -262,14 +262,15 @@ pub(crate) fn match_relays_by_auth_data<'a>( /// match, a single transient pipe relay dialing the builder URL the auth data /// names (self-URL guarded, see [`transient_pipe_relay`]). Shared by the bid /// and preferences endpoints so their demux cannot diverge. -pub(crate) fn resolve_addressed_relays( +pub(crate) async fn resolve_addressed_relays( relays: &[RelayClient], auth_data: &[u8], advertised_urls: &[Url], + pipe_client: &reqwest::Client, ) -> Result, PbsClientError> { let matched = match_relays_by_auth_data(relays, auth_data); if matched.is_empty() { - Ok(vec![transient_pipe_relay(auth_data, advertised_urls)?]) + Ok(vec![transient_pipe_relay(auth_data, advertised_urls, pipe_client).await?]) } else { Ok(matched.into_iter().cloned().collect()) } @@ -308,9 +309,10 @@ fn pipe_relay_placeholder_pubkey() -> BlsPublicKey { /// empty `advertised_urls`, which cannot rule that out - is an /// `AuthDataMismatch`, never a self-dial. Data carrying no URL at all names /// no builder and mismatches as before. -pub(crate) fn transient_pipe_relay( +pub(crate) async fn transient_pipe_relay( received_data: &[u8], advertised_urls: &[Url], + pipe_client: &reqwest::Client, ) -> Result { let Some(url) = decode_auth_data_url(received_data) else { return Err(PbsClientError::AuthDataMismatch); @@ -332,9 +334,49 @@ pub(crate) fn transient_pipe_relay( return Err(PbsClientError::AuthDataMismatch); } - let id = url.host_str().map(str::to_owned).unwrap_or_else(|| url.to_string()); + // SSRF guard: the URL comes straight from untrusted auth data, so refuse a + // target that resolves into loopback/private/link-local space before + // building the client. We resolve-then-dial and do NOT pin the resolved IP, + // so a DNS rebind between this lookup and the dial can still slip through; + // that TOCTOU window is an accepted v1 limitation, not a plugged hole. The + // check is always compiled and always runs in production; it is skippable + // only under the `testing-flags` feature, so an e2e test can dial a local + // mock builder on an address this guard would otherwise block. + if pipe_target_check_enabled() { + let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) else { + warn!(%url, "pipe target has no resolvable host/port, refusing to dial"); + return Err(PbsClientError::PipeTargetBlocked); + }; + let resolved = tokio::net::lookup_host((host, port)).await.map_err(|err| { + // Fail closed: a target we cannot resolve is a target we cannot verify. + warn!(%url, %err, "pipe target DNS resolution failed, refusing to dial"); + PbsClientError::PipeTargetBlocked + })?; + let mut resolved_any = false; + for addr in resolved { + resolved_any = true; + if ip_is_disallowed(addr.ip()) { + warn!(%url, "pipe target resolves to a disallowed (loopback/private/link-local) address, refusing to dial"); + return Err(PbsClientError::PipeTargetBlocked); + } + } + if !resolved_any { + // Fail closed: no address to check is no address we verified. + warn!(%url, "pipe target resolved to no addresses, refusing to dial"); + return Err(PbsClientError::PipeTargetBlocked); + } + } + let config = RelayConfig { - entry: RelayEntry { id, pubkey: pipe_relay_placeholder_pubkey(), url }, + // Fixed sentinel id: this id becomes the `relay_id` metric label, and + // the host comes from an untrusted URL, so deriving the label from it + // would let an attacker spray unbounded Prometheus series. The real URL + // is still dialed via `entry.url`. + entry: RelayEntry { + id: PIPE_RELAY_ID.to_string(), + pubkey: pipe_relay_placeholder_pubkey(), + url, + }, id: None, headers: None, get_params: None, @@ -347,12 +389,74 @@ pub(crate) fn transient_pipe_relay( max_execution_payment_gwei: None, expected_auth_data: None, }; - RelayClient::new(config).map_err(|err| { + RelayClient::with_client(config, pipe_client.clone()).map_err(|err| { warn!(%err, "failed to build the pipe relay client"); PbsClientError::Internal }) } +/// The fixed `relay_id` metric label for every transient pipe request. The pipe +/// dials attacker-influenced hosts, so a per-host label would be an unbounded +/// Prometheus cardinality vector; one constant collapses them into a single +/// series (the real URL is still dialed, only the label is the sentinel). +const PIPE_RELAY_ID: &str = "pipe"; + +/// Whether the pipe SSRF target check runs. Always true in a normal build; only +/// the `testing-flags` feature can turn it off, and only via a thread-local a +/// test sets, so an e2e test can dial a local mock builder. +fn pipe_target_check_enabled() -> bool { + #[cfg(feature = "testing-flags")] + { + !SKIP_PIPE_TARGET_CHECK.with(|f| f.get()) + } + #[cfg(not(feature = "testing-flags"))] + { + true + } +} + +#[cfg(feature = "testing-flags")] +thread_local! { + static SKIP_PIPE_TARGET_CHECK: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// TEST-ONLY (`testing-flags`): skip the pipe SSRF target check so an e2e test +/// can dial a local mock builder on a loopback/unspecified address the guard +/// would otherwise block. Never compiled into a release binary. +#[cfg(feature = "testing-flags")] +pub fn set_skip_pipe_target_check(val: bool) { + SKIP_PIPE_TARGET_CHECK.with(|f| f.set(val)); +} + +/// True for an address the ePBS pipe must never dial: loopback and unspecified +/// in both families, plus the IPv4 private/link-local/broadcast ranges and the +/// IPv6 unique-local (`fc00::/7`) and link-local (`fe80::/10`) ranges. A +/// v4-mapped IPv6 address (`::ffff:a.b.c.d`) is unwrapped and re-checked as +/// IPv4 so an internal target cannot hide behind the mapped form. +fn ip_is_disallowed(ip: std::net::IpAddr) -> bool { + use std::net::IpAddr; + if ip.is_loopback() || ip.is_unspecified() { + return true; + } + match ip { + IpAddr::V4(v4) => { + let oct = v4.octets(); + v4.is_private() || v4.is_link_local() || v4.is_broadcast() || + // RFC 6598 CGNAT 100.64.0.0/10, which is_private() does not cover + // but can front ISP / k8s-CNI internal infrastructure. + (oct[0] == 100 && oct[1] & 0xc0 == 0x40) + } + IpAddr::V6(v6) => { + if let Some(v4) = v6.to_ipv4_mapped() { + return ip_is_disallowed(IpAddr::V4(v4)); + } + let seg = v6.octets(); + // fc00::/7 unique-local, fe80::/10 link-local + seg[0] & 0xfe == 0xfc || u16::from_be_bytes([seg[0], seg[1]]) & 0xffc0 == 0xfe80 + } + } +} + /// Compares two URLs without checking userinfo/path/queries/frags. A relay /// entry URL embeds the relay pubkey as userinfo, so full equality would never /// match a bare builder URL. @@ -513,18 +617,18 @@ mod tests { // The pipe never dials blind: with no advertised_urls the self-URL guard // cannot rule out CB's own URL (an unconfigured key's auth data defaults // to it), so it fails closed with the same mismatch a builder would return. - #[test] - fn transient_pipe_relay_fails_closed_without_advertised_urls() { + #[tokio::test] + async fn transient_pipe_relay_fails_closed_without_advertised_urls() { assert!(matches!( - transient_pipe_relay(b"http://builder.example.com", &[]), + transient_pipe_relay(b"http://builder.example.com", &[], &reqwest::Client::new()).await, Err(PbsClientError::AuthDataMismatch) )); } // A decoded URL naming CB itself is never dialed: matching follows // `url_matches`, so userinfo/path/default-port variants still guard. - #[test] - fn transient_pipe_relay_guards_own_advertised_urls() { + #[tokio::test] + async fn transient_pipe_relay_guards_own_advertised_urls() { let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; for own in [ "http://cb.example.com:18550", @@ -533,7 +637,8 @@ mod tests { ] { assert!( matches!( - transient_pipe_relay(own.as_bytes(), &advertised), + transient_pipe_relay(own.as_bytes(), &advertised, &reqwest::Client::new()) + .await, Err(PbsClientError::AuthDataMismatch) ), "{own} must not be dialed" @@ -542,12 +647,12 @@ mod tests { } // Data carrying no URL names no builder: mismatch, no dial. - #[test] - fn transient_pipe_relay_rejects_non_url_data() { + #[tokio::test] + async fn transient_pipe_relay_rejects_non_url_data() { let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; for data in [&[0xde, 0xad][..], b"not a url", &[]] { assert!(matches!( - transient_pipe_relay(data, &advertised), + transient_pipe_relay(data, &advertised, &reqwest::Client::new()).await, Err(PbsClientError::AuthDataMismatch) )); } @@ -555,23 +660,89 @@ mod tests { // A decodable, non-self URL gets a transient client carrying no configured // relay's headers and no per-relay cap (the global default applies), so - // pipe bids rank unclamped and leak no credentials. - #[test] - fn transient_pipe_relay_builds_a_bare_client() { + // pipe bids rank unclamped and leak no credentials. A literal public IP host + // is used so the SSRF resolve step needs no network DNS. The metric id is + // the fixed `pipe` sentinel, not the untrusted host. + #[tokio::test] + async fn transient_pipe_relay_builds_a_bare_client() { let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; - let mut data = b"http://builder.example.com:8551".to_vec(); + let mut data = b"http://1.1.1.1:8551".to_vec(); data.push(0); data.extend_from_slice(&[0xde, 0xad]); - let relay = transient_pipe_relay(&data, &advertised).unwrap(); - assert_eq!(relay.config.entry.url.as_str(), "http://builder.example.com:8551/"); - assert_eq!(relay.id.as_str(), "builder.example.com"); + let relay = + transient_pipe_relay(&data, &advertised, &reqwest::Client::new()).await.unwrap(); + assert_eq!(relay.config.entry.url.as_str(), "http://1.1.1.1:8551/"); + assert_eq!(relay.id.as_str(), "pipe"); assert!(relay.config.headers.is_none()); assert!(relay.config.max_execution_payment_gwei.is_none()); assert!(relay.config.expected_auth_data.is_none()); assert!(!relay.config.enable_timing_games); } + // The SSRF guard refuses a target that resolves into loopback/private space + // even when the URL decodes and is not a self-URL. Literal-IP hosts keep the + // resolve step off the network. + #[tokio::test] + async fn transient_pipe_relay_rejects_disallowed_ip_targets() { + let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; + for host in ["http://127.0.0.1:8551", "http://10.0.0.1:8551"] { + assert!( + matches!( + transient_pipe_relay(host.as_bytes(), &advertised, &reqwest::Client::new()) + .await, + Err(PbsClientError::PipeTargetBlocked) + ), + "{host} must be refused as an internal target" + ); + } + // A public literal IP is not rejected by the IP check (it builds a client) + assert!( + transient_pipe_relay(b"http://1.1.1.1:8551", &advertised, &reqwest::Client::new()) + .await + .is_ok() + ); + } + + // The disallow predicate covers loopback/unspecified/private/link-local in + // both families, and unwraps a v4-mapped v6 so an internal target cannot + // hide behind `::ffff:a.b.c.d`. + #[test] + fn ip_is_disallowed_table() { + use std::net::IpAddr; + let dis = |s: &str| ip_is_disallowed(s.parse::().unwrap()); + // loopback / unspecified, both families + assert!(dis("127.0.0.1")); + assert!(dis("0.0.0.0")); + assert!(dis("::1")); + assert!(dis("::")); + // private v4 + assert!(dis("10.0.0.1")); + assert!(dis("192.168.1.1")); + assert!(dis("172.16.0.1")); + // link-local v4 and the v4 broadcast + assert!(dis("169.254.1.1")); + assert!(dis("255.255.255.255")); + // RFC 6598 CGNAT 100.64.0.0/10 (edges), but not 100.x outside the /10 + assert!(dis("100.64.0.1")); + assert!(dis("100.127.255.254")); + assert!(!dis("100.63.0.1")); + assert!(!dis("100.128.0.1")); + // v6 unique-local (fc00::/7) and link-local (fe80::/10) + assert!(dis("fc00::1")); + assert!(dis("fd12:3456::1")); + assert!(dis("fe80::1")); + assert!(dis("febf::1")); + // v4-mapped internal addresses are unwrapped and caught + assert!(dis("::ffff:127.0.0.1")); + assert!(dis("::ffff:10.0.0.1")); + // public addresses pass, in both families and via the v4-mapped form + assert!(!dis("1.1.1.1")); + assert!(!dis("8.8.8.8")); + assert!(!dis("2606:4700:4700::1111")); + assert!(!dis("::ffff:1.1.1.1")); + } + // The placeholder pubkey is stable across pipe requests: one process-wide // point rather than a fresh keygen per unmatched request. #[test] diff --git a/tests/Cargo.toml b/tests/Cargo.toml index c8503378c..772066868 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -8,7 +8,7 @@ version.workspace = true alloy.workspace = true axum.workspace = true cb-common.workspace = true -cb-pbs.workspace = true +cb-pbs = { workspace = true, features = ["testing-flags"] } cb-signer.workspace = true eyre.workspace = true ethereum_ssz.workspace = true diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 9e98506fe..8899b4467 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -322,6 +322,9 @@ async fn test_get_execution_payload_bid_demux_by_url_bytes() -> Result<()> { #[tokio::test] async fn test_get_execution_payload_bid_pipe_dials_unconfigured_builder() -> Result<()> { setup_test_env(); + // This test's mock builder binds to a local (unspecified/loopback) address the + // pipe's SSRF guard blocks; skip that check so the forward path is exercised. + cb_pbs::set_skip_pipe_target_check(true); let chain = Chain::Hoodi; let pbs_listener = get_free_listener().await; let pbs_port = pbs_listener.local_addr()?.port(); diff --git a/tests/tests/pbs_submit_builder_preferences.rs b/tests/tests/pbs_submit_builder_preferences.rs index a09ae27ba..e8e8dc5a6 100644 --- a/tests/tests/pbs_submit_builder_preferences.rs +++ b/tests/tests/pbs_submit_builder_preferences.rs @@ -392,6 +392,9 @@ async fn test_submit_builder_preferences_missing_body_400() -> Result<()> { #[tokio::test] async fn test_submit_builder_preferences_pipe_dials_unconfigured_builder() -> Result<()> { setup_test_env(); + // This test's mock builder binds to a local (unspecified/loopback) address the + // pipe's SSRF guard blocks; skip that check so the forward path is exercised. + cb_pbs::set_skip_pipe_target_check(true); let chain = Chain::Hoodi; let pbs_listener = get_free_listener().await; let pbs_port = pbs_listener.local_addr()?.port(); From 7dbd60e96aece9393d24143b718f6ff159af6e7c Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Wed, 2 Sep 2026 16:46:45 -0700 Subject: [PATCH 73/80] feat(pbs): clamp the ePBS bid X-Timeout-Ms budget to one slot The proposer-supplied X-Timeout-Ms sets how long CB solicits a bid, but it had no upper bound: a large value pins an outbound relay connection open and grows the timing-games poll ladder for that whole duration. Cap the budget at one slot (chain-aware) before deriving the timeout, bounding the duration a single request can hold a relay connection and the ladder depth. --- crates/pbs/src/routes/execution_payload_bid.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 8d42b81d9..911b52901 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -200,6 +200,10 @@ pub async fn get_execution_payload_bid( // timeout_get_header_ms and late_in_slot_time_ms are NOT consulted here: they // exist for the get_header path, which carries no X-Timeout-Ms. let budget_ms = request_budget_ms(&req_headers, utcnow_ms())?; + // Clamp the proposer-supplied X-Timeout-Ms to one slot: it has no upper + // bound on the wire, and a huge value would pin an outbound relay connection + // open and grow the timing-games ladder without limit. + let budget_ms = budget_ms.min(state.config.chain.slot_time_sec().saturating_mul(1000)); let max_timeout_ms = budget_ms.saturating_sub(pbs_config.proposer_deadline_buffer_ms); debug!( budget_ms, From 327a7b95603e6e28abde8b549c51def7bf7b9b39 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Wed, 2 Sep 2026 16:46:55 -0700 Subject: [PATCH 74/80] feat(km-tool): error when an advertised_url is not covered by [pbs] advertised_urls `cb-km check` now flags the self-dial misconfiguration the docs warn about: every advertised_url km-tool projects is echoed by a VC in its auth data, decoded by CB, and must be recognized as CB's own via [pbs] advertised_urls. If advertised_urls is set but does not cover a projected URL, a bid addressed to it decodes to CB's own URL and self-dials recursively. Since the operator runs `cb-km check` as the gate before apply, an uncovered URL is an error. Mirrors cb-pbs `url_matches` locally with a drift-guard test. --- crates/km-tool/src/check.rs | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/km-tool/src/check.rs b/crates/km-tool/src/check.rs index 95d37a0a0..e31171df6 100644 --- a/crates/km-tool/src/check.rs +++ b/crates/km-tool/src/check.rs @@ -10,6 +10,7 @@ use std::{ }; use eyre::Result; +use url::Url; use crate::{ client::{GetConfigOutcome, KmClient, read_token}, @@ -18,6 +19,18 @@ use crate::{ project::{ProjectionInput, project, project_with_url}, }; +/// Mirror of cb-pbs `url_matches`: scheme + canonical host (a trailing dot is +/// stripped) + effective port. Kept local to avoid a cb-pbs dependency from the +/// projection tool; the unit test pins the same cases so the two cannot drift. +fn advertised_url_matches(a: &Url, b: &Url) -> bool { + fn host_canonical(url: &Url) -> Option<&str> { + url.host_str().map(|host| host.strip_suffix('.').unwrap_or(host)) + } + a.scheme() == b.scheme() && + host_canonical(a) == host_canonical(b) && + a.port_or_known_default() == b.port_or_known_default() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Tier { Info, @@ -85,6 +98,36 @@ pub async fn run_check(input: &ProjectionInput, overlay: &Overlay) -> Result = BTreeSet::new(); + projected_urls.insert(overlay.advertised_url.as_str()); + for vc in &overlay.vcs { + if let Some(url) = &vc.advertised_url { + projected_urls.insert(url.as_str()); + } + } + for raw in projected_urls { + // overlay.validate() already rejected an unparseable advertised_url + let Ok(projected) = Url::parse(raw) else { continue }; + if !advertised.iter().any(|own| advertised_url_matches(own, &projected)) { + report.push( + Tier::Error, + "advertised-url-uncovered", + format!( + "projected advertised_url {raw} is not covered by any [pbs] advertised_urls entry; a bid addressed to it decodes to CB's own URL and self-dials recursively" + ), + ); + } + } + } + let mut key_holders: BTreeMap> = BTreeMap::new(); for vc in &overlay.vcs { @@ -277,3 +320,27 @@ fn compare_field( lines.push(format!("{name}: projected {expected}, stored {stored:?}")); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advertised_url_matches_mirrors_cb_pbs() { + let u = |s: &str| Url::parse(s).unwrap(); + // userinfo (relay pubkey) and the default port are ignored + assert!(advertised_url_matches( + &u("https://0xdead@cb.example.com"), + &u("https://cb.example.com") + )); + assert!(advertised_url_matches( + &u("https://cb.example.com:443"), + &u("https://cb.example.com") + )); + // a trailing-dot FQDN canonicalizes to its dotless form + assert!(advertised_url_matches(&u("http://cb.example.com."), &u("http://cb.example.com"))); + // scheme and host mismatches do not match + assert!(!advertised_url_matches(&u("http://cb.example.com"), &u("https://cb.example.com"))); + assert!(!advertised_url_matches(&u("https://a.example.com"), &u("https://b.example.com"))); + } +} From 09405583accbebc6e73128f2c2654ccd3b91f583 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Wed, 2 Sep 2026 16:48:37 -0700 Subject: [PATCH 75/80] style(pbs): rustfmt reflow of a block-submission test doc comment --- tests/tests/pbs_submit_signed_beacon_block.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/tests/pbs_submit_signed_beacon_block.rs b/tests/tests/pbs_submit_signed_beacon_block.rs index 7127127aa..1278382d8 100644 --- a/tests/tests/pbs_submit_signed_beacon_block.rs +++ b/tests/tests/pbs_submit_signed_beacon_block.rs @@ -355,9 +355,10 @@ async fn test_submit_signed_beacon_block_blind_json_415() -> Result<()> { Ok(()) } -/// Blind mode requires an explicit SSZ Content-Type. An unlabeled reveal defaults -/// to JSON per builder-specs, so it is rejected with 415 rather than assumed to be -/// SSZ and forwarded mislabeled (which would 500 opaquely at the builder). +/// Blind mode requires an explicit SSZ Content-Type. An unlabeled reveal +/// defaults to JSON per builder-specs, so it is rejected with 415 rather than +/// assumed to be SSZ and forwarded mislabeled (which would 500 opaquely at the +/// builder). #[tokio::test] async fn test_submit_signed_beacon_block_blind_absent_content_type_415() -> Result<()> { let chain = Chain::Hoodi; From 6d4d88ff2797336d3d7a07563f22628d23b2ff38 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 8 Sep 2026 15:46:03 -0700 Subject: [PATCH 76/80] refactor(pbs): share the ePBS handler failure arm and detached fan-out The three ePBS handlers carried the same error arm (4xx warns, 5xx errors, count the status) and the two write routes the same spawn-and-join fan-out. Both move to cb-pbs utils as `record_request_failure` and `join_detached_sends`; `expect_status` is inlined into its only caller. submitBuilderPreferences encodes the SSZ body once and shares it across the sends instead of cloning and re-encoding the request per relay. submitSignedBeaconBlock no longer re-stamps Eth-Consensus-Version with the parsed fork: the header validator accepts Gloas only, which is the value the base headers already carry. --- crates/pbs/src/routes/builder_preferences.rs | 113 ++++++----------- .../src/routes/submit_signed_beacon_block.rs | 115 +++++------------- crates/pbs/src/utils.rs | 66 ++++++---- 3 files changed, 112 insertions(+), 182 deletions(-) diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index 7f4274801..3e09a3767 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -12,10 +12,9 @@ use cb_common::{ types::Chain, wire::{decode_versioned_request_body, get_user_agent}, }; -use futures::{FutureExt, future::join_all}; use reqwest::StatusCode; use ssz::Encode; -use tracing::{Instrument, debug, error, info, warn}; +use tracing::{debug, error, info}; use crate::{ PbsStateGuard, @@ -23,8 +22,9 @@ use crate::{ error::PbsClientError, state::{BuilderApiState, PbsState}, utils::{ - epbs_base_send_headers, log_mux_selection, post_ssz_expect_accepted, record_beacon_status, - record_client_error, resolve_addressed_relays, validate_auth_data, verify_auth_signature, + epbs_base_send_headers, join_detached_sends, log_mux_selection, post_ssz_expect_accepted, + record_beacon_status, record_client_error, record_request_failure, + resolve_addressed_relays, validate_auth_data, verify_auth_signature, }, }; @@ -57,19 +57,7 @@ pub async fn handle_submit_builder_preferences( record_beacon_status("202", SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG); Ok(StatusCode::ACCEPTED.into_response()) } - Err(err) => { - // A 4xx is the caller's fault, not CB's: only a 5xx is an error! - if err.status_code().is_server_error() { - error!(%err, "submit_builder_preferences failed"); - } else { - warn!(%err, "submit_builder_preferences failed"); - } - record_beacon_status( - err.status_code().as_str(), - SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, - ); - Err(err) - } + Err(err) => Err(record_request_failure(err, SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG)), } } @@ -103,33 +91,24 @@ pub async fn submit_builder_preferences( let send_headers = epbs_base_send_headers(&req_headers)?; + // The builder decodes what the proposer signed either way, and SSZ is the + // faster wire format on the relay hop; encoded once, shared by every send + let body = Bytes::from(request.as_ssz_bytes()); + // Preferences are submitted an epoch ahead, so they share the registration // timeout rather than the block-production one let timeout_ms = pbs_config.timeout_register_validator_ms; - // Spawned like register_validator's sends: a BN disconnect must not cancel - // in-flight writes mid-fan-out, leaving some builders with the prefs and - // others without - let mut handles = Vec::with_capacity(relays.len()); - for relay in relays.iter() { - handles.push( - tokio::spawn( - send_one_submit_builder_preferences( - params.proposer_pubkey.clone(), - request.clone(), - relay.clone(), - send_headers.clone(), - timeout_ms, - ) - .in_current_span(), - ) - .map(|join_result| { - join_result.unwrap_or_else(|err| Err(PbsError::TokioJoinError(err))) - }), - ); - } - - let results = join_all(handles).await; + let results = join_detached_sends(relays.iter().map(|relay| { + send_one_submit_builder_preferences( + params.proposer_pubkey.clone(), + body.clone(), + relay.clone(), + send_headers.clone(), + timeout_ms, + ) + })) + .await; let mut accepted = 0; let mut lone_rejection = None; for (res, relay) in results.into_iter().zip(relays.iter()) { @@ -183,19 +162,17 @@ fn validate_preferences_auth( async fn send_one_submit_builder_preferences( proposer_pubkey: cb_common::types::BlsPublicKey, - request: BuilderPreferencesRequest, + body: Bytes, relay: RelayClient, headers: HeaderMap, timeout_ms: u64, ) -> Result<(), PbsError> { let url = relay.submit_builder_preferences_url(&proposer_pubkey)?; - // The builder decodes what the proposer signed either way, and SSZ is the - // faster wire format on the relay hop let request_latency = post_ssz_expect_accepted( &relay, url, - request.as_ssz_bytes(), + body, headers, timeout_ms, SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, @@ -221,6 +198,19 @@ mod tests { (utcnow_sec() - chain.genesis_time_sec()) / chain.slot_time_sec() } + fn sample_request() -> BuilderPreferencesRequest { + BuilderPreferencesRequest { + auth: SignedBuilderRequestAuth { + message: BuilderRequestAuth { + data: Default::default(), + slot: lh_types::Slot::new(3), + }, + signature: BlsSignature::empty(), + }, + preferences: BuilderPreferences { max_execution_payment: 7 }, + } + } + // Empty `auth.message.data` is rejected before sigverify, so it cannot slip // through a catch-all relay match. Guards the wiring of the shared // `validate_auth_data` into this endpoint. @@ -260,17 +250,7 @@ mod tests { /// and `Eth-Consensus-Version` is required regardless of encoding. #[test] fn decode_defaults_to_ssz_without_a_content_type() { - let request = BuilderPreferencesRequest { - auth: SignedBuilderRequestAuth { - message: BuilderRequestAuth { - data: Default::default(), - slot: lh_types::Slot::new(3), - }, - signature: BlsSignature::empty(), - }, - preferences: BuilderPreferences { max_execution_payment: 7 }, - }; - let body = Bytes::from(request.as_ssz_bytes()); + let body = Bytes::from(sample_request().as_ssz_bytes()); // Missing the header, the SSZ-default body is rejected, not misparsed let err = @@ -317,17 +297,7 @@ mod tests { /// `submitSignedBeaconBlock` too — no endpoint is lenient. #[test] fn decode_rejects_json_without_the_version_header() { - let request = BuilderPreferencesRequest { - auth: SignedBuilderRequestAuth { - message: BuilderRequestAuth { - data: Default::default(), - slot: lh_types::Slot::new(3), - }, - signature: BlsSignature::empty(), - }, - preferences: BuilderPreferences { max_execution_payment: 7 }, - }; - let body = Bytes::from(serde_json::to_vec(&request).unwrap()); + let body = Bytes::from(serde_json::to_vec(&sample_request()).unwrap()); let mut headers = HeaderMap::new(); headers.insert( @@ -347,16 +317,7 @@ mod tests { /// names the value rather than claiming the header is missing. #[test] fn decode_rejects_an_unrecognized_fork_value() { - let request = BuilderPreferencesRequest { - auth: SignedBuilderRequestAuth { - message: BuilderRequestAuth { - data: Default::default(), - slot: lh_types::Slot::new(3), - }, - signature: BlsSignature::empty(), - }, - preferences: BuilderPreferences { max_execution_payment: 7 }, - }; + let request = sample_request(); for (ct, body) in [ ("application/json", Bytes::from(serde_json::to_vec(&request).unwrap())), ("application/octet-stream", Bytes::from(request.as_ssz_bytes())), diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs index 5f71856e3..f48fca7c9 100644 --- a/crates/pbs/src/routes/submit_signed_beacon_block.rs +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -1,28 +1,24 @@ -use axum::{ - body::Bytes, - extract::State, - http::{HeaderMap, HeaderValue}, - response::IntoResponse, -}; +use axum::{body::Bytes, extract::State, http::HeaderMap, response::IntoResponse}; use cb_common::{ - pbs::{RelayClient, error::PbsError, is_gloas}, + pbs::is_gloas, wire::{ - BodyDeserializeError, CONSENSUS_VERSION_HEADER, EncodingType, - content_type_encoding_with_default, decode_signed_beacon_block, get_user_agent, - require_consensus_version_header, + BodyDeserializeError, EncodingType, content_type_encoding_with_default, + decode_signed_beacon_block, get_user_agent, require_consensus_version_header, }, }; -use futures::{FutureExt, future::join_all}; use reqwest::StatusCode; use ssz::Encode; -use tracing::{Instrument, error, info, warn}; +use tracing::{info, warn}; use crate::{ PbsStateGuard, constants::SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, error::PbsClientError, state::{BuilderApiState, PbsState}, - utils::{epbs_base_send_headers, post_ssz_expect_accepted, record_beacon_status}, + utils::{ + epbs_base_send_headers, join_detached_sends, post_ssz_expect_accepted, + record_beacon_status, record_request_failure, + }, }; /// POST /eth/v1/builder/beacon_blocks (submitSignedBeaconBlock). @@ -44,19 +40,7 @@ pub async fn handle_submit_signed_beacon_block( record_beacon_status("202", SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG); Ok(StatusCode::ACCEPTED.into_response()) } - Err(err) => { - // A 4xx is the caller's fault, not CB's: only a 5xx is an error! - if err.status_code().is_server_error() { - error!(%err, "submit_signed_beacon_block failed"); - } else { - warn!(%err, "submit_signed_beacon_block failed"); - } - record_beacon_status( - err.status_code().as_str(), - SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, - ); - Err(err) - } + Err(err) => Err(record_request_failure(err, SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG)), } } @@ -73,9 +57,10 @@ pub async fn submit_signed_beacon_block( let strict = state.pbs_config().strict_block_decode; let ua = get_user_agent(&req_headers); - // Eth-Consensus-Version is spec-required here and names the block's fork: it - // labels the outbound SSZ and, under strict decode, selects the variant. - let fork = require_consensus_version_header(&req_headers)?; + // Eth-Consensus-Version is spec-required here; under strict decode it also + // selects the SSZ variant. Only Gloas is accepted, which is the fork the + // outbound headers already carry. + require_consensus_version_header(&req_headers)?; let (out_body, slot) = if strict { // Strict: CB decodes and rejects a malformed or non-gloas reveal itself. @@ -108,40 +93,28 @@ pub async fn submit_signed_beacon_block( } info!(ua, ?slot, strict, "new request"); - // Base headers, then stamp the block's fork as the outbound - // Eth-Consensus-Version. The header validator accepts Gloas only, so `fork` - // is always Gloas today; it is passed through (not hard-coded) so widening - // the accepted set later needs no change here. - let mut send_headers = epbs_base_send_headers(&req_headers)?; - send_headers.insert( - CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&fork.to_string()).expect("fork name is always a valid header value"), - ); - + let send_headers = epbs_base_send_headers(&req_headers)?; let timeout_ms = state.pbs_config().timeout_get_payload_ms; let relays = state.all_relays(); - // Spawned like builder_preferences' sends: a BN disconnect must not cancel - // in-flight block broadcasts mid-fan-out, leaving some builders with the - // block and others without - let mut handles = Vec::with_capacity(relays.len()); - for relay in relays.iter() { - handles.push( - tokio::spawn( - send_one_submit_signed_beacon_block( - relay.clone(), - out_body.clone(), - send_headers.clone(), - timeout_ms, - ) - .in_current_span(), + let results = join_detached_sends(relays.iter().map(|relay| { + let (relay, body, headers) = (relay.clone(), out_body.clone(), send_headers.clone()); + async move { + // Every builder implements SSZ for this new endpoint, so the block is + // forwarded in SSZ (the fork travels in Eth-Consensus-Version). + let url = relay.submit_signed_beacon_block_url()?; + post_ssz_expect_accepted( + &relay, + url, + body, + headers, + timeout_ms, + SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, ) - .map(|join_result| { - join_result.unwrap_or_else(|err| Err(PbsError::TokioJoinError(err))) - }), - ); - } - - let results = join_all(handles).await; + .await?; + Ok(()) + } + })) + .await; let accepted = results .into_iter() .zip(relays.iter()) @@ -163,25 +136,3 @@ pub async fn submit_signed_beacon_block( info!(accepted, addressed = relays.len(), "signed beacon block submitted"); Ok(()) } - -async fn send_one_submit_signed_beacon_block( - relay: RelayClient, - body: Bytes, - headers: HeaderMap, - timeout_ms: u64, -) -> Result<(), PbsError> { - let url = relay.submit_signed_beacon_block_url()?; - - // Every builder implements SSZ for this new endpoint, so the block is - // forwarded in SSZ (the fork travels in Eth-Consensus-Version). - post_ssz_expect_accepted( - &relay, - url, - body, - headers, - timeout_ms, - SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, - ) - .await?; - Ok(()) -} diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index fa33cf1aa..4a432603f 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -1,4 +1,5 @@ use std::{ + future::Future, sync::{ OnceLock, atomic::{AtomicBool, Ordering}, @@ -16,11 +17,12 @@ use cb_common::{ safe_read_http_response, }, }; +use futures::future::join_all; use reqwest::{ StatusCode, header::{CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT}, }; -use tracing::{debug, warn}; +use tracing::{Instrument, debug, error, warn}; use url::Url; use crate::{ @@ -80,6 +82,36 @@ pub(crate) fn record_beacon_status(code: &str, endpoint: &str) { crate::metrics::BEACON_NODE_STATUS.with_label_values(&[code, endpoint]).inc(); } +/// Logs and counts a failed ePBS request before it is returned to the beacon +/// node. A 4xx is the caller's fault, not CB's: only a 5xx is an error. +pub(crate) fn record_request_failure(err: PbsClientError, endpoint: &str) -> PbsClientError { + if err.status_code().is_server_error() { + error!(%err, "{endpoint} failed"); + } else { + warn!(%err, "{endpoint} failed"); + } + record_beacon_status(err.status_code().as_str(), endpoint); + err +} + +/// Fans `sends` out on detached tasks and waits for all of them: a BN +/// disconnect must not cancel in-flight writes mid-fan-out, leaving some +/// builders with the data and others without. +pub(crate) async fn join_detached_sends( + sends: impl IntoIterator, +) -> Vec> +where + F: Future> + Send + 'static, +{ + let handles: Vec<_> = + sends.into_iter().map(|send| tokio::spawn(send.in_current_span())).collect(); + join_all(handles) + .await + .into_iter() + .map(|joined| joined.unwrap_or_else(|err| Err(PbsError::TokioJoinError(err)))) + .collect() +} + /// Logs which relay set an ePBS demux request resolved to (a mux's relays or /// the default set), shared by the bid and preferences endpoints. pub(crate) fn log_mux_selection( @@ -102,19 +134,6 @@ pub(crate) fn record_invalid_relay_response(reason: &str, endpoint: &str, relay_ crate::metrics::RELAY_INVALID_RESPONSE.with_label_values(&[reason, endpoint, relay_id]).inc(); } -/// The ePBS write endpoints (`submitBuilderPreferences`, -/// `submitSignedBeaconBlock`) make 202 Accepted the only success: any other -/// status means the builder did not commit. One home for that rule. -pub(crate) fn expect_status(code: StatusCode, expected: StatusCode) -> Result<(), PbsError> { - if code != expected { - return Err(PbsError::RelayResponse { - error_msg: format!("expected {}", expected.as_u16()), - code: code.as_u16(), - }); - } - Ok(()) -} - /// POSTs an SSZ body to a builder and enforces the ePBS write-endpoint /// contract: 202 Accepted is the only success. The response body is read (and /// capped) then discarded - a builder is untrusted and must not stream an @@ -138,17 +157,20 @@ pub(crate) async fn post_ssz_expect_accepted( let (res, latency) = send_to_relay(req, relay, tag).await?; let code = res.status(); safe_read_http_response(res, MAX_SIZE_DEFAULT).await?; - expect_status(code, StatusCode::ACCEPTED)?; + if code != StatusCode::ACCEPTED { + return Err(PbsError::RelayResponse { + error_msg: "expected 202".to_string(), + code: code.as_u16(), + }); + } Ok(latency) } /// Base outbound headers shared by the ePBS endpoints: the versioned /// `User-Agent` and `Eth-Consensus-Version`. All three relay hops send SSZ -/// bodies of fork-versioned wire types, so the builder needs the fork header; -/// it defaults to Gloas here and callers may override it -/// (submit_signed_beacon_block overwrites it with the inbound request's fork). -/// Callers add their endpoint-specific headers (bid adds `Accept` and the -/// timing headers). +/// bodies of fork-versioned wire types, so the builder needs the fork header, +/// and all three are Gloas-only. Callers add their endpoint-specific headers +/// (bid adds `Accept` and the timing headers). pub(crate) fn epbs_base_send_headers(req_headers: &HeaderMap) -> Result { let mut headers = HeaderMap::new(); headers.insert( @@ -368,10 +390,6 @@ pub(crate) async fn transient_pipe_relay( } let config = RelayConfig { - // Fixed sentinel id: this id becomes the `relay_id` metric label, and - // the host comes from an untrusted URL, so deriving the label from it - // would let an attacker spray unbounded Prometheus series. The real URL - // is still dialed via `entry.url`. entry: RelayEntry { id: PIPE_RELAY_ID.to_string(), pubkey: pipe_relay_placeholder_pubkey(), From ae9817d7ac7aff487f728cf2f60e768855d8d271 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 8 Sep 2026 15:46:03 -0700 Subject: [PATCH 77/80] refactor(pbs): parse the bid Accept once and forward it as a static header getExecutionPayloadBid parsed the Accept header twice: once for the response encoding and again to build the relay-side Accept. It is parsed once and threaded through; `preferred(&[Ssz, Json])` always equals the primary, so `encode_bid_response` takes an `EncodingType` and its unreachable 406 arm goes. The relay Accept is one of two static headers, so `build_outbound_accept` and its q-value ladder (only ever fed two entries) are deleted, with the JSON-first static pinned by a test like its SSZ-first twin. Also on the bid path: the request start time returned by the single poll was never read, and was the only reason `select_max_bid` was generic over its label; `ms_into_slot` and the per-relay ranking cap are computed once. The handler adopts the shared failure arm. --- crates/common/src/wire.rs | 66 ++------ .../pbs/src/routes/execution_payload_bid.rs | 149 ++++++++---------- 2 files changed, 82 insertions(+), 133 deletions(-) diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index 9fab4497d..e39d2ae24 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -296,38 +296,9 @@ fn essence_encoding(mt: &MediaType, default: EncodingType) -> Option f32 { - // `as i32` would silently wrap for large indices (e.g. usize::MAX → -1), - // which would invert the clamp. Saturate the cast explicitly. - let idx = i32::try_from(index).unwrap_or(i32::MAX); - let step = 10_i32.saturating_sub(idx).max(1); - step as f32 / 10.0 -} - -/// Format a single `Accept` header entry as `";q="`. -#[inline] -fn format_accept_entry(enc: EncodingType, q: f32) -> String { - format!("{};q={:.1}", enc.content_type(), q) -} - -/// Build an `Accept` header listing the given encodings in preference order: -/// the first entry gets q=1.0 and each subsequent one a q-value 0.1 lower. -/// Returns a ready-to-use `HeaderValue` — the output is always valid ASCII, so -/// infallible. -pub fn build_outbound_accept(preferred: AcceptedEncodings) -> HeaderValue { - let s = preferred - .iter() - .enumerate() - .map(|(i, enc)| format_accept_entry(enc, accept_q_value_for_index(i))) - .collect::>() - .join(","); - HeaderValue::from_str(&s).expect("build_outbound_accept produces valid header value") -} +/// The ePBS bid path forwards the beacon node's Accept preference to the relay. +pub static OUTBOUND_ACCEPT_JSON_FIRST: HeaderValue = + HeaderValue::from_static("application/json;q=1.0,application/octet-stream;q=0.9"); pub fn get_content_type(req_headers: &HeaderMap) -> EncodingType { EncodingType::from_str( @@ -608,11 +579,10 @@ mod test { use super::{ APPLICATION_JSON, APPLICATION_OCTET_STREAM, AcceptedEncodings, BodyDeserializeError, - CONSENSUS_VERSION_HEADER, EncodingType, NO_PREFERENCE_DEFAULT, OUTBOUND_ACCEPT_SSZ_FIRST, - WILDCARD, accept_q_value_for_index, build_outbound_accept, - content_type_encoding_with_default, decode_signed_beacon_block, deserialize_body, - format_accept_entry, get_accept_types, get_consensus_version_header, get_content_type, - parse_response_encoding_and_fork, + CONSENSUS_VERSION_HEADER, EncodingType, NO_PREFERENCE_DEFAULT, OUTBOUND_ACCEPT_JSON_FIRST, + OUTBOUND_ACCEPT_SSZ_FIRST, WILDCARD, content_type_encoding_with_default, + decode_signed_beacon_block, deserialize_body, get_accept_types, + get_consensus_version_header, get_content_type, parse_response_encoding_and_fork, }; use crate::{pbs::SignedBeaconBlock, utils::TestRandomSeed}; @@ -1082,6 +1052,12 @@ mod test { ); } + /// Format a single `Accept` header entry as `";q="`. + #[inline] + fn format_accept_entry(enc: EncodingType, q: f32) -> String { + format!("{};q={:.1}", enc.content_type(), q) + } + // Pins the wire format PBS sends to relays: SSZ preferred (q=1.0), JSON as // fallback (q=0.9). #[test] @@ -1092,21 +1068,13 @@ mod test { ); } - /// `build_outbound_accept` mirrors the static SSZ-first header for the - /// same preference order, and the q-value clamp never emits q=0. + // The ePBS mirror: JSON preferred (q=1.0), SSZ as fallback (q=0.9). #[test] - fn test_build_outbound_accept() { - let both = - AcceptedEncodings { primary: EncodingType::Ssz, fallback: Some(EncodingType::Json) }; - assert_eq!(build_outbound_accept(both), OUTBOUND_ACCEPT_SSZ_FIRST); + fn test_outbound_accept_json_first() { assert_eq!( - build_outbound_accept(AcceptedEncodings::single(EncodingType::Json)), - "application/json;q=1.0" + OUTBOUND_ACCEPT_JSON_FIRST, + "application/json;q=1.0,application/octet-stream;q=0.9" ); - assert_eq!(accept_q_value_for_index(0), 1.0); - assert_eq!(accept_q_value_for_index(9), 0.1); - assert_eq!(accept_q_value_for_index(10), 0.1); - assert_eq!(accept_q_value_for_index(usize::MAX), 0.1); } /// Present-but-unrecognized Content-Type still bails as diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 911b52901..68b913df4 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -23,8 +23,8 @@ use cb_common::{ types::Chain, utils::{ms_into_slot, utcnow_ms}, wire::{ - AcceptedEncodings, AcceptedEncodingsError, CONSENSUS_VERSION_HEADER, EncodingType, - build_outbound_accept, decode_versioned_request_body, get_accept_types_with_default, + CONSENSUS_VERSION_HEADER, EncodingType, OUTBOUND_ACCEPT_JSON_FIRST, + OUTBOUND_ACCEPT_SSZ_FIRST, decode_versioned_request_body, get_accept_types_with_default, get_user_agent, parse_response_encoding_and_fork, safe_read_http_response, }, }; @@ -49,8 +49,8 @@ use crate::{ state::{BuilderApiState, PbsState}, utils::{ check_gas_limit, epbs_base_send_headers, log_mux_selection, record_beacon_status, - record_client_error, resolve_addressed_relays, send_to_relay, validate_auth_data, - verify_auth_signature, + record_client_error, record_request_failure, resolve_addressed_relays, send_to_relay, + validate_auth_data, verify_auth_signature, }, }; @@ -79,17 +79,26 @@ pub async fn handle_get_execution_payload_bid( let ua = get_user_agent(&req_headers); let ms_into_slot = ms_into_slot(params.slot, state.config.chain); - // Parse Accept before req_headers is consumed below; server tiebreak = SSZ. // No-preference (absent Accept / wildcard) defaults to SSZ; an explicit - // Accept header is still obeyed. + // Accept header is obeyed. Parsed once here and threaded through: it picks + // both the relay-side Accept and the response encoding. let response_encoding = get_accept_types_with_default(&req_headers, EncodingType::Ssz) .inspect_err(|err| error!(%err, "error parsing accept header")) .map_err(|err| record_client_error(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG))? - .preferred(&[EncodingType::Ssz, EncodingType::Json]); + .primary; info!(ua, ms_into_slot, "new request"); - match get_execution_payload_bid(params, body, req_headers, state).await { + match get_execution_payload_bid( + params, + body, + req_headers, + response_encoding, + ms_into_slot, + state, + ) + .await + { Ok(Some(max_bid)) => { encode_bid_response(max_bid, response_encoding, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG) } @@ -99,58 +108,41 @@ pub async fn handle_get_execution_payload_bid( record_beacon_status("204", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG); Ok(StatusCode::NO_CONTENT.into_response()) } - Err(err) => { - // A 4xx is the caller's fault, not CB's: only a 5xx is an error! - if err.status_code().is_server_error() { - error!(%err, "get_execution_payload_bid failed"); - } else { - warn!(%err, "get_execution_payload_bid failed"); - } - record_beacon_status( - err.status_code().as_str(), - GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, - ); - Err(err) - } + Err(err) => Err(record_request_failure(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG)), } } /// Encodes a winning bid into the 200 response for the caller's negotiated /// encoding, stamping the required `Eth-Consensus-Version` header and counting -/// the returned status. The `None` (no supported encoding) arm is unreachable -/// in practice - `get_accept_types` already 406s an unsupported Accept, and it -/// is counted here so a request emits exactly one label - but is kept as a -/// defensive 406. +/// the returned status. fn encode_bid_response( max_bid: GetExecutionPayloadBidResponse, - response_encoding: Option, + response_encoding: EncodingType, endpoint: &str, ) -> Result { - info!(trustless_bid_eth = format_gwei_as_eth(max_bid.value()), execution_payment_eth = format_gwei_as_eth(max_bid.execution_payment()), block_hash =% max_bid.block_hash(), builder_index = max_bid.builder_index(), "received header"); + info!( + trustless_bid_eth = format_gwei_as_eth(max_bid.value()), + execution_payment_eth = format_gwei_as_eth(max_bid.execution_payment()), + block_hash = %max_bid.block_hash(), + builder_index = max_bid.builder_index(), + "received header" + ); // Eth-Consensus-Version is required on the 200 for both encodings let consensus_version_header = HeaderValue::from_str(&max_bid.version.to_string()) .expect("fork name is always a valid header value"); - match response_encoding { - None => { - record_beacon_status("406", endpoint); - Err(PbsClientError::HeaderError(AcceptedEncodingsError::UnsupportedAcceptType)) - } - Some(EncodingType::Ssz) => { - record_beacon_status("200", endpoint); + record_beacon_status("200", endpoint); + let mut res = match response_encoding { + EncodingType::Ssz => { let mut res = max_bid.data.as_ssz_bytes().into_response(); - res.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); res.headers_mut().insert(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()); - Ok(res) - } - Some(EncodingType::Json) => { - record_beacon_status("200", endpoint); - let mut res = axum::Json(max_bid).into_response(); - res.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); - Ok(res) + res } - } + EncodingType::Json => axum::Json(max_bid).into_response(), + }; + res.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + Ok(res) } /// Implements https://ethereum.github.io/builder-specs/?urls.primaryName=dev#/Builder/getExecutionPayloadBid @@ -160,9 +152,10 @@ pub async fn get_execution_payload_bid( params: GetExecutionPayloadBidParams, body: Arc, req_headers: HeaderMap, + response_encoding: EncodingType, + ms_into_slot: u64, state: PbsState, ) -> Result, PbsClientError> { - let ms_into_slot = ms_into_slot(params.slot, state.config.chain); let (pbs_config, relays, maybe_mux_id) = state.mux_config_and_relays(¶ms.proposer_pubkey); log_mux_selection(maybe_mux_id, relays.len(), ¶ms.proposer_pubkey); @@ -226,23 +219,18 @@ pub async fn get_execution_payload_bid( // `send_one_get_execution_payload_bid` let mut send_headers = epbs_base_send_headers(&req_headers)?; - // Forward the caller's Accept preference to the relay so it returns the - // format the BN wants, avoiding a decode->re-encode. No-preference defaults - // to SSZ (this endpoint is SSZ-by-default). Always offer both encodings as - // fallback so a format-limited relay still returns a bid. - let caller_accept = get_accept_types_with_default(&req_headers, EncodingType::Ssz) - .map_err(|_| PbsClientError::Internal)?; - let relay_accept = AcceptedEncodings { - primary: caller_accept.primary, - fallback: Some(match caller_accept.primary { - EncodingType::Ssz => EncodingType::Json, - EncodingType::Json => EncodingType::Ssz, - }), + // Ask the relay for the format the BN wants first, avoiding a + // decode->re-encode, but always offer the other encoding as fallback so a + // format-limited relay still returns a bid. + let relay_accept = match response_encoding { + EncodingType::Ssz => &OUTBOUND_ACCEPT_SSZ_FIRST, + EncodingType::Json => &OUTBOUND_ACCEPT_JSON_FIRST, }; - send_headers.insert(ACCEPT, build_outbound_accept(relay_accept)); + send_headers.insert(ACCEPT, relay_accept.clone()); + let caps: Vec = relays.iter().map(|relay| ranking_cap_gwei(relay, pbs_config)).collect(); let mut handles = Vec::with_capacity(relays.len()); - for relay in relays.iter() { + for (relay, &cap) in relays.iter().zip(&caps) { handles.push( send_timed_get_execution_payload_bid( params.clone(), @@ -251,7 +239,7 @@ pub async fn get_execution_payload_bid( send_headers.clone(), ms_into_slot, max_timeout_ms, - ranking_cap_gwei(relay, pbs_config), + cap, ValidationContext { extra_validation_enabled: state.extra_validation_enabled(), parent_block: parent_block.clone(), @@ -263,7 +251,7 @@ pub async fn get_execution_payload_bid( let results = join_all(handles).await; let mut relay_bids = Vec::with_capacity(relays.len()); - for (res, relay) in results.into_iter().zip(relays.iter()) { + for ((res, relay), &cap) in results.into_iter().zip(relays.iter()).zip(&caps) { let relay_id = relay.id.as_str(); match res { @@ -272,9 +260,9 @@ pub async fn get_execution_payload_bid( // value() is already gwei (the gauge is labelled gwei), so it is set unscaled RELAY_HEADER_VALUE.with_label_values(&[relay_id]).set(res.value() as i64); - relay_bids.push((relay_id, res, ranking_cap_gwei(relay, pbs_config))) + relay_bids.push((relay_id, res, cap)) } - Ok(_) => {} + Ok(None) => {} Err(err) if err.is_timeout() => error!(err = "Timed Out", relay_id), Err(err) => error!(%err, relay_id), } @@ -411,10 +399,9 @@ fn ranking_payment(bid: &impl GetExecutionPayloadBidInfo, cap_gwei: u64) -> u64 bid.value().saturating_add(bid.execution_payment().min(cap_gwei)) } -// `L` is an opaque label (relay id for the cross-relay layer, request start -// time for the per-relay in-flight layer) carried through to the winner; the -// u64 is that bid's relay execution-payment cap in gwei. -fn select_max_bid(bids: Vec<(L, I, u64)>) -> Option<(L, I)> { +/// The winner among `(relay id, bid, that relay's execution-payment cap in +/// gwei)`, ranked by [`ranking_payment`]. +fn select_max_bid(bids: Vec<(&str, I, u64)>) -> Option<(&str, I)> { bids.into_iter() .max_by_key(|(_, bid, cap_gwei)| ranking_payment(bid, *cap_gwei)) .map(|(label, bid, _)| (label, bid)) @@ -549,12 +536,12 @@ async fn send_timed_get_execution_payload_bid( .filter_map(|res| { // ignore join error and timeouts, log other errors res.ok().and_then(|inner_res| match inner_res { - Ok((start_time, Some(header))) => { + Ok(Some(header)) => { n_headers += 1; - Some((start_time, header, ranking_cap_gwei)) + Some((relay.id.as_str(), header, ranking_cap_gwei)) } // a 204 is the relay answering "no bid", not failing - Ok((_, None)) => { + Ok(None) => { served_no_bid = true; None } @@ -595,7 +582,6 @@ async fn send_timed_get_execution_payload_bid( validation, ) .await - .map(|(_, maybe_header)| maybe_header) } struct RequestContext { @@ -616,9 +602,8 @@ async fn send_one_get_execution_payload_bid( relay: RelayClient, mut req_config: RequestContext, validation: ValidationContext, -) -> Result<(u64, Option), PbsError> { - let start_request_time = utcnow_ms(); - req_config.headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); +) -> Result, PbsError> { + req_config.headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(utcnow_ms())); // The timeout header indicating how long a relay has to respond, so they can // minimize timing games without losing the bid @@ -661,7 +646,7 @@ async fn send_one_get_execution_payload_bid( response = ?response_bytes, "no header from relay" ); - return Ok((start_request_time, None)); + return Ok(None); } let get_header_response = match content_type { @@ -755,7 +740,7 @@ async fn send_one_get_execution_payload_bid( } } - Ok((start_request_time, Some(get_header_response))) + Ok(Some(get_header_response)) } struct HeaderInfo { @@ -1240,18 +1225,14 @@ mod tests { // TOTAL payment, not the latest-started response. #[test] fn test_inflight_selection_prefers_max_total_not_latest() { - // Labels are request start times (utcnow_ms), as in the timing-games path. - let early = 1_000u64; - let late = 1_050u64; - let mid = 1_025u64; // Max total is neither first nor last, and the later-started response // pays LESS: this fails both latest-wins and first-wins. let bids = vec![ - (late, MockBid { value: 3, execution_payment: 1 }, u64::MAX), // total 4 - (early, MockBid { value: 10, execution_payment: 5 }, u64::MAX), // total 15 (winner) - (mid, MockBid { value: 6, execution_payment: 2 }, u64::MAX), // total 8 + ("late", MockBid { value: 3, execution_payment: 1 }, u64::MAX), // total 4 + ("early", MockBid { value: 10, execution_payment: 5 }, u64::MAX), // total 15 (winner) + ("mid", MockBid { value: 6, execution_payment: 2 }, u64::MAX), // total 8 ]; - let (winner_start, _) = select_max_bid(bids).unwrap(); - assert_eq!(winner_start, early, "must pick highest total, not latest- or first-started"); + let (winner, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner, "early", "must pick highest total, not latest- or first-started"); } } From 7f416773ce0ecbdba8a2c0a2f6a364ffc02df67c Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 8 Sep 2026 15:46:03 -0700 Subject: [PATCH 78/80] refactor(pbs): carry the builder's status in BuilderRejected The variant held a u16 that `status_code` converted back with a 502 fallback its only constructor could never reach. It holds the `StatusCode` directly. --- crates/pbs/src/error.rs | 18 ++++++++---------- crates/pbs/src/routes/builder_preferences.rs | 5 ++--- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index 27171dd66..866f0b9b7 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -41,8 +41,11 @@ pub enum PbsClientError { MissingTimingHeader, #[error("auth slot does not match the request path")] AuthSlotMismatch, - #[error("the addressed builder rejected the request with {code}")] - BuilderRejected { code: u16 }, + /// A lone addressed builder's own 400/401 from the preferences endpoint, + /// propagated so the proposer learns whether its auth data or its signature + /// was rejected (a blanket 500 would hide that). + #[error("the addressed builder rejected the request with {}", .0.as_u16())] + BuilderRejected(StatusCode), #[error("auth signature verification failed")] AuthSigVerify, #[error("submitted block is not a Gloas block")] @@ -67,12 +70,7 @@ impl PbsClientError { PbsClientError::EmptyAuthData => StatusCode::BAD_REQUEST, PbsClientError::MissingTimingHeader => StatusCode::BAD_REQUEST, PbsClientError::AuthSlotMismatch => StatusCode::BAD_REQUEST, - // A lone addressed builder's own 400/401 from the preferences - // endpoint is propagated (the sole constructor guards to those two - // codes, so the 502 fallback below is currently dead). - PbsClientError::BuilderRejected { code } => { - StatusCode::from_u16(*code).unwrap_or(StatusCode::BAD_GATEWAY) - } + PbsClientError::BuilderRejected(code) => *code, PbsClientError::AuthSigVerify => StatusCode::UNAUTHORIZED, PbsClientError::NotGloasBlock => StatusCode::BAD_REQUEST, PbsClientError::NoPayload => StatusCode::BAD_GATEWAY, @@ -109,8 +107,8 @@ impl IntoResponse for PbsClientError { } // The builder's own body is never forwarded: it is untrusted and may be // arbitrarily large - PbsClientError::BuilderRejected { code } => { - format!("The addressed builder rejected the submission with status {code}") + PbsClientError::BuilderRejected(code) => { + format!("The addressed builder rejected the submission with status {}", code.as_u16()) } PbsClientError::AuthSigVerify => { "Invalid SignedBuilderRequestAuth: signature verification failed".to_string() diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index 3e09a3767..c3ad41d5c 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -130,10 +130,9 @@ pub async fn submit_builder_preferences( // One accepting builder is a successful submission: the others are separate // destinations, not replicas, and the proposer addressed each by auth data if accepted == 0 { - // A lone builder's own 400/401 tells the proposer whether its auth data or - // its signature was rejected, which a blanket 502 would hide return Err(match lone_rejection { - Some(code @ (400 | 401)) => PbsClientError::BuilderRejected { code }, + Some(400) => PbsClientError::BuilderRejected(StatusCode::BAD_REQUEST), + Some(401) => PbsClientError::BuilderRejected(StatusCode::UNAUTHORIZED), _ => PbsClientError::NoBuilderResponse, }); } From 9303c64dd410152055c5c289573a47cf19fcefb6 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 8 Sep 2026 15:46:03 -0700 Subject: [PATCH 79/80] refactor(common): one startup warning for the disabled transient pipe `load_pbs_config` and `load_pbs_custom_config` carried the same advertised_urls warning; it moves to one helper, and the runtime once-warn in the pipe stops restating it. km-tool's `resolve_min_bid` is inlined at its only call site. --- crates/common/src/config/pbs.rs | 45 +++++++++++++++------------------ crates/km-tool/src/project.rs | 19 ++------------ crates/pbs/src/utils.rs | 7 ++--- 3 files changed, 25 insertions(+), 46 deletions(-) diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 961a1ecf2..ac9f4bc41 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -401,18 +401,8 @@ pub async fn load_pbs_config(config_path: Option) -> Result<(PbsModuleC None => (None, None), }; - // The ePBS transient pipe (forwarding a bid/preferences request to a - // proposer-addressed builder that is not in the relay config) is fail-closed - // without advertised_urls: CB cannot tell an unconfigured key's self-URL - // default from an external builder, so it will not dial. Warn once at startup - // so this reads as a deliberate opt-in, not a silent 400 at request time. - if mux_lookup.is_some() && config.pbs.pbs_config.advertised_urls.is_empty() { - warn!( - "advertised_urls is unset: the ePBS transient pipe is disabled, so a bid or \ - preferences request addressed to a builder not in your relay config is rejected \ - with 400. Set advertised_urls to CB's advertised URL(s) to enable forwarding to \ - proposer-addressed builders." - ); + if mux_lookup.is_some() { + warn_if_transient_pipe_disabled(&config.pbs.pbs_config); } // Build the list of all relays, starting with muxes @@ -505,18 +495,8 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC None => (None, None), }; - // The ePBS transient pipe (forwarding a bid/preferences request to a - // proposer-addressed builder that is not in the relay config) is fail-closed - // without advertised_urls: CB cannot tell an unconfigured key's self-URL - // default from an external builder, so it will not dial. Warn once at startup - // so this reads as a deliberate opt-in, not a silent 400 at request time. - if mux_lookup.is_some() && cb_config.pbs.static_config.pbs_config.advertised_urls.is_empty() { - warn!( - "advertised_urls is unset: the ePBS transient pipe is disabled, so a bid or \ - preferences request addressed to a builder not in your relay config is rejected \ - with 400. Set advertised_urls to CB's advertised URL(s) to enable forwarding to \ - proposer-addressed builders." - ); + if mux_lookup.is_some() { + warn_if_transient_pipe_disabled(&cb_config.pbs.static_config.pbs_config); } // Build the list of all relays, starting with muxes @@ -580,6 +560,23 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC )) } +/// The ePBS transient pipe (forwarding a bid/preferences request to a +/// proposer-addressed builder that is not in the relay config) is fail-closed +/// without advertised_urls: CB cannot tell an unconfigured key's self-URL +/// default from an external builder, so it will not dial. Warned once at +/// startup so this reads as a deliberate opt-in, not a silent 400 at request +/// time. +fn warn_if_transient_pipe_disabled(pbs_config: &PbsConfig) { + if pbs_config.advertised_urls.is_empty() { + warn!( + "advertised_urls is unset: the ePBS transient pipe is disabled, so a bid or \ + preferences request addressed to a builder not in your relay config is rejected \ + with 400. Set advertised_urls to CB's advertised URL(s) to enable forwarding to \ + proposer-addressed builders." + ); + } +} + /// Default URL for the user's SSV node API endpoint (/v1/validators). fn default_ssv_node_api_url() -> Url { Url::parse("http://localhost:16000/v1/").expect("default URL is valid") diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs index 40e183ad8..4c33f724c 100644 --- a/crates/km-tool/src/project.rs +++ b/crates/km-tool/src/project.rs @@ -342,7 +342,8 @@ fn project_mux( // values, so the key level only governs p2p bids and entries that omit // their own. Unset p2p fields fall back to the entry values (uniform doc, // today's behavior). - let min_bid = resolve_min_bid(input, mux, warnings)?; + let min_bid_wei = mux.min_bid_wei.unwrap_or(input.cfg.pbs.pbs_config.min_bid_wei); + let min_bid = wei_to_gwei_floor(&mux.id, min_bid_wei, warnings)?.to_string(); let key_min_bid = match mux.min_bid_p2p_wei.or(input.cfg.pbs.pbs_config.min_bid_p2p_wei) { Some(wei) => wei_to_gwei_floor(&mux.id, wei, warnings)?.to_string(), None => min_bid.clone(), @@ -391,22 +392,6 @@ fn project_mux( }) } -/// Per-mux min_bid in Gwei: the MuxConfig `min_bid_wei` when set, else the -/// global `min_bid_wei`. Wei sources floor-divide with a warning on a sub-Gwei -/// remainder. -fn resolve_min_bid( - input: &ProjectionInput, - mux: &MuxConfig, - warnings: &mut Vec, -) -> Result { - let gwei = if let Some(wei) = mux.min_bid_wei { - wei_to_gwei_floor(&mux.id, wei, warnings)? - } else { - wei_to_gwei_floor(&mux.id, input.cfg.pbs.pbs_config.min_bid_wei, warnings)? - }; - Ok(gwei.to_string()) -} - fn wei_to_gwei_floor(mux_id: &str, wei: U256, warnings: &mut Vec) -> Result { let divisor = U256::from(WEI_PER_GWEI); let gwei = wei / divisor; diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 4a432603f..a1605459a 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -340,11 +340,8 @@ pub(crate) async fn transient_pipe_relay( return Err(PbsClientError::AuthDataMismatch); }; if advertised_urls.is_empty() { - // Fail closed, but tell the operator why (once, to avoid per-request - // spam): without advertised_urls CB cannot distinguish an unconfigured - // key's self-URL default from an external builder. Setting advertised_urls - // to CB's advertised URL(s) enables forwarding to proposer-addressed - // builders. The same condition is warned once at startup in load_pbs_config. + // Fail closed; warned once per process here and once at startup in + // load_pbs_config, never per request static WARNED: AtomicBool = AtomicBool::new(false); if !WARNED.swap(true, Ordering::Relaxed) { warn!(%url, "advertised_urls is unset: the ePBS transient pipe is disabled, not forwarding to this proposer-addressed builder; set advertised_urls to CB's advertised URL(s) to enable it"); From 6a0b37ddf84912e42b189513d9c7ef626469cdbb Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 8 Sep 2026 15:46:03 -0700 Subject: [PATCH 80/80] test(pbs): boot the ePBS suites through spawn_mock_relay and setup_pbs The bid suite hand-rolled 23 PBS boots and the preferences suite two; they run through `spawn_mock_relay` + `setup_pbs`, which the existing `setup_relay*` helpers now delegate to. The default JSON bid request and the hand-built spec URL each get one helper, the proposer pubkey literal becomes `TEST_PROPOSER_PUBKEY`, and two clippy nits in the rewritten file are fixed. The mock relay computes its served fork and signing key once. --- tests/src/mock_relay.rs | 36 +- tests/src/mock_validator.rs | 10 +- tests/src/utils.rs | 101 +-- tests/tests/pbs_get_execution_payload_bid.rs | 817 +++++------------- tests/tests/pbs_submit_builder_preferences.rs | 78 +- 5 files changed, 282 insertions(+), 760 deletions(-) diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index e3257a810..23fd408fb 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -550,25 +550,15 @@ async fn handle_get_execution_payload_bid( ..Default::default() }; - let object_root = message.tree_hash_root(); - let signature = if state.epbs_invalid_signature { - let wrong_key = random_secret(); - sign_execution_payload_bid_root( - &wrong_key, - &object_root, - GLOAS_FORK_VERSION, - GENESIS_VALIDATORS_ROOT.into(), - ) - } else { - sign_execution_payload_bid_root( - &state.signer, - &object_root, - GLOAS_FORK_VERSION, - GENESIS_VALIDATORS_ROOT.into(), - ) - }; - + let wrong_signer = state.epbs_invalid_signature.then(random_secret); + let signature = sign_execution_payload_bid_root( + wrong_signer.as_ref().unwrap_or(&state.signer), + &message.tree_hash_root(), + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ); let data = SignedExecutionPayloadBid { message, signature }; + let served_fork = if state.epbs_wrong_fork { ForkName::Capella } else { ForkName::Gloas }; // Negotiate the RESPONSE encoding from the forwarded Accept, mirroring // handle_get_header: honor supported_content_types + the caller's Accept. @@ -598,7 +588,7 @@ async fn handle_get_execution_payload_bid( // JSON carries the fork-versioned wrapper (fork is in the body). EncodingType::Json => { let versioned = GetExecutionPayloadBidResponse { - version: if state.epbs_wrong_fork { ForkName::Capella } else { ForkName::Gloas }, + version: served_fork, data, metadata: Default::default(), }; @@ -611,10 +601,10 @@ async fn handle_get_execution_payload_bid( // (non-self-describing) SSZ bytes. The omit knob drives the PBS // "SSZ response missing Eth-Consensus-Version" error path. if !state.epbs_omit_consensus_version { - let fork = if state.epbs_wrong_fork { ForkName::Capella } else { ForkName::Gloas }; - response - .headers_mut() - .insert(CONSENSUS_VERSION_HEADER, HeaderValue::from_str(&fork.to_string()).unwrap()); + response.headers_mut().insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&served_fork.to_string()).unwrap(), + ); } response .headers_mut() diff --git a/tests/src/mock_validator.rs b/tests/src/mock_validator.rs index 8dee79dce..6605a1e51 100644 --- a/tests/src/mock_validator.rs +++ b/tests/src/mock_validator.rs @@ -15,7 +15,7 @@ use reqwest::{ }; use ssz::Encode; -use crate::utils::generate_mock_relay; +use crate::utils::{TEST_PROPOSER_PUBKEY, generate_mock_relay}; /// Timeout a test beacon node advertises on bid requests; long enough that the /// deadline never bites in tests. @@ -80,9 +80,7 @@ impl MockValidator { request: &BuilderPreferencesRequest, content_type: EncodingType, ) -> eyre::Result { - let default_pubkey = bls_pubkey_from_hex( - "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", - )?; + let default_pubkey = bls_pubkey_from_hex(TEST_PROPOSER_PUBKEY)?; let url = self.comm_boost.submit_builder_preferences_url(&pubkey.unwrap_or(default_pubkey))?; @@ -162,9 +160,7 @@ impl MockValidator { accept: Vec, timeout_ms: u64, ) -> eyre::Result { - let default_pubkey = bls_pubkey_from_hex( - "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", - )?; + let default_pubkey = bls_pubkey_from_hex(TEST_PROPOSER_PUBKEY)?; let url = self.comm_boost.get_execution_payload_bid_url( slot, &parent_hash, diff --git a/tests/src/utils.rs b/tests/src/utils.rs index 77f05dd5e..964b58d4c 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -43,6 +43,8 @@ pub const RELAY_API_KEY: &str = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"; /// Unmatched auth data is a 400 (no catch-all), so a relay must declare the /// data it serves for opaque-data tests to route. pub const TEST_AUTH_DATA: &[u8] = &[0xde, 0xad]; +/// The proposer pubkey the mock validator's ePBS requests are filed under. +pub const TEST_PROPOSER_PUBKEY: &str = "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae"; pub fn get_local_address(port: u16) -> String { format!("http://0.0.0.0:{port}") @@ -331,63 +333,66 @@ pub fn signed_auth( auth } -/// Boot PBS in front of one mock relay, letting the test shape both the PBS -/// config and the relay entry. -pub async fn setup_relay( +/// Starts a mock relay on a free port, returning its state and port. The +/// building block of every PBS boot below, and on its own the way a test gets a +/// builder that PBS does NOT know about (the ePBS pipe target). +pub async fn spawn_mock_relay(state: MockRelayState) -> Result<(Arc, u16)> { + let listener = get_free_listener().await; + let port = listener.local_addr()?.port(); + let state = Arc::new(state); + tokio::spawn(start_mock_relay_service_with_listener(state.clone(), listener)); + Ok((state, port)) +} + +/// Boot PBS in front of already-spawned mock relays, letting the test shape +/// the PBS config first. Readiness is awaited: relay_check makes a 200 on +/// /status mean the whole chain is up. +pub async fn setup_pbs( chain: Chain, + relays: Vec, tweak: impl FnOnce(&mut PbsConfig), - make_relay: impl FnOnce(u16, BlsPublicKey) -> Result, -) -> Result<(MockValidator, Arc)> { +) -> Result { setup_test_env(); let pbs_listener = get_free_listener().await; let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = make_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); let mut pbs_config = get_pbs_config(pbs_port); tweak(&mut pbs_config); - let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); + let state = PbsState::new(to_pbs_config(chain, pbs_config, relays), PathBuf::new()); tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); let mock_validator = MockValidator::new(pbs_port)?; wait_for_ready(&mock_validator).await?; - Ok((mock_validator, mock_state)) + Ok(mock_validator) +} + +/// Boot PBS in front of one default-state mock relay, letting the test shape +/// the PBS config and the relay entry. +pub async fn setup_relay( + chain: Chain, + tweak: impl FnOnce(&mut PbsConfig), + make_relay: impl FnOnce(u16, BlsPublicKey) -> Result, +) -> Result<(MockValidator, Arc)> { + let (state, port) = spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + let relay = make_relay(port, state.signer.public_key())?; + Ok((setup_pbs(chain, vec![relay], tweak).await?, state)) } -/// Boot PBS in front of several mock relays, one per supplied `MockRelayState`, -/// so per-relay knobs and counters stay independent. Returns the validator and -/// the relay states in configuration order. +/// Boot PBS in front of several default-entry mock relays, one per state, so +/// per-relay knobs and counters stay independent. Returns the relay states in +/// configuration order. pub async fn setup_relays( chain: Chain, states: Vec, ) -> Result<(MockValidator, Vec>)> { - setup_test_env(); - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - - let mut relays = Vec::new(); - let mut arc_states = Vec::new(); + let mut relays = Vec::with_capacity(states.len()); + let mut arc_states = Vec::with_capacity(states.len()); for state in states { - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - let state = Arc::new(state); - relays.push(generate_mock_relay(relay_port, state.signer.public_key())?); - tokio::spawn(start_mock_relay_service_with_listener(state.clone(), relay_listener)); + let (state, port) = spawn_mock_relay(state).await?; + relays.push(generate_mock_relay(port, state.signer.public_key())?); arc_states.push(state); } - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; - Ok((mock_validator, arc_states)) + Ok((setup_pbs(chain, relays, |_| {}).await?, arc_states)) } /// Like [`setup_relays`], but each relay declares the `expected_auth_data` it @@ -396,32 +401,18 @@ pub async fn setup_relays_with_auth_data( chain: Chain, states: Vec<(MockRelayState, &[u8])>, ) -> Result<(MockValidator, Vec>)> { - setup_test_env(); - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - - let mut relays = Vec::new(); - let mut arc_states = Vec::new(); + let mut relays = Vec::with_capacity(states.len()); + let mut arc_states = Vec::with_capacity(states.len()); for (state, auth_data) in states { - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - let state = Arc::new(state); + let (state, port) = spawn_mock_relay(state).await?; relays.push(generate_mock_relay_with_auth_data( - relay_port, + port, state.signer.public_key(), auth_data, )?); - tokio::spawn(start_mock_relay_service_with_listener(state.clone(), relay_listener)); arc_states.push(state); } - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; - Ok((mock_validator, arc_states)) + Ok((setup_pbs(chain, relays, |_| {}).await?, arc_states)) } /// Poll /status until PBS and its relays are up. relay_check makes a 200 mean diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 8899b4467..e6719263a 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -1,11 +1,12 @@ -use std::{path::PathBuf, sync::Arc}; +use std::sync::Arc; use alloy::primitives::{B256, U256}; use cb_common::{ constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{ DEFAULT_BID_POLL_TIMEOUT_MS, GetExecutionPayloadBidInfo, GetExecutionPayloadBidResponse, - HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, SignedExecutionPayloadBid, + HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, SignedBuilderRequestAuth, + SignedExecutionPayloadBid, }, signature::sign_execution_payload_bid_root, signer::random_secret, @@ -13,15 +14,14 @@ use cb_common::{ utils::utcnow_ms, wire::{CONSENSUS_VERSION_HEADER, EncodingType}, }; -use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, + mock_relay::MockRelayState, mock_validator::MockValidator, utils::{ - generate_mock_relay, generate_mock_relay_url_only, generate_mock_relay_with_auth_data, - generate_mock_relay_with_max_payment, generate_mock_relay_with_timing_games, - get_free_listener, get_pbs_config, opaque_auth, setup_relay, setup_relays, setup_test_env, - signed_auth, to_pbs_config, wait_for_ready, + TEST_PROPOSER_PUBKEY, generate_mock_relay, generate_mock_relay_url_only, + generate_mock_relay_with_auth_data, generate_mock_relay_with_max_payment, + generate_mock_relay_with_timing_games, opaque_auth, setup_pbs, setup_relay, setup_relays, + setup_relays_with_auth_data, signed_auth, spawn_mock_relay, }, }; use eyre::Result; @@ -32,6 +32,32 @@ use tree_hash::TreeHash; const TEST_SLOT: u64 = 100; +/// The request most tests send: a JSON-accept bid request for `TEST_SLOT` on +/// a zero parent hash/root, carrying `auth`. +async fn get_json_bid( + mock_validator: &MockValidator, + auth: &SignedBuilderRequestAuth, +) -> Result { + mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(auth), vec![ + EncodingType::Json, + ]) + .await +} + +/// The literal spec URL of the bid endpoint for `TEST_SLOT`, for the tests that +/// build their request by hand (bare-URL shape, missing headers, raw bodies). +fn bid_url(mock_validator: &MockValidator) -> String { + format!( + "{}eth/v1/builder/execution_payload_bid/{}/{}/{}/{}", + mock_validator.comm_boost.config.entry.url, + TEST_SLOT, + B256::ZERO, + B256::ZERO, + TEST_PROPOSER_PUBKEY, + ) +} + /// Test requesting a bid with a single default relay #[tokio::test] async fn test_get_execution_payload_bid() -> Result<()> { @@ -169,33 +195,16 @@ async fn test_get_execution_payload_bid_highest_total_payment_wins() -> Result<( /// minimum still passes through #[tokio::test] async fn test_get_execution_payload_bid_below_min_bid_passes() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - // Default mock bid: trustless 10 gwei, no execution payment - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let mut pbs_config = get_pbs_config(pbs_port); - pbs_config.min_bid_wei = U256::from(20_000_000_000u64); // 20 gwei - let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + // Default mock bid: trustless 10 gwei, no execution payment; CB floor 20 gwei + let (mock_validator, mock_state) = setup_relay( + Chain::Hoodi, + |cfg| cfg.min_bid_wei = U256::from(20_000_000_000u64), + generate_mock_relay, + ) + .await?; let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(mock_state.received_execution_payload_bid(), 1); Ok(()) @@ -205,41 +214,17 @@ async fn test_get_execution_payload_bid_below_min_bid_passes() -> Result<()> { /// `expected_auth_data` matches is contacted, and its bid is returned. #[tokio::test] async fn test_get_execution_payload_bid_demux_routes_by_auth_data() -> Result<()> { - setup_test_env(); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - - let data_a = vec![0xaa, 0x01]; - let data_b = vec![0xbb, 0x02]; - let mut relays = Vec::new(); - let mut states = Vec::new(); - for data in [&data_a, &data_b] { - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - let state = Arc::new(MockRelayState::new(chain, random_secret())); - relays.push(generate_mock_relay_with_auth_data( - relay_port, - state.signer.public_key(), - data, - )?); - tokio::spawn(start_mock_relay_service_with_listener(state.clone(), relay_listener)); - states.push(state); - } - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let data_a = [0xaa, 0x01]; + let data_b = [0xbb, 0x02]; + let (mock_validator, states) = setup_relays_with_auth_data(chain, vec![ + (MockRelayState::new(chain, random_secret()), &data_a), + (MockRelayState::new(chain, random_secret()), &data_b), + ]) + .await?; let auth = opaque_auth(&data_a, TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(states[0].received_execution_payload_bid(), 1); assert_eq!(states[1].received_execution_payload_bid(), 0); @@ -250,40 +235,22 @@ async fn test_get_execution_payload_bid_demux_routes_by_auth_data() -> Result<() /// to the relay whose configured URL matches, ignoring the entry's userinfo. #[tokio::test] async fn test_get_execution_payload_bid_demux_by_url_bytes() -> Result<()> { - setup_test_env(); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let mut relays = Vec::new(); let mut states = Vec::new(); let mut urls = Vec::new(); for _ in 0..2 { - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - let state = Arc::new(MockRelayState::new(chain, random_secret())); + let (state, port) = spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; // No expected_auth_data: these relays are addressed by URL-carrying data - let relay = generate_mock_relay_url_only(relay_port, state.signer.public_key())?; - urls.push(format!("http://0.0.0.0:{relay_port}/")); - tokio::spawn(start_mock_relay_service_with_listener(state.clone(), relay_listener)); - relays.push(relay); + relays.push(generate_mock_relay_url_only(port, state.signer.public_key())?); + urls.push(format!("http://0.0.0.0:{port}/")); states.push(state); } - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let mock_validator = setup_pbs(chain, relays, |_| {}).await?; // data = UTF-8 bytes of relay-0's URL let auth = opaque_auth(urls[0].as_bytes(), TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(states[0].received_execution_payload_bid(), 1); assert_eq!(states[1].received_execution_payload_bid(), 0); @@ -293,22 +260,14 @@ async fn test_get_execution_payload_bid_demux_by_url_bytes() -> Result<()> { with_extra.push(0); with_extra.extend_from_slice(&[0xde, 0xad]); let auth = opaque_auth(&with_extra, TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(states[0].received_execution_payload_bid(), 1); assert_eq!(states[1].received_execution_payload_bid(), 1); // a URL matching no configured relay is a 400, nothing contacted let auth = opaque_auth(b"https://unknown-builder.example:9999/", TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::BAD_REQUEST); assert_eq!(states[0].received_execution_payload_bid(), 1); assert_eq!(states[1].received_execution_payload_bid(), 1); @@ -321,44 +280,24 @@ async fn test_get_execution_payload_bid_demux_by_url_bytes() -> Result<()> { /// unchanged (blind pipe: the ePBS path does not verify the bid signature). #[tokio::test] async fn test_get_execution_payload_bid_pipe_dials_unconfigured_builder() -> Result<()> { - setup_test_env(); // This test's mock builder binds to a local (unspecified/loopback) address the // pipe's SSRF guard blocks; skip that check so the forward path is exercised. cb_pbs::set_skip_pipe_target_check(true); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - // A configured relay, addressed by opaque bytes only - let cfg_listener = get_free_listener().await; - let cfg_port = cfg_listener.local_addr()?.port(); - let cfg_state = Arc::new(MockRelayState::new(chain, random_secret())); - let cfg_relay = - generate_mock_relay_with_auth_data(cfg_port, cfg_state.signer.public_key(), &[0xaa])?; - tokio::spawn(start_mock_relay_service_with_listener(cfg_state.clone(), cfg_listener)); - + let (mock_validator, cfg_state) = setup_relay( + chain, + |cfg| cfg.advertised_urls = vec!["http://cb.self.example:18550".parse().unwrap()], + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &[0xaa]), + ) + .await?; // The pipe builder runs but is NOT in CB's config - let pipe_listener = get_free_listener().await; - let pipe_port = pipe_listener.local_addr()?.port(); - let pipe_state = Arc::new(MockRelayState::new(chain, random_secret())); - tokio::spawn(start_mock_relay_service_with_listener(pipe_state.clone(), pipe_listener)); - - let mut pbs_config = get_pbs_config(pbs_port); - pbs_config.advertised_urls = vec!["http://cb.self.example:18550".parse()?]; - let config = to_pbs_config(chain, pbs_config, vec![cfg_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (pipe_state, pipe_port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; let pipe_url = format!("http://0.0.0.0:{pipe_port}/"); let auth = opaque_auth(pipe_url.as_bytes(), TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(pipe_state.received_execution_payload_bid(), 1); assert_eq!(cfg_state.received_execution_payload_bid(), 0, "only the piped builder is dialed"); @@ -382,36 +321,18 @@ async fn test_get_execution_payload_bid_pipe_dials_unconfigured_builder() -> Res /// defaults to CB's own URL, which must not become a self-dial loop. #[tokio::test] async fn test_get_execution_payload_bid_pipe_self_url_not_dialed() -> Result<()> { - setup_test_env(); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - // The mock stands in for whatever answers at CB's advertised URL: anything // it receives means the guard failed and a dial went out - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = - generate_mock_relay_with_auth_data(relay_port, mock_state.signer.public_key(), &[0xaa])?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let self_url = format!("http://0.0.0.0:{relay_port}/"); - let mut pbs_config = get_pbs_config(pbs_port); - pbs_config.advertised_urls = vec![self_url.parse()?]; - let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_state, port) = spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + let relay = generate_mock_relay_with_auth_data(port, mock_state.signer.public_key(), &[0xaa])?; + let self_url = format!("http://0.0.0.0:{port}/"); + let mock_validator = + setup_pbs(chain, vec![relay], |cfg| cfg.advertised_urls = vec![self_url.parse().unwrap()]) + .await?; let auth = opaque_auth(self_url.as_bytes(), TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::BAD_REQUEST); assert_eq!(mock_state.received_execution_payload_bid(), 0, "a self URL is never dialed"); Ok(()) @@ -422,37 +343,19 @@ async fn test_get_execution_payload_bid_pipe_self_url_not_dialed() -> Result<()> /// is dialed - even though the named builder is alive. #[tokio::test] async fn test_get_execution_payload_bid_pipe_requires_advertised_urls() -> Result<()> { - setup_test_env(); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let cfg_state = Arc::new(MockRelayState::new(chain, random_secret())); - let cfg_relay = - generate_mock_relay_with_auth_data(relay_port, cfg_state.signer.public_key(), &[0xaa])?; - tokio::spawn(start_mock_relay_service_with_listener(cfg_state.clone(), relay_listener)); - - let pipe_listener = get_free_listener().await; - let pipe_port = pipe_listener.local_addr()?.port(); - let pipe_state = Arc::new(MockRelayState::new(chain, random_secret())); - tokio::spawn(start_mock_relay_service_with_listener(pipe_state.clone(), pipe_listener)); - // get_pbs_config leaves advertised_urls empty - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![cfg_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, cfg_state) = setup_relay( + chain, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &[0xaa]), + ) + .await?; + let (pipe_state, pipe_port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; let auth = opaque_auth(format!("http://0.0.0.0:{pipe_port}/").as_bytes(), TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::BAD_REQUEST); assert_eq!(pipe_state.received_execution_payload_bid(), 0, "fail closed: no blind dial"); assert_eq!(cfg_state.received_execution_payload_bid(), 0); @@ -463,31 +366,15 @@ async fn test_get_execution_payload_bid_pipe_requires_advertised_urls() -> Resul /// data-mismatch message; no relay is contacted. #[tokio::test] async fn test_get_execution_payload_bid_demux_no_match_400() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = - generate_mock_relay_with_auth_data(relay_port, mock_state.signer.public_key(), &[0xaa])?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, mock_state) = setup_relay( + Chain::Hoodi, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &[0xaa]), + ) + .await?; let auth = opaque_auth(&[0xbb], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::BAD_REQUEST); assert_eq!(mock_state.received_execution_payload_bid(), 0); let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; @@ -505,30 +392,11 @@ async fn test_get_execution_payload_bid_demux_no_match_400() -> Result<()> { /// a builder would. #[tokio::test] async fn test_get_execution_payload_bid_unmatched_opaque_auth_400() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = generate_mock_relay_url_only(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay_url_only).await?; let auth = opaque_auth(&[0xcc], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::BAD_REQUEST); assert_eq!(mock_state.received_execution_payload_bid(), 0, "no relay receives anything"); let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; @@ -543,32 +411,16 @@ async fn test_get_execution_payload_bid_unmatched_opaque_auth_400() -> Result<() /// An opaque (non-URL) auth body is forwarded to the relays verbatim. #[tokio::test] async fn test_get_execution_payload_bid_forwards_opaque_auth() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - let data = vec![0xde, 0xad, 0xbe, 0xef]; - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = - generate_mock_relay_with_auth_data(relay_port, mock_state.signer.public_key(), &data)?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, mock_state) = setup_relay( + Chain::Hoodi, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &data), + ) + .await?; let auth = opaque_auth(&data, TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(mock_state.received_execution_payload_bid(), 1); assert_eq!(mock_state.received_auth_data(), Some(data)); @@ -606,11 +458,7 @@ async fn test_get_execution_payload_bid_auth_slot_mismatch_400() -> Result<()> { setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT + 1); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::BAD_REQUEST); assert_eq!( mock_state.received_execution_payload_bid(), @@ -781,35 +629,10 @@ async fn test_get_execution_payload_bid_bad_auth_signature_forwarded_by_default( #[tokio::test] async fn test_get_execution_payload_bid_spec_url() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let signer = random_secret(); - - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new(MockRelayState::new(chain, signer)); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; - let pubkey = "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae"; - let url = format!( - "{}eth/v1/builder/execution_payload_bid/{}/{}/{}/{}", - mock_validator.comm_boost.config.entry.url, - TEST_SLOT, - B256::ZERO, - B256::ZERO, - pubkey, - ); + let url = bid_url(&mock_validator); // The auth body, timing headers and version header are required, so even // the bare-URL shape test must carry them let res = mock_validator @@ -831,23 +654,7 @@ async fn test_get_execution_payload_bid_spec_url() -> Result<()> { /// Accept: application/octet-stream, with Eth-Consensus-Version on the 200. #[tokio::test] async fn test_get_execution_payload_bid_ssz_response() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, _) = setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); let res = mock_validator @@ -878,23 +685,7 @@ async fn test_get_execution_payload_bid_ssz_response() -> Result<()> { /// no-preference tiebreak is SSZ, not the legacy JSON default. #[tokio::test] async fn test_get_execution_payload_bid_no_accept_defaults_to_ssz() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, _) = setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; // An empty accept vec makes MockValidator send NO Accept header at all. let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); @@ -920,31 +711,14 @@ async fn test_get_execution_payload_bid_no_accept_defaults_to_ssz() -> Result<() /// this also covers the JSON relay-leg decode path end to end. #[tokio::test] async fn test_get_execution_payload_bid_explicit_json_obeyed() -> Result<()> { - setup_test_env(); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = - Arc::new(MockRelayState::new(chain, random_secret()).with_json_only_response()); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, _) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()).with_json_only_response(), + ]) + .await?; let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::OK); let content_type = @@ -968,36 +742,19 @@ async fn test_get_execution_payload_bid_explicit_json_obeyed() -> Result<()> { /// decode path actually ran. #[tokio::test] async fn test_get_execution_payload_bid_relay_ssz_response_roundtrip() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - // Relay serves ONLY SSZ, so PBS must decode the SSZ bid on the relay leg. - let mock_state = Arc::new( + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ MockRelayState::new(chain, random_secret()) .with_ssz_only_response() .with_trustless_bid_gwei(42), - ); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + ]) + .await?; + let mock_state = &states[0]; // BN asks for JSON; PBS decodes SSZ from the relay and re-encodes to JSON. let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(mock_state.received_execution_payload_bid(), 1); @@ -1014,27 +771,14 @@ async fn test_get_execution_payload_bid_relay_ssz_response_roundtrip() -> Result /// 200 or panicking. With a single relay this drop yields a 204 to the BN. #[tokio::test] async fn test_get_execution_payload_bid_relay_ssz_missing_version_header() -> Result<()> { - setup_test_env(); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new( + let (mock_validator, states) = setup_relays(chain, vec![ MockRelayState::new(chain, random_secret()) .with_ssz_only_response() .with_epbs_omit_consensus_version(), - ); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + ]) + .await?; + let mock_state = &states[0]; let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); let res = mock_validator @@ -1054,25 +798,12 @@ async fn test_get_execution_payload_bid_relay_ssz_missing_version_header() -> Re /// the BN under the bogus fork. Single relay -> 204. #[tokio::test] async fn test_get_execution_payload_bid_relay_wrong_fork_dropped() -> Result<()> { - setup_test_env(); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new( + let (mock_validator, states) = setup_relays(chain, vec![ MockRelayState::new(chain, random_secret()).with_ssz_only_response().with_epbs_wrong_fork(), - ); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + ]) + .await?; + let mock_state = &states[0]; let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); let res = mock_validator @@ -1092,27 +823,14 @@ async fn test_get_execution_payload_bid_relay_wrong_fork_dropped() -> Result<()> /// the BN under the bogus fork. Single relay -> 204. #[tokio::test] async fn test_get_execution_payload_bid_relay_wrong_fork_json_dropped() -> Result<()> { - setup_test_env(); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new( + let (mock_validator, states) = setup_relays(chain, vec![ MockRelayState::new(chain, random_secret()) .with_json_only_response() .with_epbs_wrong_fork(), - ); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + ]) + .await?; + let mock_state = &states[0]; let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); let res = mock_validator @@ -1130,33 +848,10 @@ async fn test_get_execution_payload_bid_relay_wrong_fork_json_dropped() -> Resul /// (a typed error, not a 500). #[tokio::test] async fn test_get_execution_payload_bid_unsupported_accept_406() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; - let pubkey = "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae"; - let url = format!( - "{}eth/v1/builder/execution_payload_bid/{}/{}/{}/{}", - mock_validator.comm_boost.config.entry.url, - TEST_SLOT, - B256::ZERO, - B256::ZERO, - pubkey, - ); + let url = bid_url(&mock_validator); let res = mock_validator .comm_boost .client @@ -1175,35 +870,16 @@ async fn test_get_execution_payload_bid_unsupported_accept_406() -> Result<()> { /// to the relay, same as JSON. #[tokio::test] async fn test_get_execution_payload_bid_ssz_auth_forwarded() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - let data = vec![0xde, 0xad, 0xbe, 0xef]; - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = - generate_mock_relay_with_auth_data(relay_port, mock_state.signer.public_key(), &data)?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, mock_state) = setup_relay( + Chain::Hoodi, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &data), + ) + .await?; let ssz_body = opaque_auth(&data, TEST_SLOT).as_ssz_bytes(); - let url = format!( - "{}eth/v1/builder/execution_payload_bid/{}/{}/{}/{}", - mock_validator.comm_boost.config.entry.url, - TEST_SLOT, - B256::ZERO, - B256::ZERO, - "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", - ); + let url = bid_url(&mock_validator); let res = mock_validator .comm_boost .client @@ -1224,32 +900,10 @@ async fn test_get_execution_payload_bid_ssz_auth_forwarded() -> Result<()> { /// JSON body, before any relay is queried. #[tokio::test] async fn test_get_execution_payload_bid_malformed_auth_400() -> Result<()> { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = generate_mock_relay(relay_port, mock_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; - let url = format!( - "{}eth/v1/builder/execution_payload_bid/{}/{}/{}/{}", - mock_validator.comm_boost.config.entry.url, - TEST_SLOT, - B256::ZERO, - B256::ZERO, - "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", - ); + let url = bid_url(&mock_validator); let res = mock_validator .comm_boost .client @@ -1276,32 +930,18 @@ async fn test_get_execution_payload_bid_malformed_auth_400() -> Result<()> { /// Boot PBS in front of a single timing-games relay driven by `mock_state`, so /// a test can observe the bid poll ladder from the builder's side. async fn setup_timing_games_relay( - mock_state: Arc, + mock_state: MockRelayState, frequency_get_header_ms: u64, bid_poll_timeout_ms: Option, -) -> Result { - setup_test_env(); - let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - - let mock_relay = generate_mock_relay_with_timing_games( - relay_port, +) -> Result<(MockValidator, Arc)> { + let (mock_state, port) = spawn_mock_relay(mock_state).await?; + let relay = generate_mock_relay_with_timing_games( + port, mock_state.signer.public_key(), frequency_get_header_ms, bid_poll_timeout_ms, )?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state, relay_listener)); - - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; - Ok(mock_validator) + Ok((setup_pbs(Chain::Hoodi, vec![relay], |_| {}).await?, mock_state)) } /// Request a bid advertising `budget_ms` as the proposer's `X-Timeout-Ms`. @@ -1310,7 +950,7 @@ async fn get_bid_with_budget( budget_ms: u64, ) -> Result { let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - Ok(mock_validator + mock_validator .do_get_execution_payload_bid_with_timeout( TEST_SLOT, B256::ZERO, @@ -1320,7 +960,7 @@ async fn get_bid_with_budget( vec![EncodingType::Json], budget_ms, ) - .await?) + .await } /// No poll may promise the builder more time than the shared deadline still has @@ -1344,9 +984,9 @@ async fn test_get_execution_payload_bid_ladder_timeout_shape() -> Result<()> { const POLL_TIMEOUT_MS: u64 = 100; const BUDGET_MS: u64 = 2_000; - let mock_state = Arc::new(MockRelayState::new(Chain::Hoodi, random_secret())); - let mock_validator = - setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; assert_eq!(res.status(), StatusCode::OK); @@ -1394,13 +1034,11 @@ async fn test_get_execution_payload_bid_ladder_returns_best_poll() -> Result<()> const STEP_GWEI: u64 = 7; const BASE_GWEI: u64 = 10; - let mock_state = Arc::new( - MockRelayState::new(Chain::Hoodi, random_secret()) - .with_trustless_bid_gwei(BASE_GWEI) - .with_improving_bids(STEP_GWEI), - ); - let mock_validator = - setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()) + .with_trustless_bid_gwei(BASE_GWEI) + .with_improving_bids(STEP_GWEI); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; assert_eq!(res.status(), StatusCode::OK); @@ -1429,10 +1067,9 @@ async fn test_get_execution_payload_bid_ladder_slow_builder_still_bids() -> Resu const DELAY_MS: u64 = 150; const BUDGET_MS: u64 = 1_500; - let mock_state = - Arc::new(MockRelayState::new(Chain::Hoodi, random_secret()).with_bid_delay_ms(DELAY_MS)); - let mock_validator = - setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()).with_bid_delay_ms(DELAY_MS); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; assert_eq!(res.status(), StatusCode::OK, "the last poll outlasts the builder's delay"); @@ -1465,10 +1102,9 @@ async fn test_get_execution_payload_bid_ladder_early_polls_land_bids() -> Result const DELAY_MS: u64 = 100; const BUDGET_MS: u64 = 1_500; - let mock_state = - Arc::new(MockRelayState::new(Chain::Hoodi, random_secret()).with_bid_delay_ms(DELAY_MS)); - let mock_validator = - setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()).with_bid_delay_ms(DELAY_MS); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; assert_eq!(res.status(), StatusCode::OK); @@ -1496,9 +1132,9 @@ async fn test_get_execution_payload_bid_deadline_clamps_ladder() -> Result<()> { let mut polls = Vec::new(); for budget_ms in [SMALL_BUDGET_MS, LARGE_BUDGET_MS] { - let mock_state = Arc::new(MockRelayState::new(Chain::Hoodi, random_secret())); - let mock_validator = - setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; let res = get_bid_with_budget(&mock_validator, budget_ms).await?; assert_eq!(res.status(), StatusCode::OK, "budget {budget_ms}"); @@ -1528,9 +1164,9 @@ async fn test_get_execution_payload_bid_short_budget_single_poll() -> Result<()> const BUDGET_MS: u64 = 300; // Default bid_poll_timeout_ms, which is larger than the whole budget here - assert!(DEFAULT_BID_POLL_TIMEOUT_MS > BUDGET_MS); - let mock_state = Arc::new(MockRelayState::new(Chain::Hoodi, random_secret())); - let mock_validator = setup_timing_games_relay(mock_state.clone(), FREQ_MS, None).await?; + const _: () = assert!(DEFAULT_BID_POLL_TIMEOUT_MS > BUDGET_MS); + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()); + let (mock_validator, mock_state) = setup_timing_games_relay(mock_state, FREQ_MS, None).await?; let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; assert_eq!(res.status(), StatusCode::OK); @@ -1558,9 +1194,9 @@ async fn test_get_execution_payload_bid_poll_timeout_override() -> Result<()> { (None, DEFAULT_BID_POLL_TIMEOUT_MS), (Some(CUSTOM_POLL_TIMEOUT_MS), CUSTOM_POLL_TIMEOUT_MS), ] { - let mock_state = Arc::new(MockRelayState::new(Chain::Hoodi, random_secret())); - let mock_validator = - setup_timing_games_relay(mock_state.clone(), FREQ_MS, configured).await?; + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, configured).await?; let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; assert_eq!(res.status(), StatusCode::OK, "configured={configured:?}"); @@ -1584,10 +1220,9 @@ async fn test_get_execution_payload_bid_ladder_no_bid_is_204() -> Result<()> { const POLL_TIMEOUT_MS: u64 = 200; const BUDGET_MS: u64 = 1_000; - let mock_state = - Arc::new(MockRelayState::new(Chain::Hoodi, random_secret()).with_no_epbs_bid()); - let mock_validator = - setup_timing_games_relay(mock_state.clone(), FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()).with_no_epbs_bid(); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; assert_eq!(res.status(), StatusCode::NO_CONTENT, "a 204 from every poll must stay a 204"); @@ -1613,11 +1248,7 @@ async fn test_get_execution_payload_bid_one_relay_fails_other_wins_200() -> Resu states[0].set_response_override(StatusCode::INTERNAL_SERVER_ERROR); let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::OK, "a surviving relay still wins the auction"); let decoded = serde_json::from_slice::(&res.bytes().await?)?; @@ -1662,11 +1293,7 @@ async fn test_get_execution_payload_bid_all_relays_fail_204_not_502() -> Result< } let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!( res.status(), StatusCode::NO_CONTENT, @@ -1879,59 +1506,31 @@ async fn test_get_execution_payload_bid_per_relay_cap_clamps_ranking() -> Result // clamped: 5 + min(1000, 5) = 10 loses to 20 (Some(RELAY_CAP_GWEI), HONEST_TRUSTLESS_GWEI), ] { - setup_test_env(); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - - let overclaimer_listener = get_free_listener().await; - let overclaimer_port = overclaimer_listener.local_addr()?.port(); - let overclaimer_state = Arc::new( + let (overclaimer_state, overclaimer_port) = spawn_mock_relay( MockRelayState::new(chain, random_secret()) .with_trustless_bid_gwei(OVERCLAIMER_TRUSTLESS_GWEI) .with_trusted_bid_gwei(OVERCLAIMED_TRUSTED_GWEI), - ); + ) + .await?; + let overclaimer_pubkey = overclaimer_state.signer.public_key(); let overclaimer_relay = match relay_cap { - Some(cap) => generate_mock_relay_with_max_payment( - overclaimer_port, - overclaimer_state.signer.public_key(), - cap, - )?, - None => generate_mock_relay(overclaimer_port, overclaimer_state.signer.public_key())?, + Some(cap) => { + generate_mock_relay_with_max_payment(overclaimer_port, overclaimer_pubkey, cap)? + } + None => generate_mock_relay(overclaimer_port, overclaimer_pubkey)?, }; - tokio::spawn(start_mock_relay_service_with_listener( - overclaimer_state.clone(), - overclaimer_listener, - )); - - let honest_listener = get_free_listener().await; - let honest_port = honest_listener.local_addr()?.port(); - let honest_state = Arc::new( + let (honest_state, honest_port) = spawn_mock_relay( MockRelayState::new(chain, random_secret()) .with_trustless_bid_gwei(HONEST_TRUSTLESS_GWEI), - ); + ) + .await?; let honest_relay = generate_mock_relay(honest_port, honest_state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(honest_state.clone(), honest_listener)); - - let pbs_config = get_pbs_config(pbs_port); - let config = to_pbs_config(chain, pbs_config, vec![overclaimer_relay, honest_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let mock_validator = + setup_pbs(chain, vec![overclaimer_relay, honest_relay], |_| {}).await?; let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid( - TEST_SLOT, - B256::ZERO, - B256::ZERO, - None, - Some(&auth), - vec![EncodingType::Json], - ) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), StatusCode::OK, "relay_cap={relay_cap:?}"); assert_eq!(overclaimer_state.received_execution_payload_bid(), 1); assert_eq!(honest_state.received_execution_payload_bid(), 1); @@ -1968,42 +1567,22 @@ async fn test_get_execution_payload_bid_impl_opts( max_execution_payment_gwei: u64, require_relay_signature: bool, ) -> Result<()> { - // Setup test environment - setup_test_env(); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - - // Run one mock relay per state so per-relay knobs and counters work let mut relays = Vec::new(); let mut states = Vec::new(); for state in relay_states { - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - let state = Arc::new(state); - let relay = generate_mock_relay(relay_port, state.signer.public_key())?; - tokio::spawn(start_mock_relay_service_with_listener(state.clone(), relay_listener)); - relays.push(relay); + let (state, port) = spawn_mock_relay(state).await?; + relays.push(generate_mock_relay(port, state.signer.public_key())?); states.push(state); } - - // Run the PBS service - let mut pbs_config = get_pbs_config(pbs_port); - pbs_config.max_execution_payment_gwei = Some(max_execution_payment_gwei); - let config = to_pbs_config(chain, pbs_config, relays); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let mock_validator = setup_pbs(chain, relays, |pbs_config| { + pbs_config.max_execution_payment_gwei = Some(max_execution_payment_gwei) + }) + .await?; info!("Sending get execution payload bid"); let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); - let res = mock_validator - .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ - EncodingType::Json, - ]) - .await?; + let res = get_json_bid(&mock_validator, &auth).await?; assert_eq!(res.status(), expected_code); for (state, expected) in states.iter().zip(expected_relay_counts) { assert_eq!(state.received_execution_payload_bid(), *expected); diff --git a/tests/tests/pbs_submit_builder_preferences.rs b/tests/tests/pbs_submit_builder_preferences.rs index e8e8dc5a6..4e2f285eb 100644 --- a/tests/tests/pbs_submit_builder_preferences.rs +++ b/tests/tests/pbs_submit_builder_preferences.rs @@ -1,5 +1,3 @@ -use std::{path::PathBuf, sync::Arc}; - use cb_common::{ pbs::{BuilderPreferences, BuilderPreferencesRequest, SignedBuilderRequestAuth}, signer::random_secret, @@ -7,15 +5,12 @@ use cb_common::{ utils::utcnow_ms, wire::{CONSENSUS_VERSION_HEADER, EncodingType}, }; -use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, - mock_validator::MockValidator, + mock_relay::MockRelayState, utils::{ TEST_AUTH_DATA, generate_mock_relay, generate_mock_relay_url_only, - generate_mock_relay_with_auth_data, get_free_listener, get_pbs_config, opaque_auth, - setup_relay, setup_relays, setup_relays_with_auth_data, setup_test_env, signed_auth, - to_pbs_config, wait_for_ready, + generate_mock_relay_with_auth_data, opaque_auth, setup_pbs, setup_relay, setup_relays, + setup_relays_with_auth_data, signed_auth, spawn_mock_relay, }, }; use eyre::Result; @@ -391,36 +386,20 @@ async fn test_submit_builder_preferences_missing_body_400() -> Result<()> { /// and accepted with the builder's 202. #[tokio::test] async fn test_submit_builder_preferences_pipe_dials_unconfigured_builder() -> Result<()> { - setup_test_env(); // This test's mock builder binds to a local (unspecified/loopback) address the // pipe's SSRF guard blocks; skip that check so the forward path is exercised. cb_pbs::set_skip_pipe_target_check(true); let chain = Chain::Hoodi; - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - // A configured relay, addressed by opaque bytes only - let cfg_listener = get_free_listener().await; - let cfg_port = cfg_listener.local_addr()?.port(); - let cfg_state = Arc::new(MockRelayState::new(chain, random_secret())); - let cfg_relay = - generate_mock_relay_with_auth_data(cfg_port, cfg_state.signer.public_key(), &[0xaa])?; - tokio::spawn(start_mock_relay_service_with_listener(cfg_state.clone(), cfg_listener)); - + let (mock_validator, cfg_state) = setup_relay( + chain, + |cfg| cfg.advertised_urls = vec!["http://cb.self.example:18550".parse().unwrap()], + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &[0xaa]), + ) + .await?; // The pipe builder runs but is NOT in CB's config - let pipe_listener = get_free_listener().await; - let pipe_port = pipe_listener.local_addr()?.port(); - let pipe_state = Arc::new(MockRelayState::new(chain, random_secret())); - tokio::spawn(start_mock_relay_service_with_listener(pipe_state.clone(), pipe_listener)); - - let mut pbs_config = get_pbs_config(pbs_port); - pbs_config.advertised_urls = vec!["http://cb.self.example:18550".parse()?]; - let config = to_pbs_config(chain, pbs_config, vec![cfg_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (pipe_state, pipe_port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; let pipe_url = format!("http://0.0.0.0:{pipe_port}/"); let auth = opaque_auth(pipe_url.as_bytes(), future_slot(chain)); @@ -442,35 +421,22 @@ async fn test_submit_builder_preferences_pipe_dials_unconfigured_builder() -> Re /// `advertised_urls` unset the guard fails closed the same way. #[tokio::test] async fn test_submit_builder_preferences_pipe_self_url_not_dialed() -> Result<()> { - setup_test_env(); let chain = Chain::Hoodi; for advertise_self in [true, false] { - let pbs_listener = get_free_listener().await; - let pbs_port = pbs_listener.local_addr()?.port(); - let relay_listener = get_free_listener().await; - let relay_port = relay_listener.local_addr()?.port(); - // The mock stands in for whatever answers at the named URL: anything // it receives means a dial went out - let mock_state = Arc::new(MockRelayState::new(chain, random_secret())); - let mock_relay = - generate_mock_relay_with_auth_data(relay_port, mock_state.signer.public_key(), &[ - 0xaa, - ])?; - tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); - - let self_url = format!("http://0.0.0.0:{relay_port}/"); - let mut pbs_config = get_pbs_config(pbs_port); - if advertise_self { - pbs_config.advertised_urls = vec![self_url.parse()?]; - } - let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); - let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - - let mock_validator = MockValidator::new(pbs_port)?; - wait_for_ready(&mock_validator).await?; + let (mock_state, port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + let relay = + generate_mock_relay_with_auth_data(port, mock_state.signer.public_key(), &[0xaa])?; + let self_url = format!("http://0.0.0.0:{port}/"); + let mock_validator = setup_pbs(chain, vec![relay], |pbs_config| { + if advertise_self { + pbs_config.advertised_urls = vec![self_url.parse().unwrap()]; + } + }) + .await?; let auth = opaque_auth(self_url.as_bytes(), future_slot(chain)); let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT);