diff --git a/Cargo.lock b/Cargo.lock index d797a53b..140c3303 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,13 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "tree_hash 0.12.1", - "tree_hash_derive 0.12.1", + "tree_hash", + "tree_hash_derive", + "typenum", "types", "unicode-normalization", "url", - "uuid 1.26.1", + "uuid", ] [[package]] @@ -1931,7 +1978,7 @@ dependencies = [ "axum 0.8.9", "cb-common", "eyre", - "prometheus", + "prometheus 0.14.0", "thiserror 2.0.20", "tokio", "tracing", @@ -1948,14 +1995,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 +2012,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 +2037,7 @@ dependencies = [ "jsonwebtoken", "lazy_static", "parking_lot", - "prometheus", + "prometheus 0.14.0", "prost", "rand 0.9.5", "rustls", @@ -1999,8 +2046,8 @@ dependencies = [ "tonic", "tonic-build", "tracing", - "tree_hash 0.12.1", - "uuid 1.26.1", + "tree_hash", + "uuid", ] [[package]] @@ -2013,7 +2060,7 @@ dependencies = [ "cb-pbs", "cb-signer", "eth2", - "ethereum_ssz 0.10.4", + "ethereum_ssz", "eyre", "futures", "jsonwebtoken", @@ -2021,6 +2068,7 @@ dependencies = [ "reqwest 0.13.5", "serde", "serde_json", + "ssz_types", "tempfile", "tokio", "tokio-tungstenite", @@ -2028,7 +2076,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-test", - "tree_hash 0.12.1", + "tree_hash", "types", "url", ] @@ -2075,8 +2123,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 +2288,8 @@ dependencies = [ "tempfile", "tokio", "tracing", - "tree_hash 0.12.1", - "tree_hash_derive 0.12.1", + "tree_hash", + "tree_hash_derive", ] [[package]] @@ -2395,6 +2445,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 +2534,7 @@ dependencies = [ "crossterm_winapi", "document-features", "parking_lot", - "rustix", + "rustix 1.1.4", "winapi", ] @@ -2543,7 +2602,7 @@ dependencies = [ "commit-boost", "eyre", "lazy_static", - "prometheus", + "prometheus 0.14.0", "serde", "serde_json", "tokio", @@ -3113,14 +3172,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 +3191,7 @@ dependencies = [ "sensitive_url", "serde", "serde_json", - "ssz_types 0.14.1", + "ssz_types", "superstruct", "types", ] @@ -3138,20 +3199,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 +3224,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 +3241,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 +3282,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 +3298,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 +3401,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 +3413,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 +3452,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 +3970,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 +4172,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 +4211,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 +4514,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 +4552,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 +4597,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 +4702,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 +4745,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 +4797,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 +4852,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 +5038,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 +5338,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 +5405,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 +5478,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 +5541,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 +5896,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 +5909,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower 0.5.3", @@ -5942,6 +6031,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 +6113,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 +6135,7 @@ dependencies = [ "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -6313,6 +6416,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 +6684,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 +6724,6 @@ version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" dependencies = [ - "arbitrary", "serde", ] @@ -6651,37 +6763,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 +6787,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 +6831,7 @@ dependencies = [ "commit-boost", "eyre", "lazy_static", - "prometheus", + "prometheus 0.14.0", "reqwest 0.13.5", "serde", "tokio", @@ -6764,10 +6888,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 +7002,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6888,15 +7012,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 +7183,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 +7538,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 +7605,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 +7638,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 +7756,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 +7775,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 +8275,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 +8318,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 +8458,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 3d5b23b1..b0281eae 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/config.example.toml b/config.example.toml index a825f653..4ce750cb 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 7350b85c..483b64a4 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 @@ -53,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 cdade80e..22f7a1ed 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 0ac6ce1b..6d948adc 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/config/utils.rs b/crates/common/src/config/utils.rs index 2e47eb94..de3777fe 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/pbs/constants.rs b/crates/common/src/pbs/constants.rs index 66ab42f0..e7639530 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 8fcd928f..1f40fa28 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 bb043df0..ae7f4b9a 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 738221a8..ccf79e38 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,27 @@ 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; + +/// 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 +132,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/signature.rs b/crates/common/src/signature.rs index 41631e33..70bd5c61 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 a62ac199..18f7f7cf 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 c0b53b4c..c167c33d 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; @@ -27,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); @@ -428,17 +429,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 { @@ -461,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 4f426398..4355c3da 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(); @@ -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 @@ -290,6 +292,31 @@ 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), + _ => Err(unsupported()), + } +} + pub fn get_consensus_version_header(req_headers: &HeaderMap) -> Option { ForkName::from_str( req_headers @@ -384,23 +411,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 +441,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 +461,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 +1065,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 3d5ee725..5790af41 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 98f8a2f1..9b9aaf07 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/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index 576ced51..670e80b1 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 3b209ce9..a91017ec 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/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs new file mode 100644 index 00000000..dc8596e7 --- /dev/null +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -0,0 +1,108 @@ +use axum::{ + body::Bytes, + extract::{Path, State}, + http::HeaderMap, + response::IntoResponse, +}; +use cb_common::{ + pbs::{BuilderPreferencesRequest, SubmitBuilderPreferencesParams}, + wire::{decode_versioned_request_body, get_user_agent}, +}; +use reqwest::StatusCode; +use ssz::Encode; +use tracing::{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_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_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()); + + 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.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); + } else { + error!(%err, relay_id); + } + Err(builder_rejection(&err).unwrap_or(PbsClientError::NoBuilderResponse)) + } + } +} 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 00000000..9e1b9e36 --- /dev/null +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -0,0 +1,337 @@ +use std::time::Duration; + +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, HeaderValue}, + response::{IntoResponse, Response}, +}; +use cb_common::{ + pbs::{ + 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, 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, + }, +}; +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_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_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)); + 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) + .map_err(|err| record_request_failure(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)) => { + 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); + Ok(StatusCode::NO_CONTENT.into_response()) + } + Err(err) => Err(record_request_failure(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG)), + } +} + +/// 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, +} + +fn encode_bid_response( + RelayBid { bid, body, encoding }: RelayBid, + response_encoding: EncodingType, + endpoint: &str, +) -> Response { + let message = &bid.data.message; + info!( + 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" + ); + + record_beacon_status("200", endpoint); + let content_type = [(CONTENT_TYPE, response_encoding.content_type_header().clone())]; + let mut res = match response_encoding { + _ 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, GLOAS_CONSENSUS_VERSION.clone()); + 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)?; + + // 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()); + + 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(relay_bid)) => { + RELAY_LAST_SLOT.with_label_values(&[relay_id.as_str()]).set(slot as i64); + // 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(relay_bid.bid.data.message.value).unwrap_or_default()); + Ok(Some(relay_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?; + 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 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 + // 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() } + } + }; + + Ok(Some(RelayBid { bid, body: response_bytes.into(), encoding: content_type })) +} + +#[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 84853d9e..595552f5 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 e98c89c1..54118d8c 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 00000000..75bb3554 --- /dev/null +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -0,0 +1,96 @@ +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 + .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/service.rs b/crates/pbs/src/service.rs index ded5b7db..ed3d179c 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/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 782ae79b..023dc6bb 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -1,3 +1,169 @@ +use std::{ + future::Future, + time::{Duration, Instant}, +}; + +use alloy::primitives::utils::{ParseUnits, Unit}; +use axum::body::Bytes; +use cb_common::{ + pbs::{RelayClient, error::PbsError}, + types::BlsPublicKey, + wire::{ + CONSENSUS_VERSION_HEADER, EncodingType, GLOAS_CONSENSUS_VERSION, + get_user_agent_with_version, read_chunked_body_with_max, + }, +}; +use futures::future::join_all; +use reqwest::{ + StatusCode, + header::{CONTENT_TYPE, HeaderMap, 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)) +} + +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: impl Into, + endpoint: &str, +) -> PbsClientError { + let err = err.into(); + 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, +) -> impl Iterator> +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)))) +} + +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: Bytes, + 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`, 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)?, + ); + headers.insert(CONSENSUS_VERSION_HEADER, GLOAS_CONSENSUS_VERSION.clone()); + Ok(headers) +} + const GAS_LIMIT_ADJUSTMENT_FACTOR: u64 = 1024; const GAS_LIMIT_MINIMUM: u64 = 5_000; @@ -19,3 +185,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/crates/signer/src/service.rs b/crates/signer/src/service.rs index 710d7ea6..b6023a48 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/docs/docs/get_started/epbs.md b/docs/docs/get_started/epbs.md new file mode 100644 index 00000000..27cc0f75 --- /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 {#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`. + +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 3fcef6cf..e0c4f974 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 646f4994..c8503378 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 becdc1f0..5826a1d8 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -23,13 +23,17 @@ 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, + 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, @@ -40,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; @@ -93,6 +97,26 @@ 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>, + /// 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, @@ -101,6 +125,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 { @@ -119,6 +147,48 @@ 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_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()) + } pub fn large_body(&self) -> bool { self.large_body } @@ -166,10 +236,22 @@ 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), + 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), 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(), ), @@ -218,6 +300,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 { @@ -225,7 +336,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)) @@ -256,7 +371,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(), }); @@ -265,6 +380,131 @@ 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); + *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, + // 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, @@ -351,6 +591,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_ssv_node.rs b/tests/src/mock_ssv_node.rs index 7f24569d..fe0d7767 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 a014db42..4ead5c7b 100644 --- a/tests/src/mock_ssv_public.rs +++ b/tests/src/mock_ssv_public.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::sync::Arc; use axum::{ extract::{Path, State}, @@ -27,7 +27,7 @@ pub struct PublicSsvMockState { /// Creates a simple mock server to simulate the SSV API endpoint under /// various conditions for testing. Note this ignores pub async fn create_mock_public_ssv_server( - port: u16, + listener: TcpListener, state: Option, ) -> Result, axum::Error> { let data = include_str!("../../tests/data/ssv_valid_public.json"); @@ -46,8 +46,6 @@ pub async fn create_mock_public_ssv_server( .with_state(state) .into_make_service(); - let address = SocketAddr::from(([127, 0, 0, 1], port)); - let listener = TcpListener::bind(address).await.map_err(axum::Error::new)?; let server = axum::serve(listener, router).with_graceful_shutdown(async { tokio::signal::ctrl_c().await.expect("Failed to listen for shutdown signal"); }); @@ -56,7 +54,7 @@ pub async fn create_mock_public_ssv_server( eprintln!("Server error: {e}"); } })); - info!("Mock server started on http://localhost:{port}/"); + info!("mock SSV server started"); result } diff --git a/tests/src/mock_validator.rs b/tests/src/mock_validator.rs index b2da4a9c..2059a245 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/signer_service.rs b/tests/src/signer_service.rs index 550ac4ce..8338a582 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 9a4b0a2a..d3c92416 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}; @@ -14,20 +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_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}") @@ -125,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(), @@ -236,3 +250,111 @@ pub fn create_module_config(id: ModuleId, signing_id: B256) -> StaticModuleConfi pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { bls_pubkey_from_hex(hex).unwrap() } + +/// Build a `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. +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 c576a6a6..23d59e94 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(), @@ -114,8 +115,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_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs new file mode 100644 index 00000000..3eca13e2 --- /dev/null +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -0,0 +1,508 @@ +use std::{collections::HashMap, path::PathBuf}; + +use alloy::primitives::{B256, U256}; +use cb_common::{ + config::RuntimeMuxConfig, + pbs::{ + 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.data.message.block_hash.0, B256::ZERO); + assert_eq!(res.data.message.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 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 +/// 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; + 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, + ), + ( + 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?; + 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.data.message.block_hash.0) + } + }; + 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_get_header.rs b/tests/tests/pbs_get_header.rs index 21ff3e6e..e155041b 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 08afb231..82bc05a0 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 a34e5f86..6f01f502 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,11 +243,17 @@ 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; + // 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)?; @@ -277,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"); @@ -337,10 +338,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 +349,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 +358,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 +401,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 +447,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 +465,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 +508,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 5bfb4aab..10a9f1c8 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 06e143c2..3f956611 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 12601cda..22bd1a5d 100644 --- a/tests/tests/pbs_post_validators.rs +++ b/tests/tests/pbs_post_validators.rs @@ -7,9 +7,11 @@ use cb_common::{ }; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, + utils::{ + generate_mock_relay, get_free_listener, get_pbs_config, setup_test_env, to_pbs_config, + }, }; use eyre::Result; use reqwest::StatusCode; @@ -22,17 +24,20 @@ async fn test_register_validators() -> Result<()> { let pubkey: BlsPublicKey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 4000; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_port = relay_listener.local_addr()?.port(); // Run a mock relay - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; + let relays = vec![generate_mock_relay(relay_port, pubkey)?]; let mock_state = Arc::new(MockRelayState::new(chain, signer)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -68,20 +73,26 @@ async fn test_register_validators_does_not_retry_on_429() -> Result<()> { let pubkey: BlsPublicKey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 4200; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_port = relay_listener.local_addr()?.port(); // Set up mock relay state and override response to 429 let mock_state = Arc::new(MockRelayState::new(chain, signer)); mock_state.set_response_override(StatusCode::TOO_MANY_REQUESTS); // Run a mock relay - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + let relays = vec![generate_mock_relay(relay_port, pubkey)?]; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>( + state.clone(), + pbs_listener, + )); // Leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -121,14 +132,17 @@ async fn test_register_validators_retries_on_500() -> Result<()> { let pubkey: BlsPublicKey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 4300; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_port = relay_listener.local_addr()?.port(); // Set up internal mock relay with 500 response override let mock_state = Arc::new(MockRelayState::new(chain, signer)); mock_state.set_response_override(StatusCode::INTERNAL_SERVER_ERROR); // 500 - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + let relays = vec![generate_mock_relay(relay_port, pubkey)?]; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Set retry limit to 3 let mut pbs_config = get_pbs_config(pbs_port); @@ -136,7 +150,10 @@ async fn test_register_validators_retries_on_500() -> Result<()> { let config = to_pbs_config(chain, pbs_config, relays); let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>( + state.clone(), + pbs_listener, + )); tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/tests/tests/pbs_submit_builder_preferences.rs b/tests/tests/pbs_submit_builder_preferences.rs new file mode 100644 index 00000000..b1079e7e --- /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 00000000..a5e474a1 --- /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(()) +} diff --git a/tests/tests/signer_jwt_auth.rs b/tests/tests/signer_jwt_auth.rs index d1b65b3f..61cd6def 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 d6fde2a4..09f64721 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 78efbf9e..a60416c4 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 2df98d73..718100eb 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