From d3bd53fb867ce799c8bb4ad2e88e432564ba3ffa Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Mon, 5 Oct 2026 10:55:45 -0700 Subject: [PATCH 01/10] chore(deps): bump lighthouse to rev 31d8cfd for the gloas types Pin the lighthouse crates to sigp/lighthouse unstable at 31d8cfd, whose gloas containers hash as EIP-7495 progressive containers, matching the consensus specs. Mirror lighthouse's own [patch.crates-io] entries for the progressive ssz stack (a consumer does not inherit a dependency's patches) and pin the blstrs_plus patch to an explicit rev. lighthouse dropped `TestRandom` for an `arbitrary` generator, so `TestRandomSeed` now draws from `arbitrary::Arbitrary`. Validated BLS points do not randomize under `arbitrary`, so tests that need a real key use `BlsSecretKey::random()`. Also adapt to `ForkName::Heze` and to `ExecutionRequests` no longer implementing `ssz::Decode`. --- Cargo.lock | 687 ++++++++++++---------- Cargo.toml | 22 +- crates/common/Cargo.toml | 1 + crates/common/src/config/utils.rs | 7 +- crates/common/src/signature.rs | 9 +- crates/common/src/ssz.rs | 13 +- crates/common/src/utils.rs | 17 +- crates/pbs/src/mev_boost/get_header.rs | 4 +- crates/pbs/src/mev_boost/get_header_ws.rs | 1 + tests/src/mock_relay.rs | 9 +- 10 files changed, 436 insertions(+), 334 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d797a53b2..0214e688b 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", @@ -367,6 +365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5e9dbd49258ac3ab893a481d46be29b58be7f734dcac46cd80b6b13ee36566c" dependencies = [ "alloy-rlp", + "arbitrary", "bytes", "cfg-if", "const-hex", @@ -381,6 +380,7 @@ dependencies = [ "keccak-asm", "paste", "proptest", + "proptest-derive", "rand 0.9.5", "rapidhash", "ruint", @@ -557,14 +557,10 @@ dependencies = [ "alloy-primitives 1.7.3", "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.20", - "tree_hash 0.10.0", - "tree_hash_derive 0.10.0", ] [[package]] @@ -591,8 +587,6 @@ dependencies = [ "alloy-rlp", "alloy-serde", "derive_more", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", "rand 0.8.8", "serde", "strum", @@ -1509,7 +1503,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", "windows-link", @@ -1644,20 +1638,20 @@ 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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "alloy-primitives 1.7.3", "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", ] @@ -1719,7 +1713,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", @@ -1777,6 +1771,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" @@ -1819,6 +1832,38 @@ dependencies = [ "serde", ] +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +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" @@ -1875,6 +1920,7 @@ version = "0.11.0" dependencies = [ "aes", "alloy", + "arbitrary", "async-trait", "axum 0.8.9", "base64 0.22.1", @@ -1889,8 +1935,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 +1953,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", - "ssz_types 0.11.0", + "ssz_types", "tempfile", "thiserror 2.0.20", "tokio", @@ -1916,12 +1962,12 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "tree_hash 0.12.1", - "tree_hash_derive 0.12.1", + "tree_hash", + "tree_hash_derive", "types", "unicode-normalization", "url", - "uuid 1.26.1", + "uuid", ] [[package]] @@ -1931,7 +1977,7 @@ dependencies = [ "axum 0.8.9", "cb-common", "eyre", - "prometheus", + "prometheus 0.14.0", "thiserror 2.0.20", "tokio", "tracing", @@ -1948,14 +1994,14 @@ dependencies = [ "cb-common", "cb-metrics", "ethereum_serde_utils 0.7.0", - "ethereum_ssz 0.10.4", + "ethereum_ssz", "eyre", "futures", "headers", "lazy_static", "notify", "parking_lot", - "prometheus", + "prometheus 0.14.0", "reqwest 0.13.5", "rustls", "serde", @@ -1965,10 +2011,10 @@ dependencies = [ "tokio-tungstenite", "tower-http", "tracing", - "tree_hash 0.12.1", + "tree_hash", "types", "url", - "uuid 1.26.1", + "uuid", "webpki-roots 1.0.9", ] @@ -1990,7 +2036,7 @@ dependencies = [ "jsonwebtoken", "lazy_static", "parking_lot", - "prometheus", + "prometheus 0.14.0", "prost", "rand 0.9.5", "rustls", @@ -1999,8 +2045,8 @@ dependencies = [ "tonic", "tonic-build", "tracing", - "tree_hash 0.12.1", - "uuid 1.26.1", + "tree_hash", + "uuid", ] [[package]] @@ -2013,7 +2059,7 @@ dependencies = [ "cb-pbs", "cb-signer", "eth2", - "ethereum_ssz 0.10.4", + "ethereum_ssz", "eyre", "futures", "jsonwebtoken", @@ -2028,7 +2074,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-test", - "tree_hash 0.12.1", + "tree_hash", "types", "url", ] @@ -2075,8 +2121,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", ] @@ -2238,8 +2286,8 @@ dependencies = [ "tempfile", "tokio", "tracing", - "tree_hash 0.12.1", - "tree_hash_derive 0.12.1", + "tree_hash", + "tree_hash_derive", ] [[package]] @@ -2395,6 +2443,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -2475,7 +2532,7 @@ dependencies = [ "crossterm_winapi", "document-features", "parking_lot", - "rustix", + "rustix 1.1.4", "winapi", ] @@ -2543,7 +2600,7 @@ dependencies = [ "commit-boost", "eyre", "lazy_static", - "prometheus", + "prometheus 0.14.0", "serde", "serde_json", "tokio", @@ -3113,14 +3170,16 @@ 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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "bls", + "builder_types", "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", + "fork_choice", "futures", "futures-util", "mediatype 0.19.20", @@ -3130,7 +3189,7 @@ dependencies = [ "sensitive_url", "serde", "serde_json", - "ssz_types 0.14.1", + "ssz_types", "superstruct", "types", ] @@ -3138,20 +3197,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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "bls", - "ethereum_hashing 0.8.0", + "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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "bls", "num-bigint-dig", @@ -3163,7 +3222,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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "aes", "bls", @@ -3180,21 +3239,10 @@ dependencies = [ "serde_repr", "sha2", "unicode-normalization", - "uuid 0.8.2", + "uuid", "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" @@ -3232,28 +3280,13 @@ 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.7.3", - "ethereum_serde_utils 0.8.1", - "itertools 0.13.0", - "serde", - "serde_derive", - "smallvec", - "typenum", -] - [[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.7.3", + "arbitrary", "context_deserialize", "ethereum_serde_utils 0.8.1", "itertools 0.14.0", @@ -3263,23 +3296,10 @@ 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" -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", @@ -3379,7 +3399,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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "alloy-primitives 1.7.3", "safe_arith", @@ -3391,6 +3411,17 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -3419,20 +3450,22 @@ 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" +name = "fork_choice" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ - "foreign-types-shared", + "ethereum_ssz", + "ethereum_ssz_derive", + "fixed_bytes", + "logging", + "metrics", + "proto_array", + "state_processing", + "superstruct", + "tracing", + "types", ] -[[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" @@ -3935,22 +3968,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", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -4153,6 +4170,7 @@ version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ + "arbitrary", "equivalent", "hashbrown 0.17.1", "serde", @@ -4191,11 +4209,20 @@ 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?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.4" @@ -4485,22 +4512,21 @@ 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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" 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]] @@ -4524,6 +4550,18 @@ 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.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4557,6 +4595,37 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[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" @@ -4631,10 +4700,10 @@ 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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "alloy-primitives 1.7.3", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "fixed_bytes", "safe_arith", ] @@ -4674,24 +4743,32 @@ 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.7.3", + "arbitrary", "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", @@ -4718,6 +4795,16 @@ dependencies = [ "adler2", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.3" @@ -4763,23 +4850,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" -[[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" @@ -4966,59 +5036,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.2", - "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.4.0" @@ -5313,7 +5336,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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "reqwest 0.12.28", "sensitive_url", @@ -5380,6 +5403,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.2", + "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.2", + "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" @@ -5414,6 +5476,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" @@ -5466,6 +5539,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" @@ -5804,11 +5894,9 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -5819,7 +5907,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower 0.5.3", @@ -5942,6 +6029,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", @@ -6023,6 +6111,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.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -6032,7 +6133,7 @@ dependencies = [ "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -6313,6 +6414,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" @@ -6577,6 +6682,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" @@ -6611,7 +6722,6 @@ version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" dependencies = [ - "arbitrary", "serde", ] @@ -6651,37 +6761,21 @@ 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" -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", "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", ] @@ -6691,6 +6785,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" @@ -6707,7 +6829,7 @@ dependencies = [ "commit-boost", "eyre", "lazy_static", - "prometheus", + "prometheus 0.14.0", "reqwest 0.13.5", "serde", "tokio", @@ -6764,10 +6886,10 @@ 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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "alloy-primitives 1.7.3", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "fixed_bytes", ] @@ -6878,7 +7000,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6888,15 +7010,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" @@ -7068,16 +7181,6 @@ dependencies = [ "syn 3.0.5", ] -[[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.5" @@ -7433,49 +7536,22 @@ 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.7.3", - "ethereum_hashing 0.7.0", - "ethereum_ssz 0.9.1", - "smallvec", - "typenum", -] - [[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.7.3", - "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" -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", @@ -7527,29 +7603,31 @@ 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?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" dependencies = [ "alloy-primitives 1.7.3", "alloy-rlp", + "arbitrary", "bls", "compare_fields", "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", - "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", @@ -7558,17 +7636,16 @@ dependencies = [ "safe_arith", "serde", "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", + "yaml_serde", ] [[package]] @@ -7677,16 +7754,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.26.1" @@ -7706,12 +7773,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" @@ -8212,6 +8273,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" @@ -8246,6 +8316,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.2", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yasna" version = "0.5.2" @@ -8373,6 +8456,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 3d5b23b14..b0281eae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,9 @@ alloy = { version = "^1.0.35", features = [ "rpc-types-beacon", "serde", "signer-local", - "ssz", ] } alloy-primitives = "^1.3.1" +arbitrary = "1.4" assert_cmd = "2.1.2" async-trait = "0.1.80" axum = { version = "0.8.1", features = ["macros"] } @@ -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 = "https://github.com/sigp/lighthouse", tag = "v8.1.3", features = ["events"] } -lh_eth2_keystore = { package = "eth2_keystore", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } -lh_bls = { package = "bls", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } -lh_types = { package = "types", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } +lh_eth2 = { package = "eth2", git = "https://github.com/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058", features = ["events"] } +lh_eth2_keystore = { package = "eth2_keystore", git = "https://github.com/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058" } +lh_bls = { package = "bls", git = "https://github.com/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058", features = ["arbitrary"] } +lh_types = { package = "types", git = "https://github.com/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058", features = ["arbitrary"] } notify = "8.2.0" parking_lot = "0.12.3" pbkdf2 = "0.12.2" @@ -74,7 +74,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" @@ -97,4 +97,12 @@ uuid = { version = "1.8.0", features = ["fast-rng", "serde", "v4"] } webpki-roots = "1.0" [patch.crates-io] -blstrs_plus = { git = "https://github.com/Commit-Boost/blstrs" } +blstrs_plus = { git = "https://github.com/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 = "https://github.com/sigp/ssz_types", rev = "9203d56ad2d7bc5f12133d043e085843f81edcd6" } +milhouse = { git = "https://github.com/sigp/milhouse", rev = "c70f128976ac0d60ea65a978dabd117a921c36ee" } +ethereum_ssz = { git = "https://github.com/sigp/ethereum_ssz", rev = "2059c21ba52cd3a7e39a8ad537012b761812a393" } +ethereum_ssz_derive = { git = "https://github.com/sigp/ethereum_ssz", rev = "2059c21ba52cd3a7e39a8ad537012b761812a393" } +tree_hash = { git = "https://github.com/sigp/tree_hash", rev = "03d9fa474586c125f306dd7a00cf46284575a01f" } +tree_hash_derive = { git = "https://github.com/sigp/tree_hash", rev = "03d9fa474586c125f306dd7a00cf46284575a01f" } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 7350b85cc..23aa9a0c4 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 2e47eb941..de3777fe1 100644 --- a/crates/common/src/config/utils.rs +++ b/crates/common/src/config/utils.rs @@ -107,7 +107,6 @@ pub(crate) mod test_env { #[cfg(test)] mod tests { use super::{test_env::with_env, *}; - use crate::utils::TestRandomSeed; // Minimal TOML-deserializable type used by load_from_file / load_file_from_env // tests. @@ -140,8 +139,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/signature.rs b/crates/common/src/signature.rs index 41631e33a..70bd5c61c 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -171,8 +171,7 @@ mod tests { use crate::{ constants::APPLICATION_BUILDER_DOMAIN, pbs::{ - BlindedBeaconBlockElectra, BuilderBid, BuilderBidElectra, - ExecutionPayloadHeaderElectra, ExecutionRequests, + BlindedBeaconBlockElectra, BuilderBid, BuilderBidElectra, ExecutionPayloadHeaderElectra, }, types::{BlsSecretKey, Chain}, utils::TestRandomSeed, @@ -189,13 +188,13 @@ 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 { 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(), }); @@ -214,7 +213,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/ssz.rs b/crates/common/src/ssz.rs index a62ac1993..18f7f7cf5 100644 --- a/crates/common/src/ssz.rs +++ b/crates/common/src/ssz.rs @@ -1,11 +1,10 @@ use alloy::primitives::U256; use lh_bls::Signature; -use lh_types::ForkName; +use lh_types::{ExecutionRequestsElectra, ForkName, MainnetEthSpec}; 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 @@ -17,7 +16,7 @@ fn get_ssz_value_offset_for_fork(fork: ForkName) -> Result Ok(get_message_offset::() + ::ssz_fixed_len() + ::ssz_fixed_len() + - ::ssz_fixed_len()) + as ssz::Decode>::ssz_fixed_len()) } _ => Err(SszValueError::UnsupportedFork { name: fork }), @@ -76,8 +75,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 +115,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/utils.rs b/crates/common/src/utils.rs index c0b53b4c0..c026849db 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; @@ -428,17 +427,23 @@ pub async fn wait_for_signal() -> eyre::Result<()> { Ok(()) } -pub trait TestRandomSeed: TestRandom { +// lighthouse (rev 31d8cfd) replaced `TestRandom` with an `arbitrary` generator. +// Validated BLS types do NOT randomize: `impl_arbitrary!` yields the all-zeros +// point, which is an invalid pubkey. Use `BlsSecretKey::random().public_key()` +// for keys; `BlsSignature::test_random()` is a constant placeholder signature. +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 576ced51e..670e80b16 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -806,7 +806,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(); @@ -862,7 +862,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 3b209ce92..a91017ec8 100644 --- a/crates/pbs/src/mev_boost/get_header_ws.rs +++ b/crates/pbs/src/mev_boost/get_header_ws.rs @@ -69,6 +69,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 becdc1f04..336c91f1a 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -23,10 +23,9 @@ use axum::{ use cb_common::{ 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, + ExecutionPayloadElectra, ExecutionPayloadHeaderFulu, ForkName, GET_HEADER_PATH, + GET_STATUS_PATH, GetHeaderParams, GetHeaderResponse, GetPayloadInfo, PayloadAndBlobs, + REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SignedBuilderBid, SubmitBlindedBlockResponse, }, signature::sign_builder_root, types::{BlsSecretKey, Chain}, @@ -256,7 +255,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 2aac4dfe5df7cd2285da0c10e6d12fa4aaeeacbc Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Mon, 5 Oct 2026 10:57:36 -0700 Subject: [PATCH 02/10] test: bind test listeners once instead of dropping and rebinding Tests discovered a free port, dropped the listener and let the server rebind it, leaving a window where another process could take the port. PbsService and SigningService gain `run_with_listener`, the mock SSV servers take a bound listener, and the legacy suites hand their listeners straight to the server. Also add `wait_for_ready`, which polls /status instead of sleeping a fixed 100 ms. --- crates/pbs/src/service.rs | 13 ++++++-- crates/signer/src/service.rs | 15 +++++++-- tests/src/mock_ssv_node.rs | 8 ++--- tests/src/mock_ssv_public.rs | 8 ++--- tests/src/signer_service.rs | 8 +++-- tests/src/utils.rs | 19 +++++++++++ tests/tests/pbs_cfg_file_update.rs | 3 +- tests/tests/pbs_get_header.rs | 15 +++------ tests/tests/pbs_get_status.rs | 30 +++++++++-------- tests/tests/pbs_mux.rs | 40 +++++++++-------------- tests/tests/pbs_mux_refresh.rs | 8 ++--- tests/tests/pbs_post_blinded_blocks.rs | 12 +++---- tests/tests/pbs_post_validators.rs | 45 ++++++++++++++++++-------- 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 +-- 17 files changed, 158 insertions(+), 110 deletions(-) diff --git a/crates/pbs/src/service.rs b/crates/pbs/src/service.rs index ded5b7db9..ed3d179c2 100644 --- a/crates/pbs/src/service.rs +++ b/crates/pbs/src/service.rs @@ -30,7 +30,17 @@ 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: rebinding a + /// freed port races other processes for 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 +54,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/signer/src/service.rs b/crates/signer/src/service.rs index 710d7ea6e..b6023a485 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: rebinding a + /// freed port races other processes for it. + 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/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/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 9a4b0a2a6..11d192d52 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -3,6 +3,7 @@ use std::{ net::{Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, sync::{Arc, Once}, + time::Duration, }; use alloy::primitives::{B256, U256}; @@ -21,8 +22,11 @@ use cb_common::{ }; use eyre::Result; use rcgen::generate_simple_self_signed; +use reqwest::StatusCode; use url::Url; +use crate::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 @@ -236,3 +240,18 @@ 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() } + +/// 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..ec954f531 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -114,8 +114,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; 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 08afb231a..82bc05a06 100644 --- a/tests/tests/pbs_get_status.rs +++ b/tests/tests/pbs_get_status.rs @@ -3,9 +3,7 @@ use std::{io::Write, path::PathBuf, sync::Arc, time::Duration}; use cb_common::{signer::random_secret, types::Chain}; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState, STATUS_ENDPOINT_TAG}; use cb_tests::{ - mock_relay::{ - MockRelayState, start_mock_relay_service, start_mock_relay_service_with_listener, - }, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, mock_validator::MockValidator, utils::{ generate_mock_relay, generate_mock_relay_with_api_key_file, get_free_listener, @@ -23,21 +21,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; @@ -59,18 +60,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..7778fb456 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -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())?; @@ -403,8 +394,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 +440,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 +458,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())?; @@ -511,8 +501,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 diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index 5bfb4aabf..10a9f1c87 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())?; @@ -111,8 +109,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/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 328801465ec4dc294b81fa9e4fefbda78d3b2ccb Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Mon, 5 Oct 2026 19:21:13 -0700 Subject: [PATCH 03/10] feat(pbs): serve the gloas ePBS builder-API endpoints From the Gloas fork the beacon node calls three builder-API endpoints on Commit-Boost instead of get_header and get_payload: - POST /eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey} sends the request to the one relay its auth data names and returns that relay's bid (200), its 400 or 401, or 204 when it has no bid or fails. Commit-Boost does not validate or rank the bid: the beacon node checks it, and each request names exactly one builder. - POST /eth/v1/builder/builder_preferences/{proposer_pubkey} forwards to the relay the auth data names and returns that builder's answer. - POST /eth/v1/builder/beacon_blocks forwards the SSZ block bytes unparsed to every configured relay and answers 202 when one accepts. Requests must carry Eth-Consensus-Version: gloas. A body without Content-Type is JSON and a request without Accept gets JSON, as builder-specs requires. Bid requests need Date-Milliseconds and X-Timeout-Ms; Commit-Boost clamps the deadline to one slot, keeps proposer_deadline_buffer_ms back for the return trip and sends the rest to the builder as its own X-Timeout-Ms, or returns 204 when nothing is left. Routing follows builder-specs #168: auth data matches a relay when it equals the lowercase hostname of the relay URL. The first match in config order wins and unmatched auth data is a 400. Commit-Boost never verifies auth data; the builder does. Relay-supplied amounts are saturated. The new operator page (docs/get_started/epbs.md) covers writing each validator key's builder config through the keymanager API, with a copy-paste call, plus routing, timing, metrics and troubleshooting. Unreleased, shipping from v0.12.0-rc1. --- Cargo.lock | 2 + config.example.toml | 5 +- crates/common/Cargo.toml | 1 + crates/common/src/config/pbs.rs | 18 +- crates/common/src/config/signer.rs | 1 + crates/common/src/pbs/constants.rs | 11 + crates/common/src/pbs/error.rs | 2 +- crates/common/src/pbs/relay.rs | 84 +++ crates/common/src/pbs/types/mod.rs | 154 +++++- crates/common/src/utils.rs | 21 +- crates/common/src/wire.rs | 192 ++++++- crates/pbs/src/constants.rs | 6 + crates/pbs/src/error.rs | 48 +- crates/pbs/src/routes/builder_preferences.rs | 127 +++++ .../pbs/src/routes/execution_payload_bid.rs | 352 +++++++++++++ crates/pbs/src/routes/mod.rs | 6 + crates/pbs/src/routes/router.rs | 23 +- .../src/routes/submit_signed_beacon_block.rs | 97 ++++ crates/pbs/src/utils.rs | 250 +++++++++ docs/docs/get_started/epbs.md | 103 ++++ docs/sidebars.js | 1 + tests/Cargo.toml | 1 + tests/src/mock_relay.rs | 308 ++++++++++- tests/src/mock_validator.rs | 129 ++++- tests/src/utils.rs | 111 +++- tests/tests/pbs_cfg_file_update.rs | 1 + tests/tests/pbs_get_execution_payload_bid.rs | 479 ++++++++++++++++++ tests/tests/pbs_submit_builder_preferences.rs | 272 ++++++++++ tests/tests/pbs_submit_signed_beacon_block.rs | 162 ++++++ 29 files changed, 2920 insertions(+), 47 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 docs/docs/get_started/epbs.md 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 0214e688b..140c33039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1964,6 +1964,7 @@ dependencies = [ "tracing-subscriber", "tree_hash", "tree_hash_derive", + "typenum", "types", "unicode-normalization", "url", @@ -2067,6 +2068,7 @@ dependencies = [ "reqwest 0.13.5", "serde", "serde_json", + "ssz_types", "tempfile", "tokio", "tokio-tungstenite", diff --git a/config.example.toml b/config.example.toml index a825f6534..4ce750cb9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -31,6 +31,9 @@ wait_all_registrations = true # this should be lower than that, leaving some margin for overhead # OPTIONAL, DEFAULT: 950 timeout_get_header_ms = 950 +# (unreleased, from v0.12.0-rc1) ePBS bids only: ms kept back from the beacon node's X-Timeout-Ms deadline, must be under one slot, 12000 on mainnet (https://commit-boost.github.io/commit-boost-client/get_started/epbs#timing) +# 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 @@ -139,7 +142,7 @@ frequency_get_header_ms = 300 # Configuration for the PBS multiplexers, which enable different configs to be used for get header requests, depending on validator pubkey # Note that: # - multiple sets of keys can be defined by adding multiple [[mux]] sections. The validator pubkey sets need to be disjoint -# - the mux is only used for get header requests +# - the mux is only used for get header requests, and (unreleased, from v0.12.0-rc1) ePBS bid and builder preferences requests # - if any value is missing from the mux config, the default value from the main config will be used [[mux]] # Unique ID for the mux config diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 23aa9a0c4..483b64a45 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -54,6 +54,7 @@ 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 diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index cdade80ed..22f7a1eda 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -32,7 +32,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::{ @@ -202,9 +202,14 @@ 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, - /// 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. CB + /// asks the builder for `deadline - this`. + #[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, @@ -245,6 +250,15 @@ impl PbsConfig { ); ensure!(self.late_in_slot_time_ms > 0, "late_in_slot_time_ms must be greater than 0"); + // The buffer comes out of the proposer's deadline, which is clamped to + // one slot, so a buffer of a slot or more leaves no time for the bid + // request. 0 is allowed (no reserve). + let slot_time_ms = chain.slot_time_sec().saturating_mul(1000); + ensure!( + self.proposer_deadline_buffer_ms < slot_time_ms, + "proposer_deadline_buffer_ms must be less than one slot ({slot_time_ms} ms)" + ); + 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" diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 0ac6ce1b9..6d948adc2 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -476,6 +476,7 @@ mod tests { skip_sigverify: false, min_bid_wei: Uint::<256, 4>::from(0), late_in_slot_time_ms: 0, + proposer_deadline_buffer_ms: 0, extra_validation_enabled: false, rpc_url: None, http_timeout_seconds: 30, diff --git a/crates/common/src/pbs/constants.rs b/crates/common/src/pbs/constants.rs index 66ab42f06..e76395301 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; +/// 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`. +pub const PROPOSER_DEADLINE_BUFFER_MS: u64 = 50; + // 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..1f40fa287 100644 --- a/crates/common/src/pbs/error.rs +++ b/crates/common/src/pbs/error.rs @@ -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, .. }) => { diff --git a/crates/common/src/pbs/relay.rs b/crates/common/src/pbs/relay.rs index bb043df08..ae7f4b9aa 100644 --- a/crates/common/src/pbs/relay.rs +++ b/crates/common/src/pbs/relay.rs @@ -12,6 +12,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, }; @@ -219,6 +220,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) + } } /// First 4 bytes of the value's SHA-256: enough to see a rotation @@ -468,4 +502,54 @@ 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 + ); + + assert_eq!( + relay.submit_builder_preferences_url(&validator_pubkey).unwrap().to_string(), + "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz/eth/v1/builder/builder_preferences/0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae" + ); + } } diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index 738221a8d..57845d4e1 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -1,10 +1,12 @@ use alloy::primitives::{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 crate::types::BlsPublicKey; +use crate::types::{BlsPublicKey, BlsSignature}; pub const EMPTY_TX_ROOT_HASH: B256 = b256!("7ffe241ea60187fdb0187bfa22de35d1f9bed7ab061d9401fd47e34a54fbede1"); @@ -13,6 +15,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 +66,52 @@ pub struct GetHeaderParams { pub pubkey: BlsPublicKey, } +pub type ExecutionPayloadBid = lh_types::ExecutionPayloadBid; +pub type SignedExecutionPayloadBid = lh_types::SignedExecutionPayloadBid; + +/// 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 value(&self) -> u64; + fn execution_payment(&self) -> u64; + fn builder_index(&self) -> u64; +} + +impl GetExecutionPayloadBidInfo for GetExecutionPayloadBidResponse { + fn block_hash(&self) -> B256 { + self.data.message.block_hash.0 + } + + fn value(&self) -> u64 { + self.data.message.value + } + + fn execution_payment(&self) -> u64 { + self.data.message.execution_payment + } + + fn builder_index(&self) -> u64 { + self.data.message.builder_index + } +} + +/// 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 { + 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, + pub proposer_pubkey: BlsPublicKey, +} + pub trait GetHeaderInfo { fn block_hash(&self) -> B256; fn value(&self) -> &U256; @@ -107,3 +157,103 @@ impl GetPayloadInfo for SignedBlindedBeaconBlock { self.message().body().execution_payload().map(|r| r.parent_hash().0).unwrap_or_default() } } + +/// Spec limit on `auth.message.data` (builder-specs +/// `types/gloas/request_auth.yaml`). +pub type MaxBuilderAuthData = typenum::U4096; + +// `BuilderRequestAuth` is used to authenticate requests to a builder. +#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] +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 slot: Slot, +} + +#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] +pub struct SignedBuilderRequestAuth { + pub message: BuilderRequestAuth, + 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. +#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] +pub struct BuilderPreferencesRequest { + pub preferences: BuilderPreferences, + pub auth: SignedBuilderRequestAuth, +} + +/// 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::*; + + /// Spec vector for the SSZ layout of `BuilderPreferencesRequest`. + /// 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}; + + // The infinity signature: 0xc0 followed by 95 zero bytes + let mut infinity_sig = vec![0u8; 96]; + infinity_sig[0] = 0xc0; + + let auth = SignedBuilderRequestAuth { + message: BuilderRequestAuth { + 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 { + preferences: BuilderPreferences { max_execution_payment: 1_000_000_000 }, + auth: auth.clone(), + }; + + // 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(&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)); + 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/utils.rs b/crates/common/src/utils.rs index c026849db..c167c33db 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -26,11 +26,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); @@ -466,14 +468,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..16037c5e0 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -6,12 +6,13 @@ 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, 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}; @@ -72,7 +73,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(); @@ -290,6 +290,38 @@ pub fn get_content_type(req_headers: &HeaderMap) -> EncodingType { .unwrap_or(EncodingType::Json) } +/// Reads `Eth-Consensus-Version`, which builder-specs requires on every request +/// with a body: absent, or naming a fork this build does not recognize, is a +/// 400. +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()); + // Gloas-only until later forks are defined + 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 | + ForkName::Heze => Err(unsupported()), + } +} + pub fn get_consensus_version_header(req_headers: &HeaderMap) -> Option { ForkName::from_str( req_headers @@ -384,23 +416,28 @@ 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. +pub fn content_type_encoding(headers: &HeaderMap) -> 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( headers: &HeaderMap, body: Bytes, ) -> Result { - // Determine the encoding to decode with. Precedence: - // - Content-Type absent → NO_PREFERENCE_DEFAULT - // - Content-Type recognized → use it. - // - Content-Type present but unrecognized → UnsupportedMediaType. - let encoding = match headers.get(CONTENT_TYPE) { - None => NO_PREFERENCE_DEFAULT, - Some(hv) => { - let value = hv.to_str().map_err(|_| BodyDeserializeError::UnsupportedMediaType)?; - EncodingType::from_str(value).map_err(|_| BodyDeserializeError::UnsupportedMediaType)? - } - }; + let encoding = content_type_encoding(headers)?; match encoding { EncodingType::Json => match get_consensus_version_header(headers) { @@ -409,13 +446,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,6 +466,37 @@ pub fn deserialize_body( } } +/// Decode a fork-versioned ePBS request body (builder-specs fork-versions +/// `SignedBuilderRequestAuth` and `BuilderPreferencesRequest`) as JSON or SSZ, +/// defaulting to JSON 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(headers)?; + 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) + } + } +} + #[cfg(test)] mod test { use axum::http::{HeaderMap, HeaderName, HeaderValue}; @@ -1002,4 +1070,98 @@ mod test { "a headerless JSON body must not fall back to the untagged Electra match" ); } + + // ── decode_versioned_request_body ──────────────────────────────────────── + + fn sample_preferences_request() -> crate::pbs::BuilderPreferencesRequest { + use crate::pbs::{BuilderPreferences, BuilderRequestAuth, SignedBuilderRequestAuth}; + crate::pbs::BuilderPreferencesRequest { + preferences: BuilderPreferences { max_execution_payment: 7 }, + auth: SignedBuilderRequestAuth { + message: BuilderRequestAuth { + data: vec![0xde, 0xad].try_into().unwrap(), + slot: lh_types::Slot::new(3), + }, + signature: crate::types::BlsSignature::empty(), + }, + } + } + + fn versioned_headers( + content_type: Option<&'static str>, + version: Option<&'static str>, + ) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Some(ct) = content_type { + headers.insert(CONTENT_TYPE, HeaderValue::from_static(ct)); + } + if let Some(v) = version { + headers.insert( + HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(), + HeaderValue::from_static(v), + ); + } + headers + } + + /// The three ePBS request bodies share this decoder, so the encoding and + /// version-header rules are pinned here once rather than per endpoint. + #[test] + fn test_decode_versioned_request_body() { + use ssz::Encode; + + use super::decode_versioned_request_body; + use crate::pbs::BuilderPreferencesRequest; + + let request = sample_preferences_request(); + let ssz_body = Bytes::from(request.as_ssz_bytes()); + let json_body = Bytes::from(serde_json::to_vec(&request).unwrap()); + let decode = |headers: &HeaderMap, body: &Bytes| { + decode_versioned_request_body::(headers, body) + }; + + // An empty body is a missing body, whatever the headers say: checked + // before the version header, so a bare POST names the body + assert!(matches!( + decode(&versioned_headers(None, None), &Bytes::new()), + Err(BodyDeserializeError::MissingBody) + )); + + // No Content-Type means JSON (builder-specs), so SSZ must be labeled + let decoded = decode(&versioned_headers(None, Some("gloas")), &json_body).unwrap(); + assert_eq!(decoded.auth.message.slot.as_u64(), 3); + assert!(matches!( + decode(&versioned_headers(None, Some("gloas")), &ssz_body), + Err(BodyDeserializeError::SerdeJsonError(_)) + )); + let decoded = + decode(&versioned_headers(Some(APPLICATION_OCTET_STREAM), Some("gloas")), &ssz_body) + .unwrap(); + assert_eq!(decoded.preferences.max_execution_payment, 7); + + // lighthouse's FromStr lowercases, so an uppercase gloas passes + decode(&versioned_headers(None, Some("GLOAS")), &json_body).unwrap(); + + // The version header is required for both encodings + for (ct, body) in [(Some(APPLICATION_OCTET_STREAM), &ssz_body), (None, &json_body)] { + assert!(matches!( + decode(&versioned_headers(ct, None), body), + Err(BodyDeserializeError::MissingVersionHeader) + )); + // Only gloas is accepted: an earlier fork or an unknown name names + // the bad value + for bad in ["fulu", "electra", "futurefork"] { + assert!(matches!( + decode(&versioned_headers(ct, Some(bad)), body), + Err(BodyDeserializeError::InvalidVersionHeader(ref v)) if v == bad + )); + } + } + + // An unsupported media type is a 415 before the version header is read + assert!(matches!( + decode(&versioned_headers(Some("text/plain"), None), &ssz_body), + Err(BodyDeserializeError::UnsupportedMediaType) + )); + } } diff --git a/crates/pbs/src/constants.rs b/crates/pbs/src/constants.rs index 3d5ee7258..5790af416 100644 --- a/crates/pbs/src/constants.rs +++ b/crates/pbs/src/constants.rs @@ -4,6 +4,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"; pub const GET_HEADER_STREAM_ENDPOINT_TAG: &str = "get_header_stream"; @@ -32,6 +35,9 @@ 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 block carries the bid, not the payload: blinded-block-sized +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..9b9aaf078 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -20,6 +20,20 @@ struct ErrorResponse { pub enum PbsClientError { #[error("no response from relays")] NoResponse, + /// 500, not 502: 502 is in neither endpoint's builder-specs response 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, + /// Propagated so the proposer learns which builder was rejected; a blanket + /// 500 would hide that. + #[error("the addressed builder rejected the request with {}", .0.as_u16())] + BuilderRejected(StatusCode), #[error("no payload from relays")] NoPayload, #[error("internal server error")] @@ -34,6 +48,11 @@ 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::BuilderRejected(code) => *code, PbsClientError::NoPayload => StatusCode::BAD_GATEWAY, PbsClientError::Internal => StatusCode::INTERNAL_SERVER_ERROR, PbsClientError::DecodeError(BodyDeserializeError::UnsupportedMediaType) => { @@ -50,10 +69,25 @@ 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 SignedBuilderRequestAuth: auth.message.data does not match any configured builder".to_string() + } + PbsClientError::MissingTimingHeader => { + "Invalid request: Date-Milliseconds and X-Timeout-Ms headers are required".to_string() + } + PbsClientError::AuthSlotMismatch => { + "Invalid SignedBuilderRequestAuth: auth.message.slot does not match the proposal slot in the request path".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 request with status {}", code.as_u16()) + } 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 @@ -80,6 +114,16 @@ 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, + ); } #[tokio::test] diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs new file mode 100644 index 000000000..fab0902aa --- /dev/null +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -0,0 +1,127 @@ +use axum::{ + body::Bytes, + extract::{Path, State}, + http::HeaderMap, + response::IntoResponse, +}; +use cb_common::{ + pbs::{ + BuilderPreferencesRequest, RelayClient, SubmitBuilderPreferencesParams, error::PbsError, + }, + wire::{decode_versioned_request_body, get_user_agent}, +}; +use reqwest::StatusCode; +use ssz::Encode; +use tracing::{debug, error, info}; + +use crate::{ + PbsStateGuard, + constants::SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, + error::PbsClientError, + state::{BuilderApiState, PbsState}, + utils::{ + builder_rejection, epbs_base_send_headers, log_mux_selection, post_ssz_expect_accepted, + record_beacon_status, record_client_error, record_request_failure, resolve_addressed_relay, + }, +}; + +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(()) => { + record_beacon_status("202", SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG); + Ok(StatusCode::ACCEPTED.into_response()) + } + Err(err) => Err(record_request_failure(err, SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG)), + } +} + +/// Implements https://ethereum.github.io/builder-specs/?urls.primaryName=dev#/Builder/submitBuilderPreferences +/// Returns 202 if the addressed builder accepts +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); + + log_mux_selection(maybe_mux_id, relays.len(), ¶ms.proposer_pubkey); + + let relay = resolve_addressed_relay(relays, request.auth.message.data.as_ref())?; + + let send_headers = epbs_base_send_headers(&req_headers)?; + + // SSZ on the relay hop + 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; + + let relay_id = relay.id.clone(); + match send_submit_builder_preferences( + params.proposer_pubkey, + body, + relay, + send_headers, + timeout_ms, + ) + .await + { + Ok(()) => { + info!(%relay_id, "builder preferences submitted"); + Ok(()) + } + Err(err) => { + if err.is_timeout() { + error!(err = "Timed Out", %relay_id); + } else { + error!(%err, %relay_id); + } + Err(builder_rejection(&err).unwrap_or(PbsClientError::NoBuilderResponse)) + } + } +} + +async fn send_submit_builder_preferences( + proposer_pubkey: cb_common::types::BlsPublicKey, + body: Bytes, + relay: RelayClient, + headers: HeaderMap, + timeout_ms: u64, +) -> Result<(), PbsError> { + let url = relay.submit_builder_preferences_url(&proposer_pubkey)?; + + let request_latency = post_ssz_expect_accepted( + &relay, + url, + body, + 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 new file mode 100644 index 000000000..68c96774f --- /dev/null +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -0,0 +1,352 @@ +use std::time::Duration; + +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, HeaderValue}, + response::{IntoResponse, Response}, +}; +use cb_common::{ + pbs::{ + GetExecutionPayloadBidInfo, GetExecutionPayloadBidParams, GetExecutionPayloadBidResponse, + HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, RelayClient, SignedBuilderRequestAuth, + SignedExecutionPayloadBid, error::PbsError, + }, + utils::{ms_into_slot, utcnow_ms}, + wire::{ + CONSENSUS_VERSION_HEADER, EncodingType, OUTBOUND_ACCEPT_SSZ_FIRST, + decode_versioned_request_body, get_accept_types, get_user_agent, + parse_response_encoding_and_fork, safe_read_http_response, + }, +}; +use reqwest::{ + StatusCode, + header::{ACCEPT, CONTENT_TYPE}, +}; +use ssz::{Decode, Encode}; +use tracing::{debug, error, info, warn}; + +use crate::{ + PbsStateGuard, + constants::{GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, MAX_SIZE_GET_HEADER_RESPONSE}, + error::PbsClientError, + metrics::{RELAY_HEADER_VALUE, RELAY_LAST_SLOT}, + state::{BuilderApiState, PbsState}, + utils::{ + builder_rejection, epbs_base_send_headers, format_gwei_as_eth, log_mux_selection, + record_beacon_status, record_client_error, record_request_failure, resolve_addressed_relay, + send_to_relay, + }, +}; + +pub async fn handle_get_execution_payload_bid( + State(state): State>, + req_headers: HeaderMap, + Path(params): Path, + body: Bytes, +) -> Result { + let auth = 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)); + + let state = state.read().clone(); + + let ua = get_user_agent(&req_headers); + let ms_into_slot = ms_into_slot(params.slot, state.config.chain); + + let response_encoding = get_accept_types(&req_headers) + .inspect_err(|err| error!(%err, "error parsing accept header")) + .map_err(|err| record_client_error(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG))? + .primary; + + info!(ua, ms_into_slot, "new request"); + + match get_execution_payload_bid(params, auth, req_headers, state).await { + Ok(Some(bid)) => { + encode_bid_response(bid, response_encoding, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG) + } + Ok(None) => { + info!("no bid for slot"); + record_beacon_status("204", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG); + Ok(StatusCode::NO_CONTENT.into_response()) + } + Err(err) => Err(record_request_failure(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG)), + } +} + +fn encode_bid_response( + bid: GetExecutionPayloadBidResponse, + response_encoding: EncodingType, + endpoint: &str, +) -> Result { + info!( + trustless_bid_eth = format_gwei_as_eth(bid.value()), + execution_payment_eth = format_gwei_as_eth(bid.execution_payment()), + block_hash = %bid.block_hash(), + builder_index = bid.builder_index(), + "received header" + ); + + // Eth-Consensus-Version is required on the 200 for both encodings + let consensus_version_header = HeaderValue::from_str(&bid.version.to_string()) + .expect("fork name is always a valid header value"); + + record_beacon_status("200", endpoint); + let mut res = match response_encoding { + EncodingType::Ssz => { + let mut res = bid.data.as_ssz_bytes().into_response(); + res.headers_mut().insert(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()); + res + } + EncodingType::Json => axum::Json(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 +/// Returns 200 with the addressed builder's bid, else 204 +pub async fn get_execution_payload_bid( + params: GetExecutionPayloadBidParams, + auth: SignedBuilderRequestAuth, + req_headers: HeaderMap, + state: PbsState, +) -> Result, PbsClientError> { + 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); + + 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); + } + + let relay = resolve_addressed_relay(relays, auth.message.data.as_ref())?; + + // The beacon node's deadline, not timeout_get_header_ms, bounds the request + let slot_ms = state.config.chain.slot_time_sec().saturating_mul(1000); + let budget_ms = request_budget_ms(&req_headers, utcnow_ms(), slot_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" + ); + + // No bid could reach the beacon node before its deadline + if max_timeout_ms == 0 { + warn!(budget_ms, "proposer deadline reached, no time to solicit a bid"); + return Ok(None); + } + + let mut send_headers = epbs_base_send_headers(&req_headers)?; + + // The bid is re-encoded for the BN anyway, so ask for the cheaper SSZ + send_headers.insert(ACCEPT, OUTBOUND_ACCEPT_SSZ_FIRST.clone()); + let body = Bytes::from(auth.as_ssz_bytes()); + + let slot = params.slot; + let relay_id = relay.id.clone(); + // A relay that errors or times out contributes no bid: 204, never a 502. + // The builder's own 400 and 401 still reach the proposer. + match send_get_execution_payload_bid(params, body, relay, send_headers, max_timeout_ms).await { + Ok(Some(bid)) => { + RELAY_LAST_SLOT.with_label_values(&[relay_id.as_str()]).set(slot as i64); + // value() is already gwei (the gauge is labelled gwei), so it is set unscaled + RELAY_HEADER_VALUE + .with_label_values(&[relay_id.as_str()]) + .set(i64::try_from(bid.value()).unwrap_or_default()); + Ok(Some(bid)) + } + Ok(None) => Ok(None), + Err(err) if err.is_timeout() => { + error!(err = "Timed Out", %relay_id, timeout_ms = max_timeout_ms); + Ok(None) + } + Err(err) => { + error!(%err, %relay_id); + builder_rejection(&err).map_or(Ok(None), Err) + } + } +} + +/// Milliseconds left until `Date-Milliseconds + X-Timeout-Ms`, but no more than +/// `X-Timeout-Ms` from now (a proposer clock running ahead) or one slot (the +/// header has no upper bound). 0 once the deadline has passed. +fn request_budget_ms( + req_headers: &HeaderMap, + now_ms: u64, + slot_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)?; + + let until_deadline = sent_at_ms.saturating_add(timeout_ms).saturating_sub(now_ms); + Ok(until_deadline.min(timeout_ms).min(slot_ms)) +} + +/// Sends the bid request to the relay with whatever remains of the proposer's +/// deadline. +async fn send_get_execution_payload_bid( + params: GetExecutionPayloadBidParams, + body: Bytes, + relay: RelayClient, + mut headers: HeaderMap, + timeout_ms: u64, +) -> Result, PbsError> { + let url = relay.get_execution_payload_bid_url( + params.slot, + ¶ms.parent_hash, + ¶ms.parent_root, + ¶ms.proposer_pubkey, + )?; + + headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(utcnow_ms())); + + // The timeout header indicating how long a relay has to respond + headers.insert(HEADER_TIMEOUT_MS, HeaderValue::from(timeout_ms)); + + let request = 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, 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(None); + } + + let get_header_response = match content_type { + 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. + 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(|err| { + PbsError::SSZDecode { + err: format!("error decoding relay payload: {err:?}"), + fork, + } + })?; + GetExecutionPayloadBidResponse { version: fork, data, metadata: Default::default() } + } + }; + + debug!( + relay_id = relay.id.as_ref(), + header_size_bytes, + latency = ?request_latency, + version =? get_header_response.version, + 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" + ); + + Ok(Some(get_header_response)) +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[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; + const SLOT_MS: u64 = 12_000; + + // Transit delay eats the budget: the deadline is absolute + assert_eq!(request_budget_ms(&headers(now, 1000), now, SLOT_MS).unwrap(), 1000); + assert_eq!(request_budget_ms(&headers(now - 400, 1000), now, SLOT_MS).unwrap(), 600); + + // A deadline already in the past, or a zero timeout, leaves nothing + assert_eq!(request_budget_ms(&headers(now - 5000, 1000), now, SLOT_MS).unwrap(), 0); + assert_eq!(request_budget_ms(&headers(now, 0), now, SLOT_MS).unwrap(), 0); + + // A proposer clock running ahead cannot grant more than it advertised + assert_eq!(request_budget_ms(&headers(now + 10_000, 1000), now, SLOT_MS).unwrap(), 1000); + + // Nor more than one slot + assert_eq!(request_budget_ms(&headers(now, 60_000), now, SLOT_MS).unwrap(), SLOT_MS); + + // Both headers are required and must parse + 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 + }, + { + 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, SLOT_MS), + Err(PbsClientError::MissingTimingHeader) + )); + } + } +} 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..54118d8cf 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"), @@ -79,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 new file mode 100644 index 000000000..8f60f1db2 --- /dev/null +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -0,0 +1,97 @@ +use axum::{body::Bytes, extract::State, http::HeaderMap, response::IntoResponse}; +use cb_common::wire::{ + BodyDeserializeError, EncodingType, content_type_encoding, get_user_agent, + require_consensus_version_header, +}; +use reqwest::StatusCode; +use tracing::{debug, info}; + +use crate::{ + PbsStateGuard, + constants::SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, + error::PbsClientError, + state::{BuilderApiState, PbsState}, + utils::{ + epbs_base_send_headers, join_detached_sends, post_ssz_expect_accepted, + record_beacon_status, record_request_failure, + }, +}; + +pub async fn handle_submit_signed_beacon_block( + State(state): State>, + req_headers: HeaderMap, + body: Bytes, +) -> Result { + let state = state.read().clone(); + + 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()) + } + Err(err) => Err(record_request_failure(err, SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG)), + } +} + +/// Implements https://ethereum.github.io/builder-specs/?urls.primaryName=dev#/Builder/submitSignedBeaconBlock +/// Forwards the block bytes, undecoded, to every configured builder, since CB +/// keeps no auction state to know which bid won. Returns 202 if one accepts +pub async fn submit_signed_beacon_block( + body: Bytes, + req_headers: HeaderMap, + state: PbsState, +) -> Result<(), PbsClientError> { + let ua = get_user_agent(&req_headers); + + require_consensus_version_header(&req_headers)?; + + if body.is_empty() { + return Err(BodyDeserializeError::MissingBody.into()); + } + // Forwarded undecoded as SSZ, so anything else, an unlabelled (JSON) body + // included, is a 415 + if content_type_encoding(&req_headers)? != EncodingType::Ssz { + return Err(BodyDeserializeError::UnsupportedMediaType.into()); + } + + info!(ua, "new request"); + + let send_headers = epbs_base_send_headers(&req_headers)?; + let timeout_ms = state.pbs_config().timeout_get_payload_ms; + let relays = state.all_relays(); + let results = join_detached_sends(relays.iter().map(|relay| { + let (relay, body, headers) = (relay.clone(), body.clone(), send_headers.clone()); + async move { + let url = relay.submit_signed_beacon_block_url()?; + post_ssz_expect_accepted( + &relay, + url, + body, + headers, + timeout_ms, + SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, + ) + .await?; + Ok(()) + } + })) + .await; + let accepted = results + .into_iter() + .zip(relays.iter()) + .filter(|(res, relay)| match res { + Ok(()) => true, + Err(err) => { + debug!(relay_id = relay.id.as_ref(), %err, "builder did not accept the block; only the winner accepts"); + 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(()) +} diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 782ae79b6..c07da9d40 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -1,3 +1,180 @@ +use std::{ + future::Future, + time::{Duration, Instant}, +}; + +use alloy::primitives::utils::{ParseUnits, Unit}; +use cb_common::{ + pbs::{ForkName, RelayClient, error::PbsError}, + types::BlsPublicKey, + wire::{ + CONSENSUS_VERSION_HEADER, EncodingType, get_user_agent_with_version, + read_chunked_body_with_max, + }, +}; +use futures::future::join_all; +use reqwest::{ + StatusCode, + header::{CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT}, +}; +use tracing::{Instrument, debug, error, warn}; +use url::Url; + +use crate::{ + constants::{MAX_SIZE_DEFAULT, TIMEOUT_ERROR_CODE_STR}, + error::PbsClientError, + metrics::{BEACON_NODE_STATUS, RELAY_LATENCY, RELAY_STATUS_CODE}, +}; + +/// 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, + 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, so rejected requests show up as 4xx in the endpoint counter. +pub(crate) fn record_client_error( + err: impl Into, + endpoint: &str, +) -> PbsClientError { + let err = err.into(); + BEACON_NODE_STATUS.with_label_values(&[err.status_code().as_str(), endpoint]).inc(); + err +} + +pub(crate) fn record_beacon_status(code: &str, endpoint: &str) { + 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 +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() +} + +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"), + } +} + +/// POSTs an SSZ body to a builder; 202 Accepted is the only success. A failed +/// response's body, capped at `MAX_SIZE_DEFAULT`, becomes the error message. +/// Returns the request latency. +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(); + if code == StatusCode::ACCEPTED { + return Ok(latency); + } + // Read after the status check, so a body over the cap still reports the + // builder's status + let url = res.url().to_string(); + let error_msg = match read_chunked_body_with_max(res, MAX_SIZE_DEFAULT, &url).await { + Ok(body) if !body.is_empty() => String::from_utf8_lossy(&body).into_owned(), + Ok(_) => "expected 202".to_string(), + Err(err) => err.to_string(), + }; + Err(PbsError::RelayResponse { error_msg, code: code.as_u16() }) +} + +/// A gwei amount in ETH, for logs +pub(crate) fn format_gwei_as_eth(gwei: u64) -> String { + ParseUnits::from(gwei).format_units(Unit::GWEI) +} + +/// The builder's own 400 or 401, handed back to the proposer; any other relay +/// failure is the caller's to map. +pub(crate) fn builder_rejection(err: &PbsError) -> Option { + match err.relay_status_code() { + Some(400) => Some(PbsClientError::BuilderRejected(StatusCode::BAD_REQUEST)), + Some(401) => Some(PbsClientError::BuilderRejected(StatusCode::UNAUTHORIZED)), + _ => None, + } +} + +/// Headers every ePBS relay request carries: the versioned `User-Agent`, and +/// `Eth-Consensus-Version` since each body is a fork-versioned type. +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; @@ -19,3 +196,76 @@ pub fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { true } + +/// The relay an ePBS request addresses: the first whose URL hostname equals +/// `auth_data`. No match is a 400. +pub(crate) fn resolve_addressed_relay( + relays: &[RelayClient], + auth_data: &[u8], +) -> Result { + let addressed = relays.iter().find(|relay| { + relay.config.entry.url.host_str().is_some_and(|host| host.as_bytes() == auth_data) + }); + match addressed { + Some(relay) => Ok(relay.clone()), + None => { + warn!( + auth_data = %String::from_utf8_lossy(auth_data), + "auth data matches no configured relay" + ); + Err(PbsClientError::AuthDataMismatch) + } + } +} + +#[cfg(test)] +mod tests { + use cb_common::{ + config::{GetHeaderTransport, RelayConfig}, + pbs::RelayEntry, + types::BlsSecretKey, + }; + + use super::*; + + fn test_relay(url: &str) -> RelayClient { + let entry = RelayEntry { + id: url.to_string(), + pubkey: BlsSecretKey::random().public_key(), + url: Url::parse(url).unwrap(), + }; + let 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, + validator_registration_batch_size: None, + }; + RelayClient::new(config).unwrap() + } + + #[test] + fn resolve_relay_by_hostname() { + let relays = vec![ + test_relay("https://0xdeadbeef@builder-a.example.com"), + test_relay("https://builder-b.example.com:8443/eth"), + test_relay("http://[::1]:18550"), + ]; + let host = |data: &[u8]| -> Option { + resolve_addressed_relay(&relays, data) + .ok() + .map(|relay| relay.config.entry.url.host_str().unwrap().to_string()) + }; + assert_eq!(host(b"builder-a.example.com").as_deref(), Some("builder-a.example.com")); + assert_eq!(host(b"builder-b.example.com").as_deref(), Some("builder-b.example.com")); + assert_eq!(host(b"[::1]").as_deref(), Some("[::1]")); + assert!(host(b"Builder-A.example.com").is_none()); + assert!(host(b"builder-a.example.com:443").is_none()); + // a URL is not a hostname + assert!(host(b"https://builder-a.example.com").is_none()); + } +} diff --git a/docs/docs/get_started/epbs.md b/docs/docs/get_started/epbs.md new file mode 100644 index 000000000..748db1b82 --- /dev/null +++ b/docs/docs/get_started/epbs.md @@ -0,0 +1,103 @@ +--- +description: Run Commit-Boost for ePBS proposals after the Gloas fork +--- + +# ePBS + +:::info Unreleased +ePBS support is not in a Commit-Boost release yet; it ships from v0.12.0-rc1. +::: + +From the Gloas fork, a proposer commits to a builder's signed execution payload bid instead of a blinded header, and the winning builder publishes the payload itself. Commit-Boost serves the builder-API endpoints the beacon node calls for this and forwards each call to builders, relays, or MPBC operators. + +## What changes at the fork + +Normal PBS paths are unaffected. From the Gloas fork, apart from the `status` endpoint, the beacon node calls three new endpoints on Commit-Boost: + +| Endpoint | What the beacon node sends | What Commit-Boost does | +|---|---|---| +| `POST /eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}` | A bid request for the proposal slot | Asks the [addressed](#routing-by-auth-data) builder for its bid | +| `POST /eth/v1/builder/builder_preferences/{proposer_pubkey}` | The proposer's `max_execution_payment` for one builder, ahead of the slot | Forwards it to the addressed builder | +| `POST /eth/v1/builder/beacon_blocks` | The signed beacon block that commits to the winning bid | Forwards it to every builder in `[[relays]]` and `[[mux.relays]]`; only the winning builder accepts it | + +With ePBS the beacon node validates each bid and weighs it against your builder config (`min_bid`, `builder_boost_factor`) and its local block, so Commit-Boost adds no redundant verification on the hot path. + +The ePBS bid endpoint does not use these PBS options, which will be deprecated after the hard fork: + +- `skip_sigverify`, `min_bid_eth` and `extra_validation_enabled`: the beacon node checks the bid against the on-chain builder registry and applies the `min_bid` from its builder config. +- `timeout_get_header_ms`, `late_in_slot_time_ms`, their `[[mux]]` overrides and the timing-games options: the beacon node's deadline bounds the request (see [Timing](#timing)). + +A `get_header = "stream"` relay is asked for ePBS bids over plain HTTP. + +## How a bid request reaches a builder + +From the fork, each validator key has a builder config: a list of entries, each a `url` and an `auth_data`. For every entry the beacon node sends a bid request to the entry's `url`, carrying its `auth_data` in a `SignedBuilderRequestAuth` that the validator signs. `auth_data` tells a builder that a request was meant for it, so a request signed for one builder is rejected by another. Unless the proposer and builder agree on another value, it is the builder's hostname. + +To go through Commit-Boost, every entry's `url` is Commit-Boost's own URL, and its `auth_data` is the hostname of the builder it stands for. Commit-Boost reads the `auth_data` of each request, finds the relay entry with that hostname and forwards the request there. Builder preferences are routed the same way. The signed block goes to every builder, since only the one whose bid won accepts it. + +## Setup + +### 1. Add the builders to Commit-Boost + +List each builder as a relay entry, as for PBS: in `[[relays]]`, or in the `[[mux.relays]]` of the mux that lists the proposer's key. ePBS adds one option, `[pbs] proposer_deadline_buffer_ms` (default `50`): the time kept back from the beacon node's deadline (see [Timing](#timing)). It must be under one slot, `12000` on mainnet. + +### 2. Point each validator key at Commit-Boost + +Write each key's builder config through its validator client's keymanager API, which must implement the builder config endpoint ([keymanager-APIs #88](https://github.com/ethereum/keymanager-APIs/pull/88)). Add one entry per builder: `url` is Commit-Boost's URL, and `auth_data` is the hex of the builder's hostname. A key without builder config sends the default auth data of Commit-Boost's own URL, which matches no builder, so every bid request gets `400`. + +With the relay entry `url = "https://0xa1ce...@builder-a.example.com"`, Commit-Boost listening at `http://cb.example.com:18550`, the keymanager API at `$KEYMANAGER_URL`, its token in `$TOKEN` and the validator key in `$PUBKEY`: + +```bash +curl -X POST "$KEYMANAGER_URL/eth/v1/validator/$PUBKEY/builder_config" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "builders": [ + { + "url": "http://cb.example.com:18550", + "auth_data": "0x6275696c6465722d612e6578616d706c652e636f6d", + "builder_pubkeys": [] + } + ] + }' +``` + +`echo 0x$(printf builder-a.example.com | xxd -p | tr -d '\n')` prints the `auth_data` hex. + +The call is `POST /eth/v1/validator/{pubkey}/builder_config`, authenticated with the keymanager API's bearer token. The body replaces the key's config in full, and the validator client answers `202` once it is stored. Besides `url` and `auth_data`, an entry or the top level can set: + +- `min_bid` and `builder_boost_factor` are optional. At the top level they apply to every entry, and to p2p bids, unless an entry sets its own. A top-level `min_bid` also floors the bids that come through Commit-Boost, so a value above your builders' bids sends every proposal to p2p bids or a local block. +- `max_execution_payment`, when an entry omits it, gets the validator client's default, which differs between clients (Lodestar stores `0`, Nimbus the maximum). Lodestar accepts a nonzero value only when started with `--allowDangerousTrustedPayments`. +- `builder_pubkeys` limits which builder keys' bids the beacon node accepts; leave it empty to accept any. Don't copy the pubkey from the relay URL: that is the relay's key, not the builder's bid-signing key. + +## Routing by auth data + +Commit-Boost sends a bid or preferences request to the first builder serving the proposer's key (the relays of its `[[mux]]`, otherwise `[[relays]]`) whose URL hostname equals the request's `auth_data`, byte for byte. That is the [builder specs](https://github.com/ethereum/builder-specs/blob/main/specs/gloas/validator.md#default-auth-data) default auth data: the lowercase hostname, without scheme, port or path, with an IPv6 address in brackets, such as `[::1]`. Builders on one host share it, so only the first is asked. Commit-Boost routes by hostname only, so a value agreed with a builder works through it only if it is that hostname. When no builder matches, the request gets `400`. + +## Timing + +The builder specs require each bid request to carry `Date-Milliseconds` and `X-Timeout-Ms`, which together say how long the beacon node will wait for a bid. Commit-Boost subtracts your `proposer_deadline_buffer_ms` from the time remaining and sends the result to the builder as its `X-Timeout-Ms`, so the builder knows how long it has to answer. + +Think of `proposer_deadline_buffer_ms` as the slack you keep from the beacon node's remaining time: enough for the bid to get back from Commit-Boost to the beacon node and for the beacon node to process it. If no time is left, Commit-Boost does not ask the builder. + +Builder preferences use `timeout_register_validator_ms`, and the signed block `timeout_get_payload_ms`. + +## Metrics + +With [metrics](./running/metrics.md) enabled, the ePBS endpoints use the `endpoint` labels `get_execution_payload_bid`, `submit_builder_preferences` and `submit_signed_beacon_block`. + +| Question | Series | +|---|---| +| What did Commit-Boost answer the beacon node? | `cb_pbs_beacon_node_status_code_total`: `200` or `204` for a bid request, `202` for preferences and the signed block, `4xx` for a rejected request, `500` when no builder accepted preferences or the signed block | +| What did each builder answer? | `cb_pbs_relay_status_code_total`, by `relay_id`: the builder's HTTP status, or `555` when no response arrived (timeout, DNS or connection failure) | +| How fast are builders? | `cb_pbs_relay_latency`, by `relay_id` | +| What are builders bidding? | `cb_pbs_relay_header_value` (the bid's `value` in Gwei, without the execution payment) and `cb_pbs_relay_last_slot`, by `relay_id`, from each bid a builder serves | + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Bid requests get `400` "auth.message.data does not match any configured builder" | The auth data matches no builder serving the key. Usually the key has no builder config, so its auth data is Commit-Boost's own hostname | Write the key's [builder config](#validator-builder-config), or add the builder it names as a relay entry for the key | +| A builder answers bid requests with `400` (`cb_pbs_relay_status_code_total{endpoint="get_execution_payload_bid",http_status_code="400"}`) | The builder compares auth data byte for byte and expects something other than its hostname | Have the builder accept its hostname, the only auth data Commit-Boost [routes by](#routing-by-auth-data) | +| The signed block gets `415` | The beacon node sends it as JSON | Configure the beacon node to send SSZ | +| Commit-Boost returns `200` but the beacon node builds locally | The beacon node rejected the bid, or valued its local block higher after `builder_boost_factor` | Compare the bid with the key's `min_bid` and `builder_boost_factor` | diff --git a/docs/sidebars.js b/docs/sidebars.js index 3fcef6cfc..e0c4f9741 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -46,6 +46,7 @@ const sidebars = { 'get_started/running/metrics-catalog', ], }, + 'get_started/epbs', 'get_started/troubleshooting', ], }, 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 336c91f1a..a05dcbeab 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -23,12 +23,17 @@ use axum::{ use cb_common::{ pbs::{ BUILDER_V1_API_PATH, BUILDER_V2_API_PATH, BlobsBundle, BuilderBid, BuilderBidFulu, - ExecutionPayloadElectra, ExecutionPayloadHeaderFulu, ForkName, GET_HEADER_PATH, - GET_STATUS_PATH, GetHeaderParams, GetHeaderResponse, GetPayloadInfo, PayloadAndBlobs, - REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SignedBuilderBid, SubmitBlindedBlockResponse, + BuilderPreferencesRequest, ExecutionPayloadBid, ExecutionPayloadElectra, + ExecutionPayloadHeaderFulu, ForkName, ForkVersionDecode, GET_EXECUTION_PAYLOAD_BID_PATH, + GET_HEADER_PATH, GET_STATUS_PATH, GetExecutionPayloadBidResponse, GetHeaderParams, + GetHeaderResponse, GetPayloadInfo, HEADER_START_TIME_UNIX_MS, 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, - types::{BlsSecretKey, Chain}, + types::{BlsPublicKey, BlsSecretKey, BlsSignature, Chain}, utils::{TestRandomSeed, timestamp_of_slot_start_sec}, wire::{ CONSENSUS_VERSION_HEADER, EncodingType, deserialize_body, get_accept_types, @@ -39,9 +44,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; @@ -92,6 +97,24 @@ 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>, + /// 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>, + /// Hold every bid request this long before answering, simulating a builder + /// that sits on a request instead of answering promptly + bid_delay_ms: Option, + /// The last `SignedBuilderRequestAuth` forwarded on a bid request + received_auth: RwLock>, + /// The `X-Timeout-Ms` of the last bid request + received_bid_timeout_ms: RwLock>, response_override: RwLock>, bid_value: RwLock, /// The raw `Accept` header PBS sent on the most recent get_header request, @@ -100,6 +123,10 @@ 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 the bid's `value` + trustless_bid_gwei: u64, // default 10 + /// When true, an SSZ bid is served without `Eth-Consensus-Version` + epbs_omit_consensus_version: bool, } impl MockRelayState { @@ -118,6 +145,45 @@ 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 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() + } + + /// `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 `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()) + } + + /// The proposer the last preferences submission was filed under + pub fn received_preferences_pubkey(&self) -> Option { + self.received_preferences_pubkey.read().unwrap().clone() + } + + pub fn received_bid_timeout_ms(&self) -> Option { + *self.received_bid_timeout_ms.read().unwrap() + } + pub fn received_auth_data(&self) -> Option> { + self.received_auth.read().unwrap().as_ref().map(|a| a.message.data.to_vec()) + } pub fn large_body(&self) -> bool { self.large_body } @@ -165,10 +231,21 @@ 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_preferences: RwLock::new(None), + received_preferences_pubkey: RwLock::new(None), + bid_delay_ms: None, + received_auth: RwLock::new(None), + received_bid_timeout_ms: 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, + epbs_omit_consensus_version: false, supported_content_types: Arc::new( [EncodingType::Json, EncodingType::Ssz].iter().cloned().collect(), ), @@ -217,6 +294,35 @@ impl MockRelayState { pub fn with_submit_block_version(self, fork: ForkName) -> Self { Self { submit_block_version_override: Some(fork), ..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 + } + } + + /// Hold every bid request `delay_ms` before answering, so a caller with a + /// shorter `X-Timeout-Ms` times out. + 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 } + } } pub fn mock_relay_app_router(state: Arc) -> Router { @@ -224,7 +330,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)) @@ -264,6 +374,129 @@ 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 { + state.received_execution_payload_bid.fetch_add(1, Ordering::Relaxed); + // Builders MUST 400 a bid request without both timing headers + let header_u64 = + |name| headers.get(name).and_then(|v| v.to_str().ok()).and_then(|v| v.parse::().ok()); + let (Some(_), Some(timeout_ms)) = + (header_u64(HEADER_START_TIME_UNIX_MS), header_u64(HEADER_TIMEOUT_MS)) + else { + return (StatusCode::BAD_REQUEST, "missing Date-Milliseconds or X-Timeout-Ms header") + .into_response(); + }; + *state.received_bid_timeout_ms.write().unwrap() = Some(timeout_ms); + + // Decode the 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(); + } + SignedBuilderRequestAuth::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; + } + + let mut block_hash = B256::ZERO; + block_hash.0[0] = 1; + + let message = ExecutionPayloadBid { + parent_block_hash: parent_hash.into(), + parent_block_root: parent_root, + block_hash: block_hash.into(), + gas_limit: 30_000_000, + builder_index: 42, + slot: Slot::new(slot), + value: state.trustless_bid_gwei, + ..Default::default() + }; + + // CB does not verify bid signatures (the beacon node does), so the mock + // serves an unsigned bid + let data = SignedExecutionPayloadBid { message, signature: BlsSignature::empty() }; + + // 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: 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 { + response.headers_mut().insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&ForkName::Gloas.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, @@ -350,6 +583,67 @@ 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() { + // An error body over PBS's 1 KiB read cap + if state.large_body() { + return (*status, "x".repeat(2048)).into_response(); + } + return (*status).into_response(); + } + + StatusCode::ACCEPTED.into_response() +} + +/// Decodes the forwarded block (PBS always sends SSZ with +/// `Eth-Consensus-Version`), records its slot, 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()); + } + + 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_validator.rs b/tests/src/mock_validator.rs index b2da4a9c1..2059a2456 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, SignedBuilderRequestAuth, + }, types::{BlsPublicKey, KnownChain}, - utils::bls_pubkey_from_hex, + utils::{bls_pubkey_from_hex, utcnow_ms}, wire::{CONSENSUS_VERSION_HEADER, EncodingType}, }; use lh_types::ForkName; @@ -12,7 +15,11 @@ 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. +const DEFAULT_TEST_TIMEOUT_MS: u64 = 60_000; pub struct MockValidator { pub comm_boost: RelayClient, @@ -63,6 +70,122 @@ 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(TEST_PROPOSER_PUBKEY)?; + 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<&SignedBuilderRequestAuth>, + 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 + #[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<&SignedBuilderRequestAuth>, + accept: Vec, + timeout_ms: u64, + ) -> eyre::Result { + let default_pubkey = bls_pubkey_from_hex(TEST_PROPOSER_PUBKEY)?; + 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/utils.rs b/tests/src/utils.rs index 11d192d52..d3c924166 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -15,23 +15,32 @@ 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::{BuilderRequestAuth, RelayClient, RelayEntry, SignedBuilderRequestAuth}, + signer::{SignerLoader, random_secret}, + types::{BlsPublicKey, 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 url::Url; -use crate::mock_validator::MockValidator; +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 mock relays' hostname, so their default auth data +pub const TEST_AUTH_DATA: &[u8] = b"0.0.0.0"; +/// 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}") @@ -129,6 +138,7 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { skip_sigverify: false, min_bid_wei: U256::ZERO, late_in_slot_time_ms: u64::MAX, + proposer_deadline_buffer_ms: 0, extra_validation_enabled: false, ssv_node_api_url: Url::parse("http://localhost:0").unwrap(), @@ -241,6 +251,99 @@ pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { bls_pubkey_from_hex(hex).unwrap() } +/// Build a `SignedBuilderRequestAuth` carrying opaque `data`. CB forwards it +/// unmodified and leaves the signature to the builder, so an empty one +/// suffices. +pub fn opaque_auth(data: &[u8], slot: u64) -> SignedBuilderRequestAuth { + SignedBuilderRequestAuth { + message: BuilderRequestAuth { + data: ssz_types::VariableList::new(data.to_vec()) + .expect("data fits in MaxBuilderAuthData"), + slot: Slot::new(slot), + }, + signature: BlsSignature::empty(), + } +} + +/// Starts a mock relay on a free port, returning its state and port: the +/// building block of every PBS boot below. +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), +) -> Result { + setup_test_env(); + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + + let mut pbs_config = get_pbs_config(pbs_port); + tweak(&mut pbs_config); + 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) +} + +/// 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 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>)> { + let mut relays = Vec::with_capacity(states.len()); + let mut arc_states = Vec::with_capacity(states.len()); + for state in states { + let (state, port) = spawn_mock_relay(state).await?; + relays.push(generate_mock_relay(port, state.signer.public_key())?); + arc_states.push(state); + } + Ok((setup_pbs(chain, relays, |_| {}).await?, arc_states)) +} + +/// Starts one mock relay per state, each with a URL naming its host, so tests +/// can address one builder among several by its hostname. +pub async fn setup_relays_on_hosts( + chain: Chain, + states: Vec<(MockRelayState, &str)>, +) -> Result<(MockValidator, Vec>)> { + let mut relays = Vec::with_capacity(states.len()); + let mut arc_states = Vec::with_capacity(states.len()); + for (state, host) in states { + let (state, port) = spawn_mock_relay(state).await?; + let mut config = mock_relay_config(port, state.signer.public_key())?; + config.entry.url = format!("http://{host}:{port}").parse()?; + relays.push(RelayClient::new(config)?); + arc_states.push(state); + } + Ok((setup_pbs(chain, relays, |_| {}).await?, 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. diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index ec954f531..23d59e94e 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -66,6 +66,7 @@ async fn test_cfg_file_update() -> Result<()> { min_bid_wei: U256::ZERO, 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, rpc_url: None, ssv_node_api_url: Url::parse("http://example.com").unwrap(), 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..b31aabbe1 --- /dev/null +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -0,0 +1,479 @@ +use std::{collections::HashMap, path::PathBuf}; + +use alloy::primitives::{B256, U256}; +use cb_common::{ + config::RuntimeMuxConfig, + pbs::{ + GetExecutionPayloadBidInfo, GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, + HEADER_TIMEOUT_MS, SignedBuilderRequestAuth, SignedExecutionPayloadBid, + }, + 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, + mock_validator::MockValidator, + utils::{ + TEST_AUTH_DATA, TEST_PROPOSER_PUBKEY, generate_mock_relay, get_free_listener, + get_pbs_config, opaque_auth, setup_relay, setup_relays, setup_relays_on_hosts, + spawn_mock_relay, to_pbs_config, wait_for_ready, + }, +}; +use eyre::Result; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::{Decode, Encode}; +use tracing::info; + +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, + ) +} + +/// The relay is asked and its bid is returned as JSON with the 200's required +/// Eth-Consensus-Version +#[tokio::test] +async fn test_get_execution_payload_bid() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + info!("Sending get execution payload bid"); + let res = get_json_bid(&mock_validator, &opaque_auth(TEST_AUTH_DATA, TEST_SLOT)).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + + 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" + ); + + let res = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!(res.version.to_string(), "gloas"); + assert_ne!(res.block_hash(), B256::ZERO); + assert_eq!(res.value(), 10); + Ok(()) +} + +/// `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_passes() -> Result<()> { + // 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(TEST_AUTH_DATA, TEST_SLOT); + 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(()) +} + +/// Relays on one host share its default auth data: only the first configured +/// one is asked +#[tokio::test] +async fn test_get_execution_payload_bid_shared_auth_data_asks_the_first() -> 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 res = get_json_bid(&mock_validator, &opaque_auth(TEST_AUTH_DATA, TEST_SLOT)).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(()) +} + +/// The spec default auth data, the builder's hostname, routes to the relay on +/// that host, and the auth reaches the relay unchanged. A hostname no relay +/// has is a 400 with the builder's data-mismatch message, and no relay is +/// contacted. +#[tokio::test] +async fn test_get_execution_payload_bid_demux_by_hostname() -> Result<()> { + let chain = Chain::Hoodi; + // 0.0.0.0 and 127.0.0.1 both reach the local mocks under distinct hostnames + let (mock_validator, states) = setup_relays_on_hosts(chain, vec![ + (MockRelayState::new(chain, random_secret()), "0.0.0.0"), + (MockRelayState::new(chain, random_secret()), "127.0.0.1"), + ]) + .await?; + + let res = get_json_bid(&mock_validator, &opaque_auth(b"127.0.0.1", TEST_SLOT)).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(states[0].received_execution_payload_bid(), 0); + assert_eq!(states[1].received_execution_payload_bid(), 1); + assert_eq!(states[1].received_auth_data(), Some(b"127.0.0.1".to_vec())); + + let res = get_json_bid(&mock_validator, &opaque_auth(b"localhost", TEST_SLOT)).await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(states[0].received_execution_payload_bid(), 0); + assert_eq!(states[1].received_execution_payload_bid(), 1); + 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.data does not match any configured builder" + ); + Ok(()) +} + +/// A key listed in a `[[mux]]` is served by that mux's relays: its bid request +/// reaches the mux relay and not the default `[[relays]]` one. +#[tokio::test] +async fn test_get_execution_payload_bid_mux_routes_to_mux_relays() -> Result<()> { + let chain = Chain::Hoodi; + let (default_state, default_port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + let (mux_state, mux_port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + let default_relay = generate_mock_relay(default_port, default_state.signer.public_key())?; + let mux_relay = generate_mock_relay(mux_port, mux_state.signer.public_key())?; + + let pbs_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let mut config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![default_relay.clone()]); + config.all_relays = vec![mux_relay.clone(), default_relay]; + let muxed_key = random_secret().public_key(); + config.mux_lookup = Some(HashMap::from([(muxed_key.clone(), RuntimeMuxConfig { + id: "test".to_string(), + config: config.pbs_config.clone(), + relays: vec![mux_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(TEST_AUTH_DATA, TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + Some(muxed_key), + Some(&auth), + vec![EncodingType::Json], + ) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mux_state.received_execution_payload_bid(), 1); + assert_eq!(default_state.received_execution_payload_bid(), 0); + 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(TEST_AUTH_DATA, TEST_SLOT + 1); + 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, + "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 SignedBuilderRequestAuth: auth.message.slot does not match the proposal slot in the request path" + ); + Ok(()) +} + +/// Requests refused before any relay is contacted, one row per status: a +/// missing required header, an unsupported media type and an unacceptable +/// Accept. +#[tokio::test] +async fn test_get_execution_payload_bid_rejected_before_relays() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + // Unlabeled, so JSON (builder-specs) + let body = serde_json::to_vec(&opaque_auth(TEST_AUTH_DATA, TEST_SLOT))?; + + // (headers dropped, header added, expected status, message excerpt) + let cases = [ + ( + vec![CONSENSUS_VERSION_HEADER], + None, + StatusCode::BAD_REQUEST, + "missing consensus version", + ), + ( + vec![HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS], + None, + StatusCode::BAD_REQUEST, + "Invalid request: Date-Milliseconds and X-Timeout-Ms headers are required", + ), + (vec![], Some(("content-type", "text/plain")), StatusCode::UNSUPPORTED_MEDIA_TYPE, ""), + (vec![], Some(("accept", "application/xml")), StatusCode::NOT_ACCEPTABLE, ""), + ]; + for (dropped, added, status, message) in cases { + let mut headers = vec![ + (CONSENSUS_VERSION_HEADER, "gloas".to_string()), + (HEADER_START_TIME_UNIX_MS, utcnow_ms().to_string()), + (HEADER_TIMEOUT_MS, "60000".to_string()), + ]; + headers.retain(|(name, _)| !dropped.contains(name)); + if let Some((name, value)) = added { + headers.push((name, value.to_string())); + } + + let mut req = + mock_validator.comm_boost.client.post(bid_url(&mock_validator)).body(body.clone()); + for (name, value) in &headers { + req = req.header(*name, value); + } + let res = req.send().await?; + assert_eq!(res.status(), status, "headers: {headers:?}"); + let json: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(json["code"], status.as_u16(), "{json}"); + assert!(json["message"].as_str().unwrap_or_default().contains(message), "{json}"); + } + assert_eq!(mock_state.received_execution_payload_bid(), 0, "rejected before any relay"); + 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") + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(opaque_auth(TEST_AUTH_DATA, 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(()) +} + +#[tokio::test] +async fn test_get_execution_payload_bid_spec_url() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + 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 + .comm_boost + .client + .post(url) + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .header("Eth-Consensus-Version", "gloas") + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(opaque_auth(TEST_AUTH_DATA, TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + Ok(()) +} + +/// The response encoding follows the caller's Accept and defaults to JSON when +/// none is sent (builder-specs), and the 200 carries Eth-Consensus-Version +/// either way. Default relays answer in SSZ, so the last row's relay serves +/// only JSON to cover the JSON decode on the relay leg too. +#[tokio::test] +async fn test_get_execution_payload_bid_response_encoding() -> Result<()> { + let chain = Chain::Hoodi; + let cases = [ + (vec![], MockRelayState::new(chain, random_secret()), EncodingType::Json), + (vec![EncodingType::Ssz], MockRelayState::new(chain, random_secret()), EncodingType::Ssz), + ( + vec![EncodingType::Json], + MockRelayState::new(chain, random_secret()).with_json_only_response(), + EncodingType::Json, + ), + ]; + for (accept, relay, expected) in cases { + let (mock_validator, _) = setup_relays(chain, vec![relay]).await?; + let auth = opaque_auth(TEST_AUTH_DATA, TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + None, + Some(&auth), + accept, + ) + .await?; + assert_eq!(res.status(), StatusCode::OK, "{expected}"); + + let header = + |name| res.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_owned); + assert_eq!(header(CONTENT_TYPE.as_str()), Some(expected.to_string())); + assert_eq!(header(CONSENSUS_VERSION_HEADER).as_deref(), Some("gloas"), "{expected}"); + + let body = res.bytes().await?; + let (slot, block_hash) = match expected { + EncodingType::Ssz => { + let bid = SignedExecutionPayloadBid::from_ssz_bytes(&body) + .expect("body must SSZ-decode to a SignedExecutionPayloadBid"); + (bid.message.slot.as_u64(), bid.message.block_hash.0) + } + EncodingType::Json => { + let bid = serde_json::from_slice::(&body)?; + (bid.data.message.slot.as_u64(), bid.block_hash()) + } + }; + assert_eq!(slot, TEST_SLOT, "{expected}"); + assert_ne!(block_hash, B256::ZERO, "{expected}"); + } + Ok(()) +} + +/// An SSZ bid without `Eth-Consensus-Version` cannot be forwarded, since CB's +/// 200 must name the fork: that relay contributes no bid, so the request is a +/// 204. The relay is still contacted, so the 204 proves the drop. +#[tokio::test] +async fn test_get_execution_payload_bid_unversioned_ssz_bid_dropped() -> Result<()> { + let relay = MockRelayState::new(Chain::Hoodi, random_secret()) + .with_ssz_only_response() + .with_epbs_omit_consensus_version(); + let (mock_validator, states) = setup_relays(Chain::Hoodi, vec![relay]).await?; + let res = get_json_bid(&mock_validator, &opaque_auth(TEST_AUTH_DATA, TEST_SLOT)).await?; + assert_eq!(res.status(), StatusCode::NO_CONTENT); + assert_eq!(states[0].received_execution_payload_bid(), 1); + Ok(()) +} + +/// A relay error is a no-bid 204, not a 502, except the builder's own 400 and +/// 401, which reach the proposer. +#[tokio::test] +async fn test_get_execution_payload_bid_relay_status() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + // (builder's answer, CB's answer) + let cases = [ + (StatusCode::INTERNAL_SERVER_ERROR, StatusCode::NO_CONTENT), + (StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST), + (StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED), + ]; + for (builder, expected) in cases { + mock_state.set_response_override(builder); + let res = get_json_bid(&mock_validator, &opaque_auth(TEST_AUTH_DATA, TEST_SLOT)).await?; + assert_eq!(res.status(), expected, "builder answered {builder}"); + } + assert_eq!(mock_state.received_execution_payload_bid(), 3); + Ok(()) +} + +/// A relay slower than the proposer's `X-Timeout-Ms` is dropped, 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(TEST_AUTH_DATA, 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(()) +} + +/// The relay gets CB's own timing headers, not the beacon node's: an +/// `X-Timeout-Ms` of five slots is clamped to one 12 s slot, less the 100 ms +/// buffer. The mock relay 400s a request missing either header, so the 200 +/// also proves `Date-Milliseconds` arrived. +#[tokio::test] +async fn test_get_execution_payload_bid_relay_timing_headers() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |cfg| cfg.proposer_deadline_buffer_ms = 100, generate_mock_relay) + .await?; + + let auth = opaque_auth(TEST_AUTH_DATA, 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], + 60_000, + ) + .await?; + assert_eq!(res.status(), StatusCode::OK); + let timeout_ms = mock_state.received_bid_timeout_ms().expect("relay saw X-Timeout-Ms"); + assert!(0 < timeout_ms && timeout_ms <= 11_900, "relay saw X-Timeout-Ms {timeout_ms}"); + Ok(()) +} diff --git a/tests/tests/pbs_submit_builder_preferences.rs b/tests/tests/pbs_submit_builder_preferences.rs new file mode 100644 index 000000000..b1079e7e4 --- /dev/null +++ b/tests/tests/pbs_submit_builder_preferences.rs @@ -0,0 +1,272 @@ +use cb_common::{ + pbs::{BuilderPreferences, BuilderPreferencesRequest, SignedBuilderRequestAuth}, + 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, opaque_auth, setup_relay, setup_relays, + setup_relays_on_hosts, + }, +}; +use eyre::Result; +use reqwest::{StatusCode, header::CONTENT_TYPE}; + +const TEST_MAX_EXECUTION_PAYMENT: u64 = 1_000_000_000; + +/// CB does not gate preferences on slot age, so a fixed slot serves. +const TEST_SLOT: u64 = 100; + +/// 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: SignedBuilderRequestAuth, + 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, TEST_SLOT); + 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. Quotes on the Gwei +/// are optional on decode by ecosystem convention, so the unquoted number is +/// accepted too. +#[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 = TEST_SLOT; + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + + for max_execution_payment in [ + serde_json::json!(TEST_MAX_EXECUTION_PAYMENT.to_string()), + serde_json::json!(TEST_MAX_EXECUTION_PAYMENT), + ] { + let body = serde_json::json!({ + "preferences": { "max_execution_payment": max_execution_payment }, + "auth": { + // "0.0.0.0", the mock relay's hostname + "message": { "data": "0x302e302e302e30", "slot": slot.to_string() }, + "signature": format!("0x{}", "0".repeat(192)), + } + }); + let res = mock_validator + .comm_boost + .client + .post(url.clone()) + .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, "{body}"); + 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(()) +} + +/// Requests refused before any builder is asked, with an ErrorMessage body: +/// `Eth-Consensus-Version` is required for JSON and SSZ alike (builder-specs +/// #165), and an unsupported media type is a 415, distinct from the 400 of a +/// malformed body. The per-rule detail (both encodings, the JSON default, an +/// empty body) is pinned once in the wire.rs decoder test. +#[tokio::test] +async fn test_submit_builder_preferences_rejected_before_builders() -> 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, TEST_SLOT), TEST_MAX_EXECUTION_PAYMENT); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + + // (Content-Type, Eth-Consensus-Version, body, expected status, message excerpt) + let cases = [ + ( + "application/json", + None, + serde_json::to_vec(&request)?, + StatusCode::BAD_REQUEST, + "missing consensus version", + ), + ("text/plain", Some("gloas"), b"nonsense".to_vec(), StatusCode::UNSUPPORTED_MEDIA_TYPE, ""), + ]; + for (content_type, version, body, status, message) in cases { + let mut req = + mock_validator.comm_boost.client.post(url.clone()).header(CONTENT_TYPE, content_type); + if let Some(version) = version { + req = req.header(CONSENSUS_VERSION_HEADER, version); + } + let res = req.body(body).send().await?; + assert_eq!(res.status(), status, "{content_type}"); + let json: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(json["code"], status.as_u16(), "{json}"); + assert!(json["message"].as_str().unwrap_or_default().contains(message), "{json}"); + } + assert_eq!(mock_state.received_builder_preferences(), 0, "rejected before any builder"); + Ok(()) +} + +/// 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_past_slot_forwarded() -> 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::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1, "past-slot preference is forwarded"); + Ok(()) +} + +/// Auth data matching no relay's hostname is a 400 with the builder's +/// data-mismatch message: 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_auth_data_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let request = preferences(opaque_auth(&[0xbe, 0xef], TEST_SLOT), 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 SignedBuilderRequestAuth: auth.message.data does not match any configured builder" + ); + Ok(()) +} + +/// The addressed builder's answer: only 202 is an acceptance. Its 400 and +/// 401 propagate, so the proposer can see the spec's rejection and +/// authentication failure, with a CB-written message rather than the +/// builder's untrusted body. Any other status, another 2xx included, is the +/// blanket 500; the 503 row is not itself a 500, so passthrough would show. +#[tokio::test] +async fn test_submit_builder_preferences_builder_status() -> 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, TEST_SLOT), TEST_MAX_EXECUTION_PAYMENT); + + // (builder's answer, CB's answer) + let cases = [ + (StatusCode::OK, StatusCode::INTERNAL_SERVER_ERROR), + (StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST), + (StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED), + (StatusCode::SERVICE_UNAVAILABLE, StatusCode::INTERNAL_SERVER_ERROR), + ]; + for (builder, expected) in cases { + mock_state.set_response_override(builder); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + assert_eq!(res.status(), expected, "builder answered {builder}"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], expected.as_u16(), "{body}"); + if builder == StatusCode::BAD_REQUEST { + assert_eq!( + body["message"], + "The addressed builder rejected the request with status 400" + ); + } + } + assert_eq!(mock_state.received_builder_preferences(), 4, "the builder is asked every time"); + Ok(()) +} + +/// An error body over PBS's 1 KiB read cap still reports the builder's status. +#[tokio::test] +async fn test_submit_builder_preferences_large_error_body() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = + setup_relays(chain, vec![MockRelayState::new(chain, random_secret()).with_large_body()]) + .await?; + states[0].set_response_override(StatusCode::UNAUTHORIZED); + let request = preferences(opaque_auth(TEST_AUTH_DATA, TEST_SLOT), TEST_MAX_EXECUTION_PAYMENT); + + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) +} + +/// Two relays on distinct hostnames: 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 (mock_validator, states) = setup_relays_on_hosts(chain, vec![ + (MockRelayState::new(chain, random_secret()), "0.0.0.0"), + (MockRelayState::new(chain, random_secret()), "127.0.0.1"), + ]) + .await?; + + // The second relay, so that asking the first configured one would fail + let request = preferences(opaque_auth(b"127.0.0.1", TEST_SLOT), 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(), 0, "the unaddressed builder is not"); + assert_eq!(states[1].received_builder_preferences(), 1, "the 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..a5e474a16 --- /dev/null +++ b/tests/tests/pbs_submit_signed_beacon_block.rs @@ -0,0 +1,162 @@ +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, SignedBeaconBlockGloas, Slot}; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::Encode; + +const TEST_SLOT: u64 = 100; + +/// A Gloas `SignedBeaconBlock` at `slot`, otherwise random. +fn gloas_block(slot: u64) -> SignedBeaconBlock { + let mut block = SignedBeaconBlockGloas::::test_random(); + block.message.slot = Slot::new(slot); + SignedBeaconBlock::Gloas(block) +} + +/// The endpoint is stateless: a block sent to the literal spec route +/// `POST /eth/v1/builder/beacon_blocks` is broadcast to every configured +/// builder, and each decodes it, by the forwarded fork header, back to the +/// same slot. +#[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); + 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); + for state in &states { + assert_eq!(state.received_signed_beacon_block(), 1, "every builder receives the block"); + assert_eq!(state.received_block_slot(), Some(TEST_SLOT)); + } + 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); + 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); + 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(()) +} + +/// Requests refused before anything is broadcast, each with an ErrorMessage +/// body. `Eth-Consensus-Version` is required (builder-specs #165) and checked +/// first, and a missing body is named as such rather than as an undecodable +/// one. The reveal must be SSZ: a JSON reveal, and an unlabeled one +/// (builder-specs defaults it to JSON), is a 415 rather than forwarded +/// mislabeled to fail opaquely at the builder. +#[tokio::test] +async fn test_submit_signed_beacon_block_rejected_before_broadcast() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + let block = gloas_block(TEST_SLOT); + let (json_body, ssz_body) = (serde_json::to_vec(&block)?, block.as_ssz_bytes()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + + // (Content-Type, Eth-Consensus-Version, body, expected status, message excerpt) + let cases = [ + ( + Some("application/json"), + None, + json_body.clone(), + StatusCode::BAD_REQUEST, + "missing consensus version", + ), + (None, Some("gloas"), vec![], StatusCode::BAD_REQUEST, "missing request body"), + ( + Some("application/json"), + Some("gloas"), + json_body, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "", + ), + (None, Some("gloas"), ssz_body, StatusCode::UNSUPPORTED_MEDIA_TYPE, ""), + ]; + for (content_type, version, body, status, message) in cases { + let mut req = mock_validator.comm_boost.client.post(url.clone()); + if let Some(content_type) = content_type { + req = req.header(CONTENT_TYPE, content_type); + } + if let Some(version) = version { + req = req.header(CONSENSUS_VERSION_HEADER, version); + } + let res = req.body(body).send().await?; + assert_eq!(res.status(), status, "{content_type:?}, {version:?}"); + let json: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(json["code"], status.as_u16(), "{json}"); + assert!(json["message"].as_str().unwrap_or_default().contains(message), "{json}"); + } + assert_eq!(state.received_signed_beacon_block(), 0, "nothing is broadcast"); + Ok(()) +} From 2a792e64174457313bb1002f9086ad243f6c5293 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Mon, 5 Oct 2026 22:29:03 -0700 Subject: [PATCH 04/10] docs(epbs): keep the builder config anchor the troubleshooting table links to --- docs/docs/get_started/epbs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/get_started/epbs.md b/docs/docs/get_started/epbs.md index 748db1b82..27cc0f75d 100644 --- a/docs/docs/get_started/epbs.md +++ b/docs/docs/get_started/epbs.md @@ -41,7 +41,7 @@ To go through Commit-Boost, every entry's `url` is Commit-Boost's own URL, and i List each builder as a relay entry, as for PBS: in `[[relays]]`, or in the `[[mux.relays]]` of the mux that lists the proposer's key. ePBS adds one option, `[pbs] proposer_deadline_buffer_ms` (default `50`): the time kept back from the beacon node's deadline (see [Timing](#timing)). It must be under one slot, `12000` on mainnet. -### 2. Point each validator key at Commit-Boost +### 2. Point each validator key at Commit-Boost {#validator-builder-config} Write each key's builder config through its validator client's keymanager API, which must implement the builder config endpoint ([keymanager-APIs #88](https://github.com/ethereum/keymanager-APIs/pull/88)). Add one entry per builder: `url` is Commit-Boost's URL, and `auth_data` is the hex of the builder's hostname. A key without builder config sends the default auth data of Commit-Boost's own URL, which matches no builder, so every bid request gets `400`. From e6af0fdc4ee74b4c65d04e24bd460293eed8f1b4 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 6 Oct 2026 11:02:38 -0700 Subject: [PATCH 05/10] address review comments --- crates/common/src/pbs/types/mod.rs | 25 ----- crates/common/src/wire.rs | 9 +- crates/pbs/src/routes/builder_preferences.rs | 67 ++++------- .../pbs/src/routes/execution_payload_bid.rs | 104 +++++++++--------- .../src/routes/submit_signed_beacon_block.rs | 1 - crates/pbs/src/utils.rs | 37 +++---- tests/tests/pbs_get_execution_payload_bid.rs | 19 ++-- 7 files changed, 100 insertions(+), 162 deletions(-) diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index 57845d4e1..ccf79e381 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -74,31 +74,6 @@ pub type SignedExecutionPayloadBid = lh_types::SignedExecutionPayloadBid; -pub trait GetExecutionPayloadBidInfo { - fn block_hash(&self) -> B256; - fn value(&self) -> u64; - fn execution_payment(&self) -> u64; - fn builder_index(&self) -> u64; -} - -impl GetExecutionPayloadBidInfo for GetExecutionPayloadBidResponse { - fn block_hash(&self) -> B256 { - self.data.message.block_hash.0 - } - - fn value(&self) -> u64 { - self.data.message.value - } - - fn execution_payment(&self) -> u64 { - self.data.message.execution_payment - } - - fn builder_index(&self) -> u64 { - self.data.message.builder_index - } -} - /// Path params of POST /// `/eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/ /// {proposer_pubkey}` diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index 16037c5e0..48f3be15a 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -311,14 +311,7 @@ pub fn require_consensus_version_header( // Gloas-only until later forks are defined 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 | - ForkName::Heze => Err(unsupported()), + _ => Err(unsupported()), } } diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index fab0902aa..dc8596e72 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -5,14 +5,12 @@ use axum::{ response::IntoResponse, }; use cb_common::{ - pbs::{ - BuilderPreferencesRequest, RelayClient, SubmitBuilderPreferencesParams, error::PbsError, - }, + pbs::{BuilderPreferencesRequest, SubmitBuilderPreferencesParams}, wire::{decode_versioned_request_body, get_user_agent}, }; use reqwest::StatusCode; use ssz::Encode; -use tracing::{debug, error, info}; +use tracing::{error, info}; use crate::{ PbsStateGuard, @@ -21,7 +19,7 @@ use crate::{ state::{BuilderApiState, PbsState}, utils::{ builder_rejection, epbs_base_send_headers, log_mux_selection, post_ssz_expect_accepted, - record_beacon_status, record_client_error, record_request_failure, resolve_addressed_relay, + record_beacon_status, record_request_failure, resolve_addressed_relay, }, }; @@ -33,7 +31,7 @@ pub async fn handle_submit_builder_preferences( ) -> Result { let request = decode_versioned_request_body::(&req_headers, &body) - .map_err(|err| record_client_error(err, SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG))?; + .map_err(|err| record_request_failure(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()); @@ -78,50 +76,33 @@ pub async fn submit_builder_preferences( // timeout rather than the block-production one let timeout_ms = pbs_config.timeout_register_validator_ms; - let relay_id = relay.id.clone(); - match send_submit_builder_preferences( - params.proposer_pubkey, - body, - relay, - send_headers, - timeout_ms, - ) - .await - { - Ok(()) => { - info!(%relay_id, "builder preferences submitted"); + let relay_id = relay.id.as_ref(); + let sent = match relay.submit_builder_preferences_url(¶ms.proposer_pubkey) { + Ok(url) => { + post_ssz_expect_accepted( + &relay, + url, + body, + send_headers, + timeout_ms, + SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, + ) + .await + } + Err(err) => Err(err), + }; + match sent { + Ok(latency) => { + info!(relay_id, ?latency, "builder preferences submitted"); Ok(()) } Err(err) => { if err.is_timeout() { - error!(err = "Timed Out", %relay_id); + error!(err = "Timed Out", relay_id); } else { - error!(%err, %relay_id); + error!(%err, relay_id); } Err(builder_rejection(&err).unwrap_or(PbsClientError::NoBuilderResponse)) } } } - -async fn send_submit_builder_preferences( - proposer_pubkey: cb_common::types::BlsPublicKey, - body: Bytes, - relay: RelayClient, - headers: HeaderMap, - timeout_ms: u64, -) -> Result<(), PbsError> { - let url = relay.submit_builder_preferences_url(&proposer_pubkey)?; - - let request_latency = post_ssz_expect_accepted( - &relay, - url, - body, - 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 68c96774f..1385ea3c5 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -8,9 +8,9 @@ use axum::{ }; use cb_common::{ pbs::{ - GetExecutionPayloadBidInfo, GetExecutionPayloadBidParams, GetExecutionPayloadBidResponse, - HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, RelayClient, SignedBuilderRequestAuth, - SignedExecutionPayloadBid, error::PbsError, + GetExecutionPayloadBidParams, GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, + HEADER_TIMEOUT_MS, RelayClient, SignedBuilderRequestAuth, SignedExecutionPayloadBid, + error::PbsError, }, utils::{ms_into_slot, utcnow_ms}, wire::{ @@ -34,8 +34,7 @@ use crate::{ state::{BuilderApiState, PbsState}, utils::{ builder_rejection, epbs_base_send_headers, format_gwei_as_eth, log_mux_selection, - record_beacon_status, record_client_error, record_request_failure, resolve_addressed_relay, - send_to_relay, + record_beacon_status, record_request_failure, resolve_addressed_relay, send_to_relay, }, }; @@ -46,7 +45,7 @@ pub async fn handle_get_execution_payload_bid( body: Bytes, ) -> Result { let auth = decode_versioned_request_body::(&req_headers, &body) - .map_err(|err| record_client_error(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG))?; + .map_err(|err| record_request_failure(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)); @@ -58,16 +57,19 @@ pub async fn handle_get_execution_payload_bid( let ms_into_slot = ms_into_slot(params.slot, state.config.chain); let response_encoding = get_accept_types(&req_headers) - .inspect_err(|err| error!(%err, "error parsing accept header")) - .map_err(|err| record_client_error(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG))? + .map_err(|err| record_request_failure(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG))? .primary; info!(ua, ms_into_slot, "new request"); + let version = req_headers.get(CONSENSUS_VERSION_HEADER).cloned(); match get_execution_payload_bid(params, auth, req_headers, state).await { - Ok(Some(bid)) => { - encode_bid_response(bid, response_encoding, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG) - } + Ok(Some(bid)) => Ok(encode_bid_response( + bid, + response_encoding, + version, + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, + )), Ok(None) => { info!("no bid for slot"); record_beacon_status("204", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG); @@ -77,34 +79,42 @@ pub async fn handle_get_execution_payload_bid( } } -fn encode_bid_response( +/// A relay's bid with the body and encoding it arrived in, so a beacon node +/// that asks for the same encoding gets the relay's bytes unchanged +pub(crate) struct RelayBid { bid: GetExecutionPayloadBidResponse, + body: Bytes, + encoding: EncodingType, +} + +/// `version` is the beacon node's own `Eth-Consensus-Version`: the 200 names +/// the fork it asked for +fn encode_bid_response( + RelayBid { bid, body, encoding }: RelayBid, response_encoding: EncodingType, + version: Option, endpoint: &str, -) -> Result { +) -> Response { + let message = &bid.data.message; info!( - trustless_bid_eth = format_gwei_as_eth(bid.value()), - execution_payment_eth = format_gwei_as_eth(bid.execution_payment()), - block_hash = %bid.block_hash(), - builder_index = bid.builder_index(), + trustless_bid_eth = format_gwei_as_eth(message.value), + execution_payment_eth = format_gwei_as_eth(message.execution_payment), + block_hash = %message.block_hash, + builder_index = message.builder_index, "received header" ); - // Eth-Consensus-Version is required on the 200 for both encodings - let consensus_version_header = HeaderValue::from_str(&bid.version.to_string()) - .expect("fork name is always a valid header value"); - record_beacon_status("200", endpoint); + let content_type = [(CONTENT_TYPE, response_encoding.content_type_header().clone())]; let mut res = match response_encoding { - EncodingType::Ssz => { - let mut res = bid.data.as_ssz_bytes().into_response(); - res.headers_mut().insert(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()); - res - } + _ if encoding == response_encoding => (content_type, body).into_response(), + EncodingType::Ssz => (content_type, bid.data.as_ssz_bytes()).into_response(), EncodingType::Json => axum::Json(bid).into_response(), }; - res.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); - Ok(res) + if let Some(version) = version { + res.headers_mut().insert(CONSENSUS_VERSION_HEADER, version); + } + res } /// Implements https://ethereum.github.io/builder-specs/?urls.primaryName=dev#/Builder/getExecutionPayloadBid @@ -114,7 +124,7 @@ pub async fn get_execution_payload_bid( auth: SignedBuilderRequestAuth, req_headers: HeaderMap, state: PbsState, -) -> Result, PbsClientError> { +) -> Result, PbsClientError> { 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); @@ -145,7 +155,7 @@ pub async fn get_execution_payload_bid( let mut send_headers = epbs_base_send_headers(&req_headers)?; - // The bid is re-encoded for the BN anyway, so ask for the cheaper SSZ + // SSZ is smaller and reaches a beacon node that asks for SSZ unchanged send_headers.insert(ACCEPT, OUTBOUND_ACCEPT_SSZ_FIRST.clone()); let body = Bytes::from(auth.as_ssz_bytes()); @@ -154,13 +164,13 @@ pub async fn get_execution_payload_bid( // A relay that errors or times out contributes no bid: 204, never a 502. // The builder's own 400 and 401 still reach the proposer. match send_get_execution_payload_bid(params, body, relay, send_headers, max_timeout_ms).await { - Ok(Some(bid)) => { + Ok(Some(relay_bid)) => { RELAY_LAST_SLOT.with_label_values(&[relay_id.as_str()]).set(slot as i64); - // value() is already gwei (the gauge is labelled gwei), so it is set unscaled + // The bid's value is already gwei, the gauge's unit, so it is set unscaled RELAY_HEADER_VALUE .with_label_values(&[relay_id.as_str()]) - .set(i64::try_from(bid.value()).unwrap_or_default()); - Ok(Some(bid)) + .set(i64::try_from(relay_bid.bid.data.message.value).unwrap_or_default()); + Ok(Some(relay_bid)) } Ok(None) => Ok(None), Err(err) if err.is_timeout() => { @@ -205,7 +215,7 @@ async fn send_get_execution_payload_bid( relay: RelayClient, mut headers: HeaderMap, timeout_ms: u64, -) -> Result, PbsError> { +) -> Result, PbsError> { let url = relay.get_execution_payload_bid_url( params.slot, ¶ms.parent_hash, @@ -240,7 +250,6 @@ async fn send_get_execution_payload_bid( }; 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(), @@ -252,13 +261,9 @@ async fn send_get_execution_payload_bid( return Ok(None); } - let get_header_response = match content_type { - EncodingType::Json => serde_json::from_slice::( - &response_bytes, - ) - .map_err(|err| PbsError::JsonDecode { - err, - raw: String::from_utf8_lossy(&response_bytes).into_owned(), + let bid = match content_type { + 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 @@ -279,18 +284,7 @@ async fn send_get_execution_payload_bid( } }; - debug!( - relay_id = relay.id.as_ref(), - header_size_bytes, - latency = ?request_latency, - version =? get_header_response.version, - 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" - ); - - Ok(Some(get_header_response)) + Ok(Some(RelayBid { bid, body: response_bytes.into(), encoding: content_type })) } #[cfg(test)] diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs index 8f60f1db2..75bb35542 100644 --- a/crates/pbs/src/routes/submit_signed_beacon_block.rs +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -77,7 +77,6 @@ pub async fn submit_signed_beacon_block( })) .await; let accepted = results - .into_iter() .zip(relays.iter()) .filter(|(res, relay)| match res { Ok(()) => true, diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index c07da9d40..77c03765d 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -4,8 +4,9 @@ use std::{ }; use alloy::primitives::utils::{ParseUnits, Unit}; +use axum::body::Bytes; use cb_common::{ - pbs::{ForkName, RelayClient, error::PbsError}, + pbs::{RelayClient, error::PbsError}, types::BlsPublicKey, wire::{ CONSENSUS_VERSION_HEADER, EncodingType, get_user_agent_with_version, @@ -15,7 +16,7 @@ use cb_common::{ use futures::future::join_all; use reqwest::{ StatusCode, - header::{CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT}, + header::{CONTENT_TYPE, HeaderMap, USER_AGENT}, }; use tracing::{Instrument, debug, error, warn}; use url::Url; @@ -54,24 +55,17 @@ pub(crate) async fn send_to_relay( Ok((res, request_latency)) } -/// Count a request-rejection in `BEACON_NODE_STATUS` before it short-circuits -/// the handler, so rejected requests show up as 4xx in the endpoint counter. -pub(crate) fn record_client_error( - err: impl Into, - endpoint: &str, -) -> PbsClientError { - let err = err.into(); - BEACON_NODE_STATUS.with_label_values(&[err.status_code().as_str(), endpoint]).inc(); - err -} - pub(crate) fn record_beacon_status(code: &str, endpoint: &str) { 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 { +pub(crate) fn record_request_failure( + err: impl Into, + endpoint: &str, +) -> PbsClientError { + let err = err.into(); if err.status_code().is_server_error() { error!(%err, "{endpoint} failed"); } else { @@ -84,7 +78,7 @@ pub(crate) fn record_request_failure(err: PbsClientError, endpoint: &str) -> Pbs /// Fans `sends` out on detached tasks and waits for all of them pub(crate) async fn join_detached_sends( sends: impl IntoIterator, -) -> Vec> +) -> impl Iterator> where F: Future> + Send + 'static, { @@ -94,7 +88,6 @@ where .await .into_iter() .map(|joined| joined.unwrap_or_else(|err| Err(PbsError::TokioJoinError(err)))) - .collect() } pub(crate) fn log_mux_selection( @@ -116,7 +109,7 @@ pub(crate) fn log_mux_selection( pub(crate) async fn post_ssz_expect_accepted( relay: &RelayClient, url: Url, - body: impl Into, + body: Bytes, headers: HeaderMap, timeout_ms: u64, tag: &str, @@ -160,18 +153,16 @@ pub(crate) fn builder_rejection(err: &PbsError) -> Option { } /// Headers every ePBS relay request carries: the versioned `User-Agent`, and -/// `Eth-Consensus-Version` since each body is a fork-versioned type. +/// the beacon node's `Eth-Consensus-Version`, which the route has validated 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"), - ); + if let Some(version) = req_headers.get(CONSENSUS_VERSION_HEADER) { + headers.insert(CONSENSUS_VERSION_HEADER, version.clone()); + } Ok(headers) } diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index b31aabbe1..0cc4fb2fa 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -4,8 +4,8 @@ use alloy::primitives::{B256, U256}; use cb_common::{ config::RuntimeMuxConfig, pbs::{ - GetExecutionPayloadBidInfo, GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, - HEADER_TIMEOUT_MS, SignedBuilderRequestAuth, SignedExecutionPayloadBid, + GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, + SignedBuilderRequestAuth, SignedExecutionPayloadBid, }, signer::random_secret, types::Chain, @@ -77,8 +77,8 @@ async fn test_get_execution_payload_bid() -> Result<()> { let res = serde_json::from_slice::(&res.bytes().await?)?; assert_eq!(res.version.to_string(), "gloas"); - assert_ne!(res.block_hash(), B256::ZERO); - assert_eq!(res.value(), 10); + assert_ne!(res.data.message.block_hash.0, B256::ZERO); + assert_eq!(res.data.message.value, 10); Ok(()) } @@ -327,8 +327,8 @@ async fn test_get_execution_payload_bid_spec_url() -> Result<()> { /// The response encoding follows the caller's Accept and defaults to JSON when /// none is sent (builder-specs), and the 200 carries Eth-Consensus-Version -/// either way. Default relays answer in SSZ, so the last row's relay serves -/// only JSON to cover the JSON decode on the relay leg too. +/// either way. Default relays answer in SSZ; the JSON-only relays cover the +/// relay's JSON passed through and converted to SSZ. #[tokio::test] async fn test_get_execution_payload_bid_response_encoding() -> Result<()> { let chain = Chain::Hoodi; @@ -340,6 +340,11 @@ async fn test_get_execution_payload_bid_response_encoding() -> Result<()> { MockRelayState::new(chain, random_secret()).with_json_only_response(), EncodingType::Json, ), + ( + vec![EncodingType::Ssz], + MockRelayState::new(chain, random_secret()).with_json_only_response(), + EncodingType::Ssz, + ), ]; for (accept, relay, expected) in cases { let (mock_validator, _) = setup_relays(chain, vec![relay]).await?; @@ -370,7 +375,7 @@ async fn test_get_execution_payload_bid_response_encoding() -> Result<()> { } EncodingType::Json => { let bid = serde_json::from_slice::(&body)?; - (bid.data.message.slot.as_u64(), bid.block_hash()) + (bid.data.message.slot.as_u64(), bid.data.message.block_hash.0) } }; assert_eq!(slot, TEST_SLOT, "{expected}"); From bc3c590f22501d12d59782e46af3ecc93e412b33 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 6 Oct 2026 11:39:21 -0700 Subject: [PATCH 06/10] test: wait for PBS's startup status check in test_mux to fix race condition and lint test --- tests/tests/pbs_mux.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index 7778fb456..6f01f502d 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -245,8 +245,15 @@ async fn test_mux() -> Result<()> { let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); - // leave some time to start servers - tokio::time::sleep(Duration::from_millis(100)).await; + // PBS checks /status on every relay once at startup; wait for that check so + // the status count below holds only this test's request + for _ in 0..100 { + if mock_state.received_get_status() == 3 { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert_eq!(mock_state.received_get_status(), 3); // Send default request without specifying a validator key let mock_validator = MockValidator::new(pbs_port)?; @@ -271,7 +278,7 @@ async fn test_mux() -> Result<()> { // Status requests should go to all relays info!("Sending get status"); assert_eq!(mock_validator.do_get_status().await?.status(), StatusCode::OK); - assert_eq!(mock_state.received_get_status(), 3); // default + 2 mux relays were used + assert_eq!(mock_state.received_get_status(), 6); // default + 2 mux relays were used // Register requests should go to all relays info!("Sending register validator"); From 7da6e3c244338958f2d028279347d2b5a2400953 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Mon, 5 Oct 2026 22:31:09 -0700 Subject: [PATCH 07/10] feat(pbs): dial builders outside the config for ePBS requests Auth data that is an http(s) URL now also routes to the relay entry with the same scheme, host and port. Either form may end in `?` and parameters for the builder: Commit-Boost routes on the part before `?` and forwards the signed auth, parameters included, unchanged. When no relay entry matches, Commit-Boost dials the builder the auth data names for a bid or preferences request: `https://`, or the URL. The target is resolved once, and the lookup and the dial share the request's budget. A name that does not resolve, or any address that is not public unicast, gets 400; a lookup or dial setup that runs out of time counts as a builder that did not answer. At most 32 lookups run at once, since a timed-out lookup keeps its blocking thread. The dial goes only to the checked addresses, with no proxy and no redirects. A request from a Commit-Boost never leads to a dial, so a dial that reaches a Commit-Boost, this one included, goes no further. Auth data, a dialed builder's error body and a dial target come from the request, so they are logged escaped, the body capped at 1 KiB and the target as its origin. The signed block gets 202 once it has been forwarded, whatever the builders answer, since the beacon node gossips it anyway. A block whose bid came from a dialed builder, which is not a relay entry, would otherwise always get 500. When no builder accepts the block, Commit-Boost logs a warning. --- crates/common/src/pbs/relay.rs | 41 ++- crates/pbs/Cargo.toml | 3 + crates/pbs/src/dial.rs | 337 ++++++++++++++++++ crates/pbs/src/error.rs | 10 +- crates/pbs/src/lib.rs | 3 + crates/pbs/src/routes/builder_preferences.rs | 14 +- .../pbs/src/routes/execution_payload_bid.rs | 57 ++- .../src/routes/submit_signed_beacon_block.rs | 12 +- crates/pbs/src/utils.rs | 97 +++-- docs/docs/get_started/epbs.md | 26 +- tests/Cargo.toml | 2 +- tests/src/mock_relay.rs | 26 +- tests/src/mock_validator.rs | 5 +- tests/tests/pbs_get_execution_payload_bid.rs | 117 +++++- tests/tests/pbs_submit_builder_preferences.rs | 29 +- tests/tests/pbs_submit_signed_beacon_block.rs | 39 +- 16 files changed, 686 insertions(+), 132 deletions(-) create mode 100644 crates/pbs/src/dial.rs diff --git a/crates/common/src/pbs/relay.rs b/crates/common/src/pbs/relay.rs index ae7f4b9aa..b877d8580 100644 --- a/crates/common/src/pbs/relay.rs +++ b/crates/common/src/pbs/relay.rs @@ -97,6 +97,14 @@ pub struct RelayClient { impl RelayClient { pub fn new(config: RelayConfig) -> eyre::Result { + Self::with_client_builder(config, reqwest::Client::builder()) + } + + /// [`Self::new`] on a client builder that already holds extra settings + pub fn with_client_builder( + config: RelayConfig, + builder: reqwest::ClientBuilder, + ) -> eyre::Result { let stream_url = match config.get_header { GetHeaderTransport::Http => None, GetHeaderTransport::Stream => Some(stream_url(&config.entry.url)?), @@ -131,10 +139,8 @@ impl RelayClient { } } - let client = reqwest::Client::builder() - .default_headers(headers.clone()) - .timeout(DEFAULT_REQUEST_TIMEOUT) - .build()?; + let client = + builder.default_headers(headers.clone()).timeout(DEFAULT_REQUEST_TIMEOUT).build()?; Ok(Self { id: Arc::new(config.id().to_owned()), @@ -255,6 +261,13 @@ impl RelayClient { } } +/// The builder URL in URL-form auth data. Only `http` and `https` count, since +/// opaque data such as `builder-a:prod` parses as a URL too. +pub fn decode_auth_data_url(data: &[u8]) -> Option { + let url = Url::parse(std::str::from_utf8(data).ok()?).ok()?; + matches!(url.scheme(), "http" | "https").then_some(url) +} + /// First 4 bytes of the value's SHA-256: enough to see a rotation /// across reloads, too short to identify a value on its own fn value_fingerprint(value: &str) -> String { @@ -268,12 +281,30 @@ mod tests { use alloy::primitives::B256; - use super::{GetHeaderRequest, RelayClient, RelayEntry, value_fingerprint}; + use super::{ + GetHeaderRequest, RelayClient, RelayEntry, decode_auth_data_url, value_fingerprint, + }; use crate::{ config::{GetHeaderTransport, RelayConfig, test_env::RELAY_URL}, utils::bls_pubkey_from_hex_unchecked, }; + #[test] + fn decode_auth_data_url_accepts_only_http_urls() { + let cases: [(&[u8], Option<&str>); 5] = [ + (b"https://Builder-A.example.com:443/eth", Some("https://builder-a.example.com/eth")), + (b"http://10.0.0.1:18550", Some("http://10.0.0.1:18550/")), + // Opaque data and host:port parse as URLs with their own scheme + (b"builder-a:prod", None), + (b"localhost:18550", None), + (b"builder-a.example.com", None), + ]; + for (data, expected) in cases { + let decoded = decode_auth_data_url(data).map(|url| url.to_string()); + assert_eq!(decoded.as_deref(), expected, "{}", String::from_utf8_lossy(data)); + } + } + #[test] fn test_relay_entry() { let pubkey = bls_pubkey_from_hex_unchecked( diff --git a/crates/pbs/Cargo.toml b/crates/pbs/Cargo.toml index 7497aa930..3a52e12ef 100644 --- a/crates/pbs/Cargo.toml +++ b/crates/pbs/Cargo.toml @@ -5,6 +5,9 @@ publish = false rust-version.workspace = true version.workspace = true +[features] +testing-flags = [] + [dependencies] alloy.workspace = true async-trait.workspace = true diff --git a/crates/pbs/src/dial.rs b/crates/pbs/src/dial.rs new file mode 100644 index 000000000..6b7fdfdd5 --- /dev/null +++ b/crates/pbs/src/dial.rs @@ -0,0 +1,337 @@ +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr, ToSocketAddrs}, + sync::LazyLock, + time::Duration, +}; + +use cb_common::{ + config::{GetHeaderTransport, RelayConfig}, + pbs::{RelayClient, RelayEntry}, + types::BlsPublicKey, + utils::bls_pubkey_from_hex, +}; +use tokio::sync::Semaphore; +use tracing::{info, warn}; +use url::Url; + +use crate::error::PbsClientError; + +/// The `relay_id` of every dial: the target comes from request data, so a +/// per-host metric label would be unbounded +const DIAL_RELAY_ID: &str = "dial"; + +/// The BLS12-381 G1 generator, standing in for the pubkey `RelayEntry` requires +/// but only PBS reads. A workaround to leave `RelayClient` unchanged until PBS +/// is deprecated, when the pubkey can go. +static DIAL_PUBKEY: LazyLock = LazyLock::new(|| { + bls_pubkey_from_hex( + "0x97f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb", + ) + .expect("the G1 generator is a valid pubkey") +}); + +/// The address in auth data, before the first `?`. Parameters after it are +/// for the builder, which gets them inside the signed auth +pub(crate) fn auth_data_address(data: &[u8]) -> &[u8] { + data.iter().position(|&byte| byte == b'?').map_or(data, |end| &data[..end]) +} + +/// A relay for the builder that an unmatched address names. The target is +/// untrusted, so it is resolved once and dialed only at the vetted addresses. +pub(crate) async fn dial_relay( + data_url: Option, + address: &[u8], + lookup_timeout: Duration, +) -> Result { + let url = dial_target(data_url, address)?; + let origin = url.origin().ascii_serialization(); + let addrs = resolve_dial_target(&url, &origin, lookup_timeout).await?; + info!(%origin, ?addrs, "auth data names a builder outside the config, dialing it"); + pinned_relay(url, &addrs).map_err(|err| { + warn!(%err, "failed to build the dial client"); + PbsClientError::Internal + }) +} + +/// `data_url`, else `https:///` for the builder-specs default auth +/// data, a bare hostname +fn dial_target(data_url: Option, address: &[u8]) -> Result { + data_url + .or_else(|| { + let url = + Url::parse(&format!("https://{}/", std::str::from_utf8(address).ok()?)).ok()?; + (url.host_str()?.as_bytes() == address).then_some(url) + }) + .ok_or_else(|| { + warn!( + address = ?String::from_utf8_lossy(address), + "auth data matches no configured relay and is neither a hostname nor a URL" + ); + PbsClientError::AuthDataMismatch + }) +} + +/// A lookup that times out keeps its blocking thread until the resolver +/// returns, and relays' own lookups share that pool, so few run at once +static DIAL_LOOKUPS: Semaphore = Semaphore::const_new(32); + +async fn resolve_dial_target( + url: &Url, + origin: &str, + lookup_timeout: Duration, +) -> Result, PbsClientError> { + let host_port = format!( + "{}:{}", + url.host_str().unwrap_or_default(), + url.port_or_known_default().unwrap_or_default() + ); + // An IP literal needs no lookup + if let Ok(addr) = host_port.parse::() { + vet_dial_addrs(origin, &[addr])?; + return Ok(vec![addr]); + } + let Ok(permit) = DIAL_LOOKUPS.try_acquire() else { + warn!(%origin, "too many dial lookups in flight, not dialing"); + return Err(PbsClientError::NoBuilderResponse); + }; + let lookup = tokio::task::spawn_blocking(move || { + // Held until the lookup returns, which a timeout does not stop + let _permit = permit; + host_port.to_socket_addrs().map(Iterator::collect::>) + }); + let addrs = match tokio::time::timeout(lookup_timeout, lookup).await { + Ok(Ok(Ok(addrs))) => addrs, + Ok(Ok(Err(err))) => { + warn!(%origin, %err, "dial target lookup failed, not dialing"); + return Err(PbsClientError::DialTargetBlocked); + } + Ok(Err(_)) => return Err(PbsClientError::Internal), + // Transient, unlike a failed lookup: the builder counts as not answering + Err(_) => { + warn!(%origin, ?lookup_timeout, "dial target lookup timed out, not dialing"); + return Err(PbsClientError::NoBuilderResponse); + } + }; + vet_dial_addrs(origin, &addrs)?; + Ok(addrs) +} + +/// Every address is checked, since the dial may connect to any of them +fn vet_dial_addrs(origin: &str, addrs: &[SocketAddr]) -> Result<(), PbsClientError> { + if dial_target_check_enabled() && addrs.iter().any(|addr| ip_is_disallowed(addr.ip())) { + warn!(%origin, "dial target resolves to a disallowed address, not dialing"); + return Err(PbsClientError::DialTargetBlocked); + } + Ok(()) +} + +/// A relay that dials `url` only at `addrs`: a system proxy would resolve the +/// host again, and a redirect target was never checked. +fn pinned_relay(url: Url, addrs: &[SocketAddr]) -> eyre::Result { + let mut client = + reqwest::Client::builder().no_proxy().redirect(reqwest::redirect::Policy::none()); + if let Some(domain) = url.domain() { + client = client.resolve_to_addrs(domain, addrs); + } + RelayClient::with_client_builder( + RelayConfig { + entry: RelayEntry { id: DIAL_RELAY_ID.to_string(), pubkey: DIAL_PUBKEY.clone(), 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, + validator_registration_batch_size: None, + }, + client, + ) +} + +#[cfg(feature = "testing-flags")] +thread_local! { + static SKIP_DIAL_TARGET_CHECK: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Test-only: skips the dial target's address check on this thread, so a +/// test can dial a mock builder on loopback +#[cfg(feature = "testing-flags")] +pub fn set_skip_dial_target_check(skip: bool) { + SKIP_DIAL_TARGET_CHECK.with(|flag| flag.set(skip)); +} + +fn dial_target_check_enabled() -> bool { + #[cfg(feature = "testing-flags")] + { + !SKIP_DIAL_TARGET_CHECK.with(|flag| flag.get()) + } + #[cfg(not(feature = "testing-flags"))] + { + true + } +} + +/// Anything but public unicast: the special-purpose IPv4 ranges, also as the +/// IPv4 inside a v4-mapped, v4-compatible or NAT64 address, and IPv6 outside +/// 2000::/3 or in its documentation, Teredo and 6to4 ranges +fn ip_is_disallowed(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let [a, b, c, _] = v4.octets(); + v4.is_private() || + v4.is_loopback() || + v4.is_link_local() || + v4.is_multicast() || + v4.is_documentation() || + // std checks these only on nightly: 0.0.0.0/8, 100.64.0.0/10, + // 192.0.0.0/24, 198.18.0.0/15 and 240.0.0.0/4 + a == 0 || + (a == 100 && b & 0xc0 == 0x40) || + (a == 192 && b == 0 && c == 0) || + (a == 198 && b & 0xfe == 18) || + a >= 240 + } + IpAddr::V6(v6) => { + let s = v6.segments(); + let nat64 = s[..6] == [0x64, 0xff9b, 0, 0, 0, 0]; + let embedded = + if nat64 { Some(Ipv4Addr::from_bits(v6.to_bits() as u32)) } else { v6.to_ipv4() }; + match embedded { + // :: and ::1 land here as 0.0.0.0 and 0.0.0.1 + Some(v4) => ip_is_disallowed(IpAddr::V4(v4)), + None => { + s[0] & 0xe000 != 0x2000 || + (s[0] == 0x2001 && (s[1] == 0 || s[1] == 0x0db8)) || + s[0] == 0x2002 + } + } + } + } +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + use cb_common::pbs::decode_auth_data_url; + + use super::*; + + // Literal IPs keep the lookup off the network + #[tokio::test] + async fn dial_relay_targets() { + const MISMATCH: &str = "auth data does not match a configured builder"; + const BLOCKED: &str = "dial target does not resolve or resolves to a disallowed address"; + let cases: [(&[u8], Result<&str, &str>); 8] = [ + (b"http://1.1.1.1:8551", Ok("http://1.1.1.1:8551/")), + (b"1.1.1.1", Ok("https://1.1.1.1/")), + (b"1.1.1.1?ofac=1", Ok("https://1.1.1.1/")), + (b"[2606:4700:4700::1111]", Ok("https://[2606:4700:4700::1111]/")), + (b"10.0.0.1", Err(BLOCKED)), + (b"1.1.1.1:8551", Err(MISMATCH)), + (b"ftp://1.1.1.1", Err(MISMATCH)), + (&[0xde, 0xad], Err(MISMATCH)), + ]; + for (data, expected) in cases { + let address = auth_data_address(data); + let res = + dial_relay(decode_auth_data_url(address), address, Duration::from_secs(5)).await; + let case = String::from_utf8_lossy(data); + match (&res, expected) { + (Ok(relay), Ok(url)) => { + assert_eq!(relay.config.entry.url.as_str(), url, "{case}"); + assert_eq!(relay.id.as_str(), "dial", "{case}"); + } + (Err(err), Err(message)) => assert_eq!(err.to_string(), message, "{case}"), + _ => panic!("{case}: expected {expected:?}, got {:?}", res.map(|_| ())), + } + } + } + + // Holds every permit, so a hostname lookup in a unit test running beside it + // is refused + #[tokio::test] + async fn lookup_cap_refuses_hostnames_not_ip_literals() { + let _held = DIAL_LOOKUPS.try_acquire_many(32).unwrap(); + let dial = |address: &'static [u8]| dial_relay(None, address, Duration::from_secs(1)); + assert!(dial(b"1.1.1.1").await.is_ok()); + assert!(matches!(dial(b"builder.invalid").await, Err(PbsClientError::NoBuilderResponse))); + } + + #[test] + fn vet_dial_addrs_checks_every_address() { + let addr = |s: &str| s.parse::().unwrap(); + assert!(matches!( + vet_dial_addrs("https://builder.example.com", &[ + addr("1.1.1.1:443"), + addr("10.0.0.1:443") + ]), + Err(PbsClientError::DialTargetBlocked) + )); + } + + // A hostname target is dialed at the given address with no lookup + // (`.invalid` never resolves), and a redirect is not followed + #[tokio::test] + async fn pinned_relay_dials_the_given_address_and_refuses_redirects() -> eyre::Result<()> { + use axum::{Router, http::header::LOCATION, routing::post}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let builder = Router::new() + .route( + "/bid", + post(|| async { (StatusCode::TEMPORARY_REDIRECT, [(LOCATION, "/internal")]) }), + ) + .route("/internal", post(|| async { StatusCode::OK })); + tokio::spawn(async move { axum::serve(listener, builder).await }); + + let url = Url::parse(&format!("http://dial-target.invalid:{}/bid", addr.port()))?; + let res = pinned_relay(url.clone(), &[addr])?.client.post(url).send().await?; + assert_eq!(res.status(), StatusCode::TEMPORARY_REDIRECT); + Ok(()) + } + + #[test] + fn ip_is_disallowed_table() { + for (ip, disallowed) in [ + ("127.0.0.1", true), + ("0.0.0.0", true), + ("::1", true), + ("10.0.0.1", true), + ("169.254.1.1", true), + ("255.255.255.255", true), + ("100.64.0.1", true), + ("100.127.255.254", true), + ("100.63.0.1", false), + ("100.128.0.1", false), + ("fd12:3456::1", true), + ("fe80::1", true), + ("::ffff:127.0.0.1", true), + ("::10.0.0.1", true), + ("64:ff9b::a00:5", true), + ("::1.1.1.1", false), + ("64:ff9b::1.1.1.1", false), + ("::ffff:1.1.1.1", false), + ("0.1.2.3", true), + ("192.0.1.1", false), + ("198.19.255.255", true), + ("198.20.0.1", false), + ("224.0.0.1", true), + ("240.0.0.1", true), + ("192.0.0.192", true), + ("198.18.0.1", true), + ("203.0.113.1", true), + ("fec0::1", true), + ("ff02::1", true), + ("2001:db8::1", true), + ("64:ff9b:1::a00:1", true), + ("2002:a00:1::1", true), + ("2001::1", true), + ("1.1.1.1", false), + ("2606:4700:4700::1111", false), + ] { + assert_eq!(ip_is_disallowed(ip.parse().unwrap()), disallowed, "{ip}"); + } + } +} diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index 9b9aaf078..29f8dc4ca 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -20,12 +20,14 @@ struct ErrorResponse { pub enum PbsClientError { #[error("no response from relays")] NoResponse, - /// 500, not 502: 502 is in neither endpoint's builder-specs response set. - /// Legacy routes keep `NoResponse` -> 502. + /// 500, not 502: 502 is not in the preferences endpoint's builder-specs + /// response set. Legacy routes keep `NoResponse` -> 502. #[error("no builder accepted the submission")] NoBuilderResponse, #[error("auth data does not match a configured builder")] AuthDataMismatch, + #[error("dial target does not resolve or resolves to a disallowed address")] + DialTargetBlocked, #[error("missing or invalid timing headers")] MissingTimingHeader, #[error("auth slot does not match the request path")] @@ -50,6 +52,7 @@ impl PbsClientError { PbsClientError::NoResponse => StatusCode::BAD_GATEWAY, PbsClientError::NoBuilderResponse => StatusCode::INTERNAL_SERVER_ERROR, PbsClientError::AuthDataMismatch => StatusCode::BAD_REQUEST, + PbsClientError::DialTargetBlocked => StatusCode::BAD_REQUEST, PbsClientError::MissingTimingHeader => StatusCode::BAD_REQUEST, PbsClientError::AuthSlotMismatch => StatusCode::BAD_REQUEST, PbsClientError::BuilderRejected(code) => *code, @@ -73,6 +76,9 @@ impl IntoResponse for PbsClientError { PbsClientError::AuthDataMismatch => { "Invalid SignedBuilderRequestAuth: auth.message.data does not match any configured builder".to_string() } + PbsClientError::DialTargetBlocked => { + "Invalid SignedBuilderRequestAuth: the addressed builder's host does not resolve or resolves to a disallowed address".to_string() + } PbsClientError::MissingTimingHeader => { "Invalid request: Date-Milliseconds and X-Timeout-Ms headers are required".to_string() } diff --git a/crates/pbs/src/lib.rs b/crates/pbs/src/lib.rs index 8b4afdcfe..9c04224a8 100644 --- a/crates/pbs/src/lib.rs +++ b/crates/pbs/src/lib.rs @@ -1,5 +1,6 @@ mod api; mod constants; +mod dial; mod error; mod metrics; mod mev_boost; @@ -10,6 +11,8 @@ mod utils; pub use api::*; pub use constants::*; +#[cfg(feature = "testing-flags")] +pub use dial::set_skip_dial_target_check; pub use mev_boost::*; pub use service::PbsService; pub use state::{BuilderApiState, PbsState, PbsStateGuard}; diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs index dc8596e72..cba99d0ef 100644 --- a/crates/pbs/src/routes/builder_preferences.rs +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -65,17 +65,21 @@ pub async fn submit_builder_preferences( log_mux_selection(maybe_mux_id, relays.len(), ¶ms.proposer_pubkey); - let relay = resolve_addressed_relay(relays, request.auth.message.data.as_ref())?; + // Preferences are submitted an epoch ahead, so they share the registration + // timeout rather than the block-production one + let (relay, timeout_ms) = resolve_addressed_relay( + relays, + request.auth.message.data.as_ref(), + &req_headers, + pbs_config.timeout_register_validator_ms, + ) + .await?; let send_headers = epbs_base_send_headers(&req_headers)?; // SSZ on the relay hop 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; - let relay_id = relay.id.as_ref(); let sent = match relay.submit_builder_preferences_url(¶ms.proposer_pubkey) { Ok(url) => { diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 1385ea3c5..34b40eb0c 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -16,7 +16,7 @@ use cb_common::{ wire::{ CONSENSUS_VERSION_HEADER, EncodingType, OUTBOUND_ACCEPT_SSZ_FIRST, decode_versioned_request_body, get_accept_types, get_user_agent, - parse_response_encoding_and_fork, safe_read_http_response, + parse_response_encoding_and_fork, read_chunked_body_with_max, safe_read_http_response, }, }; use reqwest::{ @@ -28,7 +28,9 @@ use tracing::{debug, error, info, warn}; use crate::{ PbsStateGuard, - constants::{GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, MAX_SIZE_GET_HEADER_RESPONSE}, + constants::{ + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, MAX_SIZE_DEFAULT, MAX_SIZE_GET_HEADER_RESPONSE, + }, error::PbsClientError, metrics::{RELAY_HEADER_VALUE, RELAY_LAST_SLOT}, state::{BuilderApiState, PbsState}, @@ -134,8 +136,6 @@ pub async fn get_execution_payload_bid( return Err(PbsClientError::AuthSlotMismatch); } - let relay = resolve_addressed_relay(relays, auth.message.data.as_ref())?; - // The beacon node's deadline, not timeout_get_header_ms, bounds the request let slot_ms = state.config.chain.slot_time_sec().saturating_mul(1000); let budget_ms = request_budget_ms(&req_headers, utcnow_ms(), slot_ms)?; @@ -153,6 +153,20 @@ pub async fn get_execution_payload_bid( return Ok(None); } + let (relay, max_timeout_ms) = match resolve_addressed_relay( + relays, + auth.message.data.as_ref(), + &req_headers, + max_timeout_ms, + ) + .await + { + // A dial with no time left, or refused for too many lookups, is a builder + // that did not answer + Err(PbsClientError::NoBuilderResponse) => return Ok(None), + resolved => resolved?, + }; + let mut send_headers = epbs_base_send_headers(&req_headers)?; // SSZ is smaller and reaches a beacon node that asks for SSZ unchanged @@ -178,7 +192,7 @@ pub async fn get_execution_payload_bid( Ok(None) } Err(err) => { - error!(%err, %relay_id); + error!(err = ?err, %relay_id); builder_rejection(&err).map_or(Ok(None), Err) } } @@ -238,17 +252,18 @@ async fn send_get_execution_payload_bid( let (res, request_latency) = send_to_relay(request, &relay, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG).await?; let code = res.status(); + if !code.is_success() { + // Read after the status check, so a body over the cap still reports the + // builder's status + let url = res.url().to_string(); + let error_msg = match read_chunked_body_with_max(res, MAX_SIZE_DEFAULT, &url).await { + Ok(body) => String::from_utf8_lossy(&body).into_owned(), + Err(err) => err.to_string(), + }; + return Err(PbsError::RelayResponse { error_msg, code: code.as_u16() }); + } - // 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 (content_type, fork) = parse_response_encoding_and_fork(res.headers(), code.as_u16())?; let response_bytes = safe_read_http_response(res, MAX_SIZE_GET_HEADER_RESPONSE).await?; if code == StatusCode::NO_CONTENT { debug!( @@ -262,9 +277,15 @@ async fn send_get_execution_payload_bid( } let bid = match content_type { - EncodingType::Json => serde_json::from_slice(&response_bytes).map_err(|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[..response_bytes.len().min(MAX_SIZE_DEFAULT)], + ) + .into_owned(), + })? + } EncodingType::Ssz => { // SSZ requires the fork from Eth-Consensus-Version; its absence is a // relay protocol violation. diff --git a/crates/pbs/src/routes/submit_signed_beacon_block.rs b/crates/pbs/src/routes/submit_signed_beacon_block.rs index 75bb35542..f8471bade 100644 --- a/crates/pbs/src/routes/submit_signed_beacon_block.rs +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -4,7 +4,7 @@ use cb_common::wire::{ require_consensus_version_header, }; use reqwest::StatusCode; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use crate::{ PbsStateGuard, @@ -35,7 +35,8 @@ pub async fn handle_submit_signed_beacon_block( /// Implements https://ethereum.github.io/builder-specs/?urls.primaryName=dev#/Builder/submitSignedBeaconBlock /// Forwards the block bytes, undecoded, to every configured builder, since CB -/// keeps no auction state to know which bid won. Returns 202 if one accepts +/// keeps no auction state to know which bid won. Returns 202 whatever they +/// answer: the beacon node gossips the block anyway pub async fn submit_signed_beacon_block( body: Bytes, req_headers: HeaderMap, @@ -86,11 +87,10 @@ pub async fn submit_signed_beacon_block( } }) .count(); - - // Only the winner accepts, so one 202 across the broadcast is success if accepted == 0 { - return Err(PbsClientError::NoBuilderResponse); + warn!(addressed = relays.len(), "no builder accepted the signed beacon block"); + } else { + info!(accepted, addressed = relays.len(), "signed beacon block accepted"); } - info!(accepted, addressed = relays.len(), "signed beacon block submitted"); Ok(()) } diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 77c03765d..04a8dad51 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -6,7 +6,7 @@ use std::{ use alloy::primitives::utils::{ParseUnits, Unit}; use axum::body::Bytes; use cb_common::{ - pbs::{RelayClient, error::PbsError}, + pbs::{HEADER_VERSION_KEY, RelayClient, decode_auth_data_url, error::PbsError}, types::BlsPublicKey, wire::{ CONSENSUS_VERSION_HEADER, EncodingType, get_user_agent_with_version, @@ -23,6 +23,7 @@ use url::Url; use crate::{ constants::{MAX_SIZE_DEFAULT, TIMEOUT_ERROR_CODE_STR}, + dial::{auth_data_address, dial_relay}, error::PbsClientError, metrics::{BEACON_NODE_STATUS, RELAY_LATENCY, RELAY_STATUS_CODE}, }; @@ -188,25 +189,51 @@ pub fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { true } -/// The relay an ePBS request addresses: the first whose URL hostname equals -/// `auth_data`. No match is a 400. -pub(crate) fn resolve_addressed_relay( +/// The first configured relay that the address in `auth_data` names, by +/// hostname or, for a URL, by origin; otherwise a relay that dials it. Also +/// returns what `timeout_ms` leaves after setting up that dial. +pub(crate) async fn resolve_addressed_relay( relays: &[RelayClient], auth_data: &[u8], -) -> Result { - let addressed = relays.iter().find(|relay| { - relay.config.entry.url.host_str().is_some_and(|host| host.as_bytes() == auth_data) + req_headers: &HeaderMap, + timeout_ms: u64, +) -> Result<(RelayClient, u64), PbsClientError> { + let address = auth_data_address(auth_data); + let by_host = relays.iter().find(|relay| { + relay.config.entry.url.host_str().is_some_and(|host| host.as_bytes() == address) }); - match addressed { - Some(relay) => Ok(relay.clone()), - None => { - warn!( - auth_data = %String::from_utf8_lossy(auth_data), - "auth data matches no configured relay" - ); - Err(PbsClientError::AuthDataMismatch) - } + if let Some(relay) = by_host { + return Ok((relay.clone(), timeout_ms)); + } + let data_url = decode_auth_data_url(address); + let by_origin = data_url.as_ref().and_then(|data_url| { + relays.iter().find(|relay| { + let url = &relay.config.entry.url; + url.scheme() == data_url.scheme() && + url.host() == data_url.host() && + url.port_or_known_default() == data_url.port_or_known_default() + }) + }); + if let Some(relay) = by_origin { + return Ok((relay.clone(), timeout_ms)); + } + // Every Commit-Boost dial carries this header, so a request dialed back into + // a Commit-Boost, this one included, goes no further + if req_headers.contains_key(HEADER_VERSION_KEY) { + warn!( + auth_data = ?String::from_utf8_lossy(auth_data), + "auth data matches no configured relay and the request came from a Commit-Boost, not dialing on" + ); + return Err(PbsClientError::AuthDataMismatch); + } + let started = Instant::now(); + let relay = dial_relay(data_url, address, Duration::from_millis(timeout_ms)).await?; + let left_ms = timeout_ms.saturating_sub(started.elapsed().as_millis() as u64); + // A request with no time left would still reach the builder + if left_ms == 0 { + return Err(PbsClientError::NoBuilderResponse); } + Ok((relay, left_ms)) } #[cfg(test)] @@ -239,24 +266,38 @@ mod tests { RelayClient::new(config).unwrap() } - #[test] - fn resolve_relay_by_hostname() { + #[tokio::test] + async fn resolve_relay_by_auth_data() { let relays = vec![ test_relay("https://0xdeadbeef@builder-a.example.com"), test_relay("https://builder-b.example.com:8443/eth"), test_relay("http://[::1]:18550"), ]; - let host = |data: &[u8]| -> Option { - resolve_addressed_relay(&relays, data) + // From a Commit-Boost, so a miss is an error, not a dial + let mut from_cb = HeaderMap::new(); + from_cb.insert(HEADER_VERSION_KEY, reqwest::header::HeaderValue::from_static("test")); + let host = async |data: &[u8]| -> Option { + resolve_addressed_relay(&relays, data, &from_cb, 0) + .await .ok() - .map(|relay| relay.config.entry.url.host_str().unwrap().to_string()) + .map(|(relay, _)| relay.config.entry.url.host_str().unwrap().to_string()) }; - assert_eq!(host(b"builder-a.example.com").as_deref(), Some("builder-a.example.com")); - assert_eq!(host(b"builder-b.example.com").as_deref(), Some("builder-b.example.com")); - assert_eq!(host(b"[::1]").as_deref(), Some("[::1]")); - assert!(host(b"Builder-A.example.com").is_none()); - assert!(host(b"builder-a.example.com:443").is_none()); - // a URL is not a hostname - assert!(host(b"https://builder-a.example.com").is_none()); + assert_eq!(host(b"builder-a.example.com").await.as_deref(), Some("builder-a.example.com")); + assert_eq!(host(b"builder-b.example.com").await.as_deref(), Some("builder-b.example.com")); + assert_eq!(host(b"[::1]").await.as_deref(), Some("[::1]")); + assert!(host(b"Builder-A.example.com").await.is_none()); + assert!(host(b"builder-a.example.com:443").await.is_none()); + // A URL matches on scheme, host and port + assert_eq!( + host(b"https://builder-a.example.com:443/eth").await.as_deref(), + Some("builder-a.example.com") + ); + assert!(host(b"http://builder-a.example.com").await.is_none()); + assert!(host(b"https://builder-b.example.com").await.is_none()); + // Parameters after `?` are for the builder and do not affect routing + assert_eq!( + host(b"builder-a.example.com?ofac=1").await.as_deref(), + Some("builder-a.example.com") + ); } } diff --git a/docs/docs/get_started/epbs.md b/docs/docs/get_started/epbs.md index 27cc0f75d..df2c577d3 100644 --- a/docs/docs/get_started/epbs.md +++ b/docs/docs/get_started/epbs.md @@ -33,7 +33,7 @@ A `get_header = "stream"` relay is asked for ePBS bids over plain HTTP. From the fork, each validator key has a builder config: a list of entries, each a `url` and an `auth_data`. For every entry the beacon node sends a bid request to the entry's `url`, carrying its `auth_data` in a `SignedBuilderRequestAuth` that the validator signs. `auth_data` tells a builder that a request was meant for it, so a request signed for one builder is rejected by another. Unless the proposer and builder agree on another value, it is the builder's hostname. -To go through Commit-Boost, every entry's `url` is Commit-Boost's own URL, and its `auth_data` is the hostname of the builder it stands for. Commit-Boost reads the `auth_data` of each request, finds the relay entry with that hostname and forwards the request there. Builder preferences are routed the same way. The signed block goes to every builder, since only the one whose bid won accepts it. +To go through Commit-Boost, every entry's `url` is Commit-Boost's own URL, and its `auth_data` is the hostname of the builder it stands for. Commit-Boost reads the `auth_data` of each request, finds the relay entry with that hostname and forwards the request there. If no relay entry has it, Commit-Boost dials the builder the `auth_data` names ([builders outside your config](#builders-outside-your-config)). Builder preferences are routed the same way. The signed block goes to every builder in your config, since only the one whose bid won accepts it. ## Setup @@ -43,7 +43,7 @@ List each builder as a relay entry, as for PBS: in `[[relays]]`, or in the `[[mu ### 2. Point each validator key at Commit-Boost {#validator-builder-config} -Write each key's builder config through its validator client's keymanager API, which must implement the builder config endpoint ([keymanager-APIs #88](https://github.com/ethereum/keymanager-APIs/pull/88)). Add one entry per builder: `url` is Commit-Boost's URL, and `auth_data` is the hex of the builder's hostname. A key without builder config sends the default auth data of Commit-Boost's own URL, which matches no builder, so every bid request gets `400`. +Write each key's builder config through its validator client's keymanager API, which must implement the builder config endpoint ([keymanager-APIs #88](https://github.com/ethereum/keymanager-APIs/pull/88)). Add one entry per builder: `url` is Commit-Boost's URL, and `auth_data` is the hex of the builder's hostname. A key without builder config gets no bids through Commit-Boost ([builders outside your config](#builders-outside-your-config)). With the relay entry `url = "https://0xa1ce...@builder-a.example.com"`, Commit-Boost listening at `http://cb.example.com:18550`, the keymanager API at `$KEYMANAGER_URL`, its token in `$TOKEN` and the validator key in `$PUBKEY`: @@ -72,7 +72,17 @@ The call is `POST /eth/v1/validator/{pubkey}/builder_config`, authenticated with ## Routing by auth data -Commit-Boost sends a bid or preferences request to the first builder serving the proposer's key (the relays of its `[[mux]]`, otherwise `[[relays]]`) whose URL hostname equals the request's `auth_data`, byte for byte. That is the [builder specs](https://github.com/ethereum/builder-specs/blob/main/specs/gloas/validator.md#default-auth-data) default auth data: the lowercase hostname, without scheme, port or path, with an IPv6 address in brackets, such as `[::1]`. Builders on one host share it, so only the first is asked. Commit-Boost routes by hostname only, so a value agreed with a builder works through it only if it is that hostname. When no builder matches, the request gets `400`. +Commit-Boost sends a bid or preferences request to the first builder serving the proposer's key (the relays of its `[[mux]]`, otherwise `[[relays]]`) whose URL hostname equals the request's `auth_data`, byte for byte. That is the [builder specs](https://github.com/ethereum/builder-specs/blob/main/specs/gloas/validator.md#default-auth-data) default auth data: the lowercase hostname, without scheme, port or path, with an IPv6 address in brackets, such as `[::1]`. Builders on one host share it, so only the first is asked. + +`auth_data` can also be an `http` or `https` URL, a form some builders agree out of band. It then matches the first builder whose `url` has the same scheme, host and port; the path and the pubkey in the relay URL do not count. Any other value agreed with a builder does not route through Commit-Boost. When no builder matches, Commit-Boost [dials the builder](#builders-outside-your-config) the `auth_data` names, and answers `400` when the `auth_data` is neither a hostname nor an `http(s)` URL. + +Either form may end in `?` and form-encoded parameters for the builder, such as `builder-a.example.com?ofac=1`. Commit-Boost routes on the part before `?`. The parameters reach the builder inside the signed auth, so set them only for a builder that accepts them; one that does not answers `400`. + +## Builders outside your config {#builders-outside-your-config} + +A key's builder config may name a builder you have not added as a relay entry. Commit-Boost dials that builder anyway: a hostname at `https://` on the default port, and a URL at its scheme, host and port. It answers `400` instead when the host does not resolve, or resolves to any address that is not public unicast, such as a loopback, private, link-local or `100.64.0.0/10` address, so a builder on your own network has to be a relay entry. These requests connect directly, ignoring `HTTP_PROXY`, `HTTPS_PROXY` and `ALL_PROXY`, follow no redirects and do not carry your relay `headers`. A builder reached this way gets the signed block over gossip, not from Commit-Boost. Neither `[[relays]]` nor a mux's relays limit which builders a key can reach this way, and anyone who can reach Commit-Boost's port can make it dial a public host, so keep that port private. Commit-Boost looks up at most 32 of these hosts at once and answers further requests as if the builder had not responded. + +A key without builder config sends Commit-Boost's own hostname as its `auth_data`, so it gets no bids. Commit-Boost answers `400` when that hostname resolves to a loopback or private address, as it usually does, and otherwise dials `https://` on port 443, where Commit-Boost itself does not listen. Commit-Boost never dials for a request that came from another Commit-Boost, so chained Commit-Boosts route only through relay entries. ## Timing @@ -88,8 +98,8 @@ With [metrics](./running/metrics.md) enabled, the ePBS endpoints use the `endpoi | Question | Series | |---|---| -| What did Commit-Boost answer the beacon node? | `cb_pbs_beacon_node_status_code_total`: `200` or `204` for a bid request, `202` for preferences and the signed block, `4xx` for a rejected request, `500` when no builder accepted preferences or the signed block | -| What did each builder answer? | `cb_pbs_relay_status_code_total`, by `relay_id`: the builder's HTTP status, or `555` when no response arrived (timeout, DNS or connection failure) | +| What did Commit-Boost answer the beacon node? | `cb_pbs_beacon_node_status_code_total`: `200` or `204` for a bid request, `202` for preferences, `202` for the signed block whatever the builders answer, `4xx` for a rejected request, `500` when no builder accepted the preferences | +| What did each builder answer? | `cb_pbs_relay_status_code_total`, by `relay_id`: the builder's HTTP status, or `555` when no response arrived (timeout, DNS or connection failure). Requests to builders outside your config count under `relay_id="dial"` | | How fast are builders? | `cb_pbs_relay_latency`, by `relay_id` | | What are builders bidding? | `cb_pbs_relay_header_value` (the bid's `value` in Gwei, without the execution payment) and `cb_pbs_relay_last_slot`, by `relay_id`, from each bid a builder serves | @@ -97,7 +107,9 @@ With [metrics](./running/metrics.md) enabled, the ePBS endpoints use the `endpoi | Symptom | Cause | Fix | |---|---|---| -| Bid requests get `400` "auth.message.data does not match any configured builder" | The auth data matches no builder serving the key. Usually the key has no builder config, so its auth data is Commit-Boost's own hostname | Write the key's [builder config](#validator-builder-config), or add the builder it names as a relay entry for the key | -| A builder answers bid requests with `400` (`cb_pbs_relay_status_code_total{endpoint="get_execution_payload_bid",http_status_code="400"}`) | The builder compares auth data byte for byte and expects something other than its hostname | Have the builder accept its hostname, the only auth data Commit-Boost [routes by](#routing-by-auth-data) | +| Bid requests get `400` "auth.message.data does not match any configured builder" | The auth data is neither a hostname nor an `http(s)` URL, or the request came from another Commit-Boost and matches none of this one's relay entries | Correct the `auth_data` in the key's [builder config](#validator-builder-config), or add the builder as a relay entry on the Commit-Boost the request reaches | +| Bid requests get `400` "the addressed builder's host does not resolve or resolves to a disallowed address" | The auth data names a builder outside your config whose host does not resolve or resolves to an [internal address](#builders-outside-your-config). A key without builder config sends Commit-Boost's own hostname, which usually does | Add the builder as a relay entry for the key, or write or correct the key's [builder config](#validator-builder-config) | +| A builder answers bid requests with `400` (`cb_pbs_relay_status_code_total{endpoint="get_execution_payload_bid",http_status_code="400"}`) | The builder compares auth data byte for byte and expects something other than what the key sends, such as its hostname without `?` parameters | Send the auth data the builder expects: its hostname or its URL, the forms Commit-Boost [routes by](#routing-by-auth-data), with `?` parameters only if it accepts them | | The signed block gets `415` | The beacon node sends it as JSON | Configure the beacon node to send SSZ | +| Commit-Boost logs "no builder accepted the signed beacon block" | The winning bid came from a builder outside your config, which gets the block over gossip, or your builders did not accept it. The beacon node gossips the block either way | If the bid came from a configured builder, check its answer in `cb_pbs_relay_status_code_total{endpoint="submit_signed_beacon_block"}` | | Commit-Boost returns `200` but the beacon node builds locally | The beacon node rejected the bid, or valued its local block higher after `builder_boost_factor` | Compare the bid with the key's `min_bid` and `builder_boost_factor` | 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/src/mock_relay.rs b/tests/src/mock_relay.rs index a05dcbeab..3d679d6fb 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -127,6 +127,11 @@ pub struct MockRelayState { trustless_bid_gwei: u64, // default 10 /// When true, an SSZ bid is served without `Eth-Consensus-Version` epbs_omit_consensus_version: bool, + /// When true, a JSON bid is pretty-printed, so it differs from the compact + /// form a re-encode produces + pretty_json_bid: bool, + /// The body of the last bid served + served_bid: RwLock>, } impl MockRelayState { @@ -184,6 +189,9 @@ impl MockRelayState { pub fn received_auth_data(&self) -> Option> { self.received_auth.read().unwrap().as_ref().map(|a| a.message.data.to_vec()) } + pub fn served_bid(&self) -> Option { + self.served_bid.read().unwrap().clone() + } pub fn large_body(&self) -> bool { self.large_body } @@ -246,6 +254,8 @@ impl MockRelayState { api_keys_seen: RwLock::new(HashMap::new()), trustless_bid_gwei: 10, epbs_omit_consensus_version: false, + pretty_json_bid: false, + served_bid: RwLock::new(None), supported_content_types: Arc::new( [EncodingType::Json, EncodingType::Ssz].iter().cloned().collect(), ), @@ -323,6 +333,10 @@ impl MockRelayState { pub fn with_epbs_omit_consensus_version(self) -> Self { Self { epbs_omit_consensus_version: true, ..self } } + + pub fn with_pretty_json_bid(self) -> Self { + Self { pretty_json_bid: true, ..self } + } } pub fn mock_relay_app_router(state: Arc) -> Router { @@ -419,6 +433,10 @@ async fn handle_get_execution_payload_bid( // 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() { + // An error body over PBS's 1 KiB read cap + if state.large_body() { + return (status, "x".repeat(2048)).into_response(); + } return status.into_response(); } @@ -477,9 +495,15 @@ async fn handle_get_execution_payload_bid( data, metadata: Default::default(), }; - serde_json::to_vec(&versioned).unwrap() + if state.pretty_json_bid { + serde_json::to_vec_pretty(&versioned).unwrap() + } else { + serde_json::to_vec(&versioned).unwrap() + } } }; + let response_body = axum::body::Bytes::from(response_body); + *state.served_bid.write().unwrap() = Some(response_body.clone()); let mut response = (StatusCode::OK, response_body).into_response(); // A real builder tags the 200 with the fork so a client can decode the diff --git a/tests/src/mock_validator.rs b/tests/src/mock_validator.rs index 2059a2456..c433d322f 100644 --- a/tests/src/mock_validator.rs +++ b/tests/src/mock_validator.rs @@ -30,7 +30,10 @@ impl MockValidator { let pubkey = bls_pubkey_from_hex( "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", )?; - Ok(Self { comm_boost: generate_mock_relay(port, pubkey)? }) + let mut comm_boost = generate_mock_relay(port, pubkey)?; + // Like a beacon node, without X-CommitBoost-Version, which stops a dial + comm_boost.client = reqwest::Client::new(); + Ok(Self { comm_boost }) } pub async fn do_get_header( diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 0cc4fb2fa..561d11300 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -4,7 +4,7 @@ use alloy::primitives::{B256, U256}; use cb_common::{ config::RuntimeMuxConfig, pbs::{ - GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, + GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, RelayClient, SignedBuilderRequestAuth, SignedExecutionPayloadBid, }, signer::random_secret, @@ -121,9 +121,8 @@ async fn test_get_execution_payload_bid_shared_auth_data_asks_the_first() -> Res } /// The spec default auth data, the builder's hostname, routes to the relay on -/// that host, and the auth reaches the relay unchanged. A hostname no relay -/// has is a 400 with the builder's data-mismatch message, and no relay is -/// contacted. +/// that host, and the auth reaches the relay unchanged. An unconfigured +/// `localhost` resolves to loopback, so it is refused with 400 and not dialed. #[tokio::test] async fn test_get_execution_payload_bid_demux_by_hostname() -> Result<()> { let chain = Chain::Hoodi; @@ -148,11 +147,40 @@ async fn test_get_execution_payload_bid_demux_by_hostname() -> Result<()> { assert_eq!(body["code"], 400); assert_eq!( body["message"], - "Invalid SignedBuilderRequestAuth: auth.message.data does not match any configured builder" + "Invalid SignedBuilderRequestAuth: the addressed builder's host does not resolve or resolves to a disallowed address" ); Ok(()) } +/// Auth data that matches no relay entry is dialed, and the builder gets the +/// auth, `?` parameters included, unchanged. A request from a Commit-Boost is +/// not dialed, though a configured relay still serves it. +#[tokio::test] +async fn test_get_execution_payload_bid_dial() -> Result<()> { + // The mock builder listens on an address the dial check refuses + cb_pbs::set_skip_dial_target_check(true); + let chain = Chain::Hoodi; + let (mut mock_validator, cfg_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + let (dial_state, dial_port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + let dial_auth_data = format!("http://0.0.0.0:{dial_port}/?ofac=1").into_bytes(); + + let res = get_json_bid(&mock_validator, &opaque_auth(&dial_auth_data, TEST_SLOT)).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(dial_state.received_auth_data(), Some(dial_auth_data.clone())); + + // RelayClient::new's client sends the Commit-Boost version header + mock_validator.comm_boost = + RelayClient::new(mock_validator.comm_boost.config.as_ref().clone())?; + let res = get_json_bid(&mock_validator, &opaque_auth(&dial_auth_data, TEST_SLOT)).await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let res = get_json_bid(&mock_validator, &opaque_auth(TEST_AUTH_DATA, TEST_SLOT)).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(dial_state.received_execution_payload_bid(), 1); + assert_eq!(cfg_state.received_execution_payload_bid(), 1); + Ok(()) +} + /// A key listed in a `[[mux]]` is served by that mux's relays: its bid request /// reaches the mux relay and not the default `[[relays]]` one. #[tokio::test] @@ -274,7 +302,8 @@ async fn test_get_execution_payload_bid_rejected_before_relays() -> Result<()> { } /// 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. +/// request: CB returns 204 rather than calling a relay it cannot beat, or +/// looking up a dial target (`localhost` would be refused with 400). #[tokio::test] async fn test_get_execution_payload_bid_expired_deadline_204() -> Result<()> { let (mock_validator, mock_state) = @@ -285,18 +314,20 @@ async fn test_get_execution_payload_bid_expired_deadline_204() -> Result<()> { &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") - .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) - .body(opaque_auth(TEST_AUTH_DATA, TEST_SLOT).as_ssz_bytes()) - .send() - .await?; - assert_eq!(res.status(), StatusCode::NO_CONTENT); + for auth_data in [TEST_AUTH_DATA, b"localhost"] { + let res = mock_validator + .comm_boost + .client + .post(url.clone()) + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms() - 5_000) + .header(HEADER_TIMEOUT_MS, 1_000u64) + .header("Eth-Consensus-Version", "gloas") + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(opaque_auth(auth_data, 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(()) } @@ -384,6 +415,22 @@ async fn test_get_execution_payload_bid_response_encoding() -> Result<()> { Ok(()) } +/// A beacon node that asks for the relay's encoding gets the relay's body byte +/// for byte; a re-encode would drop the pretty-printing +#[tokio::test] +async fn test_get_execution_payload_bid_json_passthrough() -> Result<()> { + let chain = Chain::Hoodi; + let relay = MockRelayState::new(chain, random_secret()) + .with_json_only_response() + .with_pretty_json_bid(); + let (mock_validator, states) = setup_relays(chain, vec![relay]).await?; + + let res = get_json_bid(&mock_validator, &opaque_auth(TEST_AUTH_DATA, TEST_SLOT)).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(Some(res.bytes().await?), states[0].served_bid()); + Ok(()) +} + /// An SSZ bid without `Eth-Consensus-Version` cannot be forwarded, since CB's /// 200 must name the fork: that relay contributes no bid, so the request is a /// 204. The relay is still contacted, so the 204 proves the drop. @@ -421,6 +468,21 @@ async fn test_get_execution_payload_bid_relay_status() -> Result<()> { Ok(()) } +/// An error body over PBS's 1 KiB read cap still reports the builder's status. +#[tokio::test] +async fn test_get_execution_payload_bid_large_error_body() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = + setup_relays(chain, vec![MockRelayState::new(chain, random_secret()).with_large_body()]) + .await?; + for status in [StatusCode::BAD_REQUEST, StatusCode::UNAUTHORIZED] { + states[0].set_response_override(status); + let res = get_json_bid(&mock_validator, &opaque_auth(TEST_AUTH_DATA, TEST_SLOT)).await?; + assert_eq!(res.status(), status); + } + Ok(()) +} + /// A relay slower than the proposer's `X-Timeout-Ms` is dropped, so the request /// degrades to 204 rather than waiting the relay out. #[tokio::test] @@ -482,3 +544,22 @@ async fn test_get_execution_payload_bid_relay_timing_headers() -> Result<()> { assert!(0 < timeout_ms && timeout_ms <= 11_900, "relay saw X-Timeout-Ms {timeout_ms}"); Ok(()) } + +/// Auth data naming Commit-Boost's own URL is dialed once, and that hop, a +/// request from a Commit-Boost, is not dialed on: the builder's 400 +#[tokio::test] +async fn test_get_execution_payload_bid_dial_self_400() -> Result<()> { + // As if Commit-Boost's own address were public + cb_pbs::set_skip_dial_target_check(true); + let (mock_validator, cfg_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + let own = &mock_validator.comm_boost.config.entry.url; + let own = format!("http://{}:{}/", own.host_str().unwrap(), own.port().unwrap()); + + let res = get_json_bid(&mock_validator, &opaque_auth(own.as_bytes(), TEST_SLOT)).await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["message"], "The addressed builder rejected the request with status 400"); + assert_eq!(cfg_state.received_execution_payload_bid(), 0); + Ok(()) +} diff --git a/tests/tests/pbs_submit_builder_preferences.rs b/tests/tests/pbs_submit_builder_preferences.rs index b1079e7e4..302fece01 100644 --- a/tests/tests/pbs_submit_builder_preferences.rs +++ b/tests/tests/pbs_submit_builder_preferences.rs @@ -9,7 +9,7 @@ use cb_tests::{ mock_relay::MockRelayState, utils::{ TEST_AUTH_DATA, generate_mock_relay, opaque_auth, setup_relay, setup_relays, - setup_relays_on_hosts, + setup_relays_on_hosts, spawn_mock_relay, }, }; use eyre::Result; @@ -173,10 +173,9 @@ async fn test_submit_builder_preferences_past_slot_forwarded() -> Result<()> { Ok(()) } -/// Auth data matching no relay's hostname is a 400 with the builder's -/// data-mismatch message: unmatched means CB has no builder to proxy to, and -/// proposer-private preferences must never broadcast to builders the proposer -/// did not address. +/// Auth data that is neither a hostname nor a URL is a 400 with the builder's +/// data-mismatch message: 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_auth_data_400() -> Result<()> { let chain = Chain::Hoodi; @@ -270,3 +269,23 @@ async fn test_submit_builder_preferences_two_relays_addressed_one_only() -> Resu assert_eq!(states[1].received_builder_preferences(), 1, "the addressed builder is asked"); Ok(()) } + +/// Preferences whose auth data names a builder outside the config are sent +/// to it by a dial and accepted with its 202 +#[tokio::test] +async fn test_submit_builder_preferences_dial() -> Result<()> { + // The mock builder listens on an address the dial check refuses + cb_pbs::set_skip_dial_target_check(true); + let chain = Chain::Hoodi; + let (mock_validator, _) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + let (dial_state, dial_port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + + let auth = opaque_auth(format!("http://0.0.0.0:{dial_port}/").as_bytes(), TEST_SLOT); + 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!(dial_state.received_builder_preferences(), 1); + Ok(()) +} diff --git a/tests/tests/pbs_submit_signed_beacon_block.rs b/tests/tests/pbs_submit_signed_beacon_block.rs index a5e474a16..2e05c2e3e 100644 --- a/tests/tests/pbs_submit_signed_beacon_block.rs +++ b/tests/tests/pbs_submit_signed_beacon_block.rs @@ -56,18 +56,16 @@ async fn test_submit_signed_beacon_block_broadcasts_to_all_relays() -> Result<() Ok(()) } -/// The block is broadcast; if every builder rejects it, PBS maps the all-reject -/// outcome to a 500 (no builder accepted). +/// Every builder rejecting the block is still a 202: the beacon node gossips +/// the block anyway, so a builder's answer is not a failure. Each is asked. #[tokio::test] -async fn test_submit_signed_beacon_block_broadcast_all_reject_500() -> Result<()> { +async fn test_submit_signed_beacon_block_broadcast_all_reject_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?; - - // Make every builder reject the broadcast for state in &states { state.set_response_override(StatusCode::INTERNAL_SERVER_ERROR); } @@ -75,36 +73,7 @@ async fn test_submit_signed_beacon_block_broadcast_all_reject_500() -> Result<() let block = gloas_block(TEST_SLOT); 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); - 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!(res.status(), StatusCode::ACCEPTED); 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(()) From 867fd19b52eb7ab9a846096d044622de6450cd46 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Wed, 7 Oct 2026 11:30:27 -0700 Subject: [PATCH 08/10] normalize gloas version header --- crates/common/src/wire.rs | 2 ++ .../pbs/src/routes/execution_payload_bid.rs | 19 ++++----------- crates/pbs/src/utils.rs | 10 ++++---- tests/src/mock_relay.rs | 8 +++++++ tests/tests/pbs_get_execution_payload_bid.rs | 24 +++++++++++++++++++ 5 files changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index 48f3be15a..4355c3da6 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -280,6 +280,8 @@ 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"); +pub static GLOAS_CONSENSUS_VERSION: HeaderValue = HeaderValue::from_static("gloas"); + pub fn get_content_type(req_headers: &HeaderMap) -> EncodingType { EncodingType::from_str( req_headers diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs index 1385ea3c5..9e1b9e369 100644 --- a/crates/pbs/src/routes/execution_payload_bid.rs +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -14,7 +14,7 @@ use cb_common::{ }, utils::{ms_into_slot, utcnow_ms}, wire::{ - CONSENSUS_VERSION_HEADER, EncodingType, OUTBOUND_ACCEPT_SSZ_FIRST, + CONSENSUS_VERSION_HEADER, EncodingType, GLOAS_CONSENSUS_VERSION, OUTBOUND_ACCEPT_SSZ_FIRST, decode_versioned_request_body, get_accept_types, get_user_agent, parse_response_encoding_and_fork, safe_read_http_response, }, @@ -62,14 +62,10 @@ pub async fn handle_get_execution_payload_bid( info!(ua, ms_into_slot, "new request"); - let version = req_headers.get(CONSENSUS_VERSION_HEADER).cloned(); match get_execution_payload_bid(params, auth, req_headers, state).await { - Ok(Some(bid)) => Ok(encode_bid_response( - bid, - response_encoding, - version, - GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, - )), + Ok(Some(bid)) => { + Ok(encode_bid_response(bid, response_encoding, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG)) + } Ok(None) => { info!("no bid for slot"); record_beacon_status("204", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG); @@ -87,12 +83,9 @@ pub(crate) struct RelayBid { encoding: EncodingType, } -/// `version` is the beacon node's own `Eth-Consensus-Version`: the 200 names -/// the fork it asked for fn encode_bid_response( RelayBid { bid, body, encoding }: RelayBid, response_encoding: EncodingType, - version: Option, endpoint: &str, ) -> Response { let message = &bid.data.message; @@ -111,9 +104,7 @@ fn encode_bid_response( EncodingType::Ssz => (content_type, bid.data.as_ssz_bytes()).into_response(), EncodingType::Json => axum::Json(bid).into_response(), }; - if let Some(version) = version { - res.headers_mut().insert(CONSENSUS_VERSION_HEADER, version); - } + res.headers_mut().insert(CONSENSUS_VERSION_HEADER, GLOAS_CONSENSUS_VERSION.clone()); res } diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 77c03765d..023dc6bb0 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -9,8 +9,8 @@ use cb_common::{ pbs::{RelayClient, error::PbsError}, types::BlsPublicKey, wire::{ - CONSENSUS_VERSION_HEADER, EncodingType, get_user_agent_with_version, - read_chunked_body_with_max, + CONSENSUS_VERSION_HEADER, EncodingType, GLOAS_CONSENSUS_VERSION, + get_user_agent_with_version, read_chunked_body_with_max, }, }; use futures::future::join_all; @@ -153,16 +153,14 @@ pub(crate) fn builder_rejection(err: &PbsError) -> Option { } /// Headers every ePBS relay request carries: the versioned `User-Agent`, and -/// the beacon node's `Eth-Consensus-Version`, which the route has validated +/// `Eth-Consensus-Version`, which the route has validated as Gloas 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)?, ); - if let Some(version) = req_headers.get(CONSENSUS_VERSION_HEADER) { - headers.insert(CONSENSUS_VERSION_HEADER, version.clone()); - } + headers.insert(CONSENSUS_VERSION_HEADER, GLOAS_CONSENSUS_VERSION.clone()); Ok(headers) } diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index a05dcbeab..5826a1d8b 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -115,6 +115,8 @@ pub struct MockRelayState { received_auth: RwLock>, /// The `X-Timeout-Ms` of the last bid request received_bid_timeout_ms: RwLock>, + /// The raw `Eth-Consensus-Version` of the last bid request + received_bid_consensus_version: RwLock>, response_override: RwLock>, bid_value: RwLock, /// The raw `Accept` header PBS sent on the most recent get_header request, @@ -181,6 +183,9 @@ impl MockRelayState { pub fn received_bid_timeout_ms(&self) -> Option { *self.received_bid_timeout_ms.read().unwrap() } + pub fn received_bid_consensus_version(&self) -> Option { + self.received_bid_consensus_version.read().unwrap().clone() + } pub fn received_auth_data(&self) -> Option> { self.received_auth.read().unwrap().as_ref().map(|a| a.message.data.to_vec()) } @@ -240,6 +245,7 @@ impl MockRelayState { bid_delay_ms: None, received_auth: RwLock::new(None), received_bid_timeout_ms: RwLock::new(None), + received_bid_consensus_version: RwLock::new(None), response_override: RwLock::new(None), bid_value: RwLock::new(U256::from(10)), received_get_header_accept: RwLock::new(None), @@ -391,6 +397,8 @@ async fn handle_get_execution_payload_bid( .into_response(); }; *state.received_bid_timeout_ms.write().unwrap() = Some(timeout_ms); + *state.received_bid_consensus_version.write().unwrap() = + headers.get(CONSENSUS_VERSION_HEADER).and_then(|v| v.to_str().ok()).map(str::to_owned); // Decode the request auth the way a real builder does: Content-Type // selects JSON vs SSZ. The wire type is fork-versioned per builder-specs, diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs index 0cc4fb2fa..3eca13e22 100644 --- a/tests/tests/pbs_get_execution_payload_bid.rs +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -325,6 +325,30 @@ async fn test_get_execution_payload_bid_spec_url() -> Result<()> { Ok(()) } +/// The beacon node's `Eth-Consensus-Version` parses in any case, and CB sends +/// the spec's lowercase name on to the relay and back in the 200 +#[tokio::test] +async fn test_get_execution_payload_bid_sends_the_spec_consensus_version() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + let res = mock_validator + .comm_boost + .client + .post(bid_url(&mock_validator)) + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .header(CONSENSUS_VERSION_HEADER, "GLOAS") + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(opaque_auth(TEST_AUTH_DATA, TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::OK); + let echoed = res.headers().get(CONSENSUS_VERSION_HEADER).and_then(|v| v.to_str().ok()); + assert_eq!(echoed, Some("gloas")); + assert_eq!(mock_state.received_bid_consensus_version().as_deref(), Some("gloas")); + Ok(()) +} + /// The response encoding follows the caller's Accept and defaults to JSON when /// none is sent (builder-specs), and the 200 carries Eth-Consensus-Version /// either way. Default relays answer in SSZ; the JSON-only relays cover the From cefa60f3168fd8fed691e955fff2b9adfbff9027 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Wed, 7 Oct 2026 15:34:47 -0700 Subject: [PATCH 09/10] fix(pbs): require an ePBS deadline buffer above 0 proposer_deadline_buffer_ms must now be greater than 0 as well as less than one slot. With 0 the builder gets the whole deadline, so a bid it sends at the deadline reaches the beacon node late. The range is one check in PbsConfig::validate, so the service, a hot reload and a custom PBS module's loader all refuse it. The test fixtures use the default, 50. The metrics catalog documents the ePBS endpoint values, the codes the ePBS endpoints return to the beacon node, the relay_id="dial" label, and that ePBS bids set the relay gauges. The ePBS page corrects two builder config bullets: a top-level min_bid does not floor an entry that sets its own, and the per-client max_execution_payment defaults are dropped. A builder Commit-Boost refuses to dial is not counted under relay_id="dial". --- config.example.toml | 2 +- crates/common/src/config/pbs.rs | 45 ++++++++++++++++--- docs/docs/get_started/epbs.md | 11 ++--- .../get_started/running/metrics-catalog.md | 12 ++--- tests/src/utils.rs | 2 +- tests/tests/pbs_cfg_file_update.rs | 2 +- 6 files changed, 55 insertions(+), 19 deletions(-) diff --git a/config.example.toml b/config.example.toml index 4ce750cb9..1c6354e60 100644 --- a/config.example.toml +++ b/config.example.toml @@ -31,7 +31,7 @@ wait_all_registrations = true # this should be lower than that, leaving some margin for overhead # OPTIONAL, DEFAULT: 950 timeout_get_header_ms = 950 -# (unreleased, from v0.12.0-rc1) ePBS bids only: ms kept back from the beacon node's X-Timeout-Ms deadline, must be under one slot, 12000 on mainnet (https://commit-boost.github.io/commit-boost-client/get_started/epbs#timing) +# (unreleased, from v0.12.0-rc1) ePBS bids only: ms kept back from the beacon node's X-Timeout-Ms deadline, must be above 0 and under one slot, 12000 on mainnet (https://commit-boost.github.io/commit-boost-client/get_started/epbs#timing) # OPTIONAL, DEFAULT: 50 # proposer_deadline_buffer_ms = 50 # Timeout in milliseconds for the `submit_blinded_block` call to relays. diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 22f7a1eda..294c5b535 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -250,13 +250,12 @@ impl PbsConfig { ); ensure!(self.late_in_slot_time_ms > 0, "late_in_slot_time_ms must be greater than 0"); - // The buffer comes out of the proposer's deadline, which is clamped to - // one slot, so a buffer of a slot or more leaves no time for the bid - // request. 0 is allowed (no reserve). + // 0 leaves the bid no time to get back to the beacon node, and a slot or + // more leaves the builder none, as the proposer's deadline is clamped to a slot let slot_time_ms = chain.slot_time_sec().saturating_mul(1000); ensure!( - self.proposer_deadline_buffer_ms < slot_time_ms, - "proposer_deadline_buffer_ms must be less than one slot ({slot_time_ms} ms)" + (1..slot_time_ms).contains(&self.proposer_deadline_buffer_ms), + "proposer_deadline_buffer_ms must be greater than 0 and less than one slot ({slot_time_ms} ms)" ); ensure!( @@ -557,6 +556,42 @@ mod tests { use super::*; use crate::config::test_env::{RELAY_URL, with_env}; + fn config_with_buffer(buffer: u64) -> String { + format!( + "chain = \"Holesky\"\n[pbs]\nproposer_deadline_buffer_ms = {buffer}\n\ + [[relays]]\nurl = \"{RELAY_URL}\"\n" + ) + } + + #[tokio::test] + async fn test_deadline_buffer_range() { + for (buffer, accepted) in [(0, false), (1, true), (11999, true), (12000, false)] { + let config: CommitBoostConfig = toml::from_str(&config_with_buffer(buffer)).unwrap(); + assert_eq!(config.validate().await.is_ok(), accepted, "{buffer}"); + } + } + + // The service and a custom PBS module load [pbs] through different functions + #[test] + fn test_loaders_reject_zero_deadline_buffer() { + #[derive(Deserialize)] + struct NoExtra {} + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cb-config.toml"); + std::fs::write(&path, config_with_buffer(0)).unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + let service = runtime.block_on(load_pbs_config(Some(path.clone()))).map(|_| ()); + let custom = with_env(&[(CONFIG_ENV, path.to_str())], || { + runtime.block_on(load_pbs_custom_config::()).map(|_| ()) + }); + for result in [service, custom] { + let err = result.expect_err("a zero deadline buffer loaded"); + let expected = + "proposer_deadline_buffer_ms must be greater than 0 and less than one slot"; + assert!(format!("{err:#}").contains(expected), "{err:#}"); + } + } + fn relay_with_headers(headers: &str) -> Result { toml::from_str(&format!("url = \"{RELAY_URL}\"\nheaders = {headers}\n")) } diff --git a/docs/docs/get_started/epbs.md b/docs/docs/get_started/epbs.md index df2c577d3..1c14de6c8 100644 --- a/docs/docs/get_started/epbs.md +++ b/docs/docs/get_started/epbs.md @@ -39,7 +39,7 @@ To go through Commit-Boost, every entry's `url` is Commit-Boost's own URL, and i ### 1. Add the builders to Commit-Boost -List each builder as a relay entry, as for PBS: in `[[relays]]`, or in the `[[mux.relays]]` of the mux that lists the proposer's key. ePBS adds one option, `[pbs] proposer_deadline_buffer_ms` (default `50`): the time kept back from the beacon node's deadline (see [Timing](#timing)). It must be under one slot, `12000` on mainnet. +List each builder as a relay entry, as for PBS: in `[[relays]]`, or in the `[[mux.relays]]` of the mux that lists the proposer's key. ePBS adds one option, `[pbs] proposer_deadline_buffer_ms` (default `50`): the time kept back from the beacon node's deadline (see [Timing](#timing)). It must be above `0` and under one slot, `12000` on mainnet. ### 2. Point each validator key at Commit-Boost {#validator-builder-config} @@ -66,8 +66,8 @@ curl -X POST "$KEYMANAGER_URL/eth/v1/validator/$PUBKEY/builder_config" \ The call is `POST /eth/v1/validator/{pubkey}/builder_config`, authenticated with the keymanager API's bearer token. The body replaces the key's config in full, and the validator client answers `202` once it is stored. Besides `url` and `auth_data`, an entry or the top level can set: -- `min_bid` and `builder_boost_factor` are optional. At the top level they apply to every entry, and to p2p bids, unless an entry sets its own. A top-level `min_bid` also floors the bids that come through Commit-Boost, so a value above your builders' bids sends every proposal to p2p bids or a local block. -- `max_execution_payment`, when an entry omits it, gets the validator client's default, which differs between clients (Lodestar stores `0`, Nimbus the maximum). Lodestar accepts a nonzero value only when started with `--allowDangerousTrustedPayments`. +- `min_bid` and `builder_boost_factor` are optional. At the top level they apply to p2p bids and to every entry that does not set its own, so a top-level `min_bid` above your builders' bids sends every proposal to a p2p bid or a local block, unless an entry sets a lower one of its own. +- `max_execution_payment`, when an entry omits it, gets the validator client's default, which differs between clients and can be `0`, so no execution payment counts. Lodestar accepts a nonzero value only when started with `--allowDangerousTrustedPayments`. - `builder_pubkeys` limits which builder keys' bids the beacon node accepts; leave it empty to accept any. Don't copy the pubkey from the relay URL: that is the relay's key, not the builder's bid-signing key. ## Routing by auth data @@ -88,7 +88,7 @@ A key without builder config sends Commit-Boost's own hostname as its `auth_data The builder specs require each bid request to carry `Date-Milliseconds` and `X-Timeout-Ms`, which together say how long the beacon node will wait for a bid. Commit-Boost subtracts your `proposer_deadline_buffer_ms` from the time remaining and sends the result to the builder as its `X-Timeout-Ms`, so the builder knows how long it has to answer. -Think of `proposer_deadline_buffer_ms` as the slack you keep from the beacon node's remaining time: enough for the bid to get back from Commit-Boost to the beacon node and for the beacon node to process it. If no time is left, Commit-Boost does not ask the builder. +Think of `proposer_deadline_buffer_ms` as the slack you keep from the beacon node's remaining time: enough for the bid to get back from Commit-Boost to the beacon node and for the beacon node to process it. If no time is left, Commit-Boost does not ask the builder. With `0`, Commit-Boost refuses to start or reload: the builder gets the whole deadline, so a bid it sends at the deadline reaches the beacon node late. Builder preferences use `timeout_register_validator_ms`, and the signed block `timeout_get_payload_ms`. @@ -99,7 +99,7 @@ With [metrics](./running/metrics.md) enabled, the ePBS endpoints use the `endpoi | Question | Series | |---|---| | What did Commit-Boost answer the beacon node? | `cb_pbs_beacon_node_status_code_total`: `200` or `204` for a bid request, `202` for preferences, `202` for the signed block whatever the builders answer, `4xx` for a rejected request, `500` when no builder accepted the preferences | -| What did each builder answer? | `cb_pbs_relay_status_code_total`, by `relay_id`: the builder's HTTP status, or `555` when no response arrived (timeout, DNS or connection failure). Requests to builders outside your config count under `relay_id="dial"` | +| What did each builder answer? | `cb_pbs_relay_status_code_total`, by `relay_id`: the builder's HTTP status, or `555` when no response arrived (timeout, DNS or connection failure). Requests to builders outside your config count under `relay_id="dial"`, except one Commit-Boost [refuses to dial](#builders-outside-your-config), which gets no request | | How fast are builders? | `cb_pbs_relay_latency`, by `relay_id` | | What are builders bidding? | `cb_pbs_relay_header_value` (the bid's `value` in Gwei, without the execution payment) and `cb_pbs_relay_last_slot`, by `relay_id`, from each bid a builder serves | @@ -110,6 +110,7 @@ With [metrics](./running/metrics.md) enabled, the ePBS endpoints use the `endpoi | Bid requests get `400` "auth.message.data does not match any configured builder" | The auth data is neither a hostname nor an `http(s)` URL, or the request came from another Commit-Boost and matches none of this one's relay entries | Correct the `auth_data` in the key's [builder config](#validator-builder-config), or add the builder as a relay entry on the Commit-Boost the request reaches | | Bid requests get `400` "the addressed builder's host does not resolve or resolves to a disallowed address" | The auth data names a builder outside your config whose host does not resolve or resolves to an [internal address](#builders-outside-your-config). A key without builder config sends Commit-Boost's own hostname, which usually does | Add the builder as a relay entry for the key, or write or correct the key's [builder config](#validator-builder-config) | | A builder answers bid requests with `400` (`cb_pbs_relay_status_code_total{endpoint="get_execution_payload_bid",http_status_code="400"}`) | The builder compares auth data byte for byte and expects something other than what the key sends, such as its hostname without `?` parameters | Send the auth data the builder expects: its hostname or its URL, the forms Commit-Boost [routes by](#routing-by-auth-data), with `?` parameters only if it accepts them | +| Commit-Boost does not start or reload, or `commit-boost init` fails, with "proposer_deadline_buffer_ms must be greater than 0 and less than one slot" | `proposer_deadline_buffer_ms` is `0`, or one slot or more | Set it above `0` and under one slot, or remove it to use the default `50` | | The signed block gets `415` | The beacon node sends it as JSON | Configure the beacon node to send SSZ | | Commit-Boost logs "no builder accepted the signed beacon block" | The winning bid came from a builder outside your config, which gets the block over gossip, or your builders did not accept it. The beacon node gossips the block either way | If the bid came from a configured builder, check its answer in `cb_pbs_relay_status_code_total{endpoint="submit_signed_beacon_block"}` | | Commit-Boost returns `200` but the beacon node builds locally | The beacon node rejected the bid, or valued its local block higher after `builder_boost_factor` | Compare the bid with the key's `min_bid` and `builder_boost_factor` | diff --git a/docs/docs/get_started/running/metrics-catalog.md b/docs/docs/get_started/running/metrics-catalog.md index fe44cc3fc..4db35ea41 100644 --- a/docs/docs/get_started/running/metrics-catalog.md +++ b/docs/docs/get_started/running/metrics-catalog.md @@ -10,19 +10,19 @@ Every metric emitted by the Commit-Boost PBS and Signer services, together with ## PBS metrics -PBS metrics use a custom Prometheus registry with namespace prefix `cb_pbs_`. The registry is created via `Registry::new_custom(Some("cb_pbs"), None)` in `crates/pbs/src/metrics.rs`. All wire names shown below include this prefix. +PBS metrics use a custom Prometheus registry with namespace prefix `cb_pbs_`. The registry is created via `Registry::new_custom(Some("cb_pbs"), None)` in `crates/pbs/src/metrics.rs`. All wire names shown below include this prefix. (unreleased, from v0.12.0-rc1) ePBS requests to a builder outside your config are labeled `relay_id="dial"`. | Metric name (wire) | Type | Labels | Description | |---|---|---|---| -| `cb_pbs_relay_status_code_total` | Counter | `http_status_code`, `endpoint`, `relay_id` | HTTP status code received by relay. Incremented once per relay response. Two synthetic codes stand in for outcomes that never reach a status line: `"555"` when the request hit its deadline, and `"556"` for a bid-stream transport failure (connect failed, the stream broke mid-window, or the handshake was answered with a success code instead of the upgrade). Endpoint values: `get_header`, `get_header_stream`, `register_validator`, `submit_blinded_block`, `status`. | -| `cb_pbs_relay_latency` | Histogram | `endpoint`, `relay_id` | HTTP latency (duration in seconds) by relay. Records the duration of relay HTTP requests. Endpoint values: `get_header`, `get_header_stream`, `register_validator`, `submit_blinded_block`, `status`. Under `get_header_stream` the observation is the time from opening the websocket to the first bid update, not a round trip, and a window that received no bid records nothing. | -| `cb_pbs_relay_last_slot` | Gauge | `relay_id` | Latest slot for which a relay delivered a header. Only updated in the `get_header` handler. Set to the current slot on each successful header from that relay. | -| `cb_pbs_relay_header_value` | Gauge | `relay_id` | Header value in gwei delivered by a relay. Converted from raw wei (÷ 1e9) in the `get_header` handler. | +| `cb_pbs_relay_status_code_total` | Counter | `http_status_code`, `endpoint`, `relay_id` | HTTP status code received by relay. Incremented once per relay response. Two synthetic codes stand in for outcomes that never reach a status line: `"555"` when no response arrived (timeout, DNS or connection failure), and `"556"` for a bid-stream transport failure (connect failed, the stream broke mid-window, or the handshake was answered with a success code instead of the upgrade). Endpoint values: `get_header`, `get_header_stream`, `register_validator`, `submit_blinded_block`, `status`, and (unreleased, from v0.12.0-rc1) `get_execution_payload_bid`, `submit_builder_preferences`, `submit_signed_beacon_block`. | +| `cb_pbs_relay_latency` | Histogram | `endpoint`, `relay_id` | HTTP latency (duration in seconds) by relay. Records the duration of relay HTTP requests. Endpoint values: `get_header`, `get_header_stream`, `register_validator`, `submit_blinded_block`, `status`, and (unreleased, from v0.12.0-rc1) `get_execution_payload_bid`, `submit_builder_preferences`, `submit_signed_beacon_block`. Under `get_header_stream` the observation is the time from opening the websocket to the first bid update, not a round trip, and a window that received no bid records nothing. | +| `cb_pbs_relay_last_slot` | Gauge | `relay_id` | Latest slot for which a relay delivered a bid. Set to the current slot on each successful `get_header` bid and (unreleased, from v0.12.0-rc1) each ePBS bid served from that relay. | +| `cb_pbs_relay_header_value` | Gauge | `relay_id` | Value in gwei of the bid a relay delivered: for `get_header`, the bid's value converted from wei (÷ 1e9). (unreleased, from v0.12.0-rc1) For ePBS, the returned bid's `value`, without its execution payment. | | `cb_pbs_relay_stream_connect_latency` | Histogram | `relay_id` | Websocket handshake latency in seconds, for a relay with `get_header = "stream"`. Observed once per successful handshake; a handshake that fails or runs out the bid window records nothing here. Custom buckets from 5 ms to 1 s. | | `cb_pbs_relay_stream_updates` | Histogram | `relay_id` | Bid updates received per stream window. Observed once per `get_header` served by the stream, including windows that received nothing. Buckets: `0, 1, 2, 3, 5, 10, 20, 50, 100`. | | `cb_pbs_relay_stream_invalid_frames_total` | Counter | `relay_id` | Websocket frames that could not be parsed as a bid. Incremented only for windows that saw at least one, so the series is absent while zero. | | `cb_pbs_relay_stream_fallback_total` | Counter | `relay_id` | Stream attempts that fell back to a plain HTTP `get_header`. Only a handshake failure with bid window left to retry counts here; one that consumed the window shows on the status series alone. The fallback's own result is recorded under `endpoint="get_header"`. | -| `cb_pbs_beacon_node_status_code_total` | Counter | `http_status_code`, `endpoint` | HTTP status code returned to the beacon node. Tracks what status codes the PBS returns for beacon node-facing requests. Endpoint values: `get_header`, `register_validator`, `submit_blinded_block`, `status`, `reload`. Error status codes (`502` for `NoResponse`/`NoPayload`, `500` for `Internal`) are set via `PbsClientError`. The handlers also record `406` directly when the request's `Accept` header offers no supported encoding, `204` when no bid is available on `get_header`, and `202` for accepted v2 `submit_blinded_block` requests. | +| `cb_pbs_beacon_node_status_code_total` | Counter | `http_status_code`, `endpoint` | HTTP status code returned to the beacon node. Tracks what status codes the PBS returns for beacon node-facing requests. Endpoint values: `get_header`, `register_validator`, `submit_blinded_block`, `status`, `reload`, and (unreleased, from v0.12.0-rc1) `get_execution_payload_bid`, `submit_builder_preferences`, `submit_signed_beacon_block`. Error status codes (`502` for `NoResponse`/`NoPayload`, `500` for `Internal`) are set via `PbsClientError`. The handlers also record `406` directly when the request's `Accept` header offers no supported encoding, `204` when no bid is available on `get_header`, and `202` for accepted v2 `submit_blinded_block` requests. (unreleased, from v0.12.0-rc1) The ePBS endpoints record `200` for a bid, `204` for none, `202` for accepted preferences and for every signed block it forwards, `400` for a request Commit-Boost refuses, a builder's own `400` or `401`, `406` for a bid request whose `Accept` offers no supported encoding, `415` for an unsupported `Content-Type`, and `500` when no builder accepts the preferences. | | `cb_pbs_pbs_submit_block_v2_unsupported_total` | Counter | `relay_id` | Count of v2 `submit_blinded_block` requests a relay could not serve because it returned 404 on the v2 endpoint. A non-zero value means the relay does not support `submitBlindedBlockV2` and those blocks were not submitted via that relay. The double `pbs` in the wire name comes from the registry prefix plus the metric name `pbs_submit_block_v2_unsupported_total`. | --- diff --git a/tests/src/utils.rs b/tests/src/utils.rs index d3c924166..1839b3c9c 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -138,7 +138,7 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { skip_sigverify: false, min_bid_wei: U256::ZERO, late_in_slot_time_ms: u64::MAX, - proposer_deadline_buffer_ms: 0, + proposer_deadline_buffer_ms: 50, extra_validation_enabled: 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 23d59e94e..c4c02ce17 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -66,7 +66,7 @@ async fn test_cfg_file_update() -> Result<()> { min_bid_wei: U256::ZERO, 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, + proposer_deadline_buffer_ms: 50, extra_validation_enabled: false, rpc_url: None, ssv_node_api_url: Url::parse("http://example.com").unwrap(), From 7524262431f23b4e0051c529776fc6a3e08ff46d Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Thu, 8 Oct 2026 08:24:48 -0700 Subject: [PATCH 10/10] perf(pbs): share one client across dials A client per dial existed only because resolve_to_addrs pins addresses per client. The shared client's resolver now checks every address it resolves, so a dial still connects only to checked addresses, and a second dial to a builder reuses the first one's connection instead of a new TCP and TLS handshake. Dial hosts are chosen by the request, so the client keeps at most one idle connection per host, for one slot. The check before the dial is unchanged, so a blocked target still gets 400 before anything is sent. --- crates/pbs/Cargo.toml | 3 + crates/pbs/src/dial.rs | 205 ++++++++++++++++++++++++++++++++--------- 2 files changed, 162 insertions(+), 46 deletions(-) diff --git a/crates/pbs/Cargo.toml b/crates/pbs/Cargo.toml index 3a52e12ef..7037a566f 100644 --- a/crates/pbs/Cargo.toml +++ b/crates/pbs/Cargo.toml @@ -38,3 +38,6 @@ url.workspace = true uuid.workspace = true webpki-roots.workspace = true thiserror.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/pbs/src/dial.rs b/crates/pbs/src/dial.rs index 6b7fdfdd5..d5a75a007 100644 --- a/crates/pbs/src/dial.rs +++ b/crates/pbs/src/dial.rs @@ -1,6 +1,7 @@ use std::{ + io, net::{IpAddr, Ipv4Addr, SocketAddr, ToSocketAddrs}, - sync::LazyLock, + sync::{Arc, LazyLock, OnceLock}, time::Duration, }; @@ -10,7 +11,8 @@ use cb_common::{ types::BlsPublicKey, utils::bls_pubkey_from_hex, }; -use tokio::sync::Semaphore; +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; +use tokio::{sync::Semaphore, task::JoinHandle}; use tracing::{info, warn}; use url::Url; @@ -36,8 +38,8 @@ pub(crate) fn auth_data_address(data: &[u8]) -> &[u8] { data.iter().position(|&byte| byte == b'?').map_or(data, |end| &data[..end]) } -/// A relay for the builder that an unmatched address names. The target is -/// untrusted, so it is resolved once and dialed only at the vetted addresses. +/// A relay for the builder an unmatched address names, checked before the dial +/// and, for a hostname, again on each new connection pub(crate) async fn dial_relay( data_url: Option, address: &[u8], @@ -47,10 +49,7 @@ pub(crate) async fn dial_relay( let origin = url.origin().ascii_serialization(); let addrs = resolve_dial_target(&url, &origin, lookup_timeout).await?; info!(%origin, ?addrs, "auth data names a builder outside the config, dialing it"); - pinned_relay(url, &addrs).map_err(|err| { - warn!(%err, "failed to build the dial client"); - PbsClientError::Internal - }) + dial_client(url) } /// `data_url`, else `https:///` for the builder-specs default auth @@ -90,15 +89,10 @@ async fn resolve_dial_target( vet_dial_addrs(origin, &[addr])?; return Ok(vec![addr]); } - let Ok(permit) = DIAL_LOOKUPS.try_acquire() else { + let Some(lookup) = spawn_lookup(host_port) else { warn!(%origin, "too many dial lookups in flight, not dialing"); return Err(PbsClientError::NoBuilderResponse); }; - let lookup = tokio::task::spawn_blocking(move || { - // Held until the lookup returns, which a timeout does not stop - let _permit = permit; - host_port.to_socket_addrs().map(Iterator::collect::>) - }); let addrs = match tokio::time::timeout(lookup_timeout, lookup).await { Ok(Ok(Ok(addrs))) => addrs, Ok(Ok(Err(err))) => { @@ -116,6 +110,16 @@ async fn resolve_dial_target( Ok(addrs) } +/// `None` when every lookup slot is taken +fn spawn_lookup(host_port: String) -> Option>>> { + let permit = DIAL_LOOKUPS.try_acquire().ok()?; + Some(tokio::task::spawn_blocking(move || { + // Held until the lookup returns, which a timeout does not stop + let _permit = permit; + host_port.to_socket_addrs().map(Iterator::collect::>) + })) +} + /// Every address is checked, since the dial may connect to any of them fn vet_dial_addrs(origin: &str, addrs: &[SocketAddr]) -> Result<(), PbsClientError> { if dial_target_check_enabled() && addrs.iter().any(|addr| ip_is_disallowed(addr.ip())) { @@ -125,30 +129,75 @@ fn vet_dial_addrs(origin: &str, addrs: &[SocketAddr]) -> Result<(), PbsClientErr Ok(()) } -/// A relay that dials `url` only at `addrs`: a system proxy would resolve the -/// host again, and a redirect target was never checked. -fn pinned_relay(url: Url, addrs: &[SocketAddr]) -> eyre::Result { - let mut client = - reqwest::Client::builder().no_proxy().redirect(reqwest::redirect::Policy::none()); - if let Some(domain) = url.domain() { - client = client.resolve_to_addrs(domain, addrs); - } +/// Dial hosts are chosen by the request, so an idle connection is reused for at +/// most one slot. The pool sweeps at this interval, so it closes within two. +const DIAL_IDLE_TIMEOUT: Duration = Duration::from_secs(12); + +/// Lets a builder dialed again reuse its connection. A failed build is not +/// kept, so the next dial tries again. +static DIAL_CLIENT: OnceLock = OnceLock::new(); + +/// No proxy, which would resolve the host itself, and no redirects, since one +/// to an IP address would skip the resolver +fn build_dial_client() -> eyre::Result { RelayClient::with_client_builder( - RelayConfig { - entry: RelayEntry { id: DIAL_RELAY_ID.to_string(), pubkey: DIAL_PUBKEY.clone(), 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, - validator_registration_batch_size: None, - }, - client, + dial_config(Url::parse("https://dial.invalid/").expect("a valid URL")), + reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .dns_resolver(DialResolver) + .pool_idle_timeout(DIAL_IDLE_TIMEOUT) + .pool_max_idle_per_host(1), ) } +/// The shared dial client, addressed to `url` +fn dial_client(url: Url) -> Result { + let shared = match DIAL_CLIENT.get() { + Some(shared) => shared, + None => { + let built = build_dial_client().map_err(|err| { + warn!(%err, "failed to build the dial client"); + PbsClientError::Internal + })?; + DIAL_CLIENT.get_or_init(|| built) + } + }; + let mut relay = shared.clone(); + relay.config = Arc::new(dial_config(url)); + Ok(relay) +} + +fn dial_config(url: Url) -> RelayConfig { + RelayConfig { + entry: RelayEntry { id: DIAL_RELAY_ID.to_string(), pubkey: DIAL_PUBKEY.clone(), 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, + validator_registration_batch_size: None, + } +} + +/// Run for each new connection to a hostname (an IP address skips it): refuses +/// it while every lookup slot is taken or if any address is disallowed +struct DialResolver; + +impl Resolve for DialResolver { + fn resolve(&self, name: Name) -> Resolving { + Box::pin(async move { + let lookup = spawn_lookup(format!("{}:0", name.as_str())) + .ok_or("too many dial lookups in flight")?; + let addrs = lookup.await??; + vet_dial_addrs(name.as_str(), &addrs)?; + Ok(Box::new(addrs.into_iter()) as Addrs) + }) + } +} + #[cfg(feature = "testing-flags")] thread_local! { static SKIP_DIAL_TARGET_CHECK: std::cell::Cell = const { std::cell::Cell::new(false) }; @@ -212,6 +261,8 @@ fn ip_is_disallowed(ip: IpAddr) -> bool { #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use axum::http::StatusCode; use cb_common::pbs::decode_auth_data_url; @@ -248,10 +299,13 @@ mod tests { } } - // Holds every permit, so a hostname lookup in a unit test running beside it - // is refused + /// Taken by every test that holds or takes a lookup slot, since the slots + /// are shared by the whole test binary + static LOOKUP_SLOTS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + #[tokio::test] async fn lookup_cap_refuses_hostnames_not_ip_literals() { + let _slots = LOOKUP_SLOTS.lock().await; let _held = DIAL_LOOKUPS.try_acquire_many(32).unwrap(); let dial = |address: &'static [u8]| dial_relay(None, address, Duration::from_secs(1)); assert!(dial(b"1.1.1.1").await.is_ok()); @@ -270,25 +324,84 @@ mod tests { )); } - // A hostname target is dialed at the given address with no lookup - // (`.invalid` never resolves), and a redirect is not followed - #[tokio::test] - async fn pinned_relay_dials_the_given_address_and_refuses_redirects() -> eyre::Result<()> { - use axum::{Router, http::header::LOCATION, routing::post}; + /// A builder on loopback whose `/bid` redirects to `/internal` and whose + /// `/slow` answers after 50 ms, and its count of accepted connections + async fn mock_builder() -> eyre::Result<(u16, Arc)> { + use axum::{Router, http::header::LOCATION, routing::post, serve::ListenerExt}; + let connections = Arc::new(AtomicUsize::new(0)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; + let port = listener.local_addr()?.port(); + let accepted = connections.clone(); + let listener = listener.tap_io(move |_| { + accepted.fetch_add(1, Ordering::Relaxed); + }); let builder = Router::new() .route( "/bid", post(|| async { (StatusCode::TEMPORARY_REDIRECT, [(LOCATION, "/internal")]) }), ) - .route("/internal", post(|| async { StatusCode::OK })); + .route("/internal", post(|| async { StatusCode::OK })) + .route( + "/slow", + post(|| async { + tokio::time::sleep(Duration::from_millis(50)).await; + StatusCode::OK + }), + ); tokio::spawn(async move { axum::serve(listener, builder).await }); + Ok((port, connections)) + } + + // An IP literal skips the resolver, which would refuse loopback + #[tokio::test] + async fn dials_share_connections_and_refuse_redirects() -> eyre::Result<()> { + let (port, connections) = mock_builder().await?; + let url = Url::parse(&format!("http://127.0.0.1:{port}/bid"))?; + for _ in 0..2 { + let res = dial_client(url.clone())?.client.post(url.clone()).send().await?; + assert_eq!(res.status(), StatusCode::TEMPORARY_REDIRECT); + } + assert_eq!(connections.load(Ordering::Relaxed), 1); - let url = Url::parse(&format!("http://dial-target.invalid:{}/bid", addr.port()))?; - let res = pinned_relay(url.clone(), &[addr])?.client.post(url).send().await?; - assert_eq!(res.status(), StatusCode::TEMPORARY_REDIRECT); + tokio::time::pause(); + tokio::time::advance(DIAL_IDLE_TIMEOUT + Duration::from_secs(1)).await; + tokio::time::resume(); + dial_client(url.clone())?.client.post(url).send().await?; + assert_eq!(connections.load(Ordering::Relaxed), 2); + Ok(()) + } + + // Two requests at once need two connections, and only one stays idle + #[tokio::test] + async fn dial_client_keeps_one_idle_connection_per_host() -> eyre::Result<()> { + let (port, connections) = mock_builder().await?; + let url = Url::parse(&format!("http://127.0.0.1:{port}/slow"))?; + let client = dial_client(url.clone())?.client; + for _ in 0..2 { + tokio::try_join!(client.post(url.clone()).send(), client.post(url.clone()).send())?; + } + assert_eq!(connections.load(Ordering::Relaxed), 3); + Ok(()) + } + + // No check runs before these dials, so only the resolver refuses + // `localhost`, which resolves to loopback + #[cfg(feature = "testing-flags")] + #[tokio::test] + async fn dial_client_checks_the_addresses_it_resolves() -> eyre::Result<()> { + let _slots = LOOKUP_SLOTS.lock().await; + let (port, _) = mock_builder().await?; + let url = Url::parse(&format!("http://localhost:{port}/bid"))?; + let dial = || dial_client(url.clone()).map(|relay| relay.client.post(url.clone()).send()); + assert!(dial()?.await.is_err()); + set_skip_dial_target_check(true); + let held = DIAL_LOOKUPS.try_acquire_many(32)?; + assert!(dial()?.await.is_err()); + drop(held); + let res = dial()?.await; + set_skip_dial_target_check(false); + assert_eq!(res?.status(), StatusCode::TEMPORARY_REDIRECT); Ok(()) }