diff --git a/Cargo.lock b/Cargo.lock index 1ea2b358..4cd85d0a 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 = "f007e257069855bdf21d27762fd3f3705a613f805c9a08309bf353503f081d71" 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.6.1", "alloy-rpc-types-engine", "derive_more", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", "serde", "serde_json", "serde_with", "thiserror 2.0.19", - "tree_hash 0.10.0", - "tree_hash_derive 0.10.0", ] [[package]] @@ -591,8 +587,6 @@ dependencies = [ "alloy-rlp", "alloy-serde", "derive_more", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", "rand 0.8.7", "serde", "strum", @@ -1632,20 +1626,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.6.1", "arbitrary", "blst", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "ethereum_serde_utils 0.8.1", - "ethereum_ssz 0.10.4", + "ethereum_ssz", "fixed_bytes", "hex", "rand 0.9.5", "safe_arith", "serde", - "tree_hash 0.12.1", + "tree_hash", "zeroize", ] @@ -1707,7 +1701,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", @@ -1765,6 +1759,25 @@ dependencies = [ "serde_core", ] +[[package]] +name = "builder_types" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" +dependencies = [ + "bls", + "context_deserialize", + "ethereum_serde_utils 0.8.1", + "ethereum_ssz", + "ethereum_ssz_derive", + "sensitive_url", + "serde", + "ssz_types", + "tree_hash", + "tree_hash_derive", + "typenum", + "types", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -1807,6 +1820,38 @@ dependencies = [ "serde", ] +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver 1.0.28", + "serde", + "serde_json", + "thiserror 2.0.19", +] + [[package]] name = "cast" version = "0.3.0" @@ -1863,6 +1908,7 @@ version = "0.11.0" dependencies = [ "aes", "alloy", + "arbitrary", "async-trait", "axum 0.8.9", "base64", @@ -1877,8 +1923,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", @@ -1890,12 +1936,11 @@ dependencies = [ "rand 0.9.5", "rayon", "reqwest 0.13.4", - "reqwest-eventsource", "serde", "serde_json", "serde_yaml", "sha2", - "ssz_types 0.11.0", + "ssz_types", "tempfile", "thiserror 2.0.19", "tokio", @@ -1904,12 +1949,33 @@ 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.24.0", + "uuid", +] + +[[package]] +name = "cb-km-tool" +version = "0.11.0" +dependencies = [ + "alloy-primitives 1.6.1", + "axum 0.8.9", + "cb-common", + "clap", + "eyre", + "reqwest 0.13.4", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "url", ] [[package]] @@ -1919,7 +1985,7 @@ dependencies = [ "axum 0.8.9", "cb-common", "eyre", - "prometheus", + "prometheus 0.14.0", "thiserror 2.0.19", "tokio", "tracing", @@ -1936,14 +2002,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.4", "rustls", "serde", @@ -1951,12 +2017,13 @@ dependencies = [ "thiserror 2.0.19", "tokio", "tokio-tungstenite", + "toml", "tower-http", "tracing", - "tree_hash 0.12.1", + "tree_hash", "types", "url", - "uuid 1.24.0", + "uuid", "webpki-roots 1.0.9", ] @@ -1978,7 +2045,7 @@ dependencies = [ "jsonwebtoken", "lazy_static", "parking_lot", - "prometheus", + "prometheus 0.14.0", "prost", "rand 0.9.5", "rustls", @@ -1987,8 +2054,8 @@ dependencies = [ "tonic", "tonic-build", "tracing", - "tree_hash 0.12.1", - "uuid 1.24.0", + "tree_hash", + "uuid", ] [[package]] @@ -2001,7 +2068,7 @@ dependencies = [ "cb-pbs", "cb-signer", "eth2", - "ethereum_ssz 0.10.4", + "ethereum_ssz", "eyre", "futures", "jsonwebtoken", @@ -2009,6 +2076,7 @@ dependencies = [ "reqwest 0.13.4", "serde", "serde_json", + "ssz_types", "tempfile", "tokio", "tokio-tungstenite", @@ -2016,7 +2084,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-test", - "tree_hash 0.12.1", + "tree_hash", "types", "url", ] @@ -2063,8 +2131,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", ] @@ -2226,8 +2296,8 @@ dependencies = [ "tempfile", "tokio", "tracing", - "tree_hash 0.12.1", - "tree_hash_derive 0.12.1", + "tree_hash", + "tree_hash_derive", ] [[package]] @@ -2377,6 +2447,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -2457,7 +2536,7 @@ dependencies = [ "crossterm_winapi", "document-features", "parking_lot", - "rustix", + "rustix 1.1.4", "winapi", ] @@ -2525,7 +2604,7 @@ dependencies = [ "commit-boost", "eyre", "lazy_static", - "prometheus", + "prometheus 0.14.0", "serde", "serde_json", "tokio", @@ -3024,14 +3103,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", @@ -3041,7 +3122,7 @@ dependencies = [ "sensitive_url", "serde", "serde_json", - "ssz_types 0.14.1", + "ssz_types", "superstruct", "types", ] @@ -3049,20 +3130,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", @@ -3074,7 +3155,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", @@ -3091,21 +3172,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" @@ -3143,28 +3213,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.6.1", - "ethereum_serde_utils 0.8.1", - "itertools 0.13.0", - "serde", - "serde_derive", - "smallvec", - "typenum", -] - [[package]] name = "ethereum_ssz" version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e462875ad8693755ea8913d6e905715c76ea4836e2254e18c9cf0f7a8f8c2a13" +source = "git+https://github.com/sigp/ethereum_ssz?rev=2059c21ba52cd3a7e39a8ad537012b761812a393#2059c21ba52cd3a7e39a8ad537012b761812a393" dependencies = [ "alloy-primitives 1.6.1", + "arbitrary", "context_deserialize", "ethereum_serde_utils 0.8.1", "itertools 0.14.0", @@ -3174,23 +3229,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", @@ -3289,7 +3331,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.6.1", "safe_arith", @@ -3301,6 +3343,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -3329,20 +3381,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" @@ -3845,22 +3899,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" @@ -4062,6 +4100,7 @@ version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ + "arbitrary", "equivalent", "hashbrown 0.17.1", "serde", @@ -4100,11 +4139,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.3" @@ -4340,22 +4388,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]] @@ -4379,6 +4426,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" @@ -4412,6 +4471,37 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logging" +version = "0.2.0" +source = "git+https://github.com/sigp/lighthouse?rev=31d8cfd40d228c2dd902a67b5142ff6abf00c058#31d8cfd40d228c2dd902a67b5142ff6abf00c058" +dependencies = [ + "chrono", + "logroller", + "metrics", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-appender", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "workspace_members", +] + +[[package]] +name = "logroller" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7525242cbd0624fe9b76dfebc1923a2d20c003e8c5d1a7eab1324bd8c0f615ac" +dependencies = [ + "chrono", + "flate2", + "regex", + "thiserror 1.0.69", +] + [[package]] name = "lru" version = "0.16.4" @@ -4486,10 +4576,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.6.1", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "fixed_bytes", "safe_arith", ] @@ -4529,24 +4619,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.6.1", + "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", @@ -4571,6 +4669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -4591,23 +4690,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nom" version = "7.1.3" @@ -4794,59 +4876,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", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-src" -version = "300.6.1+3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - [[package]] name = "owo-colors" version = "4.3.0" @@ -5126,7 +5161,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", @@ -5193,6 +5228,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", + "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", + "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" @@ -5227,6 +5301,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" @@ -5279,6 +5364,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" @@ -5617,11 +5719,9 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -5632,7 +5732,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower 0.5.3", @@ -5755,6 +5854,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", @@ -5836,6 +5936,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", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5845,7 +5958,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.52.0", ] @@ -6126,6 +6239,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" @@ -6389,6 +6506,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" @@ -6423,7 +6546,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ - "arbitrary", "serde", ] @@ -6463,37 +6585,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", ] @@ -6503,6 +6609,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" @@ -6519,7 +6653,7 @@ dependencies = [ "commit-boost", "eyre", "lazy_static", - "prometheus", + "prometheus 0.14.0", "reqwest 0.13.4", "serde", "tokio", @@ -6576,10 +6710,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.6.1", - "ethereum_hashing 0.8.0", + "ethereum_hashing", "fixed_bytes", ] @@ -6690,7 +6824,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.52.0", ] @@ -6700,15 +6834,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" @@ -6889,16 +7014,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7254,49 +7369,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.6.1", - "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.6.1", - "ethereum_hashing 0.8.0", - "ethereum_ssz 0.10.4", + "ethereum_hashing", + "ethereum_ssz", "smallvec", "typenum", ] -[[package]] -name = "tree_hash_derive" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bee2ea1551f90040ab0e34b6fb7f2fa3bad8acc925837ac654f2c78a13e3089" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tree_hash_derive" version = "0.12.1" -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", @@ -7348,29 +7436,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.6.1", "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", @@ -7379,17 +7469,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]] @@ -7498,16 +7587,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.17", - "serde", -] - [[package]] name = "uuid" version = "1.24.0" @@ -7527,12 +7606,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" @@ -8033,6 +8106,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.3" @@ -8067,6 +8149,19 @@ dependencies = [ "tap", ] +[[package]] +name = "yaml_serde" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7a8266fc7ce9d77db272ca5b40b704a1753e2a2e2ab7b1f2da22672941bf93" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yasna" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 3d5b23b1..b7a18e17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ alloy = { version = "^1.0.35", features = [ "rpc-types-beacon", "serde", "signer-local", - "ssz", ] } alloy-primitives = "^1.3.1" assert_cmd = "2.1.2" @@ -42,6 +41,7 @@ const_format = "0.2.34" ctr = "0.9.2" derive_more = { version = "2.0.1", features = ["deref", "display", "from", "into"] } docker-compose-types = "0.16.0" +arbitrary = "1.4" docker-image = "0.2.1" ethereum_serde_utils = "0.7.0" ethereum_ssz = "0.10" @@ -54,10 +54,10 @@ indexmap = "2.2.6" jsonwebtoken = { version = "9.3.1", default-features = false } lazy_static = "1.5.0" mediatype = "0.20.0" -lh_eth2 = { package = "eth2", git = "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/sigp/lighthouse", tag = "v8.1.3", features = ["events"] } -lh_eth2_keystore = { package = "eth2_keystore", git = "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/sigp/lighthouse", tag = "v8.1.3" } -lh_bls = { package = "bls", git = "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/sigp/lighthouse", tag = "v8.1.3" } -lh_types = { package = "types", git = "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/sigp/lighthouse", tag = "v8.1.3" } +lh_eth2 = { package = "eth2", git = "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/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058", features = ["events"] } +lh_eth2_keystore = { package = "eth2_keystore", git = "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/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058" } +lh_bls = { package = "bls", git = "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/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058", features = ["arbitrary"] } +lh_types = { package = "types", git = "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/sigp/lighthouse", rev = "31d8cfd40d228c2dd902a67b5142ff6abf00c058", features = ["arbitrary"] } notify = "8.2.0" parking_lot = "0.12.3" pbkdf2 = "0.12.2" @@ -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 = "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/Commit-Boost/blstrs" } +blstrs_plus = { git = "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/Commit-Boost/blstrs", rev = "c4ea6b21193886ee9849867397a62e9c243c1fbb" } +# Progressive-container ssz stack required by lighthouse-unstable (EIP-7495/7916). +# A consumer does NOT inherit a dependency's [patch]; mirror lighthouse's here. +ssz_types = { git = "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/sigp/ssz_types", rev = "9203d56ad2d7bc5f12133d043e085843f81edcd6" } +milhouse = { git = "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/sigp/milhouse", rev = "c70f128976ac0d60ea65a978dabd117a921c36ee" } +ethereum_ssz = { git = "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/sigp/ethereum_ssz", rev = "2059c21ba52cd3a7e39a8ad537012b761812a393" } +ethereum_ssz_derive = { git = "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/sigp/ethereum_ssz", rev = "2059c21ba52cd3a7e39a8ad537012b761812a393" } +tree_hash = { git = "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/sigp/tree_hash", rev = "03d9fa474586c125f306dd7a00cf46284575a01f" } +tree_hash_derive = { git = "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/sigp/tree_hash", rev = "03d9fa474586c125f306dd7a00cf46284575a01f" } diff --git a/benches/pbs/src/main.rs b/benches/pbs/src/main.rs index 4a000765..986d2173 100644 --- a/benches/pbs/src/main.rs +++ b/benches/pbs/src/main.rs @@ -161,7 +161,10 @@ fn get_mock_validator(bench: BenchConfig) -> RelayClient { enable_timing_games: false, target_first_request_ms: None, frequency_get_header_ms: None, + bid_poll_timeout_ms: None, validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: None, }; RelayClient::new(config).unwrap() diff --git a/config.example.toml b/config.example.toml index d1fefe63..ea952aee 100644 --- a/config.example.toml +++ b/config.example.toml @@ -28,9 +28,17 @@ relay_check = true # OPTIONAL, DEFAULT: true wait_all_registrations = true # Timeout in milliseconds for the `get_header` call to relays. Note that the CL has also a timeout (e.g. 1 second) so -# this should be lower than that, leaving some margin for overhead +# this should be lower than that, leaving some margin for overhead. +# LEGACY get_header path only: the ePBS bid path derives its timeout from the beacon node's own +# X-Timeout-Ms header instead (see proposer_deadline_buffer_ms below). # OPTIONAL, DEFAULT: 950 timeout_get_header_ms = 950 +# ePBS bid path only. The beacon node sends CB its own deadline (X-Timeout-Ms) on every bid request, +# so CB no longer needs a static timeout: it asks the builder for `deadline - proposer_deadline_buffer_ms`, +# reserving this many ms for the winning bid's return trip to the beacon node and the node's own +# bid-selection/assembly. Configure the margin directly instead of a deadline-minus-margin absolute. +# OPTIONAL, DEFAULT: 50 +proposer_deadline_buffer_ms = 50 # Timeout in milliseconds for the `submit_blinded_block` call to relays. # OPTIONAL, DEFAULT: 4000 timeout_get_payload_ms = 4000 @@ -44,6 +52,34 @@ skip_sigverify = false # Can be specified as a float or a string for extra precision (e.g. "0.01") # OPTIONAL, DEFAULT: 0.0 min_bid_eth = 0.0 +# Projection-only: consumed by KM tooling, not read by the PBS runtime. +# The ePBS KEY-LEVEL minimum total payment in ETH: it governs p2p bids and builder entries that +# omit their own min_bid (projected entries always carry explicit per-entry values sourced from +# the mux/global min_bid). Float or string +# OPTIONAL, DEFAULT: unset (key level = mux/global min_bid) +# min_bid_p2p_eth = "0.2" +# Projection-only: consumed by KM tooling, not read by the PBS runtime. +# The ePBS KEY-LEVEL builder_boost_factor: it governs p2p bids and builder entries that omit +# their own (entry values stay mux-sourced) +# OPTIONAL, DEFAULT: unset +# builder_boost_factor_p2p = 100 +# Execution-payment cap in Gwei used when ranking ePBS bids: a bid ranks at +# `value + min(execution_payment, cap)`, matching how the beacon node values it (the BN clamps the +# trusted payment at the cap instead of rejecting the bid). This does not accept or reject bids. +# Can be overridden per relay with `max_execution_payment_gwei` on the relay entry +# OPTIONAL, DEFAULT: unset (unclamped) +# max_execution_payment_gwei = 1000000000 +# Whether to verify the BLS signature of the `SignedBuilderRequestAuth` on an ePBS request against the +# proposer pubkey in the request path, rejecting a bad signature with 401. CB forwards by default +# because the downstream builder must re-verify anyway; operators terminating trust at CB set it true +# OPTIONAL, DEFAULT: false +verify_builder_request_auth = false +# Strict decoding of the reveal at POST /eth/v1/builder/beacon_blocks. Default (false): CB is a blind +# pipe, forwarding the reveal bytes to the builder without parsing them, so the reveal MUST be SSZ in +# blind mode (the builder validates and rejects a bad block per builder-specs). When true: CB decodes +# the gloas SignedBeaconBlock itself and rejects a non-gloas or otherwise undecodable body with a 400 +# OPTIONAL, DEFAULT: false +# strict_block_decode = false # How late in milliseconds in the slot is "late". This impacts the `get_header` requests, by shortening timeouts for `get_header` calls to # relays and make sure a header is returned within this deadline. If the request from the CL comes later in the slot, then fetching headers is skipped # to force local building and miniminzing the risk of missed slots. See also the timing games section below @@ -78,6 +114,18 @@ validator_registration_batch_size = "" # enabled, this value will not be used. # OPTIONAL, DEFAULT: 384 mux_registry_refresh_interval_seconds = 384 +# Commit-Boost's externally-reachable URLs, i.e. the ones a validator client's builder config points +# at. Gates the ePBS pipe: when a request's auth data names a builder URL no configured relay serves, +# CB dials that URL directly through a transient client (trust is the validator client's job via its +# keymanager builder_pubkeys). Since an unconfigured key's auth data defaults to CB's own URL, the +# pipe first checks the decoded URL against this list and rejects a match instead of dialing itself. +# The guard fails closed: while this list is empty, unmatched auth data is always rejected and the +# pipe never dials, so set it to enable piping to builders outside the [[relays]] below. +# List EVERY scheme/host/IP form CB is reachable by: the self-URL guard matches on scheme+host+port +# exactly, so an equivalent form you omit (e.g. an IP instead of a hostname, or http vs https) is not +# recognized as CB's own and the pipe could self-dial. +# OPTIONAL, DEFAULT: [] +# advertised_urls = ["http://cb.example.com:18550"] # The PBS module needs one or more [[relays]] as defined below. [[relays]] @@ -130,6 +178,21 @@ target_first_request_ms = 200 # Frequency in ms to send get_header requests # OPTIONAL frequency_get_header_ms = 300 +# How long each ePBS bid poll may take, in ms. Every poll shares the proposer's deadline, so bounding +# the early ones lands a floor of progressively better bids instead of leaving everything to the last +# instant; the final poll always holds for the remaining budget. Set this above your round trip to +# the builder, or every early poll times out +# OPTIONAL, DEFAULT: 500 +# bid_poll_timeout_ms = 500 +# Per-relay override of the ePBS bid-ranking execution-payment cap in Gwei (see the PBS-level +# `max_execution_payment_gwei`) +# OPTIONAL, DEFAULT: the PBS-level `max_execution_payment_gwei` (unset = unclamped) +# max_execution_payment_gwei = 1000000000 +# The ePBS auth data this relay serves: a bid request routes here only when its `auth.message.data` +# equals this value. When unset, the relay is matched only by auth data carrying its URL; auth data +# matching no configured relay is rejected with 400 +# OPTIONAL, DEFAULT: unset +# expected_auth_data = "0x68747470733a2f2f6275696c6465722e6578616d706c652e636f6d" # Configuration for the PBS multiplexers, which enable different configs to be used for get header requests, depending on validator pubkey # Note that: @@ -145,6 +208,20 @@ validator_pubkeys = [ "0x80c7f782b2467c5898c5516a8b6595d75623960b4afc4f71ee07d40985d20e117ba35e7cd352a3e75fb85a8668a3b745", "0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09fe73ccd21f88eab31d6de16194d17782e", ] +# Projection-only: consumed by KM tooling (builder_config projection for this mux's keys), not read +# by the PBS runtime +# OPTIONAL, DEFAULT: unset +# builder_boost_factor = 100 +# Projection-only: the per-key-group minimum total payment for this mux's keys, in ETH (same format +# as the PBS-level `min_bid_eth`). Consumed by KM tooling, not read by the PBS runtime +# OPTIONAL, DEFAULT: unset +# min_bid_eth = 0.0 +# Projection-only: the KEY-LEVEL p2p overrides for this mux's keys. Consumed by KM tooling, not read +# by the PBS runtime. When set they take precedence over the PBS-level `builder_boost_factor_p2p` / +# `min_bid_p2p_eth`; unset inherits the PBS-level value +# OPTIONAL, DEFAULT: unset (inherit the PBS-level p2p value) +# builder_boost_factor_p2p = 100 +# min_bid_p2p_eth = "0.2" # Loader for validator pubkeys. Three types of loaders are supported: # - File: path to a file containing a list of validator pubkeys in JSON format # - URL: URL to an HTTP endpoint returning a list of validator pubkeys in JSON format diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 7350b85c..41cbae21 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,10 +54,10 @@ tracing-appender.workspace = true tracing-subscriber.workspace = true tree_hash.workspace = true tree_hash_derive.workspace = true +typenum.workspace = true unicode-normalization.workspace = true url.workspace = true uuid.workspace = true -reqwest-eventsource.workspace = true [dev-dependencies] - tempfile.workspace = true \ No newline at end of file + tempfile.workspace = true diff --git a/crates/common/src/config/log.rs b/crates/common/src/config/log.rs index a792ebc8..0235ba1a 100644 --- a/crates/common/src/config/log.rs +++ b/crates/common/src/config/log.rs @@ -16,7 +16,9 @@ pub struct LogsSettings { impl LogsSettings { pub fn from_env_config() -> Result { - let (mut config, _) = CommitBoostConfig::from_env_path()?; + // Silent load: the service's primary config load already warns about + // unknown mux fields; warning here too would log each key twice + let (mut config, _) = CommitBoostConfig::from_env_path_silent()?; // Override log dir path if env var is set if let Some(log_dir) = load_optional_env_var(LOGS_DIR_ENV) { diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index e0958342..a21dd70a 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -52,17 +52,33 @@ impl CommitBoostConfig { ) } + for relay in self.relays.iter() { + relay.validate()?; + } + Ok(()) } pub fn from_file(path: &PathBuf) -> Result { let (config, _): (Self, _) = load_from_file(path)?; + warn_unknown_mux_fields(path); + warn_unknown_pbs_fields(path); Ok(config) } // When loading the config from the environment, it's important that every path // is replaced with the correct value if the config is loaded inside a container pub fn from_env_path() -> Result<(Self, PathBuf)> { + let (config, config_path) = Self::from_env_path_silent()?; + warn_unknown_mux_fields(&config_path); + warn_unknown_pbs_fields(&config_path); + Ok((config, config_path)) + } + + /// [`Self::from_env_path`] without the unknown-mux-field warnings. For + /// secondary loads (e.g. log settings) in processes whose primary config + /// load already warns, so each unknown key is logged once per process. + pub(crate) fn from_env_path_silent() -> Result<(Self, PathBuf)> { let (helper_config, config_path): (HelperConfig, PathBuf) = load_file_from_env(CONFIG_ENV)?; let chain = match helper_config.chain { diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index 1b1f0c38..632d2587 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -24,7 +24,7 @@ use crate::{ interop::{lido::utils::*, ssv::utils::*, stader::utils::*}, pbs::RelayClient, types::{BlsPublicKey, Chain, StaderPool}, - utils::default_bool, + utils::{as_opt_eth_str, default_bool}, wire::safe_read_http_response, }; @@ -100,8 +100,24 @@ impl PbsMuxes { "using mux" ); + // Serde-renamed names, so the message column is explicit + for (present, name) in [ + (mux.builder_boost_factor.is_some(), "builder_boost_factor"), + (mux.min_bid_wei.is_some(), "min_bid_eth"), + (mux.builder_boost_factor_p2p.is_some(), "builder_boost_factor_p2p"), + (mux.min_bid_p2p_wei.is_some(), "min_bid_p2p_eth"), + ] { + if present { + info!( + "field {name} on mux {} is applied via KM tooling, not by the PBS runtime", + mux.id + ); + } + } + let mut relay_clients = Vec::with_capacity(mux.relays.len()); for config in mux.relays.into_iter() { + config.validate()?; relay_clients.push(RelayClient::new(config)?); } @@ -153,6 +169,32 @@ pub struct MuxConfig { pub loader: Option, pub timeout_get_header_ms: Option, pub late_in_slot_time_ms: Option, + // The projection-only fields below are consumed by KM tooling, not read by + // the PBS runtime. + /// The ePBS builder_boost_factor for this mux's keys + #[serde(skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, + /// The ePBS per-key-group minimum total payment for this mux's keys + #[serde( + rename = "min_bid_eth", + with = "as_opt_eth_str", + default, + skip_serializing_if = "Option::is_none" + )] + pub min_bid_wei: Option, + /// The ePBS KEY-LEVEL builder_boost_factor governing p2p bids for this + /// mux's keys. Overrides the global `[pbs] builder_boost_factor_p2p`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_boost_factor_p2p: Option, + /// The ePBS KEY-LEVEL minimum total payment governing p2p bids for this + /// mux's keys. Overrides the global `[pbs] min_bid_p2p_eth`. + #[serde( + rename = "min_bid_p2p_eth", + with = "as_opt_eth_str", + default, + skip_serializing_if = "Option::is_none" + )] + pub min_bid_p2p_wei: Option, } impl MuxConfig { @@ -300,12 +342,62 @@ impl MuxKeysLoader { }, }?; - // Remove duplicates let deduped_keys = remove_duplicate_keys(keys); Ok(deduped_keys) } } +/// The keys serde recognizes on a `[[mux]]` table. Kept in lockstep with +/// [`MuxConfig`] (use the serde-renamed form). +const KNOWN_MUX_FIELDS: &[&str] = &[ + "id", + "relays", + "validator_pubkeys", + "loader", + "timeout_get_header_ms", + "late_in_slot_time_ms", + "builder_boost_factor", + "min_bid_eth", + "builder_boost_factor_p2p", + "min_bid_p2p_eth", +]; + +/// Unknown keys on the `[[mux]]` tables of a raw config document, as +/// (mux id, key) pairs. `MuxConfig` cannot take `serde(deny_unknown_fields)` +/// (it would reject previously-valid configs carrying stray fields), so typo +/// visibility comes from this extra pass over the raw TOML instead. +pub fn unknown_mux_fields(raw: &toml::Value) -> Vec<(String, String)> { + let Some(muxes) = raw.get("mux").and_then(|value| value.as_array()) else { + return Vec::new(); + }; + + let mut unknown = Vec::new(); + for (i, mux) in muxes.iter().enumerate() { + let Some(table) = mux.as_table() else { continue }; + let id = table + .get("id") + .and_then(|value| value.as_str()) + .map(str::to_owned) + .unwrap_or_else(|| format!("#{i}")); + for key in table.keys() { + if !KNOWN_MUX_FIELDS.contains(&key.as_str()) { + unknown.push((id.clone(), key.clone())); + } + } + } + unknown +} + +/// WARN-logs every unknown `[[mux]]` key in the config file at `path`. +/// Best-effort: unreadable/unparseable input is serde's problem to report. +pub fn warn_unknown_mux_fields(path: &Path) { + let Ok(raw) = std::fs::read_to_string(path) else { return }; + let Ok(value) = raw.parse::() else { return }; + for (mux_id, key) in unknown_mux_fields(&value) { + warn!("unknown field `{key}` on mux `{mux_id}` is ignored by the PBS runtime"); + } +} + fn load_file + std::fmt::Debug>(path: P) -> eyre::Result { std::fs::read_to_string(&path).wrap_err(format!("Unable to find mux keys file: {path:?}")) } @@ -547,3 +639,119 @@ async fn fetch_ssv_pubkeys_from_public_api( Ok(pubkeys) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_projection_only_mux_fields() { + let mux: MuxConfig = toml::from_str( + r#" + id = "test" + relays = [] + builder_boost_factor = 120 + min_bid_eth = "0.5" + "#, + ) + .unwrap(); + assert_eq!(mux.builder_boost_factor, Some(120)); + assert_eq!(mux.min_bid_wei, Some(U256::from(500_000_000_000_000_000u64))); + + // p2p projection fields parse the same way as their non-p2p siblings + let mux: MuxConfig = toml::from_str( + r#" + id = "test" + relays = [] + builder_boost_factor_p2p = 90 + min_bid_p2p_eth = "0.2" + "#, + ) + .unwrap(); + assert_eq!(mux.builder_boost_factor_p2p, Some(90)); + assert_eq!(mux.min_bid_p2p_wei, Some(U256::from(200_000_000_000_000_000u64))); + + // Float form, matching the global min_bid_eth + let mux: MuxConfig = toml::from_str( + r#" + id = "test" + relays = [] + min_bid_eth = 0.5 + "#, + ) + .unwrap(); + assert_eq!(mux.min_bid_wei, Some(U256::from(500_000_000_000_000_000u64))); + + // Absent fields stay None (legacy configs parse unchanged) + let mux: MuxConfig = toml::from_str( + r#" + id = "test" + relays = [] + "#, + ) + .unwrap(); + assert_eq!(mux.builder_boost_factor, None); + assert_eq!(mux.min_bid_wei, None); + assert_eq!(mux.builder_boost_factor_p2p, None); + assert_eq!(mux.min_bid_p2p_wei, None); + } + + #[test] + fn mux_config_none_fields_roundtrip() { + // None-valued KM projection fields must be skipped on serialization so + // a config re-serialized to TOML stays loadable + let mux: MuxConfig = toml::from_str( + r#" + id = "test" + relays = [] + "#, + ) + .unwrap(); + assert_eq!(mux.builder_boost_factor, None); + assert_eq!(mux.min_bid_wei, None); + + let serialized = toml::to_string(&mux).unwrap(); + assert!(!serialized.contains("builder_boost_factor")); + assert!(!serialized.contains("min_bid_eth")); + assert!(!serialized.contains("min_bid_p2p_eth")); + + let roundtripped: MuxConfig = toml::from_str(&serialized).unwrap(); + assert_eq!(roundtripped.builder_boost_factor, None); + assert_eq!(roundtripped.min_bid_wei, None); + assert_eq!(roundtripped.builder_boost_factor_p2p, None); + assert_eq!(roundtripped.min_bid_p2p_wei, None); + } + + #[test] + fn unknown_mux_fields_flags_typos_only() { + let raw: toml::Value = r#" + [[mux]] + id = "a" + relays = [] + builder_boost_factor = 100 + min_bid_eth = "0.1" + builder_boost_factor_p2p = 100 + min_bid_p2p_eth = "0.1" + bulder_boost_factor = 100 + + [[mux]] + id = "b" + relays = [] + timeout_get_header_ms = 900 + + [[mux]] + relays = [] + stray = 1 + "# + .parse() + .unwrap(); + assert_eq!(unknown_mux_fields(&raw), vec![ + ("a".to_string(), "bulder_boost_factor".to_string()), + ("#2".to_string(), "stray".to_string()), + ]); + + // No [[mux]] tables at all + let raw: toml::Value = "[pbs]\nport = 1".parse().unwrap(); + assert!(unknown_mux_fields(&raw).is_empty()); + } +} diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index c261db59..ac9f4bc4 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -3,17 +3,18 @@ use std::{ collections::HashMap, net::{Ipv4Addr, SocketAddr}, - path::PathBuf, + path::{Path, PathBuf}, sync::Arc, }; use alloy::{ - primitives::{U256, utils::format_ether}, + primitives::{Bytes, U256, utils::format_ether}, providers::{Provider, ProviderBuilder}, }; use docker_image::DockerImage; use eyre::{Result, ensure}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use tracing::{info, warn}; use url::Url; use super::{ @@ -29,15 +30,17 @@ 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::{ - WEI_PER_ETH, as_eth_str, default_bool, default_host, default_u16, default_u32, default_u64, - default_u256, + WEI_PER_ETH, as_eth_str, as_opt_eth_str, default_bool, default_host, default_u16, + default_u32, default_u64, default_u256, }, }; +/// How CB fetches get_header bids from this relay: `Http` = request/response, +/// `Stream` = the relay's WebSocket bid stream. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum GetHeaderTransport { @@ -58,7 +61,6 @@ pub struct RelayConfig { pub headers: Option>, /// Optional GET parameters to add to each request pub get_params: Option>, - /// How to fetch headers from this relay #[serde(default)] pub get_header: GetHeaderTransport, /// Whether to enable timing games @@ -68,10 +70,20 @@ pub struct RelayConfig { pub target_first_request_ms: Option, /// Frequency in ms to send get_header requests pub frequency_get_header_ms: Option, + /// How long each ePBS bid poll may take, except the last which holds until + /// the proposer's deadline + pub bid_poll_timeout_ms: Option, /// Maximum number of validators to send to relays in one registration /// request #[serde(deserialize_with = "empty_string_as_none", default)] pub validator_registration_batch_size: Option, + /// Per-relay override of the ePBS bid-ranking execution-payment cap in + /// Gwei (see `PbsConfig::max_execution_payment_gwei`) + pub max_execution_payment_gwei: Option, + /// ePBS auth data this relay serves: a bid request routes here only when + /// its `auth.message.data` equals this value. When unset, the relay is + /// matched only by auth data carrying its URL + pub expected_auth_data: Option, } fn empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> @@ -98,6 +110,19 @@ impl RelayConfig { pub fn id(&self) -> &str { self.id.as_deref().unwrap_or(self.entry.id.as_str()) } + + /// Validate relay-level knobs the PBS runtime reads directly. The timing + /// knobs are optional, but a zero would stall the ePBS bid poll / timing + /// games rather than mean "unset", so reject it explicitly. + pub fn validate(&self) -> Result<()> { + if let Some(ms) = self.bid_poll_timeout_ms { + ensure!(ms > 0, "bid_poll_timeout_ms must be greater than 0 when set"); + } + if let Some(ms) = self.frequency_get_header_ms { + ensure!(ms > 0, "frequency_get_header_ms must be greater than 0 when set"); + } + Ok(()) + } } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -129,12 +154,43 @@ 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" + /// Execution-payment cap in Gwei used when RANKING ePBS bids: a bid ranks + /// at `value + min(execution_payment, cap)`, mirroring the BN's valuation + /// (beacon-APIs #630 clamps at `max_execution_payment` instead of + /// rejecting). Not an accept/reject check; the BN enforces the cap. + /// Default: unset (None) = unclamped + #[serde(default)] + pub max_execution_payment_gwei: Option, + /// When enabled, the BLS signature of an ePBS request's + /// `SignedBuilderRequestAuth` is verified against the proposer pubkey. + /// False by default: CB forwards because the downstream builder must + /// re-verify anyway; operators terminating trust at CB set it true + #[serde(default = "default_bool::")] + pub verify_builder_request_auth: bool, + /// How late in the slot we consider to be "late" (legacy get_header path) #[serde(default = "default_u64::")] pub late_in_slot_time_ms: u64, + /// ePBS bid path only: ms reserved before the proposer's declared deadline + /// (Date-Milliseconds + X-Timeout-Ms) for the winning bid's return trip to + /// the beacon node and the beacon node's own selection/assembly. CB asks + /// the builder for `deadline - this`, deriving its timeout from the + /// BN's live X-Timeout-Ms instead of a static config; + /// timeout_get_header_ms and late_in_slot_time_ms (legacy get_header + /// knobs, which carry no X-Timeout-Ms) are not consulted on the ePBS + /// bid path. + #[serde(default = "default_u64::")] + pub proposer_deadline_buffer_ms: u64, /// Enable extra validation of get_header responses #[serde(default = "default_bool::")] pub extra_validation_enabled: bool, + /// Opt-in strict decoding of the reveal at POST + /// /eth/v1/builder/beacon_blocks. Default (false): CB is a blind pipe, + /// forwarding the block bytes to the builder without parsing them (the + /// builder validates and rejects, per builder-specs). When true: CB + /// decodes the SignedBeaconBlock, rejects a non-gloas or undecodable + /// body with 400, and re-encodes it outbound. + #[serde(default = "default_bool::")] + pub strict_block_decode: bool, /// Execution Layer RPC url to use for extra validation pub rpc_url: Option, /// URL for the user's own SSV node API endpoint @@ -158,6 +214,25 @@ pub struct PbsConfig { /// from the registry, in seconds #[serde(default = "default_u64::<{ DEFAULT_REGISTRY_REFRESH_SECONDS }>")] pub mux_registry_refresh_interval_seconds: u64, + /// CB's externally-reachable URLs; used by the ePBS pipe self-URL guard + #[serde(default)] + pub advertised_urls: Vec, + // The p2p projection-only fields below are consumed by KM tooling, not read + // by the PBS runtime. + /// The ePBS KEY-LEVEL minimum total payment: it governs p2p bids and + /// builder entries that omit their own min_bid (projected entries always + /// carry explicit per-entry values sourced from the mux/global min_bid) + #[serde( + rename = "min_bid_p2p_eth", + with = "as_opt_eth_str", + default, + skip_serializing_if = "Option::is_none" + )] + pub min_bid_p2p_wei: Option, + /// The ePBS KEY-LEVEL builder_boost_factor: it governs p2p bids and + /// builder entries that omit their own (entry values stay mux-sourced) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_boost_factor_p2p: Option, } impl PbsConfig { @@ -172,6 +247,24 @@ impl PbsConfig { ); ensure!(self.late_in_slot_time_ms > 0, "late_in_slot_time_ms must be greater than 0"); + // The buffer is subtracted from the proposer's own deadline; a value at + // or above one slot would leave no time for the bid poll. 0 is allowed + // (no reserve). + const MAX_PROPOSER_DEADLINE_BUFFER_MS: u64 = 12_000; + ensure!( + self.proposer_deadline_buffer_ms < MAX_PROPOSER_DEADLINE_BUFFER_MS, + "proposer_deadline_buffer_ms must be less than one slot ({MAX_PROPOSER_DEADLINE_BUFFER_MS} ms)" + ); + + if self.min_bid_p2p_wei.is_some() { + info!("field min_bid_p2p_eth is applied via KM tooling, not by the PBS runtime"); + } + if self.builder_boost_factor_p2p.is_some() { + info!( + "field builder_boost_factor_p2p is applied via KM tooling, not by the PBS runtime" + ); + } + ensure!( self.timeout_get_header_ms < self.late_in_slot_time_ms, "timeout_get_header_ms must be less than late_in_slot_time_ms" @@ -294,7 +387,6 @@ pub async fn load_pbs_config(config_path: Option) -> Result<(PbsModuleC SocketAddr::from((config.pbs.pbs_config.host, config.pbs.pbs_config.port)) }; - // Get the list of relays from the default config let relay_clients = config.relays.into_iter().map(RelayClient::new).collect::>>()?; let mut all_relays = HashMap::with_capacity(relay_clients.len()); @@ -309,6 +401,10 @@ pub async fn load_pbs_config(config_path: Option) -> Result<(PbsModuleC None => (None, None), }; + if mux_lookup.is_some() { + warn_if_transient_pipe_disabled(&config.pbs.pbs_config); + } + // Build the list of all relays, starting with muxes if let Some(muxes) = &mux_lookup { for (_, mux) in muxes.iter() { @@ -363,7 +459,9 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC } // load module config including the extra data (if any) - let (cb_config, _): (StubConfig, _) = load_file_from_env(CONFIG_ENV)?; + let (cb_config, config_path): (StubConfig, _) = load_file_from_env(CONFIG_ENV)?; + super::warn_unknown_mux_fields(&config_path); + warn_unknown_pbs_fields(&config_path); cb_config.pbs.static_config.validate(cb_config.chain).await?; // use endpoint from env if set, otherwise use default host and port @@ -376,7 +474,12 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC )) }; - // Get the list of relays from the default config + // Get the list of relays from the default config. Validate each first: the + // default binary validates top-level relays via `CommitBoostConfig::validate`, + // which this custom-module load path does not call. + for relay in cb_config.relays.iter() { + relay.validate()?; + } let relay_clients = cb_config.relays.into_iter().map(RelayClient::new).collect::>>()?; let mut all_relays = HashMap::with_capacity(relay_clients.len()); @@ -392,6 +495,10 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC None => (None, None), }; + if mux_lookup.is_some() { + warn_if_transient_pipe_disabled(&cb_config.pbs.static_config.pbs_config); + } + // Build the list of all relays, starting with muxes if let Some(muxes) = &mux_lookup { for (_, mux) in muxes.iter() { @@ -453,6 +560,23 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC )) } +/// The ePBS transient pipe (forwarding a bid/preferences request to a +/// proposer-addressed builder that is not in the relay config) is fail-closed +/// without advertised_urls: CB cannot tell an unconfigured key's self-URL +/// default from an external builder, so it will not dial. Warned once at +/// startup so this reads as a deliberate opt-in, not a silent 400 at request +/// time. +fn warn_if_transient_pipe_disabled(pbs_config: &PbsConfig) { + if pbs_config.advertised_urls.is_empty() { + warn!( + "advertised_urls is unset: the ePBS transient pipe is disabled, so a bid or \ + preferences request addressed to a builder not in your relay config is rejected \ + with 400. Set advertised_urls to CB's advertised URL(s) to enable forwarding to \ + proposer-addressed builders." + ); + } +} + /// Default URL for the user's SSV node API endpoint (/v1/validators). fn default_ssv_node_api_url() -> Url { Url::parse("http://localhost:16000/v1/").expect("default URL is valid") @@ -462,3 +586,108 @@ fn default_ssv_node_api_url() -> Url { fn default_public_ssv_api_url() -> Url { Url::parse("https://api.ssv.network/api/v4/").expect("default URL is valid") } + +/// The serde keys recognized on the `[pbs]` table: the `StaticPbsConfig` +/// wrapper (`docker_image`, `with_signer`) plus the flattened `PbsConfig` +/// fields in their serde-renamed form (e.g. `min_bid_eth`, not `min_bid_wei`). +/// Kept in lockstep with those two structs. +const KNOWN_PBS_FIELDS: &[&str] = &[ + // StaticPbsConfig wrapper + "docker_image", + "with_signer", + // PbsConfig (flattened) + "host", + "port", + "relay_check", + "wait_all_registrations", + "timeout_get_header_ms", + "timeout_get_payload_ms", + "timeout_register_validator_ms", + "skip_sigverify", + "min_bid_eth", + "max_execution_payment_gwei", + "verify_builder_request_auth", + "late_in_slot_time_ms", + "proposer_deadline_buffer_ms", + "extra_validation_enabled", + "strict_block_decode", + "rpc_url", + "ssv_node_api_url", + "ssv_public_api_url", + "http_timeout_seconds", + "register_validator_retry_limit", + "validator_registration_batch_size", + "mux_registry_refresh_interval_seconds", + "advertised_urls", + "min_bid_p2p_eth", + "builder_boost_factor_p2p", +]; + +/// Unknown keys on the `[pbs]` table of a raw config document. `PbsConfig` is +/// `#[serde(flatten)]`ed into `StaticPbsConfig`, and a flattened struct cannot +/// take `#[serde(deny_unknown_fields)]` (it would reject the outer struct's own +/// keys), so typo visibility comes from this extra pass over the raw TOML +/// instead (mirrors [`super::unknown_mux_fields`]). +pub fn unknown_pbs_fields(raw: &toml::Value) -> Vec { + let Some(table) = raw.get("pbs").and_then(|value| value.as_table()) else { + return Vec::new(); + }; + table.keys().filter(|key| !KNOWN_PBS_FIELDS.contains(&key.as_str())).cloned().collect() +} + +/// WARN-logs every unknown `[pbs]` key in the config file at `path`. +/// Best-effort: unreadable/unparseable input is serde's problem to report. +pub fn warn_unknown_pbs_fields(path: &Path) { + let Ok(raw) = std::fs::read_to_string(path) else { return }; + let Ok(value) = raw.parse::() else { return }; + for key in unknown_pbs_fields(&value) { + warn!("unknown field `{key}` on the [pbs] table is ignored by the PBS runtime"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Projection-only p2p fields: parsed for KM tooling, absent = None so + // existing configs project exactly as before. + #[test] + fn p2p_projection_fields_parse_and_default() { + let cfg: PbsConfig = toml::from_str("").unwrap(); + assert_eq!(cfg.min_bid_p2p_wei, None); + assert_eq!(cfg.builder_boost_factor_p2p, None); + + let cfg: PbsConfig = toml::from_str( + r#" + min_bid_p2p_eth = "0.2" + builder_boost_factor_p2p = 0 + "#, + ) + .unwrap(); + assert_eq!(cfg.min_bid_p2p_wei, Some(U256::from(200_000_000_000_000_000u64))); + assert_eq!(cfg.builder_boost_factor_p2p, Some(0)); + } + + // The false-positive trap: a renamed field (`min_bid_eth`) or a wrapper key + // (`docker_image`) must not be flagged as unknown. + #[test] + fn unknown_pbs_fields_flags_typos_only() { + let raw: toml::Value = r#" + [pbs] + docker_image = "x" + with_signer = false + min_bid_eth = 0.0 + max_execution_payment_gwei = 1000000000 + strict_block_decode = true + proposer_deadline_buffer_ms = 50 + skip_sigverify = false + bid_poll_timeout_ms = 500 + "# + .parse() + .unwrap(); + assert_eq!(unknown_pbs_fields(&raw), vec!["bid_poll_timeout_ms".to_string()]); + + let raw: toml::Value = "chain = \"Holesky\"".parse().unwrap(); + assert!(unknown_pbs_fields(&raw).is_empty()); + } +} diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 0ac6ce1b..2f3e2f5f 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -475,8 +475,12 @@ mod tests { timeout_register_validator_ms: 0, skip_sigverify: false, min_bid_wei: Uint::<256, 4>::from(0), + max_execution_payment_gwei: None, + verify_builder_request_auth: false, late_in_slot_time_ms: 0, + proposer_deadline_buffer_ms: 0, extra_validation_enabled: false, + strict_block_decode: false, rpc_url: None, http_timeout_seconds: 30, register_validator_retry_limit: 3, @@ -484,6 +488,9 @@ mod tests { mux_registry_refresh_interval_seconds: 5, ssv_node_api_url: Url::parse("https://example.net").unwrap(), ssv_public_api_url: Url::parse("https://example.net").unwrap(), + advertised_urls: vec![], + min_bid_p2p_wei: None, + builder_boost_factor_p2p: None, }, with_signer: true, }, diff --git a/crates/common/src/config/utils.rs b/crates/common/src/config/utils.rs index 9bcf1595..5e52dc8f 100644 --- a/crates/common/src/config/utils.rs +++ b/crates/common/src/config/utils.rs @@ -74,7 +74,6 @@ mod tests { use std::sync::Mutex; use super::*; - use crate::utils::TestRandomSeed; // Serializes all tests that read/write environment variables. // std::env::set_var is unsafe (Rust 1.81+) because mutating `environ` @@ -135,8 +134,10 @@ mod tests { #[test] fn test_remove_duplicate_keys() { - let key1 = BlsPublicKey::test_random(); - let key2 = BlsPublicKey::test_random(); + // Real, distinct keys: `arbitrary` for a validated point falls back to + // the (invalid) all-zeros pubkey, so derive from random secret keys. + let key1 = crate::types::BlsSecretKey::random().public_key(); + let key2 = crate::types::BlsSecretKey::random().public_key(); let keys = vec![key1.clone(), key2.clone(), key1.clone()]; let unique_keys = remove_duplicate_keys(keys); diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs index c075ed13..14e07a2b 100644 --- a/crates/common/src/constants.rs +++ b/crates/common/src/constants.rs @@ -1,4 +1,12 @@ pub const APPLICATION_BUILDER_DOMAIN: [u8; 4] = [0, 0, 0, 1]; +// In-protocol builder domain (consensus-specs DOMAIN_BEACON_BUILDER); not the +// legacy builder domain 0x00000001 nor the request-auth domain 0x0B000001. +pub const DOMAIN_BEACON_BUILDER: [u8; 4] = [0x0B, 0x00, 0x00, 0x00]; +// Out-of-protocol Builder API request-auth domain (builder-specs +// DOMAIN_BUILDER_REQUEST_AUTH), for `BuilderRequestAuth` only. +pub const DOMAIN_BUILDER_REQUEST_AUTH: [u8; 4] = [0x0B, 0x00, 0x00, 0x01]; +// TODO placeholders: gloas devnet fork version +pub const GLOAS_FORK_VERSION: [u8; 4] = [0x80, 0x43, 0x50, 0x48]; pub const GENESIS_VALIDATORS_ROOT: [u8; 32] = [0; 32]; pub const COMMIT_BOOST_DOMAIN: [u8; 4] = [109, 109, 111, 67]; pub const COMMIT_BOOST_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/common/src/pbs/constants.rs b/crates/common/src/pbs/constants.rs index 66ab42f0..6566295d 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,19 @@ impl DefaultTimeout { pub const LATE_IN_SLOT_TIME_MS: u64 = 2000; +/// ePBS bid path: ms reserved before the proposer's own deadline +/// (Date-Milliseconds + X-Timeout-Ms) for the winning bid's return trip to the +/// beacon node and the beacon node's own selection/assembly. CB asks the +/// builder for `proposer_deadline − this`. 50ms covers a same-host CB<->BN +/// comfortably. +pub const PROPOSER_DEADLINE_BUFFER_MS: u64 = 50; + +/// How long each ePBS bid poll may take before the next one supersedes it. Set +/// generously: a proposer far from its builders needs more than the poll +/// cadence to land any bid at all, and a value below the round trip would time +/// out every poll. The final poll ignores this and holds until the deadline. +pub const DEFAULT_BID_POLL_TIMEOUT_MS: u64 = 500; + // Maximum number of retries for validator registration request per relay pub const REGISTER_VALIDATOR_RETRY_LIMIT: u32 = 3; diff --git a/crates/common/src/pbs/error.rs b/crates/common/src/pbs/error.rs index 8fcd928f..b2d1c01d 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, .. }) => { @@ -93,6 +93,9 @@ pub enum ValidationError { #[error("parent hash mismatch: expected {expected} got {got}")] ParentHashMismatch { expected: B256, got: B256 }, + #[error("parent root mismatch: expected {expected} got {got}")] + ParentRootMismatch { expected: B256, got: B256 }, + #[error("block hash mismatch: expected {expected} got {got}")] BlockHashMismatch { expected: B256, got: B256 }, @@ -112,6 +115,9 @@ pub enum ValidationError { #[error("bid below minimum: min: {min} got {got}")] BidTooLow { min: U256, got: U256 }, + #[error("empty parent root")] + EmptyParentRoot, + #[error("empty tx root")] EmptyTxRoot, @@ -124,6 +130,9 @@ pub enum ValidationError { #[error("wrong block number: parent: {parent} header: {header}")] BlockNumberMismatch { parent: u64, header: u64 }, + #[error("wrong slot number: expected: {expected} got: {got}")] + SlotNumberMismatch { expected: u64, got: u64 }, + #[error("invalid gas limit: parent: {parent} header: {header}")] GasLimit { parent: u64, header: u64 }, diff --git a/crates/common/src/pbs/relay.rs b/crates/common/src/pbs/relay.rs index 91327a95..c6979088 100644 --- a/crates/common/src/pbs/relay.rs +++ b/crates/common/src/pbs/relay.rs @@ -10,6 +10,7 @@ use super::{ HEADER_VERSION_KEY, HEADER_VERSION_VALUE, constants::{ GET_HEADER_STREAM_PATH, GET_STATUS_PATH, REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, + SUBMIT_SIGNED_BEACON_BLOCK_PATH, }, error::PbsError, }; @@ -92,35 +93,51 @@ pub struct RelayClient { pub config: Arc, } +/// The baseline outbound headers for a relay: the CommitBoost version header +/// plus any operator-configured custom headers. Shared by the client's default +/// headers and the get_header stream handshake so the two cannot diverge. +fn relay_headers(config: &RelayConfig) -> eyre::Result { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_VERSION_KEY, HeaderValue::from_static(HEADER_VERSION_VALUE)); + + if let Some(custom_headers) = &config.headers { + for (key, value) in custom_headers { + headers.insert( + HeaderName::from_str(key).wrap_err("{key} is an invalid header name")?, + HeaderValue::from_str(value).wrap_err("{key} has an invalid header value")?, + ); + } + } + + Ok(headers) +} + impl RelayClient { pub fn new(config: RelayConfig) -> eyre::Result { + let client = reqwest::Client::builder() + .default_headers(relay_headers(&config)?) + .timeout(DEFAULT_REQUEST_TIMEOUT) + .build()?; + + Self::with_client(config, client) + } + + /// Builds a relay client that reuses an existing `reqwest::Client` (its + /// connection pool and TLS config) instead of constructing a fresh one. + /// The ePBS transient pipe dials per request, so it reuses one process-wide + /// client rather than paying a cold client build on every dial. Otherwise + /// identical to [`RelayClient::new`]. + pub fn with_client(config: RelayConfig, client: reqwest::Client) -> eyre::Result { let stream_url = match config.get_header { GetHeaderTransport::Http => None, GetHeaderTransport::Stream => Some(stream_url(&config.entry.url)?), }; - let mut headers = HeaderMap::new(); - headers.insert(HEADER_VERSION_KEY, HeaderValue::from_static(HEADER_VERSION_VALUE)); - - if let Some(custom_headers) = &config.headers { - for (key, value) in custom_headers { - headers.insert( - HeaderName::from_str(key).wrap_err("{key} is an invalid header name")?, - HeaderValue::from_str(value).wrap_err("{key} has an invalid header value")?, - ); - } - } - - let client = reqwest::Client::builder() - .default_headers(headers.clone()) - .timeout(DEFAULT_REQUEST_TIMEOUT) - .build()?; - Ok(Self { id: Arc::new(config.id().to_owned()), client, stream_url, - stream_headers: Arc::new(headers), + stream_headers: Arc::new(relay_headers(&config)?), config: Arc::new(config), }) } @@ -200,6 +217,39 @@ impl RelayClient { pub fn submit_block_url(&self, api_version: BuilderApiVersion) -> Result { self.builder_api_url(SUBMIT_BLOCK_PATH, api_version) } + + /// builder-API: POST /eth/v1/builder/execution_payload_bid/{slot}/ + /// {parent_hash}/{parent_root}/{proposer_pubkey} + pub fn get_execution_payload_bid_url( + &self, + slot: u64, + parent_hash: &B256, + parent_root: &B256, + validator_pubkey: &BlsPublicKey, + ) -> Result { + self.builder_api_url( + &format!( + "/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{validator_pubkey}" + ), + BuilderApiVersion::V1, + ) + } + + /// builder-API: POST /eth/v1/builder/builder_preferences/{proposer_pubkey} + pub fn submit_builder_preferences_url( + &self, + validator_pubkey: &BlsPublicKey, + ) -> Result { + self.builder_api_url( + &format!("/builder_preferences/{validator_pubkey}"), + BuilderApiVersion::V1, + ) + } + + /// builder-API: POST /eth/v1/builder/beacon_blocks + pub fn submit_signed_beacon_block_url(&self) -> Result { + self.builder_api_url(SUBMIT_SIGNED_BEACON_BLOCK_PATH, BuilderApiVersion::V1) + } } #[cfg(test)] @@ -273,9 +323,8 @@ mod tests { let validator_pubkey = bls_pubkey_from_hex_unchecked( "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", ); - // Note: HashMap iteration order is not guaranteed, so we can't predict the - // exact order of parameters Instead of hard-coding the order, we'll - // check that both parameters are present in the URL + // HashMap iteration order is not guaranteed, so assert both parameters are + // present rather than hard-coding their order in the URL. let url_prefix = format!( "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz/eth/v1/builder/header/{slot}/{parent_hash}/{validator_pubkey}?" ); @@ -401,4 +450,85 @@ mod tests { let config = serde_json::from_str::(relay_config).unwrap(); assert_eq!(config.get_header, GetHeaderTransport::Http); } + + #[test] + fn test_relay_url_get_execution_payload() { + let slot = 0; + let parent_hash = B256::ZERO; + let parent_root = B256::ZERO; + let validator_pubkey = bls_pubkey_from_hex_unchecked( + "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", + ); + let expected = format!( + "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz/eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{validator_pubkey}" + ); + + let relay_config = r#" + { + "url": "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz" + }"#; + + let config = serde_json::from_str::(relay_config).unwrap(); + let relay = RelayClient::new(config).unwrap(); + + assert_eq!( + relay + .get_execution_payload_bid_url(slot, &parent_hash, &parent_root, &validator_pubkey) + .unwrap() + .to_string(), + expected + ); + + let relay_config = r#" + { + "url": "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz//" + }"#; + + let config = serde_json::from_str::(relay_config).unwrap(); + let relay = RelayClient::new(config).unwrap(); + + assert_eq!( + relay + .get_execution_payload_bid_url(slot, &parent_hash, &parent_root, &validator_pubkey) + .unwrap() + .to_string(), + expected + ); + } + + #[test] + fn test_relay_url_with_get_params_get_execution_payload() { + let slot = 0; + let parent_hash = B256::ZERO; + let parent_root = B256::ZERO; + let validator_pubkey = bls_pubkey_from_hex_unchecked( + "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", + ); + // HashMap iteration order is not guaranteed, so assert both parameters are + // present rather than hard-coding their order in the URL. + let url_prefix = format!( + "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz/eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{validator_pubkey}?" + ); + + let mut get_params = HashMap::new(); + get_params.insert("param1".to_string(), "value1".to_string()); + get_params.insert("param2".to_string(), "value2".to_string()); + + let relay_config = r#" + { + "url": "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz" + }"#; + + let mut config = serde_json::from_str::(relay_config).unwrap(); + config.get_params = Some(get_params); + let relay = RelayClient::new(config).unwrap(); + + let url = relay + .get_execution_payload_bid_url(slot, &parent_hash, &parent_root, &validator_pubkey) + .unwrap() + .to_string(); + assert!(url.starts_with(&url_prefix)); + assert!(url.contains("param1=value1")); + assert!(url.contains("param2=value2")); + } } diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index 738221a8..1c968309 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -1,10 +1,13 @@ use alloy::primitives::{B256, U256, b256}; pub use lh_eth2::ForkVersionedResponse; pub use lh_types::ForkName; -use lh_types::{BlindedPayload, ExecPayload, MainnetEthSpec}; +use lh_types::{BlindedPayload, ExecPayload, MainnetEthSpec, Slot}; use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use tree_hash_derive::TreeHash; -use crate::types::BlsPublicKey; +use crate::types::{BlsPublicKey, BlsSignature}; pub const EMPTY_TX_ROOT_HASH: B256 = b256!("7ffe241ea60187fdb0187bfa22de35d1f9bed7ab061d9401fd47e34a54fbede1"); @@ -13,6 +16,8 @@ pub type ExecutionRequests = lh_types::ExecutionRequests; /// Request object of POST `/eth/v1/builder/blinded_blocks` pub type SignedBlindedBeaconBlock = lh_types::SignedBlindedBeaconBlock; +/// Request object of POST `/eth/v1/builder/beacon_blocks` (Gloas onwards) +pub type SignedBeaconBlock = lh_types::SignedBeaconBlock; pub type BlindedBeaconBlock<'a> = lh_types::BeaconBlockRef<'a, MainnetEthSpec, BlindedPayload>; pub type BlindedBeaconBlockElectra = @@ -62,6 +67,78 @@ pub struct GetHeaderParams { pub pubkey: BlsPublicKey, } +pub type ExecutionPayloadBid = lh_types::ExecutionPayloadBid; +pub type SignedExecutionPayloadBid = lh_types::SignedExecutionPayloadBid; + +/// Whether `block` is a Gloas block. The submit endpoint is Gloas-only per +/// spec. +pub fn is_gloas(block: &SignedBeaconBlock) -> bool { + matches!(block, lh_types::SignedBeaconBlock::Gloas(_)) +} + +/// Response object of POST +/// `/eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/ +/// {proposer_pubkey}` +pub type GetExecutionPayloadBidResponse = ForkVersionedResponse; + +pub trait GetExecutionPayloadBidInfo { + fn block_hash(&self) -> B256; + fn parent_hash(&self) -> B256; + fn parent_root(&self) -> B256; + fn value(&self) -> u64; + fn execution_payment(&self) -> u64; + fn builder_index(&self) -> u64; + fn slot(&self) -> u64; + fn gas_limit(&self) -> u64; +} + +impl GetExecutionPayloadBidInfo for GetExecutionPayloadBidResponse { + fn block_hash(&self) -> B256 { + self.data.message.block_hash.0 + } + + fn parent_hash(&self) -> B256 { + self.data.message.parent_block_hash.0 + } + + fn parent_root(&self) -> B256 { + self.data.message.parent_block_root + } + + fn value(&self) -> u64 { + self.data.message.value + } + + fn execution_payment(&self) -> u64 { + self.data.message.execution_payment + } + + fn builder_index(&self) -> u64 { + self.data.message.builder_index + } + + fn slot(&self) -> u64 { + self.data.message.slot.as_u64() + } + + fn gas_limit(&self) -> u64 { + self.data.message.gas_limit + } +} + +/// Path params of POST +/// `/eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/ +/// {proposer_pubkey}` +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GetExecutionPayloadBidParams { + 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 +184,176 @@ impl GetPayloadInfo for SignedBlindedBeaconBlock { self.message().body().execution_payload().map(|r| r.parent_hash().0).unwrap_or_default() } } + +#[allow(non_camel_case_types)] +pub type MAX_BUILDER_AUTH_DATA_SIZE = typenum::U4096; + +// `BuilderRequestAuth` is used to authenticate requests to a builder. This is +// useful so that other builders do not DDOS or run replay attacks on the +// builder. +#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone, TreeHash)] +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. +/// SSZ field order `(preferences, auth)` per builder-specs +/// `types/gloas/builder_preferences.yaml`. +#[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::*; + + /// `data` is an opaque hex STRING on the wire + #[test] + fn test_builder_request_auth_data_serializes_as_hex() { + let auth = SignedBuilderRequestAuth { + message: BuilderRequestAuth { + data: VariableList::new(vec![0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]) + .unwrap(), + slot: Slot::new(100), + }, + signature: BlsSignature::empty(), + }; + let json = serde_json::to_value(&auth).unwrap(); + assert_eq!(json["message"]["data"], "0x1234567890abcdef"); + assert_eq!(json["message"]["slot"], "100"); + } + + /// Round-trip the spec's wire shape back into the struct + #[test] + fn test_builder_request_auth_deserializes_spec_json() { + let json = r#"{ + "message": { + "data": "0x1234567890abcdef", + "slot": "100" + }, + "signature": "0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }"#; + let auth: SignedBuilderRequestAuth = serde_json::from_str(json).unwrap(); + assert_eq!(auth.message.data.to_vec(), vec![ + 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef + ]); + assert_eq!(auth.message.slot, Slot::new(100)); + } + + /// Spec vector for the SSZ layout of `BuilderPreferencesRequest` (field + /// order documented on the struct). The order-determining fixed part is + /// cross-checked byte-for-byte against the canonical example + /// `examples/gloas/builder_preferences_request.ssz`. + #[test] + fn test_builder_preferences_request_ssz_spec_vector() { + 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); + } + + /// Regression guard for the lighthouse-unstable bump that unlocks real ePBS + /// bid signature verification. Under EIP-7495 progressive containers the + /// gloas `ExecutionPayloadBid` must tree-hash to the go-eth2-client ground + /// truth root `0x04f8e548…d268` (fixture captured from a devnet builder + /// bid, slot 297). This root is the object the builder signs; a silent + /// progressive-hashing regression here would forge a different signing root + /// and break every bid signature check, so pin it byte-for-byte. + #[test] + fn test_gloas_execution_payload_bid_progressive_tree_hash_root() { + use tree_hash::TreeHash; + + let bid_json = r#"{ + "parent_block_hash": "0x8f44cac724ae314ba6e846c745d7c2749985e5ef5ca5ce64c3edeaa05eab022a", + "parent_block_root": "0xfa8193d2de4cdf9fa3a038ab6fe5e451fe50efa8c143d957aee7a778a32e948a", + "block_hash": "0xc2687f3daaaa8e1db9335a33659cb79cc693c8b4f98ba2ad44b3c5deeedcff9c", + "prev_randao": "0x4af131519d8829635bba95750c1b6276d602c9370785091eaeb2906dd6e86258", + "fee_recipient": "0x8943545177806ED17B9F23F0a21ee5948eCaa776", + "gas_limit": "200000000", + "builder_index": "0", + "slot": "297", + "value": "101000000", + "execution_payment": "0", + "blob_kzg_commitments": [], + "execution_requests_root": "0x87b69a306c8e430d0857f7c4ac5e27cecffa1108d43c2e5df7388056fea7a423" + }"#; + + let bid: ExecutionPayloadBid = + serde_json::from_str(bid_json).expect("deserialize gloas ExecutionPayloadBid"); + let root = bid.tree_hash_root(); + + assert_eq!( + root.to_string(), + "0x04f8e548db621e7908410cde0fe878d29e3d9778c63cd3c71eaca336d8f7d268", + "progressive-container tree-hash root regressed" + ); + } +} diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index 41631e33..0770991e 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -3,7 +3,10 @@ use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ - constants::{COMMIT_BOOST_DOMAIN, GENESIS_VALIDATORS_ROOT}, + constants::{ + COMMIT_BOOST_DOMAIN, DOMAIN_BEACON_BUILDER, DOMAIN_BUILDER_REQUEST_AUTH, + GENESIS_VALIDATORS_ROOT, + }, signer::{EcdsaSignature, verify_bls_signature, verify_ecdsa_signature}, types::{self, BlsPublicKey, BlsSecretKey, BlsSignature, Chain, SignatureRequestInfo}, }; @@ -35,10 +38,21 @@ pub fn compute_prop_commit_signing_root( } } -// NOTE: this currently works only for builder domain signatures and -// verifications -// ref: https://github.com/ralexstokes/ethereum-consensus/blob/cf3c404043230559660810bc0c9d6d5a8498d819/ethereum-consensus/src/builder/mod.rs#L26-L29 +// Signing domain from a chain + 4-byte domain mask (genesis fork version, zero +// root). ref: https://github.com/ralexstokes/ethereum-consensus/blob/cf3c404043230559660810bc0c9d6d5a8498d819/ethereum-consensus/src/builder/mod.rs#L26-L29 pub fn compute_domain(chain: Chain, domain_mask: &B32) -> B256 { + compute_domain_with_fork_version( + chain.genesis_fork_version(), + GENESIS_VALIDATORS_ROOT.into(), + domain_mask, + ) +} + +pub fn compute_domain_with_fork_version( + fork_version: [u8; 4], + genesis_validators_root: B256, + domain_mask: &B32, +) -> B256 { #[derive(Debug, TreeHash)] struct ForkData { fork_version: [u8; 4], @@ -48,8 +62,7 @@ pub fn compute_domain(chain: Chain, domain_mask: &B32) -> B256 { let mut domain = [0u8; 32]; domain[..4].copy_from_slice(&domain_mask.0); - let fork_version = chain.genesis_fork_version(); - let fd = ForkData { fork_version, genesis_validators_root: GENESIS_VALIDATORS_ROOT.into() }; + let fd = ForkData { fork_version, genesis_validators_root }; let fork_data_root = fd.tree_hash_root(); domain[4..].copy_from_slice(&fork_data_root[..28]); @@ -57,6 +70,49 @@ pub fn compute_domain(chain: Chain, domain_mask: &B32) -> B256 { B256::from(domain) } +/// ePBS bid signing domain, mirroring consensus-specs +/// `get_domain(state, DOMAIN_BEACON_BUILDER)`. +pub fn execution_payload_bid_domain(fork_version: [u8; 4], genesis_validators_root: B256) -> B256 { + compute_domain_with_fork_version( + fork_version, + genesis_validators_root, + &B32::from(DOMAIN_BEACON_BUILDER), + ) +} + +/// Builder API request-auth signing domain. The request WIRE type is +/// fork-versioned per builder-specs, but the signing domain is not: the spec's +/// `compute_domain(DOMAIN_BUILDER_REQUEST_AUTH)` takes the genesis fork version +/// and a zero root, exactly like the validator registrations it replaces. +pub fn builder_request_auth_domain(chain: Chain) -> B256 { + compute_domain(chain, &B32::from(DOMAIN_BUILDER_REQUEST_AUTH)) +} + +pub fn sign_builder_request_auth_root( + secret_key: &BlsSecretKey, + object_root: &B256, + chain: Chain, +) -> BlsSignature { + let signing_data = types::SigningData { + object_root: *object_root, + signing_domain: builder_request_auth_domain(chain), + }; + sign_message(secret_key, signing_data.tree_hash_root()) +} + +pub fn verify_builder_request_auth_signature( + pubkey: &BlsPublicKey, + msg: &T, + signature: &BlsSignature, + chain: Chain, +) -> bool { + let signing_data = types::SigningData { + object_root: msg.tree_hash_root(), + signing_domain: builder_request_auth_domain(chain), + }; + verify_bls_signature(pubkey, signing_data.tree_hash_root(), signature) +} + pub fn verify_signed_message( chain: Chain, pubkey: &BlsPublicKey, @@ -95,6 +151,19 @@ pub fn sign_builder_root( sign_message(secret_key, signing_root) } +pub fn sign_execution_payload_bid_root( + secret_key: &BlsSecretKey, + object_root: &B256, + fork_version: [u8; 4], + genesis_validators_root: B256, +) -> BlsSignature { + let signing_data = types::SigningData { + object_root: *object_root, + signing_domain: execution_payload_bid_domain(fork_version, genesis_validators_root), + }; + sign_message(secret_key, signing_data.tree_hash_root()) +} + pub fn sign_commit_boost_root( chain: Chain, secret_key: &BlsSecretKey, @@ -171,8 +240,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 +257,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 +282,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..30680142 100644 --- a/crates/common/src/ssz.rs +++ b/crates/common/src/ssz.rs @@ -4,8 +4,7 @@ use lh_types::ForkName; use ssz::BYTES_PER_LENGTH_OFFSET; use crate::pbs::{ - BuilderBidFulu, ExecutionPayloadHeaderFulu, ExecutionRequests, KzgCommitments, - error::SszValueError, + BuilderBidFulu, ExecutionPayloadHeaderFulu, KzgCommitments, error::SszValueError, }; // Get the offset of the message in a SignedBuilderBid SSZ structure @@ -14,10 +13,16 @@ fn get_ssz_value_offset_for_fork(fork: ForkName) -> Result ForkName::Fulu => { // Message goes header -> blob_kzg_commitments -> execution_requests -> value -> // pubkey + // `execution_requests` (ExecutionRequestsElectra) is variable-length, + // so in the container's fixed section it is a 4-byte offset pointer, + // not its serialized body. Since lighthouse-unstable turned + // ExecutionRequests into a superstruct enum that no longer implements + // `ssz::Decode`, spell the offset width out as the SSZ constant rather + // than `<_ as Decode>::ssz_fixed_len()`. Ok(get_message_offset::() + ::ssz_fixed_len() + ::ssz_fixed_len() + - ::ssz_fixed_len()) + BYTES_PER_LENGTH_OFFSET) } _ => Err(SszValueError::UnsupportedFork { name: fork }), @@ -76,8 +81,8 @@ mod test { use super::get_bid_value_from_signed_builder_bid_ssz; use crate::{ pbs::{ - BuilderBid, BuilderBidFulu, ExecutionPayloadHeaderFulu, ExecutionRequests, - SignedBuilderBid, error::SszValueError, + BuilderBid, BuilderBidFulu, ExecutionPayloadHeaderFulu, SignedBuilderBid, + error::SszValueError, }, types::{BlsPublicKeyBytes, BlsSignature}, utils::TestRandomSeed, @@ -116,7 +121,7 @@ mod test { let message = BuilderBid::Fulu(BuilderBidFulu { header: ExecutionPayloadHeaderFulu::test_random(), blob_kzg_commitments: Default::default(), - execution_requests: ExecutionRequests::default(), + execution_requests: Default::default(), value: known_value, pubkey, }); diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index c0b53b4c..104b446f 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); @@ -120,6 +121,49 @@ pub mod as_eth_str { } } +/// `as_eth_str` for an optional field: absent stays `None` instead of being +/// forced through the ETH-string codec. +pub mod as_opt_eth_str { + use alloy::primitives::{ + U256, + utils::{format_ether, parse_ether}, + }; + use serde::{Deserialize, Deserializer, Serializer}; + + use super::eth_to_wei; + + pub fn serialize(data: &Option, serializer: S) -> Result + where + S: Serializer, + { + match data { + Some(wei) => serializer.serialize_str(&format_ether(*wei)), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrF64 { + Str(String), + F64(f64), + } + + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(StringOrF64::Str(s)) => { + Some(parse_ether(&s).map_err(|_| serde::de::Error::custom("invalid eth amount"))?) + } + Some(StringOrF64::F64(f)) => Some(eth_to_wei(f)), + None => None, + }) + } +} + pub const fn default_u64() -> u64 { U } @@ -428,17 +472,21 @@ pub async fn wait_for_signal() -> eyre::Result<()> { Ok(()) } -pub trait TestRandomSeed: TestRandom { +// lighthouse v8.2.x replaced the `TestRandom` trait with an `arbitrary`-based +// generator; build test instances from OS entropy so each call differs. +pub trait TestRandomSeed: for<'a> arbitrary::Arbitrary<'a> { fn test_random() -> Self where Self: Sized, { - let mut rng = XorShiftRng::from_os_rng(); - Self::random_for_test(&mut rng) + let mut bytes = vec![0u8; 256 * 1024]; + rand::rng().fill_bytes(&mut bytes); + let mut u = arbitrary::Unstructured::new(&bytes); + Self::arbitrary(&mut u).expect("enough entropy for an arbitrary test instance") } } -impl TestRandomSeed for T {} +impl arbitrary::Arbitrary<'a>> TestRandomSeed for T {} pub fn bls_pubkey_from_hex(hex: &str) -> eyre::Result { let Ok(bytes) = hex::decode(hex) else { @@ -461,14 +509,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..e39d2ae2 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -6,15 +6,19 @@ use axum::http::HeaderValue; use bytes::Bytes; use futures::StreamExt; use headers_accept::Accept; -use lh_types::{BeaconBlock, ForkName, SignedBeaconBlock, map_fork_name}; +use lh_types::{ + BeaconBlock, ForkName, ForkVersionDecode, SignedBeaconBlock as LhSignedBeaconBlock, + map_fork_name, +}; use mediatype::{MediaType, ReadParams, names}; use reqwest::{ Response, header::{ACCEPT, CONTENT_TYPE, HeaderMap, ToStrError}, }; +use ssz::Decode; use thiserror::Error; -use crate::pbs::{HEADER_VERSION_VALUE, SignedBlindedBeaconBlock}; +use crate::pbs::{HEADER_VERSION_VALUE, SignedBeaconBlock, SignedBlindedBeaconBlock}; pub const APPLICATION_JSON: &str = "application/json"; pub const APPLICATION_OCTET_STREAM: &str = "application/octet-stream"; @@ -72,7 +76,6 @@ pub async fn read_chunked_body_with_max( max_size: usize, request_url: &str, ) -> Result, ResponseReadError> { - // Get the content length from the response headers #[cfg(not(feature = "testing-flags"))] let content_length = res.content_length(); @@ -206,6 +209,20 @@ impl IntoIterator for AcceptedEncodings { /// q-value, then original order). pub fn get_accept_types( req_headers: &HeaderMap, +) -> Result { + get_accept_types_with_default(req_headers, NO_PREFERENCE_DEFAULT) +} + +/// Like `get_accept_types`, but the caller chooses the encoding used when the +/// request expresses NO format preference. This covers both the wildcard +/// (`*/*`, `application/*`) Accept ranges and the "no Accept header AND no +/// Content-Type" case. An explicit `Accept`/`Content-Type` is always obeyed — +/// only the no-preference tiebreak changes. Legacy callers use +/// [`get_accept_types`] (default JSON); SSZ-by-default endpoints pass +/// `EncodingType::Ssz`. +pub fn get_accept_types_with_default( + req_headers: &HeaderMap, + no_preference_default: EncodingType, ) -> Result { // Only two supported media types, so the ordered set is at most two // entries: primary + optional fallback. @@ -235,7 +252,7 @@ pub fn get_accept_types( continue; } - if let Some(enc) = essence_encoding(&mt.essence()) { + if let Some(enc) = essence_encoding(&mt.essence(), no_preference_default) { had_supported = true; match primary { None => primary = Some(enc), @@ -254,24 +271,24 @@ pub fn get_accept_types( return Err(AcceptedEncodingsError::UnsupportedAcceptType) } - // No Accept header (or only q=0 rejections): per the Builder API a missing - // Accept means JSON, and request/response encodings are independent — so do - // NOT inherit the request Content-Type (an SSZ request still gets JSON). - Ok(AcceptedEncodings::single(NO_PREFERENCE_DEFAULT)) + // No Accept header (or only q=0 rejections): request and response encodings + // are independent, so do NOT inherit the request Content-Type; fall back to + // this endpoint's no-preference default. + Ok(AcceptedEncodings::single(no_preference_default)) } -fn essence_encoding(mt: &MediaType) -> Option { +fn essence_encoding(mt: &MediaType, default: EncodingType) -> Option { if mt.suffix.is_some() { return None; } match () { - _ if mt.ty == names::_STAR && mt.subty == names::_STAR => Some(NO_PREFERENCE_DEFAULT), + _ if mt.ty == names::_STAR && mt.subty == names::_STAR => Some(default), _ if mt.ty == names::APPLICATION && mt.subty == names::OCTET_STREAM => { Some(EncodingType::Ssz) } _ if mt.ty == names::APPLICATION && mt.subty == names::JSON => Some(EncodingType::Json), - _ if mt.ty == names::APPLICATION && mt.subty == names::_STAR => Some(NO_PREFERENCE_DEFAULT), + _ if mt.ty == names::APPLICATION && mt.subty == names::_STAR => Some(default), _ => None, } } @@ -279,6 +296,9 @@ fn essence_encoding(mt: &MediaType) -> Option { /// The `Accept` header PBS sends to relays: SSZ first, JSON as fallback. pub static OUTBOUND_ACCEPT_SSZ_FIRST: HeaderValue = HeaderValue::from_static("application/octet-stream;q=1.0,application/json;q=0.9"); +/// The ePBS bid path forwards the beacon node's Accept preference to the relay. +pub static OUTBOUND_ACCEPT_JSON_FIRST: HeaderValue = + HeaderValue::from_static("application/json;q=1.0,application/octet-stream;q=0.9"); pub fn get_content_type(req_headers: &HeaderMap) -> EncodingType { EncodingType::from_str( @@ -290,6 +310,41 @@ pub fn get_content_type(req_headers: &HeaderMap) -> EncodingType { .unwrap_or(EncodingType::Json) } +/// The strict form of [`get_consensus_version_header`], per builder-specs +/// (specs/gloas/builder.md): the header is required on every request that +/// carries a body, and the builder MUST 400 when it is absent or names a fork +/// it does not recognize. On this branch "recognized" means GLOAS ONLY +pub fn require_consensus_version_header( + req_headers: &HeaderMap, +) -> Result { + let value = req_headers + .get(CONSENSUS_VERSION_HEADER) + .ok_or(BodyDeserializeError::MissingVersionHeader)?; + let value = value + .to_str() + .map_err(|_| BodyDeserializeError::InvalidVersionHeader("".to_string()))?; + if value.is_empty() { + return Err(BodyDeserializeError::InvalidVersionHeader("".to_string())); + } + // Echoed into the 400 body, so bound attacker-controlled length + let unsupported = + || BodyDeserializeError::InvalidVersionHeader(value.chars().take(64).collect()); + // The endpoint is defined for the Gloas fork, and only Gloas is accepted for + // now: a later fork's semantics are not yet validated here, so it is 400'd + // rather than silently handled as gloas. + match ForkName::from_str(value).map_err(|_| unsupported())? { + ForkName::Gloas => Ok(ForkName::Gloas), + ForkName::Base | + ForkName::Altair | + ForkName::Bellatrix | + ForkName::Capella | + ForkName::Deneb | + ForkName::Electra | + ForkName::Fulu | + ForkName::Heze => Err(unsupported()), + } +} + pub fn get_consensus_version_header(req_headers: &HeaderMap) -> Option { ForkName::from_str( req_headers @@ -343,7 +398,8 @@ impl FromStr for EncodingType { // (e.g. `application/json; charset=utf-8`). Compare essence only. let parsed = MediaType::parse(value).map_err(|e| format!("invalid content type {value}: {e}"))?; - essence_encoding(&parsed).ok_or_else(|| format!("unsupported encoding type: {value}")) + essence_encoding(&parsed, EncodingType::Json) + .ok_or_else(|| format!("unsupported encoding type: {value}")) } } @@ -384,6 +440,30 @@ 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, letting the +/// caller choose the encoding used when the request has no Content-Type header. +/// This is the Content-Type analogue of [`get_accept_types_with_default`]. +/// Precedence: +/// - Content-Type absent → `no_preference_default` +/// - Content-Type recognized → use it +/// - Content-Type present but unrecognized → UnsupportedMediaType +pub fn content_type_encoding_with_default( + headers: &HeaderMap, + no_preference_default: EncodingType, +) -> Result { + match headers.get(CONTENT_TYPE) { + None => Ok(no_preference_default), + Some(hv) => { + let value = hv.to_str().map_err(|_| BodyDeserializeError::UnsupportedMediaType)?; + EncodingType::from_str(value).map_err(|_| BodyDeserializeError::UnsupportedMediaType) + } + } } pub fn deserialize_body( @@ -409,13 +489,13 @@ pub fn deserialize_body( // reports the wrong fork for every Fulu block. Some(version) => Ok(map_fork_name!( version, - SignedBeaconBlock, + LhSignedBeaconBlock, serde_json::from_slice(&body).map_err(BodyDeserializeError::SerdeJsonError)? )), // builder-specs doesn't require the header for JSON bodies. // A request without it still has to decode and an untagged decode would silently pick // Electra. Assume Fulu to be conservative until ePBS warrants the refactor - None => Ok(SignedBeaconBlock::Fulu( + None => Ok(LhSignedBeaconBlock::Fulu( serde_json::from_slice(&body).map_err(BodyDeserializeError::SerdeJsonError)?, )), }, @@ -429,19 +509,82 @@ pub fn deserialize_body( } } +/// Decode a fork-versioned ePBS request body (builder-specs fork-versions +/// `SignedBuilderRequestAuth` and `BuilderPreferencesRequest`) as JSON or SSZ, +/// defaulting to SSZ when no `Content-Type` is set. An empty body is rejected +/// first so a missing body reads as `MissingBody`. `Eth-Consensus-Version` is +/// required for BOTH encodings and its value must name a fork this build +/// recognizes (absent -> `MissingVersionHeader`, unrecognized -> +/// `InvalidVersionHeader`, both -> 400), per builder-specs +/// specs/gloas/builder.md. +pub fn decode_versioned_request_body( + headers: &HeaderMap, + body: &Bytes, +) -> Result +where + T: serde::de::DeserializeOwned + Decode, +{ + if body.is_empty() { + return Err(BodyDeserializeError::MissingBody); + } + // Content-Type first so an unsupported media type stays a 415 + let encoding = content_type_encoding_with_default(headers, EncodingType::Ssz)?; + require_consensus_version_header(headers)?; + match encoding { + EncodingType::Json => { + serde_json::from_slice(body.as_ref()).map_err(BodyDeserializeError::SerdeJsonError) + } + EncodingType::Ssz => { + T::from_ssz_bytes(body.as_ref()).map_err(BodyDeserializeError::SszDecodeError) + } + } +} + +/// Decode a `submitSignedBeaconBlock` request body. Like the other ePBS +/// endpoints it defaults to SSZ when no `Content-Type` is set, and +/// `Eth-Consensus-Version` is required and must name a known fork for BOTH +/// encodings (absent -> `MissingVersionHeader`, unrecognized -> +/// `InvalidVersionHeader`, both -> 400; spec PR #165). +pub fn decode_signed_beacon_block( + headers: &HeaderMap, + body: &Bytes, +) -> Result { + if body.is_empty() { + return Err(BodyDeserializeError::MissingBody); + } + // The header is required and must name a known fork on every body-carrying + // request. SSZ uses the fork to select the variant; JSON + // self-describes via `deny_unknown_fields`, so a recognized-but-mismatched + // value is ignored rather than second-guessing a decodable body. + // Absent Content-Type defaults to JSON, per the builder-specs preamble + // ("all requests by default send and receive JSON"); octet-stream is only + // for bodies that carry SSZ. + let encoding = content_type_encoding_with_default(headers, EncodingType::Json)?; + let fork = require_consensus_version_header(headers)?; + match encoding { + EncodingType::Json => serde_json::from_slice::(body.as_ref()) + .map_err(BodyDeserializeError::SerdeJsonError), + EncodingType::Ssz => SignedBeaconBlock::from_ssz_bytes_by_fork(body.as_ref(), fork) + .map_err(BodyDeserializeError::SszDecodeError), + } +} + #[cfg(test)] mod test { use axum::http::{HeaderMap, HeaderName, HeaderValue}; use bytes::Bytes; - use lh_types::ForkName; + use lh_types::{ForkName, MainnetEthSpec, SignedBeaconBlockElectra, SignedBeaconBlockGloas}; use reqwest::header::{ACCEPT, CONTENT_TYPE}; + use ssz::Encode; use super::{ APPLICATION_JSON, APPLICATION_OCTET_STREAM, AcceptedEncodings, BodyDeserializeError, - CONSENSUS_VERSION_HEADER, EncodingType, NO_PREFERENCE_DEFAULT, OUTBOUND_ACCEPT_SSZ_FIRST, - WILDCARD, deserialize_body, get_accept_types, get_consensus_version_header, - get_content_type, parse_response_encoding_and_fork, + CONSENSUS_VERSION_HEADER, EncodingType, NO_PREFERENCE_DEFAULT, OUTBOUND_ACCEPT_JSON_FIRST, + OUTBOUND_ACCEPT_SSZ_FIRST, WILDCARD, content_type_encoding_with_default, + decode_signed_beacon_block, deserialize_body, get_accept_types, + get_consensus_version_header, get_content_type, parse_response_encoding_and_fork, }; + use crate::{pbs::SignedBeaconBlock, utils::TestRandomSeed}; const APPLICATION_TEXT: &str = "application/text"; @@ -925,6 +1068,15 @@ mod test { ); } + // The ePBS mirror: JSON preferred (q=1.0), SSZ as fallback (q=0.9). + #[test] + fn test_outbound_accept_json_first() { + assert_eq!( + OUTBOUND_ACCEPT_JSON_FIRST, + "application/json;q=1.0,application/octet-stream;q=0.9" + ); + } + /// Present-but-unrecognized Content-Type still bails as /// `UnsupportedMediaType`; the fallback only covers *missing* headers. #[tokio::test] @@ -1002,4 +1154,108 @@ mod test { "a headerless JSON body must not fall back to the untagged Electra match" ); } + + // ── content_type_encoding_with_default ─────────────────────────────────── + + /// With no Content-Type the caller's default wins: SSZ for the + /// SSZ-by-default endpoints, not the shared JSON default. + #[test] + fn test_content_type_encoding_with_default_absent_uses_default() { + let headers = HeaderMap::new(); + assert_eq!( + content_type_encoding_with_default(&headers, EncodingType::Ssz).unwrap(), + EncodingType::Ssz + ); + assert_eq!( + content_type_encoding_with_default(&headers, EncodingType::Json).unwrap(), + EncodingType::Json + ); + } + + /// An explicit recognized Content-Type is obeyed regardless of the default, + /// while an unrecognized one is UnsupportedMediaType. + #[test] + fn test_content_type_encoding_with_default_explicit_obeyed() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_JSON)); + assert_eq!( + content_type_encoding_with_default(&headers, EncodingType::Ssz).unwrap(), + EncodingType::Json + ); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); + assert!(matches!( + content_type_encoding_with_default(&headers, EncodingType::Ssz), + Err(BodyDeserializeError::UnsupportedMediaType) + )); + } + + // ── decode_signed_beacon_block ─────────────────────────────────────────── + + /// builder-specs requires the header on every body-carrying request: a + /// JSON body with no version header is rejected, superseding the earlier + /// best-effort policy (spec PR #165). + #[test] + fn test_decode_signed_beacon_block_json_missing_version_header_rejected() { + let block = + SignedBeaconBlock::Gloas(SignedBeaconBlockGloas::::test_random()); + let body = Bytes::from(serde_json::to_vec(&block).unwrap()); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_JSON)); + // no Eth-Consensus-Version header + + let err = decode_signed_beacon_block(&headers, &body).unwrap_err(); + assert!(matches!(err, BodyDeserializeError::MissingVersionHeader)); + + // Present but unrecognized is a DISTINCT 400, naming the bad value + headers.insert( + HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(), + HeaderValue::from_static("futurefork"), + ); + let err = decode_signed_beacon_block(&headers, &body).unwrap_err(); + assert!(matches!(err, BodyDeserializeError::InvalidVersionHeader(v) if v == "futurefork")); + } + + /// gloas is the ONLY accepted value, so a mismatch-within-the-window is + /// impossible; a `fulu` label is rejected at the wire layer like any other + /// deprecated fork. + #[test] + fn test_decode_signed_beacon_block_fulu_label_rejected() { + let block = + SignedBeaconBlock::Gloas(SignedBeaconBlockGloas::::test_random()); + let body = Bytes::from(serde_json::to_vec(&block).unwrap()); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_JSON)); + headers.insert( + HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(), + HeaderValue::from_static("fulu"), + ); + + let err = decode_signed_beacon_block(&headers, &body).unwrap_err(); + assert!(matches!(err, BodyDeserializeError::InvalidVersionHeader(v) if v == "fulu")); + } + + /// An SSZ body whose bytes are a different fork than the header claims is a + /// clean `SszDecodeError`, never a panic: Electra bytes decoded as Gloas + /// cannot succeed, and the decode must surface an error rather than + /// aborting. + #[test] + fn test_decode_signed_beacon_block_ssz_fork_bytes_mismatch() { + let electra = + SignedBeaconBlock::Electra(SignedBeaconBlockElectra::::test_random()); + let body = Bytes::from(electra.as_ssz_bytes()); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_OCTET_STREAM)); + headers.insert( + HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(), + HeaderValue::from_static("gloas"), + ); + + let err = decode_signed_beacon_block(&headers, &body).unwrap_err(); + assert!(matches!(err, BodyDeserializeError::SszDecodeError(_)), "got {err:?}"); + } } diff --git a/crates/km-tool/Cargo.toml b/crates/km-tool/Cargo.toml new file mode 100644 index 00000000..54c8ba7a --- /dev/null +++ b/crates/km-tool/Cargo.toml @@ -0,0 +1,28 @@ +[package] +edition.workspace = true +name = "cb-km-tool" +publish = false +rust-version.workspace = true +version.workspace = true + +[[bin]] +name = "cb-km" +path = "src/main.rs" + +[dependencies] +alloy-primitives.workspace = true +cb-common.workspace = true +clap.workspace = true +eyre.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +url.workspace = true + +[dev-dependencies] +axum.workspace = true +tempfile.workspace = true diff --git a/crates/km-tool/src/apply.rs b/crates/km-tool/src/apply.rs new file mode 100644 index 00000000..203549dd --- /dev/null +++ b/crates/km-tool/src/apply.rs @@ -0,0 +1,468 @@ +//! `cb-km apply`: push projected docs to every VC and verify each projected +//! key was accepted by exactly one of them. + +use std::{ + collections::{BTreeMap, BTreeSet, HashSet}, + path::{Path, PathBuf}, +}; + +use eyre::{Context, Result, ensure}; +use tracing::{error, info, warn}; + +use crate::{ + client::{GetConfigOutcome, KmClient, PostOutcome, read_token}, + doc::{BuilderConfigDoc, BuilderEntryDoc}, + overlay::{Overlay, VcConfig}, + project::{ + MAX_BUILDER_ENTRIES, MAX_BUILDER_PUBKEYS, Projection, ProjectionInput, project, + project_with_url, + }, +}; + +#[derive(Debug, Default, Clone)] +pub struct ApplyOptions { + pub dry_run: bool, + pub emit_dir: Option, + pub prune: bool, + /// Preserve third-party builder entries a VC already stores (see --help). + pub preserve_entries: bool, +} + +#[derive(Debug, Default)] +pub struct ApplyReport { + pub errors: Vec, + pub warnings: Vec, + pub info: Vec, + /// projected key -> VCs that accepted it (202) + pub accepted: BTreeMap>, + /// (vc, key) pairs pruned with `POST {}` + pub pruned: Vec<(String, String)>, +} + +impl ApplyReport { + pub fn ok(&self) -> bool { + self.errors.is_empty() + } + + fn error(&mut self, msg: String) { + error!("{msg}"); + self.errors.push(msg); + } + + fn warn(&mut self, msg: String) { + warn!("{msg}"); + self.warnings.push(msg); + } + + fn note(&mut self, msg: String) { + info!("{msg}"); + self.info.push(msg); + } +} + +enum Preflight { + Supported, + Unsupported, + /// No enumerated key to probe: proceed and let the POST responses tell + Unknown, +} + +/// Probes #88 builder_config support with a key the VC itself enumerated: a +/// 404 then means the ROUTE is missing, never "key elsewhere". Transport +/// errors and non-404 failures propagate; they are not evidence about +/// support. +async fn preflight_builder_config(client: &KmClient, enumerated: &[String]) -> Result { + let Some(probe_key) = enumerated.first() else { + return Ok(Preflight::Unknown); + }; + match client.get_builder_config(probe_key).await? { + GetConfigOutcome::Ok(_) => Ok(Preflight::Supported), + GetConfigOutcome::NotFound => Ok(Preflight::Unsupported), + } +} + +pub async fn run_apply( + input: &ProjectionInput, + overlay: &Overlay, + opts: &ApplyOptions, +) -> Result { + let mut report = ApplyReport::default(); + let global = project(input, overlay)?; + report.warnings.extend(global.warnings.iter().cloned()); + let projected_keys: BTreeSet = global.docs.keys().map(|k| k.to_string()).collect(); + + if opts.dry_run { + for (key, doc) in &global.docs { + let doc = redact_secrets_for_display(doc); + report.note(format!("would apply {key}: {}", serde_json::to_string(&doc)?)); + } + report.note(format!("dry-run: {} keys projected, nothing sent", global.docs.len())); + return Ok(report); + } + + if let Some(dir) = &opts.emit_dir { + emit(dir, &global)?; + report.note(format!("emitted {} per-key docs to {dir:?}", global.docs.len())); + return Ok(report); + } + + ensure!(!overlay.vcs.is_empty(), "no [[vcs]] configured in the overlay"); + + let mut all_enumerated: BTreeSet = BTreeSet::new(); + + for vc in &overlay.vcs { + apply_to_vc(vc, input, overlay, opts, &projected_keys, &mut all_enumerated, &mut report) + .await?; + } + + // coverage warning (default on): enumerated keys outside the projection + // route to CB's own URL under the pipe default and are best-effort only + for key in all_enumerated.difference(&projected_keys) { + report.warn(format!("{key} enumerated but unprojected: best-effort only")); + } + + // exit rule: every projected key accepted by exactly one VC + for key in &projected_keys { + match report.accepted.get(key).map(Vec::len).unwrap_or(0) { + 0 => report.error(format!("{key} was accepted by NO vc")), + 1 => {} + n => report.error(format!( + "{key} was accepted by {n} VCs: DUPLICATE KEY across VCs, slashing risk; \ + partition your keys" + )), + } + } + + Ok(report) +} + +/// Applies the projection to one VC: enumerate its keys, confirm #88 support, +/// POST each projected doc (folding in preserved entries when asked), and prune +/// unprojected keys. A per-VC failure is recorded on the report and returns +/// early rather than aborting the whole run; only a transport/setup error +/// propagates. +async fn apply_to_vc( + vc: &VcConfig, + input: &ProjectionInput, + overlay: &Overlay, + opts: &ApplyOptions, + projected_keys: &BTreeSet, + all_enumerated: &mut BTreeSet, + report: &mut ApplyReport, +) -> Result<()> { + let vc_name = vc.url.to_string(); + let token = read_token(Path::new(&vc.token_path))?; + let client = KmClient::new(vc.url.clone(), token)?; + + let enumerated = match client.list_keystores().await { + Ok(keys) => keys, + Err(err) => { + report.error(format!("{vc_name}: keystores preflight failed: {err}")); + return Ok(()); + } + }; + all_enumerated.extend(enumerated.iter().cloned()); + + match preflight_builder_config(&client, &enumerated).await { + Ok(Preflight::Supported) => {} + Ok(Preflight::Unsupported) => { + report.error(format!( + "{vc_name}: no builder_config support (keymanager-APIs #88); skipping" + )); + return Ok(()); + } + Ok(Preflight::Unknown) => { + report.warn(format!( + "{vc_name}: no keys to probe for builder_config support; POSTing anyway" + )); + } + Err(err) => { + report.error(format!("{vc_name}: builder_config probe failed: {err}")); + return Ok(()); + } + } + + let vc_projection = project_with_url(input, overlay.advertised_url_for(vc))?; + for (key, doc) in &vc_projection.docs { + let key = key.to_string(); + + // --preserve-entries: fold any third-party builder entries the VC + // already stores back into our doc so the full-replace POST does + // not erase them. + let merged; + let doc = if opts.preserve_entries { + match client.get_builder_config(&key).await { + Ok(GetConfigOutcome::Ok(stored)) => { + match merge_preserved_entries(&key, doc, &stored) { + Ok(m) => { + merged = m; + &merged + } + Err(err) => { + report.error(format!( + "{vc_name}: preserve-entries merge for {key} failed: {err}" + )); + continue; + } + } + } + // nothing stored (or route absent for this key): POST ours + Ok(GetConfigOutcome::NotFound) => doc, + Err(err) => { + report + .error(format!("{vc_name}: preserve-entries GET for {key} failed: {err}")); + continue; + } + } + } else { + doc + }; + + match client.post_builder_config(&key, doc).await { + Ok(PostOutcome::Accepted) => { + report.accepted.entry(key).or_default().push(vc_name.clone()); + } + Ok(PostOutcome::KeyNotFound) => {} + Ok(PostOutcome::ConfigFileManaged) => { + report.warn(format!("{vc_name}: {key} is config-file-managed, cannot override")); + } + Err(err) => report.error(format!("{vc_name}: POST {key} failed: {err}")), + } + } + + if opts.prune { + for key in &enumerated { + if !projected_keys.contains(key) { + // POST {} is spec-equal to DELETE and a no-op when nothing + // is stored + match client.post_builder_config(key, &BuilderConfigDoc::default()).await { + Ok(PostOutcome::Accepted) => { + report.pruned.push((vc_name.clone(), key.clone())); + } + Ok(other) => { + report.warn(format!("{vc_name}: prune of {key} not accepted: {other:?}")) + } + Err(err) => report.error(format!("{vc_name}: prune of {key} failed: {err}")), + } + } + } + } + + Ok(()) +} + +/// An entry's identity for the preserve merge: its URL and DECODED auth_data +/// bytes (hex case is not identity). Two entries collide iff both agree. +fn entry_identity(entry: &BuilderEntryDoc) -> Result<(String, Option>)> { + let auth = match &entry.auth_data { + Some(hex) => Some(crate::doc::decode_auth_data(hex)?), + // Dead in practice: a resolved KM GET always populates auth_data and our + // projection always sets Some, so identities collide correctly. + None => None, + }; + Ok((entry.url.clone(), auth)) +} + +/// Folds third-party builder entries from a VC's stored (resolved) doc into our +/// projected doc. Identity is `(url, decoded auth_data)`. Every stored entry +/// whose identity our projection ALSO produces is OURS: a GET returns the doc +/// fully resolved, so field values on our own entries are VC defaults, not +/// third-party data, and ours win on collision. Every other stored entry was +/// pinned by someone else and is preserved, appended after ours. Key-level +/// fields (min_bid / boost) are p2p policy we own and stay ours. The KM entry +/// caps are re-checked on the MERGED set: a merge that would break the spec +/// (e.g. >64 combined entries) fails loudly rather than silently dropping. +fn merge_preserved_entries( + key: &str, + projected: &BuilderConfigDoc, + stored: &BuilderConfigDoc, +) -> Result { + let our_entries = projected.builders.clone().unwrap_or_default(); + let stored_entries = stored.builders.as_deref().unwrap_or_default(); + + let mut seen: HashSet<(String, Option>)> = HashSet::new(); + for entry in &our_entries { + seen.insert(entry_identity(entry)?); + } + + let mut merged = our_entries.clone(); + for entry in stored_entries { + let id = entry_identity(entry)?; + // ours win on identity collision; dedup a stored doc's own repeats + if seen.insert(id) { + merged.push(entry.clone()); + } + } + + ensure!( + merged.len() <= MAX_BUILDER_ENTRIES, + "{key}: --preserve-entries would keep {} builder entries, exceeding the KM maximum of \ + {MAX_BUILDER_ENTRIES}; refusing to POST rather than silently drop a pinned entry", + merged.len() + ); + for entry in &merged { + if let Some(pubkeys) = &entry.builder_pubkeys { + ensure!( + pubkeys.len() <= MAX_BUILDER_PUBKEYS, + "{key}: --preserve-entries merged entry {} has {} builder_pubkeys, exceeding the \ + KM maximum of {MAX_BUILDER_PUBKEYS}", + entry.url, + pubkeys.len() + ); + } + } + + Ok(BuilderConfigDoc { + min_bid: projected.min_bid.clone(), + builder_boost_factor: projected.builder_boost_factor.clone(), + builders: Some(merged), + }) +} + +/// Writes per-key JSON docs plus a manifest instead of POSTing (GitOps / +/// orchestrator-consumable). Emitted files can carry bilateral-secret +/// auth_data, so the dir and every file are restricted to the owner. +fn emit(dir: &Path, projection: &Projection) -> Result<()> { + std::fs::create_dir_all(dir).wrap_err_with(|| format!("cannot create emit dir {dir:?}"))?; + restrict_to_owner(dir, 0o700)?; + let mut manifest = Vec::new(); + for (key, doc) in &projection.docs { + let file = format!("{key}.json"); + let path = dir.join(&file); + std::fs::write(&path, serde_json::to_string_pretty(doc)?)?; + restrict_to_owner(&path, 0o600)?; + manifest.push(serde_json::json!({ "pubkey": key.to_string(), "file": file })); + } + let manifest_path = dir.join("manifest.json"); + std::fs::write( + &manifest_path, + serde_json::to_string_pretty(&serde_json::json!({ "keys": manifest }))?, + )?; + restrict_to_owner(&manifest_path, 0o600)?; + Ok(()) +} + +/// Restricts a path to the owner. `mode` is 0o600 for files, 0o700 for the dir +/// (a directory needs its execute bit to stay traversable). No-op off Unix. +#[cfg(unix)] +fn restrict_to_owner(path: &Path, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .wrap_err_with(|| format!("cannot restrict permissions on {path:?}")) +} + +#[cfg(not(unix))] +fn restrict_to_owner(_path: &Path, _mode: u32) -> Result<()> { + Ok(()) +} + +/// For dry-run display: replace any bilateral-secret auth_data (bytes that are +/// not a valid UTF-8 URL) with a length-only placeholder. URL-form auth_data is +/// public and printed as-is. +fn redact_secrets_for_display(doc: &BuilderConfigDoc) -> BuilderConfigDoc { + let mut doc = doc.clone(); + if let Some(builders) = &mut doc.builders { + for entry in builders { + let Some(hex) = &entry.auth_data else { continue }; + if auth_data_is_url(hex) { + continue; + } + let len = crate::doc::decode_auth_data(hex).map(|b| b.len()).unwrap_or(0); + entry.auth_data = Some(format!("{len} bytes (secret)")); + } + } + doc +} + +/// Whether hex-encoded auth_data decodes to a valid UTF-8 URL (the public +/// form). +fn auth_data_is_url(hex: &str) -> bool { + crate::doc::decode_auth_data(hex) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|s| url::Url::parse(&s).ok()) + .is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doc::encode_auth_data; + + fn entry(url: &str, auth: &[u8], pubkeys: &[&str]) -> BuilderEntryDoc { + BuilderEntryDoc { + url: url.to_string(), + auth_data: Some(encode_auth_data(auth)), + builder_pubkeys: Some(pubkeys.iter().map(|s| s.to_string()).collect()), + max_execution_payment: None, + min_bid: None, + builder_boost_factor: None, + } + } + + // Our projected doc: builder_pubkeys is emitted empty (the projection no + // longer unions in relay-identity pubkeys). Preserved third-party entries + // may still carry their own pubkeys, so `entry()` keeps the parameter. + fn ours() -> BuilderConfigDoc { + BuilderConfigDoc { + min_bid: Some("500000000".into()), + builder_boost_factor: None, + builders: Some(vec![entry("https://cb.example.com", b"https://relay-a", &[])]), + } + } + + #[test] + fn third_party_entry_is_preserved_after_ours() { + let stored = BuilderConfigDoc { + builders: Some(vec![entry("https://other.example.com", b"https://relay-x", &["0xff"])]), + ..Default::default() + }; + let merged = merge_preserved_entries("k", &ours(), &stored).unwrap(); + let entries = merged.builders.unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].url, "https://cb.example.com"); + assert_eq!(entries[1].url, "https://other.example.com"); + // key-level fields stay ours + assert_eq!(merged.min_bid, Some("500000000".into())); + } + + #[test] + fn our_identity_wins_over_stored_resolved_version() { + // stored holds OUR identity (same url + auth_data) but with VC-resolved + // fields and a different pubkey set: ours must win, no duplicate + let stored = BuilderConfigDoc { + builders: Some(vec![BuilderEntryDoc { + min_bid: Some("999".into()), + ..entry("https://cb.example.com", b"https://relay-a", &["0xbb"]) + }]), + ..Default::default() + }; + let merged = merge_preserved_entries("k", &ours(), &stored).unwrap(); + let entries = merged.builders.unwrap(); + assert_eq!(entries.len(), 1); + // ours won on identity: empty builder_pubkeys and ours' (None) min_bid, + // not the stored 0xbb / 999 + assert_eq!(entries[0].builder_pubkeys, Some(vec![])); + assert_eq!(entries[0].min_bid, None); + } + + #[test] + fn identity_ignores_hex_case() { + let mut theirs = entry("https://cb.example.com", b"https://relay-a", &["0xcc"]); + theirs.auth_data = Some(theirs.auth_data.unwrap().to_uppercase().replacen("0X", "0x", 1)); + let stored = BuilderConfigDoc { builders: Some(vec![theirs]), ..Default::default() }; + // same identity as ours despite uppercase hex -> collapses to ours + let merged = merge_preserved_entries("k", &ours(), &stored).unwrap(); + assert_eq!(merged.builders.unwrap().len(), 1); + } + + #[test] + fn merge_exceeding_max_entries_fails() { + let extras: Vec<_> = (0..MAX_BUILDER_ENTRIES) + .map(|i| entry("https://other.example.com", format!("relay-{i}").as_bytes(), &["0xff"])) + .collect(); + let stored = BuilderConfigDoc { builders: Some(extras), ..Default::default() }; + let err = merge_preserved_entries("k", &ours(), &stored).unwrap_err(); + assert!(err.to_string().contains("exceeding the KM maximum"), "{err}"); + } +} diff --git a/crates/km-tool/src/check.rs b/crates/km-tool/src/check.rs new file mode 100644 index 00000000..e31171df --- /dev/null +++ b/crates/km-tool/src/check.rs @@ -0,0 +1,346 @@ +//! `cb-km check`: read-only comparison of stored VC docs against the +//! projection. Comparison is canonical (see `doc::CanonicalDoc`), never a +//! byte-diff: a GET returns the doc fully RESOLVED, so fields the projection +//! intentionally omits (they resolve to the VC's own config) are skipped +//! rather than reported as drift. + +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, +}; + +use eyre::Result; +use url::Url; + +use crate::{ + client::{GetConfigOutcome, KmClient, read_token}, + doc::{CanonicalDoc, CanonicalEntry}, + overlay::Overlay, + project::{ProjectionInput, project, project_with_url}, +}; + +/// Mirror of cb-pbs `url_matches`: scheme + canonical host (a trailing dot is +/// stripped) + effective port. Kept local to avoid a cb-pbs dependency from the +/// projection tool; the unit test pins the same cases so the two cannot drift. +fn advertised_url_matches(a: &Url, b: &Url) -> bool { + fn host_canonical(url: &Url) -> Option<&str> { + url.host_str().map(|host| host.strip_suffix('.').unwrap_or(host)) + } + a.scheme() == b.scheme() && + host_canonical(a) == host_canonical(b) && + a.port_or_known_default() == b.port_or_known_default() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Tier { + Info, + Warn, + Error, +} + +impl std::fmt::Display for Tier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Tier::Info => write!(f, "INFO"), + Tier::Warn => write!(f, "WARN"), + Tier::Error => write!(f, "ERROR"), + } + } +} + +#[derive(Debug)] +pub struct Finding { + pub tier: Tier, + pub code: &'static str, + pub msg: String, +} + +#[derive(Debug, Default)] +pub struct CheckReport { + pub findings: Vec, +} + +impl CheckReport { + fn push(&mut self, tier: Tier, code: &'static str, msg: String) { + self.findings.push(Finding { tier, code, msg }); + } + + /// Whether any finding is at or above the given tier. + pub fn fails(&self, fail_on: Tier) -> bool { + self.findings.iter().any(|f| f.tier >= fail_on) + } +} + +pub async fn run_check(input: &ProjectionInput, overlay: &Overlay) -> Result { + let mut report = CheckReport::default(); + let global = project(input, overlay)?; + let projected_keys: BTreeSet = global.docs.keys().map(|k| k.to_string()).collect(); + + // every auth_data byte-string a projected entry carries + let projected_auth: BTreeSet> = global + .docs + .values() + .flat_map(|doc| doc.builders.iter().flatten()) + .filter_map(|entry| entry.auth_data.as_ref()) + .filter_map(|hex| crate::doc::decode_auth_data(hex).ok()) + .collect(); + + for candidate in &global.relay_candidates { + if !projected_auth.contains(&candidate.bytes) { + report.push( + Tier::Info, + "relay-unreferenced", + format!( + "relay {} ({}) is referenced by no projected key", + candidate.relay_id, candidate.source + ), + ); + } + } + + // Self-dial consistency (the transient pipe): every advertised_url km-tool + // projects is echoed by a VC in its auth data, decoded by CB, and must be + // recognized as CB's own via [pbs] advertised_urls. If advertised_urls is set + // but does not cover a projected URL, a bid addressed to that URL decodes to + // CB's own URL and self-dials recursively. The operator runs `cb-km check` as + // the gate before apply, so this is an error, not a warning. + let advertised = &input.cfg.pbs.pbs_config.advertised_urls; + if !advertised.is_empty() { + let mut projected_urls: BTreeSet<&str> = BTreeSet::new(); + projected_urls.insert(overlay.advertised_url.as_str()); + for vc in &overlay.vcs { + if let Some(url) = &vc.advertised_url { + projected_urls.insert(url.as_str()); + } + } + for raw in projected_urls { + // overlay.validate() already rejected an unparseable advertised_url + let Ok(projected) = Url::parse(raw) else { continue }; + if !advertised.iter().any(|own| advertised_url_matches(own, &projected)) { + report.push( + Tier::Error, + "advertised-url-uncovered", + format!( + "projected advertised_url {raw} is not covered by any [pbs] advertised_urls entry; a bid addressed to it decodes to CB's own URL and self-dials recursively" + ), + ); + } + } + } + + let mut key_holders: BTreeMap> = BTreeMap::new(); + + for vc in &overlay.vcs { + let vc_name = vc.url.to_string(); + let token = read_token(Path::new(&vc.token_path))?; + let client = KmClient::new(vc.url.clone(), token)?; + let enumerated = client.list_keystores().await?; + for key in &enumerated { + key_holders.entry(key.clone()).or_default().push(vc_name.clone()); + } + + let vc_projection = project_with_url(input, overlay.advertised_url_for(vc))?; + let vc_docs: BTreeMap = + vc_projection.docs.iter().map(|(k, d)| (k.to_string(), d)).collect(); + + for key in &enumerated { + let stored = match client.get_builder_config(key).await? { + GetConfigOutcome::Ok(doc) => doc, + GetConfigOutcome::NotFound => { + // 404 on a key the VC itself enumerated: the ROUTE is + // missing, not the key + report.push( + Tier::Error, + "no-builder-config-route", + format!( + "{vc_name}: {key} enumerated but has no builder_config route \ + (keymanager-APIs #88 unsupported?)" + ), + ); + continue; + } + }; + let stored = CanonicalDoc::from_doc(&stored)?; + + // unroutable stored auth_data: no configured relay's candidate + // bytes equal it + for entry in stored.builders.iter().flatten() { + if let Some(bytes) = &entry.auth_data && + !global.relay_candidates.iter().any(|c| &c.bytes == bytes) + { + // WARN, not ERROR: v1 CB is a pure pipe, an out-of-band + // builder URL routes fine + report.push( + Tier::Warn, + "unroutable-auth-data", + format!( + "{vc_name}: {key} stores auth_data {} not matched by any configured \ + relay; will be served via the pipe", + display_auth_data(bytes) + ), + ); + } + } + + match vc_docs.get(key) { + Some(projected) => { + let projected = CanonicalDoc::from_doc(projected)?; + for drift in drift_lines(&projected, &stored) { + report.push( + Tier::Warn, + "drift", + format!("{vc_name}: {key} drifted: {drift}"), + ); + } + } + None => { + if stored.builders.as_ref().is_some_and(|b| !b.is_empty()) { + report.push( + Tier::Warn, + "stored-unprojected", + format!("{vc_name}: {key} has a stored doc but is not projected"), + ); + } + } + } + } + } + + for (key, holders) in &key_holders { + if holders.len() > 1 { + report.push( + Tier::Error, + "duplicate-key", + format!("{key} is held by {} VCs ({holders:?}): slashing risk", holders.len()), + ); + } + if !projected_keys.contains(key) { + report.push( + Tier::Warn, + "unprojected", + format!("{key} enumerated but unprojected: best-effort only"), + ); + } + } + + Ok(report) +} + +/// Compares a projected doc against a stored (resolved) one. A field the +/// projection left unset resolves to the VC's own config on GET, so only +/// projected values are compared. The entry sets are diffed both ways: a +/// projected entry with no stored match is missing, and a stored entry with no +/// projected match is surplus; each is itemized individually. +fn drift_lines(projected: &CanonicalDoc, stored: &CanonicalDoc) -> Vec { + let mut lines = Vec::new(); + compare_field(&mut lines, "min_bid", &projected.min_bid, &stored.min_bid); + compare_field( + &mut lines, + "builder_boost_factor", + &projected.builder_boost_factor, + &stored.builder_boost_factor, + ); + + let Some(projected_entries) = &projected.builders else { + return lines; + }; + let stored_entries = stored.builders.as_deref().unwrap_or_default(); + let stored_by_key: BTreeMap<(String, Option>), &CanonicalEntry> = stored_entries + .iter() + .map(|entry| ((entry.url.clone(), entry.auth_data.clone()), entry)) + .collect(); + + for entry in projected_entries { + let key = (entry.url.clone(), entry.auth_data.clone()); + let Some(stored_entry) = stored_by_key.get(&key) else { + lines.push(format!( + "entry ({}, {}) is missing", + entry.url, + entry.auth_data.as_deref().map(display_auth_data).unwrap_or_default() + )); + continue; + }; + if entry.builder_pubkeys != stored_entry.builder_pubkeys { + lines.push(format!("entry {}: builder_pubkeys differ", entry.url)); + } + compare_field(&mut lines, "entry min_bid", &entry.min_bid, &stored_entry.min_bid); + compare_field( + &mut lines, + "entry max_execution_payment", + &entry.max_execution_payment, + &stored_entry.max_execution_payment, + ); + compare_field( + &mut lines, + "entry builder_boost_factor", + &entry.builder_boost_factor, + &stored_entry.builder_boost_factor, + ); + } + + let projected_by_key: BTreeSet<(String, Option>)> = projected_entries + .iter() + .map(|entry| (entry.url.clone(), entry.auth_data.clone())) + .collect(); + for entry in stored_entries { + let key = (entry.url.clone(), entry.auth_data.clone()); + if !projected_by_key.contains(&key) { + lines.push(format!( + "entry ({}, {}) is stored but not projected", + entry.url, + entry.auth_data.as_deref().map(display_auth_data).unwrap_or_default() + )); + } + } + lines +} + +/// Renders decoded auth_data for a finding: a public builder URL is shown +/// as-is, but a bilateral secret (non-URL bytes) is reduced to a length-only +/// placeholder so a check report never prints the secret. Mirrors apply.rs +/// `redact_secrets_for_display`. +fn display_auth_data(bytes: &[u8]) -> String { + let is_url = std::str::from_utf8(bytes).ok().and_then(|s| url::Url::parse(s).ok()).is_some(); + if is_url { + crate::doc::encode_auth_data(bytes) + } else { + format!("{} bytes (secret)", bytes.len()) + } +} + +fn compare_field( + lines: &mut Vec, + name: &str, + projected: &Option, + stored: &Option, +) { + if let Some(expected) = projected && + stored != &Some(*expected) + { + lines.push(format!("{name}: projected {expected}, stored {stored:?}")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advertised_url_matches_mirrors_cb_pbs() { + let u = |s: &str| Url::parse(s).unwrap(); + // userinfo (relay pubkey) and the default port are ignored + assert!(advertised_url_matches( + &u("https://0xdead@cb.example.com"), + &u("https://cb.example.com") + )); + assert!(advertised_url_matches( + &u("https://cb.example.com:443"), + &u("https://cb.example.com") + )); + // a trailing-dot FQDN canonicalizes to its dotless form + assert!(advertised_url_matches(&u("http://cb.example.com."), &u("http://cb.example.com"))); + // scheme and host mismatches do not match + assert!(!advertised_url_matches(&u("http://cb.example.com"), &u("https://cb.example.com"))); + assert!(!advertised_url_matches(&u("https://a.example.com"), &u("https://b.example.com"))); + } +} diff --git a/crates/km-tool/src/client.rs b/crates/km-tool/src/client.rs new file mode 100644 index 00000000..832a0ecc --- /dev/null +++ b/crates/km-tool/src/client.rs @@ -0,0 +1,192 @@ +//! Minimal keymanager API client: bearer-token auth, keystores listing and +//! the builder_config endpoints (keymanager-APIs #88). + +use std::{path::Path, time::Duration}; + +use eyre::{Context, Result, bail}; +use serde::Deserialize; +use tracing::warn; +use url::Url; + +use crate::doc::BuilderConfigDoc; + +const HTTP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Whether a token file mode leaks reads beyond the owner. +#[cfg(unix)] +fn token_mode_overexposed(mode: u32) -> bool { + mode & 0o044 != 0 +} + +/// Reads a bearer token file, trimming surrounding whitespace. Warns when the +/// file is group- or world-readable. +pub fn read_token(path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(path) && + token_mode_overexposed(meta.permissions().mode()) + { + warn!("token file {path:?} is group- or world-readable"); + } + } + let token = std::fs::read_to_string(path) + .wrap_err_with(|| format!("unable to read token file: {path:?}"))?; + let token = token.trim().to_string(); + if token.is_empty() { + bail!("token file {path:?} is empty"); + } + Ok(token) +} + +#[derive(Debug, Deserialize)] +struct KeystoresResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct KeystoreEntry { + validating_pubkey: String, +} + +#[derive(Debug, Deserialize)] +struct GetBuilderConfigResponse { + data: BuilderConfigDoc, +} + +/// Result of a builder_config GET +#[derive(Debug)] +pub enum GetConfigOutcome { + Ok(BuilderConfigDoc), + NotFound, +} + +/// Result of a builder_config POST +#[derive(Debug, PartialEq, Eq)] +pub enum PostOutcome { + /// 202: stored + Accepted, + /// 404: this VC does not hold the key (or does not serve the route) + KeyNotFound, + /// 403: the config is file-managed on this VC and cannot be overridden + ConfigFileManaged, +} + +pub struct KmClient { + http: reqwest::Client, + base: Url, + token: String, +} + +/// Whether a URL's host is a loopback address (or `localhost`), where sending +/// the bearer token over plain HTTP does not expose it on the wire. +fn host_is_loopback(url: &Url) -> bool { + match url.host() { + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + Some(url::Host::Domain(host)) => host == "localhost", + None => false, + } +} + +impl KmClient { + pub fn new(base: Url, token: String) -> Result { + if base.scheme() != "https" && !host_is_loopback(&base) { + warn!( + "VC keymanager URL {base} is not HTTPS and not loopback; the bearer token is sent in cleartext" + ); + } + let http = reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + Ok(Self { http, base, token }) + } + + pub fn base(&self) -> &Url { + &self.base + } + + fn endpoint(&self, path: &str) -> Result { + // string concat, not Url::join: an absolute path would drop a base + // path prefix (https://vc.example/prefix) + let base = self.base.as_str().trim_end_matches('/'); + Url::parse(&format!("{base}{path}")).wrap_err_with(|| format!("invalid endpoint {path}")) + } + + /// GET /eth/v1/keystores; returns lowercased validating pubkeys. Doubles + /// as the auth + keymanager-API preflight. + pub async fn list_keystores(&self) -> Result> { + let url = self.endpoint("/eth/v1/keystores")?; + let resp = self.http.get(url).bearer_auth(&self.token).send().await?; + let status = resp.status(); + if !status.is_success() { + bail!("keystores listing failed on {}: {status}", self.base); + } + let body: KeystoresResponse = resp.json().await.wrap_err("invalid keystores response")?; + Ok(body.data.into_iter().map(|k| k.validating_pubkey.to_lowercase()).collect()) + } + + pub async fn get_builder_config(&self, pubkey: &str) -> Result { + let url = self.endpoint(&format!("/eth/v1/validator/{pubkey}/builder_config"))?; + let resp = self.http.get(url).bearer_auth(&self.token).send().await?; + match resp.status().as_u16() { + 200 => { + let body: GetBuilderConfigResponse = + resp.json().await.wrap_err("invalid builder_config response")?; + Ok(GetConfigOutcome::Ok(body.data)) + } + 404 => Ok(GetConfigOutcome::NotFound), + status => bail!("builder_config GET for {pubkey} on {} failed: {status}", self.base), + } + } + + pub async fn post_builder_config( + &self, + pubkey: &str, + doc: &BuilderConfigDoc, + ) -> Result { + let url = self.endpoint(&format!("/eth/v1/validator/{pubkey}/builder_config"))?; + let resp = self.http.post(url).bearer_auth(&self.token).json(doc).send().await?; + match resp.status().as_u16() { + 202 => Ok(PostOutcome::Accepted), + 404 => Ok(PostOutcome::KeyNotFound), + 403 => Ok(PostOutcome::ConfigFileManaged), + status => bail!("builder_config POST for {pubkey} on {} failed: {status}", self.base), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn client(base: &str) -> KmClient { + KmClient::new(Url::parse(base).unwrap(), "t".into()).unwrap() + } + + #[test] + fn endpoint_preserves_base_path_prefix() { + let url = client("https://vc.example/prefix").endpoint("/eth/v1/keystores").unwrap(); + assert_eq!(url.as_str(), "https://vc.example/prefix/eth/v1/keystores"); + // trailing slash on the base collapses, no double slash + let url = client("https://vc.example/prefix/").endpoint("/eth/v1/keystores").unwrap(); + assert_eq!(url.as_str(), "https://vc.example/prefix/eth/v1/keystores"); + } + + #[test] + fn endpoint_without_prefix_unchanged() { + let url = client("http://127.0.0.1:5062").endpoint("/eth/v1/keystores").unwrap(); + assert_eq!(url.as_str(), "http://127.0.0.1:5062/eth/v1/keystores"); + } + + #[cfg(unix)] + #[test] + fn token_mode_overexposure() { + assert!(token_mode_overexposed(0o644)); + assert!(token_mode_overexposed(0o640)); + assert!(token_mode_overexposed(0o604)); + assert!(!token_mode_overexposed(0o600)); + assert!(!token_mode_overexposed(0o620)); + } +} diff --git a/crates/km-tool/src/doc.rs b/crates/km-tool/src/doc.rs new file mode 100644 index 00000000..37dc2bb4 --- /dev/null +++ b/crates/km-tool/src/doc.rs @@ -0,0 +1,232 @@ +//! Wire types for the keymanager builder_config document +//! (keymanager-APIs `types/builder_entry.yaml`): Uint64s are JSON strings, +//! `auth_data` is 0x-prefixed hex, omitted fields are omitted on the wire. + +use eyre::{Result, bail, ensure}; +use serde::{Deserialize, Serialize}; + +/// `BuilderConfig` as POSTed to / returned by the keymanager API. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct BuilderConfigDoc { + #[serde(skip_serializing_if = "Option::is_none")] + pub min_bid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub builders: Option>, +} + +/// `BuilderEntry` as it appears in `BuilderConfigDoc.builders`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuilderEntryDoc { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub builder_pubkeys: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_execution_payment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_bid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, +} + +/// Encodes bytes as the KM `auth_data` wire form: 0x-prefixed lowercase hex. +pub fn encode_auth_data(bytes: &[u8]) -> String { + format!("0x{}", alloy_primitives::hex::encode(bytes)) +} + +/// Decodes a 0x-prefixed hex `auth_data`, accepting either hex case. +pub fn decode_auth_data(hex: &str) -> Result> { + let Some(body) = hex.strip_prefix("0x") else { + bail!("auth_data must be 0x-prefixed: {hex}"); + }; + ensure!(!body.is_empty(), "auth_data must not be empty"); + ensure!(body.len() % 2 == 0, "auth_data has odd hex length: {hex}"); + (0..body.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&body[i..i + 2], 16) + .map_err(|err| eyre::eyre!("invalid hex in auth_data {hex}: {err}")) + }) + .collect() +} + +/// A `BuilderConfigDoc` reduced to comparable values: hex decoded, Uint64 +/// strings parsed, entries sorted by `(url, auth_data bytes)`, pubkeys treated +/// as a case-insensitive set. The spec promises none of entry order, hex case, +/// or pubkey order, so stored docs are compared canonically, never byte-wise. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalDoc { + pub min_bid: Option, + pub builder_boost_factor: Option, + pub builders: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct CanonicalEntry { + pub url: String, + pub auth_data: Option>, + pub builder_pubkeys: Vec, + pub max_execution_payment: Option, + pub min_bid: Option, + pub builder_boost_factor: Option, +} + +fn parse_uint64(field: &str, value: &Option) -> Result> { + match value { + None => Ok(None), + Some(s) => { + let n = s.parse::().map_err(|err| { + eyre::eyre!("{field} must be a Uint64 JSON string, got {s:?}: {err}") + })?; + Ok(Some(n)) + } + } +} + +impl CanonicalDoc { + pub fn from_doc(doc: &BuilderConfigDoc) -> Result { + let builders = match &doc.builders { + None => None, + Some(entries) => { + let mut out = Vec::with_capacity(entries.len()); + for entry in entries { + let auth_data = match &entry.auth_data { + None => None, + Some(hex) => Some(decode_auth_data(hex)?), + }; + let mut builder_pubkeys: Vec = entry + .builder_pubkeys + .clone() + .unwrap_or_default() + .iter() + .map(|pk| pk.to_lowercase()) + .collect(); + builder_pubkeys.sort(); + out.push(CanonicalEntry { + url: entry.url.clone(), + auth_data, + builder_pubkeys, + max_execution_payment: parse_uint64( + "max_execution_payment", + &entry.max_execution_payment, + )?, + min_bid: parse_uint64("min_bid", &entry.min_bid)?, + builder_boost_factor: parse_uint64( + "builder_boost_factor", + &entry.builder_boost_factor, + )?, + }); + } + out.sort(); + Some(out) + } + }; + Ok(Self { + min_bid: parse_uint64("min_bid", &doc.min_bid)?, + builder_boost_factor: parse_uint64("builder_boost_factor", &doc.builder_boost_factor)?, + builders, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_data_round_trips_lowercase() { + let bytes = b"https://builder.example.com"; + let hex = encode_auth_data(bytes); + assert_eq!(hex, "0x68747470733a2f2f6275696c6465722e6578616d706c652e636f6d"); + assert_eq!(decode_auth_data(&hex).unwrap(), bytes); + } + + #[test] + fn decode_accepts_uppercase_hex() { + assert_eq!(decode_auth_data("0xDEADBEEF").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]); + } + + #[test] + fn decode_rejects_bad_forms() { + assert!(decode_auth_data("deadbeef").is_err()); + assert!(decode_auth_data("0x").is_err()); + assert!(decode_auth_data("0xabc").is_err()); + assert!(decode_auth_data("0xzz").is_err()); + } + + #[test] + fn empty_doc_serializes_to_empty_object() { + let doc = BuilderConfigDoc::default(); + assert_eq!(serde_json::to_string(&doc).unwrap(), "{}"); + } + + #[test] + fn omitted_fields_stay_off_the_wire() { + let doc = BuilderConfigDoc { + min_bid: Some("10000000".into()), + builder_boost_factor: None, + builders: Some(vec![BuilderEntryDoc { + url: "https://cb.example.com".into(), + auth_data: Some("0xaa".into()), + builder_pubkeys: Some(vec![]), + max_execution_payment: None, + min_bid: None, + builder_boost_factor: None, + }]), + }; + let json = serde_json::to_string(&doc).unwrap(); + assert_eq!( + json, + r#"{"min_bid":"10000000","builders":[{"url":"https://cb.example.com","auth_data":"0xaa","builder_pubkeys":[]}]}"# + ); + } + + #[test] + fn canonical_compare_ignores_order_and_hex_case() { + let entry = |auth: &str, pks: Vec<&str>| BuilderEntryDoc { + url: "https://cb.example.com".into(), + auth_data: Some(auth.into()), + builder_pubkeys: Some(pks.into_iter().map(String::from).collect()), + max_execution_payment: None, + min_bid: Some("5".into()), + builder_boost_factor: None, + }; + let a = BuilderConfigDoc { + min_bid: Some("5".into()), + builder_boost_factor: None, + builders: Some(vec![entry("0xaabb", vec!["0xAA", "0xBB"]), entry("0x0102", vec![])]), + }; + let b = BuilderConfigDoc { + min_bid: Some("5".into()), + builder_boost_factor: None, + builders: Some(vec![entry("0x0102", vec![]), entry("0xAABB", vec!["0xbb", "0xaa"])]), + }; + assert_eq!(CanonicalDoc::from_doc(&a).unwrap(), CanonicalDoc::from_doc(&b).unwrap()); + } + + #[test] + fn canonical_compare_distinguishes_values() { + let a = BuilderConfigDoc { min_bid: Some("5".into()), ..Default::default() }; + let b = BuilderConfigDoc { min_bid: Some("6".into()), ..Default::default() }; + assert_ne!(CanonicalDoc::from_doc(&a).unwrap(), CanonicalDoc::from_doc(&b).unwrap()); + } + + #[test] + fn canonical_rejects_non_numeric_uint64() { + let doc = BuilderConfigDoc { min_bid: Some("1e9".into()), ..Default::default() }; + assert!(CanonicalDoc::from_doc(&doc).is_err()); + } + + #[test] + fn canonical_empty_builders_differs_from_omitted() { + let empty = BuilderConfigDoc { builders: Some(vec![]), ..Default::default() }; + let omitted = BuilderConfigDoc::default(); + assert_ne!( + CanonicalDoc::from_doc(&empty).unwrap(), + CanonicalDoc::from_doc(&omitted).unwrap() + ); + } +} diff --git a/crates/km-tool/src/lib.rs b/crates/km-tool/src/lib.rs new file mode 100644 index 00000000..1a03b328 --- /dev/null +++ b/crates/km-tool/src/lib.rs @@ -0,0 +1,15 @@ +//! cb-km-tool: reference keymanager builder_config projector for Commit-Boost +//! mux configs (keymanager-APIs #88). Library-first: `project` turns a CB +//! config + operational overlay into per-key KM docs; `apply` POSTs them to +//! validator clients; `check` compares stored docs canonically. + +pub mod apply; +pub mod check; +pub mod client; +pub mod doc; +pub mod overlay; +pub mod project; + +pub use doc::{BuilderConfigDoc, BuilderEntryDoc, CanonicalDoc}; +pub use overlay::Overlay; +pub use project::{Projection, ProjectionInput, project, project_with_url}; diff --git a/crates/km-tool/src/main.rs b/crates/km-tool/src/main.rs new file mode 100644 index 00000000..ca4cb695 --- /dev/null +++ b/crates/km-tool/src/main.rs @@ -0,0 +1,139 @@ +use std::path::PathBuf; + +use cb_km_tool::{ + Overlay, ProjectionInput, + apply::{ApplyOptions, run_apply}, + check::{Tier, run_check}, +}; +use clap::{Parser, Subcommand, ValueEnum}; +use eyre::Result; + +#[derive(Parser)] +#[command( + name = "cb-km", + about = "Project a Commit-Boost mux config into keymanager builder_config docs", + after_help = "WARNING: check-green does not mean apply-is-a-no-op: GET returns resolved docs, \ + so third-party-pinned values for fields the projection omits (boost, cap) are \ + invisible to check and will be ERASED by apply (POST replaces in full). Pass \ + `apply --preserve-entries` to fold any builder entry another writer pinned back \ + into the POST instead of erasing it." +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(clap::Args)] +struct CommonArgs { + /// Commit-Boost config TOML + #[arg(long, default_value = "config.toml")] + config: PathBuf, + /// Operational overlay TOML (advertised_url, vcs) + #[arg(long, default_value = "km-overlay.toml")] + overlay: PathBuf, +} + +#[derive(Subcommand)] +enum Command { + /// POST projected docs to the configured VCs + Apply { + #[command(flatten)] + common: CommonArgs, + /// Print the projected docs without contacting any VC + #[arg(long)] + dry_run: bool, + /// Write per-key JSON docs plus a manifest to a directory instead of + /// POSTing + #[arg(long, value_name = "DIR")] + emit: Option, + /// POST {} for stored-but-unprojected enumerated keys + #[arg(long)] + prune: bool, + /// GET each key first and keep any builder entry pinned by another + /// writer (identity = url + auth_data) that our projection does not + /// produce, so the full-replace POST does not erase it. Off by default + /// (today's exact-projection replace). Fails loudly if the merge would + /// break a KM cap (e.g. >64 entries) rather than dropping an entry. + /// (read-modify-write; not atomic vs a concurrent writer -- the + /// entry-level PATCH endpoint is the real fix). + #[arg(long)] + preserve_entries: bool, + }, + /// Compare the stored VC docs against the projection (read-only) + Check { + #[command(flatten)] + common: CommonArgs, + /// Lowest finding tier that makes the exit code non-zero + #[arg(long, value_enum, default_value_t = FailOn::Error)] + fail_on: FailOn, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum FailOn { + Info, + Warn, + Error, +} + +impl From for Tier { + fn from(value: FailOn) -> Self { + match value { + FailOn::Info => Tier::Info, + FailOn::Warn => Tier::Warn, + FailOn::Error => Tier::Error, + } + } +} + +fn load(common: &CommonArgs) -> Result<(ProjectionInput, Overlay)> { + Ok((ProjectionInput::from_file(&common.config)?, Overlay::from_file(&common.overlay)?)) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + let cli = Cli::parse(); + + match cli.command { + Command::Apply { common, dry_run, emit, prune, preserve_entries } => { + let (input, overlay) = load(&common)?; + let opts = ApplyOptions { dry_run, emit_dir: emit, prune, preserve_entries }; + let report = run_apply(&input, &overlay, &opts).await?; + for msg in &report.info { + println!("{msg}"); + } + for msg in &report.warnings { + println!("WARN: {msg}"); + } + for (key, vcs) in &report.accepted { + println!("accepted: {key} on {vcs:?}"); + } + for (vc, key) in &report.pruned { + println!("pruned: {key} on {vc}"); + } + for msg in &report.errors { + eprintln!("ERROR: {msg}"); + } + if !report.ok() { + std::process::exit(1); + } + } + Command::Check { common, fail_on } => { + let (input, overlay) = load(&common)?; + let report = run_check(&input, &overlay).await?; + for finding in &report.findings { + println!("{} [{}] {}", finding.tier, finding.code, finding.msg); + } + if report.fails(fail_on.into()) { + std::process::exit(1); + } + } + } + Ok(()) +} diff --git a/crates/km-tool/src/overlay.rs b/crates/km-tool/src/overlay.rs new file mode 100644 index 00000000..956f2053 --- /dev/null +++ b/crates/km-tool/src/overlay.rs @@ -0,0 +1,113 @@ +//! The operational overlay: where/how to apply, kept out of the CB config. +//! Fleet-describing fields (boost, min_bid) live in the CB mux config; this +//! file carries only the advertised sidecar URL and the VC endpoints. + +use std::path::Path; + +use eyre::{Context, Result, ensure}; +use serde::Deserialize; +use url::Url; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Overlay { + /// URL the VCs should send builder requests to (Commit-Boost's endpoint). + /// Kept as the exact configured string: `Url` round-trip serialization + /// normalizes (adds a trailing slash) and the entry `url` must stay as + /// written. + pub advertised_url: String, + #[serde(default)] + pub vcs: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VcConfig { + pub url: Url, + pub token_path: String, + /// Per-VC override of the advertised URL + pub advertised_url: Option, +} + +impl Overlay { + pub fn parse_str(s: &str) -> Result { + let overlay: Self = toml::from_str(s).wrap_err("could not parse overlay TOML")?; + overlay.validate()?; + Ok(overlay) + } + + pub fn from_file(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .wrap_err_with(|| format!("unable to read overlay file: {path:?}"))?; + Self::parse_str(&text) + } + + fn validate(&self) -> Result<()> { + for url in std::iter::once(&self.advertised_url) + .chain(self.vcs.iter().filter_map(|vc| vc.advertised_url.as_ref())) + { + ensure!(Url::parse(url).is_ok(), "advertised_url is not a valid URL: {url}"); + } + Ok(()) + } + + /// The advertised URL in effect for one VC. + pub fn advertised_url_for<'a>(&'a self, vc: &'a VcConfig) -> &'a str { + vc.advertised_url.as_deref().unwrap_or(&self.advertised_url) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal_overlay() { + let overlay = Overlay::parse_str(r#"advertised_url = "https://cb.example.com""#).unwrap(); + assert_eq!(overlay.advertised_url, "https://cb.example.com"); + assert!(overlay.vcs.is_empty()); + } + + #[test] + fn parses_full_overlay_and_vc_override() { + let overlay = Overlay::parse_str( + r#" + advertised_url = "https://cb.example.com" + + [[vcs]] + url = "http://vc1:7500" + token_path = "/tmp/token1" + + [[vcs]] + url = "http://vc2:7500" + token_path = "/tmp/token2" + advertised_url = "https://cb2.example.com" + "#, + ) + .unwrap(); + assert_eq!(overlay.vcs.len(), 2); + assert_eq!(overlay.advertised_url_for(&overlay.vcs[0]), "https://cb.example.com"); + assert_eq!(overlay.advertised_url_for(&overlay.vcs[1]), "https://cb2.example.com"); + } + + #[test] + fn requires_advertised_url() { + assert!(Overlay::parse_str("").is_err()); + } + + #[test] + fn rejects_invalid_advertised_url() { + assert!(Overlay::parse_str(r#"advertised_url = "not a url""#).is_err()); + } + + #[test] + fn rejects_unknown_fields() { + assert!( + Overlay::parse_str( + r#"advertised_url = "https://a.com" +typo_field = 1"# + ) + .is_err() + ); + } +} diff --git a/crates/km-tool/src/project.rs b/crates/km-tool/src/project.rs new file mode 100644 index 00000000..4c33f724 --- /dev/null +++ b/crates/km-tool/src/project.rs @@ -0,0 +1,1054 @@ +//! Pure projection: CB mux config + overlay -> per-key KM builder_config docs. +//! +//! One KM entry per auth_data EQUIVALENCE CLASS: relays whose candidate +//! auth_data byte-strings are identical share one entry. Its `builder_pubkeys` +//! is emitted EMPTY (accept any builder for the key): the only pubkey cb-km can +//! see is the relay URL's userinfo pubkey, which is the relay's identity, not +//! the builder's bid-signing key, and binding the wrong key rejects every bid +//! (see `project_mux`). The candidate is `expected_auth_data` when set, else +//! the UTF-8 bytes of the relay URL as configured with the userinfo stripped +//! (userinfo, and thus any pubkey credential, is intentionally NOT part of the +//! emitted url or auth_data). Grouping-by-identical-bytes is a +//! reimplementation of the demux contract of cb-pbs's +//! `match_relays_by_auth_data` (pub(crate) there); the CB-side contract tests +//! pin those semantics. CAVEAT (also in the plan): CB's own matching is LAXER +//! (`url_matches` ignores userinfo/path/case), so a projected auth_data can +//! round-trip against CB and still mismatch a builder's exact-byte check; +//! prefer `expected_auth_data` when the relay URL is not byte-identical to +//! the builder's advertised URL. + +use std::{ + collections::{BTreeMap, HashSet}, + path::Path, +}; + +use alloy_primitives::U256; +use cb_common::{ + config::{CommitBoostConfig, MUX_PATH_ENV, MuxConfig, RelayConfig, load_optional_env_var}, + types::BlsPublicKey, +}; +use eyre::{Context, Result, bail, ensure}; +use tracing::warn; + +use crate::{ + doc::{BuilderConfigDoc, BuilderEntryDoc, encode_auth_data}, + overlay::Overlay, +}; + +/// KM spec limits (builder_entry.yaml) +pub const MAX_BUILDER_ENTRIES: usize = 64; +pub const MAX_BUILDER_PUBKEYS: usize = 64; +pub const MAX_BUILDER_AUTH_DATA_SIZE: usize = 4096; + +const WEI_PER_GWEI: u64 = 1_000_000_000; + +/// A BLS pubkey ordered by its compressed bytes, so projections are +/// deterministic maps (lighthouse's `PublicKey` has no `Ord`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrderedPubkey(pub BlsPublicKey); + +impl Ord for OrderedPubkey { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.serialize().cmp(&other.0.serialize()) + } +} + +impl PartialOrd for OrderedPubkey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::fmt::Display for OrderedPubkey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.as_hex_string()) + } +} + +/// The parsed CB config plus the RAW relay URL strings from the same TOML +/// text. The auth_data convention wants the URL bytes exactly as configured, +/// but `RelayEntry` holds a parsed `Url` whose serialization normalizes them +/// (see `Overlay::advertised_url`), so the raw strings are kept alongside. +pub struct ProjectionInput { + pub cfg: CommitBoostConfig, + mux_relay_urls: Vec>, + default_relay_urls: Vec, +} + +impl ProjectionInput { + pub fn parse_str(text: &str) -> Result { + let cfg: CommitBoostConfig = + toml::from_str(text).wrap_err("could not parse Commit-Boost config")?; + let raw: toml::Value = toml::from_str(text)?; + + let mux_relay_urls = raw_urls_per_mux(&raw)?; + let mux_count = cfg.muxes.as_ref().map(|m| m.muxes.len()).unwrap_or(0); + ensure!( + mux_relay_urls.len() == mux_count, + "raw TOML mux count {} does not match parsed config {}", + mux_relay_urls.len(), + mux_count + ); + if let Some(muxes) = &cfg.muxes { + for (mux, urls) in muxes.muxes.iter().zip(&mux_relay_urls) { + ensure!( + mux.relays.len() == urls.len(), + "raw TOML relay count does not match parsed config in mux {}", + mux.id + ); + } + } + + let default_relay_urls = raw_url_array(raw.get("relays"))?; + ensure!( + default_relay_urls.len() == cfg.relays.len(), + "raw TOML default-relay count does not match parsed config" + ); + + Ok(Self { cfg, mux_relay_urls, default_relay_urls }) + } + + pub fn from_file(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .wrap_err_with(|| format!("unable to read config file: {path:?}"))?; + Self::parse_str(&text) + } +} + +fn raw_urls_per_mux(raw: &toml::Value) -> Result>> { + let Some(muxes) = raw.get("mux") else { + return Ok(vec![]); + }; + let muxes = muxes.as_array().ok_or_else(|| eyre::eyre!("mux is not an array"))?; + muxes.iter().map(|mux| raw_url_array(mux.get("relays"))).collect() +} + +fn raw_url_array(relays: Option<&toml::Value>) -> Result> { + let Some(relays) = relays else { + return Ok(vec![]); + }; + let relays = relays.as_array().ok_or_else(|| eyre::eyre!("relays is not an array"))?; + relays + .iter() + .map(|relay| { + relay + .get("url") + .and_then(|u| u.as_str()) + .map(String::from) + .ok_or_else(|| eyre::eyre!("relay entry has no url string")) + }) + .collect() +} + +/// Strips the userinfo from a URL by string surgery, leaving everything else +/// exactly as written (no trailing-slash or port normalization). +pub fn strip_userinfo(url: &str) -> String { + let Some(scheme_end) = url.find("://") else { + return url.to_string(); + }; + let after = &url[scheme_end + 3..]; + let authority_end = after.find(['/', '?', '#']).unwrap_or(after.len()); + match after[..authority_end].rfind('@') { + Some(at) => format!("{}{}", &url[..scheme_end + 3], &after[at + 1..]), + None => url.to_string(), + } +} + +/// One relay's candidate auth_data bytes: `expected_auth_data` when set, else +/// the UTF-8 bytes of the configured URL with userinfo stripped. +fn candidate_auth_data(relay: &RelayConfig, raw_url: &str) -> Vec { + match &relay.expected_auth_data { + Some(expected) => expected.to_vec(), + None => strip_userinfo(raw_url).into_bytes(), + } +} + +/// Where a relay's candidate auth_data came from, for check-side routing and +/// reference findings. +#[derive(Debug, Clone)] +pub struct RelayAuthCandidate { + /// Mux id, or "[[relays]]" for the default relay list + pub source: String, + pub relay_id: String, + pub bytes: Vec, + /// Whether this relay is part of a projected mux (default relays are not) + pub projected: bool, +} + +#[derive(Debug)] +pub struct Projection { + pub docs: BTreeMap, + pub warnings: Vec, + /// Candidate auth_data of every configured relay (muxes and defaults) + pub relay_candidates: Vec, +} + +/// Projects per-key KM docs with the overlay's global advertised URL. +pub fn project(input: &ProjectionInput, overlay: &Overlay) -> Result { + project_with_url(input, &overlay.advertised_url) +} + +/// Projects with an explicit advertised URL (per-VC overrides). +pub fn project_with_url(input: &ProjectionInput, advertised_url: &str) -> Result { + let mut warnings = Vec::new(); + let mut docs = BTreeMap::new(); + let mut relay_candidates = Vec::new(); + let mut seen_keys: HashSet> = HashSet::new(); + + if let Some(muxes) = &input.cfg.muxes { + for (mux, raw_urls) in muxes.muxes.iter().zip(&input.mux_relay_urls) { + let keys = resolve_mux_keys(mux, &mut warnings)?; + let doc = project_mux(input, mux, raw_urls, advertised_url, &mut warnings)?; + + for (relay, raw_url) in mux.relays.iter().zip(raw_urls) { + relay_candidates.push(RelayAuthCandidate { + source: mux.id.clone(), + relay_id: relay.id().to_string(), + bytes: candidate_auth_data(relay, raw_url), + projected: !keys.is_empty(), + }); + } + + for key in keys { + let bytes = key.serialize().to_vec(); + if !seen_keys.insert(bytes) { + bail!("duplicate validator pubkey in muxes: {}", key.as_hex_string()); + } + docs.insert(OrderedPubkey(key), doc.clone()); + } + } + } + + for (relay, raw_url) in input.cfg.relays.iter().zip(&input.default_relay_urls) { + relay_candidates.push(RelayAuthCandidate { + source: "[[relays]]".to_string(), + relay_id: relay.id().to_string(), + bytes: candidate_auth_data(relay, raw_url), + projected: false, + }); + } + + Ok(Projection { docs, warnings, relay_candidates }) +} + +fn push_warn(warnings: &mut Vec, msg: String) { + warn!("{msg}"); + warnings.push(msg); +} + +/// Resolves a mux's key set. Explicit `validator_pubkeys` always project. Of +/// the loaders only the File variant resolves offline; HTTP and Registry +/// loaders need the network and are skipped with a warning in v1 (schedule +/// km-apply/km-check when using them, per the plan's drift bounds). +fn resolve_mux_keys(mux: &MuxConfig, warnings: &mut Vec) -> Result> { + let mut keys = mux.validator_pubkeys.clone(); + + if let Some(loader) = &mux.loader { + match loader { + cb_common::config::MuxKeysLoader::File(path) => { + // same semantics as MuxKeysLoader::load: env var overrides path + let path = load_optional_env_var(&format!("{MUX_PATH_ENV}_{}", mux.id)) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| path.clone()); + let file = std::fs::read_to_string(&path) + .wrap_err_with(|| format!("unable to read mux keys file: {path:?}"))?; + let extra: Vec = + serde_json::from_str(&file).wrap_err("failed to parse mux keys file")?; + keys.extend(extra); + } + other => { + push_warn( + warnings, + format!( + "mux {}: loader {:?} needs the network and is not resolved by this tool; \ + its keys are NOT projected (best-effort only)", + mux.id, other + ), + ); + } + } + } + + let keys = cb_common::config::remove_duplicate_keys(keys); + if keys.is_empty() { + push_warn(warnings, format!("mux {}: no projectable keys, skipping", mux.id)); + } + Ok(keys) +} + +struct AuthClass { + relay_ids: Vec, + max_execution_payment_gwei: Option, +} + +/// Groups a mux's relays into auth_data equivalence classes keyed by identical +/// candidate bytes, unioning each class's builder pubkeys, requiring one shared +/// execution-payment cap per class, and enforcing the KM entry-count limit. +fn build_auth_classes( + mux: &MuxConfig, + raw_urls: &[String], +) -> Result, AuthClass>> { + let mut classes: BTreeMap, AuthClass> = BTreeMap::new(); + for (relay, raw_url) in mux.relays.iter().zip(raw_urls) { + let bytes = candidate_auth_data(relay, raw_url); + ensure!( + !bytes.is_empty() && bytes.len() <= MAX_BUILDER_AUTH_DATA_SIZE, + "mux {} relay {}: auth_data must be 1..={MAX_BUILDER_AUTH_DATA_SIZE} bytes, got {}", + mux.id, + relay.id(), + bytes.len() + ); + let class = classes.entry(bytes).or_insert_with(|| AuthClass { + relay_ids: vec![], + max_execution_payment_gwei: relay.max_execution_payment_gwei, + }); + ensure!( + class.max_execution_payment_gwei == relay.max_execution_payment_gwei, + "mux {}: relays {} and {} share an auth_data class but disagree on \ + max_execution_payment_gwei ({:?} vs {:?}); a KM entry carries one cap", + mux.id, + class.relay_ids.first().cloned().unwrap_or_default(), + relay.id(), + class.max_execution_payment_gwei, + relay.max_execution_payment_gwei + ); + class.relay_ids.push(relay.id().to_string()); + } + + ensure!( + classes.len() <= MAX_BUILDER_ENTRIES, + "mux {}: {} builder entries exceed the KM maximum of {MAX_BUILDER_ENTRIES}", + mux.id, + classes.len() + ); + + Ok(classes) +} + +fn project_mux( + input: &ProjectionInput, + mux: &MuxConfig, + raw_urls: &[String], + advertised_url: &str, + warnings: &mut Vec, +) -> Result { + ensure!(!mux.relays.is_empty(), "mux {} has no relays", mux.id); + + let classes = build_auth_classes(mux, raw_urls)?; + + // Entry values are mux/global-sourced; the KEY-LEVEL values come from the + // projection-only p2p fields when set, resolving MUX p2p > global [pbs] + // p2p. Rationale: projected entries always carry explicit per-entry + // values, so the key level only governs p2p bids and entries that omit + // their own. Unset p2p fields fall back to the entry values (uniform doc, + // today's behavior). + let min_bid_wei = mux.min_bid_wei.unwrap_or(input.cfg.pbs.pbs_config.min_bid_wei); + let min_bid = wei_to_gwei_floor(&mux.id, min_bid_wei, warnings)?.to_string(); + let key_min_bid = match mux.min_bid_p2p_wei.or(input.cfg.pbs.pbs_config.min_bid_p2p_wei) { + Some(wei) => wei_to_gwei_floor(&mux.id, wei, warnings)?.to_string(), + None => min_bid.clone(), + }; + let boost = mux.builder_boost_factor.map(|b| b.to_string()); + let key_boost = mux + .builder_boost_factor_p2p + .or(input.cfg.pbs.pbs_config.builder_boost_factor_p2p) + .map(|b| b.to_string()) + .or_else(|| boost.clone()); + + // `(url, auth_data-bytes)` uniqueness: classes are keyed by bytes and all + // entries share the advertised URL, so uniqueness holds by construction; + // asserted anyway to keep the invariant loud. + let mut seen: HashSet<(&str, &[u8])> = HashSet::new(); + let mut entries = Vec::with_capacity(classes.len()); + // BTreeMap iterates classes in byte order = the KM (url, bytes) sort + for (bytes, class) in &classes { + ensure!( + seen.insert((advertised_url, bytes)), + "mux {}: duplicate (url, auth_data) pair", + mux.id + ); + // Emit an EMPTY builder_pubkeys. The only builder pubkey cb-km can see + // is the relay URL's userinfo pubkey, which is the relay's IDENTITY, not + // the builder's bid-SIGNING key. Lodestar rejects any builder-API bid + // whose signing pubkey is not in builder_pubkeys (the check is skipped + // when the array is empty), so populating it with the relay identity + // would silently bind the VC to the wrong key and reject every bid. An + // empty array = accept any builder for this key. Populate this in future + // once cb-km can supply the builder's actual bid-signing pubkey. + entries.push(BuilderEntryDoc { + url: advertised_url.to_string(), + auth_data: Some(encode_auth_data(bytes)), + builder_pubkeys: Some(Vec::new()), + max_execution_payment: class.max_execution_payment_gwei.map(|g| g.to_string()), + min_bid: Some(min_bid.clone()), + builder_boost_factor: boost.clone(), + }); + } + + Ok(BuilderConfigDoc { + min_bid: Some(key_min_bid), + builder_boost_factor: key_boost, + builders: Some(entries), + }) +} + +fn wei_to_gwei_floor(mux_id: &str, wei: U256, warnings: &mut Vec) -> Result { + let divisor = U256::from(WEI_PER_GWEI); + let gwei = wei / divisor; + if wei % divisor != U256::ZERO { + push_warn( + warnings, + format!( + "mux {mux_id}: min_bid {wei} wei has a sub-Gwei remainder, flooring to {gwei} Gwei" + ), + ); + } + gwei.try_into().map_err(|_| eyre::eyre!("mux {mux_id}: min_bid {wei} wei exceeds u64 Gwei")) +} + +#[cfg(test)] +mod tests { + use super::*; + + // relay pubkeys from config.example.toml (valid BLS points) + const RELAY_PK_A: &str = "0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2"; + const RELAY_PK_B: &str = "0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09fe73ccd21f88eab31d6de16194d17782e"; + + fn random_key_hex() -> String { + cb_common::types::BlsSecretKey::random().public_key().as_hex_string() + } + + fn overlay() -> Overlay { + Overlay::parse_str(r#"advertised_url = "https://cb.example.com""#).unwrap() + } + + #[test] + fn strip_userinfo_surgery() { + assert_eq!(strip_userinfo("https://0xabc@relay.example.com"), "https://relay.example.com"); + assert_eq!( + strip_userinfo("https://0xabc@relay.example.com:8443/path"), + "https://relay.example.com:8443/path" + ); + // no trailing slash is ADDED (Url::as_str would add one) + assert_eq!(strip_userinfo("http://pk@host"), "http://host"); + // no userinfo: unchanged + assert_eq!(strip_userinfo("https://host/x?q=1"), "https://host/x?q=1"); + // '@' after the authority is not userinfo + assert_eq!(strip_userinfo("https://host/a@b"), "https://host/a@b"); + } + + fn config_toml(keys: &[String]) -> String { + let keys = keys.iter().map(|k| format!("\"{k}\"")).collect::>().join(", "); + format!( + r#" +chain = "Holesky" + +[pbs] +port = 18550 +min_bid_eth = 0.5 + +[[mux]] +id = "mux1" +validator_pubkeys = [{keys}] + +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" + +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "0x736563726574" +"# + ) + } + + #[test] + fn projects_literal_json_doc() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(std::slice::from_ref(&key))).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + + assert_eq!(projection.docs.len(), 1); + let (pk, doc) = projection.docs.iter().next().unwrap(); + assert_eq!(pk.to_string(), key); + + // entries sorted by auth_data bytes: 0x736563726574 ("secret") sorts + // after the https URL bytes (0x68...). builder_pubkeys is emitted EMPTY + // for every entry (relay identity is not the builder's bid-signing key). + let json = serde_json::to_string(doc).unwrap(); + assert_eq!( + json, + concat!( + r#"{"min_bid":"500000000","builders":["#, + r#"{"url":"https://cb.example.com","#, + r#""auth_data":"0x68747470733a2f2f72656c61792d612e6578616d706c652e636f6d","#, + r#""builder_pubkeys":[],"min_bid":"500000000"},"#, + r#"{"url":"https://cb.example.com","#, + r#""auth_data":"0x736563726574","#, + r#""builder_pubkeys":[],"min_bid":"500000000"}]}"# + ) + ); + } + + #[test] + fn stripped_userinfo_no_trailing_slash_in_auth_data() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + let auth = doc.builders.as_ref().unwrap()[0].auth_data.clone().unwrap(); + // "https://relay-a.example.com" exactly: no pubkey, no trailing slash + assert_eq!( + crate::doc::decode_auth_data(&auth).unwrap(), + b"https://relay-a.example.com".to_vec() + ); + } + + // Two relays sharing an auth_data class collapse to ONE entry. The entry's + // builder_pubkeys is emitted empty (no relay-identity pubkeys unioned in). + #[test] + fn equivalence_class_groups_into_one_entry_with_empty_pubkeys() { + let key = random_key_hex(); + // both relays carry the same expected_auth_data -> one entry + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +expected_auth_data = "0xaabb" +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "0xaabb" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + let entries = doc.builders.as_ref().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].builder_pubkeys.as_ref().unwrap(), &Vec::::new()); + } + + #[test] + fn class_cap_disagreement_errors() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +expected_auth_data = "0xaabb" +max_execution_payment_gwei = 100 +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "0xaabb" +max_execution_payment_gwei = 200 +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let err = project(&input, &overlay()).unwrap_err(); + assert!(err.to_string().contains("max_execution_payment_gwei"), "{err}"); + } + + #[test] + fn relay_cap_projects_or_omits() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +max_execution_payment_gwei = 250000000 +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + let entries = doc.builders.as_ref().unwrap(); + assert_eq!(entries.len(), 2); + let by_cap: Vec<_> = entries.iter().map(|e| e.max_execution_payment.clone()).collect(); + assert!(by_cap.contains(&Some("250000000".to_string()))); + // the un-capped relay's entry OMITS the field (resolves to VC config) + assert!(by_cap.contains(&None)); + } + + #[test] + fn min_bid_falls_back_global_with_floor_warning() { + let key = random_key_hex(); + // 1.5 gwei in eth: 0.0000000015 eth = 1500000000 wei... use + // min_bid_eth for a sub-gwei remainder: 0.0000000000015 ETH = 1500 wei + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +min_bid_eth = 0.0000000000015 +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.min_bid, Some("0".to_string())); + assert!( + projection.warnings.iter().any(|w| w.contains("sub-Gwei")), + "{:?}", + projection.warnings + ); + } + + // The p2p fields split the doc: KEY-LEVEL min_bid/boost come from the + // global projection-only p2p fields (they govern p2p bids and entries + // omitting their own), while ENTRIES keep the mux/global-sourced values. + #[test] + fn p2p_fields_differentiate_key_level_from_entries() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +min_bid_p2p_eth = "0.2" +builder_boost_factor_p2p = 0 +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +min_bid_eth = "0.000001" +builder_boost_factor = 100 +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + + // key level: 0.2 ETH = 200000000 Gwei floor, boost 0 + assert_eq!(doc.min_bid, Some("200000000".to_string())); + assert_eq!(doc.builder_boost_factor, Some("0".to_string())); + + // entry level: mux min_bid 0.000001 ETH = 1000 Gwei, mux boost 100 + let entry = &doc.builders.as_ref().unwrap()[0]; + assert_eq!(entry.min_bid, Some("1000".to_string())); + assert_eq!(entry.builder_boost_factor, Some("100".to_string())); + } + + // A mux p2p field wins over a different global p2p field at the key level. + #[test] + fn mux_p2p_override_wins_over_global() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +min_bid_p2p_eth = "0.2" +builder_boost_factor_p2p = 50 +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +min_bid_p2p_eth = "0.7" +builder_boost_factor_p2p = 130 +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + // key level uses the MUX p2p values, not the global ones + assert_eq!(doc.min_bid, Some("700000000".to_string())); + assert_eq!(doc.builder_boost_factor, Some("130".to_string())); + } + + // Only the global p2p fields are set: every mux uses them at the key level. + #[test] + fn global_p2p_applies_to_all_muxes() { + let key_a = random_key_hex(); + let key_b = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +min_bid_p2p_eth = "0.2" +builder_boost_factor_p2p = 50 +[[mux]] +id = "ma" +validator_pubkeys = ["{key_a}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +[[mux]] +id = "mb" +validator_pubkeys = ["{key_b}"] +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + assert_eq!(projection.docs.len(), 2); + for doc in projection.docs.values() { + assert_eq!(doc.min_bid, Some("200000000".to_string())); + assert_eq!(doc.builder_boost_factor, Some("50".to_string())); + } + } + + // Mix: mux A overrides the global p2p, mux B inherits it. + #[test] + fn mux_p2p_override_and_inherit_mix() { + let key_a = random_key_hex(); + let key_b = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +min_bid_p2p_eth = "0.2" +builder_boost_factor_p2p = 50 +[[mux]] +id = "ma" +validator_pubkeys = ["{key_a}"] +min_bid_p2p_eth = "0.7" +builder_boost_factor_p2p = 130 +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +[[mux]] +id = "mb" +validator_pubkeys = ["{key_b}"] +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let by_key = |k: &str| { + projection.docs.iter().find(|(pk, _)| pk.to_string() == k).map(|(_, doc)| doc).unwrap() + }; + // mux A: its own p2p values + let doc_a = by_key(&key_a); + assert_eq!(doc_a.min_bid, Some("700000000".to_string())); + assert_eq!(doc_a.builder_boost_factor, Some("130".to_string())); + // mux B: the global p2p values + let doc_b = by_key(&key_b); + assert_eq!(doc_b.min_bid, Some("200000000".to_string())); + assert_eq!(doc_b.builder_boost_factor, Some("50".to_string())); + } + + // Asymmetric: a mux sets ONLY min_bid_p2p (not builder_boost_factor_p2p). + // The key-level min_bid must take the mux p2p value, while the key-level + // boost must NOT be dragged along with it: boost independently follows the + // global/uniform p2p fallback, never the mux. Guards the two p2p fields + // against a coupling regression. + #[test] + fn mux_min_bid_p2p_does_not_drag_boost_p2p() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +builder_boost_factor_p2p = 50 +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +min_bid_p2p_eth = "0.7" +builder_boost_factor = 100 +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + + // key-level min_bid: the MUX p2p value (0.7 ETH = 700000000 Gwei floor) + assert_eq!(doc.min_bid, Some("700000000".to_string())); + // key-level boost: the GLOBAL p2p value, NOT the mux's own boost (100) + assert_eq!(doc.builder_boost_factor, Some("50".to_string())); + + // entry-level boost is still the mux's own value, confirming the split + let entry = &doc.builders.as_ref().unwrap()[0]; + assert_eq!(entry.builder_boost_factor, Some("100".to_string())); + } + + // Unset p2p fields keep today's uniform projection (key = entry values). + #[test] + fn p2p_fields_unset_keep_uniform_projection() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + let entry = &doc.builders.as_ref().unwrap()[0]; + assert_eq!(doc.min_bid, entry.min_bid); + assert_eq!(doc.builder_boost_factor, None); + assert_eq!(entry.builder_boost_factor, None); + } + + #[test] + fn boost_omitted_without_source() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.builder_boost_factor, None); + assert_eq!(doc.builders.as_ref().unwrap()[0].builder_boost_factor, None); + } + + #[test] + fn determinism_under_toml_permutation() { + let key_a = random_key_hex(); + let key_b = random_key_hex(); + let base = config_toml(&[key_a.clone(), key_b.clone()]); + // permute validator key order AND relay order + let permuted = format!( + r#" +chain = "Holesky" + +[pbs] +port = 18550 +min_bid_eth = 0.5 + +[[mux]] +id = "mux1" +validator_pubkeys = ["{key_b}", "{key_a}"] + +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "0x736563726574" + +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let overlay = overlay(); + let a = project(&ProjectionInput::parse_str(&base).unwrap(), &overlay).unwrap(); + let b = project(&ProjectionInput::parse_str(&permuted).unwrap(), &overlay).unwrap(); + let ser = |p: &Projection| { + p.docs + .iter() + .map(|(k, d)| (k.to_string(), serde_json::to_string(d).unwrap())) + .collect::>() + }; + assert_eq!(ser(&a), ser(&b)); + } + + #[test] + fn duplicate_key_across_muxes_errors() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m1" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +[[mux]] +id = "m2" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + assert!(project(&input, &overlay()).unwrap_err().to_string().contains("duplicate")); + } + + #[test] + fn network_loader_mux_warns_and_projects_explicit_keys() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +loader = {{ url = "http://localhost:8000/keys" }} +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + assert_eq!(projection.docs.len(), 1); + assert!(projection.warnings.iter().any(|w| w.contains("NOT projected"))); + } + + #[test] + fn file_loader_resolves_keys() { + let key_a = random_key_hex(); + let key_b = random_key_hex(); + let dir = tempfile::tempdir().unwrap(); + let keys_path = dir.path().join("keys.json"); + std::fs::write(&keys_path, format!(r#"["{key_b}"]"#)).unwrap(); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "filemux" +validator_pubkeys = ["{key_a}"] +loader = "{}" +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"#, + keys_path.display() + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let keys: Vec = projection.docs.keys().map(|k| k.to_string()).collect(); + assert_eq!(projection.docs.len(), 2); + assert!(keys.contains(&key_a) && keys.contains(&key_b)); + } + + #[test] + fn default_relays_are_candidates_but_not_projected() { + let key = random_key_hex(); + let toml_text = format!( + r#" +chain = "Holesky" +[[relays]] +url = "https://{RELAY_PK_B}@default-relay.example.com" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let default_candidate = + projection.relay_candidates.iter().find(|c| c.source == "[[relays]]").unwrap(); + assert!(!default_candidate.projected); + assert_eq!(default_candidate.bytes, b"https://default-relay.example.com".to_vec()); + // the projected doc references only the mux relay + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.builders.as_ref().unwrap().len(), 1); + } + + #[test] + fn lax_equivalent_urls_project_as_separate_entries() { + let key = random_key_hex(); + // byte-distinct candidates that CB's url_matches would see as one relay + // (https://h vs https://h:443) project as two byte-distinct entries. + // builder_pubkeys is empty, so both accept any builder and there is + // nothing to split. + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay.example.com" +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay.example.com:443" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.builders.as_ref().unwrap().len(), 2); + } + + #[test] + fn lax_collision_via_expected_auth_data_url_projects_two_entries() { + let key = random_key_hex(); + // expected_auth_data holds URL bytes lax-equivalent to the other + // relay's URL-derived candidate: hex of "HTTPS://RELAY.EXAMPLE.COM" + // is byte-distinct but host-case-insensitively the same relay. Both + // project as separate entries with empty builder_pubkeys. + let upper_hex = crate::doc::encode_auth_data(b"https://RELAY.EXAMPLE.COM"); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay.example.com" +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "{upper_hex}" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + assert_eq!(doc.builders.as_ref().unwrap().len(), 2); + } + + #[test] + fn distinct_hosts_do_not_lax_collide() { + let key = random_key_hex(); + let input = ProjectionInput::parse_str(&config_toml(&[key])).unwrap(); + // relay-a and relay-b: different hosts, projection succeeds + assert_eq!(project(&input, &overlay()).unwrap().docs.len(), 1); + } + + #[test] + fn same_byte_urls_still_group_into_one_class() { + let key = random_key_hex(); + // identical URL bytes after userinfo strip: one class, no ambiguity + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +id = "r1" +url = "https://{RELAY_PK_A}@relay.example.com" +[[mux.relays]] +id = "r2" +url = "https://{RELAY_PK_B}@relay.example.com" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + let projection = project(&input, &overlay()).unwrap(); + let doc = projection.docs.values().next().unwrap(); + let entries = doc.builders.as_ref().unwrap(); + assert_eq!(entries.len(), 1); + // grouped into one class; builder_pubkeys is emitted empty + assert_eq!(entries[0].builder_pubkeys.as_ref().unwrap().len(), 0); + } + + #[test] + fn auth_data_size_limit_enforced() { + let key = random_key_hex(); + let big = "ab".repeat(4097); + let toml_text = format!( + r#" +chain = "Holesky" +[pbs] +[[mux]] +id = "m" +validator_pubkeys = ["{key}"] +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" +expected_auth_data = "0x{big}" +"# + ); + let input = ProjectionInput::parse_str(&toml_text).unwrap(); + assert!(project(&input, &overlay()).unwrap_err().to_string().contains("4096")); + } +} diff --git a/crates/km-tool/tests/mock_km.rs b/crates/km-tool/tests/mock_km.rs new file mode 100644 index 00000000..5ac74fc4 --- /dev/null +++ b/crates/km-tool/tests/mock_km.rs @@ -0,0 +1,613 @@ +//! Integration tests against a mock keymanager server (axum): auth, +//! preflight, POST outcome handling, prune body shape, dry-run/emit network +//! silence, and canonical check comparison. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use axum::{ + Router, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::get, +}; +use cb_km_tool::{ + Overlay, ProjectionInput, + apply::{ApplyOptions, run_apply}, + check::{Tier, run_check}, + project, +}; + +const RELAY_PK_A: &str = "0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2"; +const RELAY_PK_B: &str = "0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09fe73ccd21f88eab31d6de16194d17782e"; +const TOKEN: &str = "test-token"; + +#[derive(Clone, Default)] +struct MockVc { + /// keys this VC holds (lowercase hex) + keystores: Vec, + /// whether the VC serves the #88 builder_config route + supports_builder_config: bool, + /// stored docs returned on GET (raw JSON, so tests control hex case and + /// entry order) + stored: HashMap, + /// POST status override per key (default: 202 when held, else 404) + post_status: HashMap, + /// blanket GET builder_config status override (e.g. 500) + get_status: Option, + /// recorded (pubkey, raw body) of every builder_config POST + posts: Arc>>, +} + +impl MockVc { + fn holding(keys: &[String]) -> Self { + Self { keystores: keys.to_vec(), supports_builder_config: true, ..Default::default() } + } + + fn posts(&self) -> Vec<(String, String)> { + self.posts.lock().unwrap().clone() + } +} + +fn authed(headers: &HeaderMap) -> bool { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == format!("Bearer {TOKEN}")) +} + +async fn keystores(State(vc): State, headers: HeaderMap) -> impl IntoResponse { + if !authed(&headers) { + return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + let data: Vec<_> = + vc.keystores.iter().map(|pk| serde_json::json!({ "validating_pubkey": pk })).collect(); + axum::Json(serde_json::json!({ "data": data })).into_response() +} + +async fn get_config( + State(vc): State, + Path(pubkey): Path, + headers: HeaderMap, +) -> impl IntoResponse { + if !authed(&headers) { + return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + if let Some(status) = vc.get_status { + return (StatusCode::from_u16(status).unwrap(), "overridden").into_response(); + } + if !vc.supports_builder_config || !vc.keystores.contains(&pubkey) { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + let doc = vc.stored.get(&pubkey).cloned().unwrap_or(serde_json::json!({})); + axum::Json(serde_json::json!({ "data": doc })).into_response() +} + +async fn post_config( + State(vc): State, + Path(pubkey): Path, + headers: HeaderMap, + body: String, +) -> impl IntoResponse { + if !authed(&headers) { + return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + if !vc.supports_builder_config { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + vc.posts.lock().unwrap().push((pubkey.clone(), body)); + let status = vc + .post_status + .get(&pubkey) + .copied() + .unwrap_or_else(|| if vc.keystores.contains(&pubkey) { 202 } else { 404 }); + StatusCode::from_u16(status).unwrap().into_response() +} + +/// Serves a mock VC on an ephemeral port, returning its base URL. +async fn serve(vc: MockVc) -> String { + let app = Router::new() + .route("/eth/v1/keystores", get(keystores)) + .route("/eth/v1/validator/{pubkey}/builder_config", get(get_config).post(post_config)) + .with_state(vc); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") +} + +fn random_key() -> String { + cb_common::types::BlsSecretKey::random().public_key().as_hex_string() +} + +fn config_toml(keys: &[String]) -> String { + let keys = keys.iter().map(|k| format!("\"{k}\"")).collect::>().join(", "); + format!( + r#" +chain = "Holesky" + +[pbs] +min_bid_eth = 0.5 + +[[mux]] +id = "mux1" +validator_pubkeys = [{keys}] + +[[mux.relays]] +url = "https://{RELAY_PK_A}@relay-a.example.com" + +[[mux.relays]] +url = "https://{RELAY_PK_B}@relay-b.example.com" +expected_auth_data = "0x736563726574" +"# + ) +} + +struct TestEnv { + input: ProjectionInput, + overlay: Overlay, + _token_file: tempfile::NamedTempFile, +} + +fn env_for(keys: &[String], vc_urls: &[String]) -> TestEnv { + let token_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(token_file.path(), format!("{TOKEN}\n")).unwrap(); + let vcs = vc_urls + .iter() + .map(|url| { + format!("[[vcs]]\nurl = \"{url}\"\ntoken_path = \"{}\"\n", token_file.path().display()) + }) + .collect::>() + .join("\n"); + let overlay = + Overlay::parse_str(&format!("advertised_url = \"https://cb.example.com\"\n{vcs}")).unwrap(); + let input = ProjectionInput::parse_str(&config_toml(keys)).unwrap(); + TestEnv { input, overlay, _token_file: token_file } +} + +#[tokio::test] +async fn apply_partitioned_keys_accepted_once_each() { + let (k1, k2) = (random_key(), random_key()); + let vc1 = MockVc::holding(std::slice::from_ref(&k1)); + let vc2 = MockVc::holding(std::slice::from_ref(&k2)); + let (url1, url2) = (serve(vc1.clone()).await, serve(vc2.clone()).await); + + let env = env_for(&[k1.clone(), k2.clone()], &[url1, url2]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + + assert!(report.ok(), "{:?}", report.errors); + assert_eq!(report.accepted.get(&k1).map(Vec::len), Some(1)); + assert_eq!(report.accepted.get(&k2).map(Vec::len), Some(1)); + + // the accepted POST body is the projected doc + let posts = vc1.posts(); + let accepted: Vec<_> = posts.iter().filter(|(pk, _)| pk == &k1).collect(); + assert_eq!(accepted.len(), 1); + let projection = project(&env.input, &env.overlay).unwrap(); + let expected = projection + .docs + .iter() + .find(|(pk, _)| pk.to_string() == k1) + .map(|(_, doc)| serde_json::to_string(doc).unwrap()) + .unwrap(); + assert_eq!(accepted[0].1, expected); +} + +#[tokio::test] +async fn apply_401_reports_preflight_error() { + let key = random_key(); + let vc = MockVc::holding(std::slice::from_ref(&key)); + let url = serve(vc).await; + + let mut env = env_for(std::slice::from_ref(&key), &[url]); + // wrong token + std::fs::write(env._token_file.path(), "wrong-token").unwrap(); + env.overlay = Overlay::parse_str(&format!( + "advertised_url = \"https://cb.example.com\"\n[[vcs]]\nurl = \"{}\"\ntoken_path = \"{}\"\n", + env.overlay.vcs[0].url, + env._token_file.path().display() + )) + .unwrap(); + + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(!report.ok()); + assert!(report.errors.iter().any(|e| e.contains("keystores preflight failed"))); +} + +#[tokio::test] +async fn apply_detects_missing_88_support() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + vc.supports_builder_config = false; + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(!report.ok()); + assert!( + report.errors.iter().any(|e| e.contains("no builder_config support")), + "{:?}", + report.errors + ); +} + +#[tokio::test] +async fn apply_probe_transport_failure_is_its_own_error() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + vc.get_status = Some(500); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(!report.ok()); + assert!( + report.errors.iter().any(|e| e.contains("builder_config probe failed")), + "{:?}", + report.errors + ); + assert!(!report.errors.iter().any(|e| e.contains("no builder_config support"))); + // a failed probe skips the VC: nothing POSTed + assert!(vc.posts().is_empty()); +} + +#[tokio::test] +async fn apply_empty_keystore_vc_warns_and_still_posts() { + let key = random_key(); + let empty_vc = MockVc::holding(&[]); + let holder_vc = MockVc::holding(std::slice::from_ref(&key)); + let (empty_url, holder_url) = (serve(empty_vc.clone()).await, serve(holder_vc).await); + + let env = env_for(std::slice::from_ref(&key), &[empty_url, holder_url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(report.ok(), "{:?}", report.errors); + assert!( + report.warnings.iter().any(|w| w.contains("no keys to probe")), + "{:?}", + report.warnings + ); + // supported-unknown: the empty VC still gets the POST; its 404 answers + assert_eq!(empty_vc.posts().len(), 1); + assert_eq!(report.accepted.get(&key).map(Vec::len), Some(1)); +} + +#[tokio::test] +async fn apply_zero_acceptors_is_an_error() { + let (projected, other) = (random_key(), random_key()); + // the VC holds a DIFFERENT key: POST of the projected key 404s + let vc = MockVc::holding(std::slice::from_ref(&other)); + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&projected), &[url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(!report.ok()); + assert!(report.errors.iter().any(|e| e.contains("accepted by NO vc")), "{:?}", report.errors); + // coverage warning fires for the enumerated-but-unprojected key + assert!(report.warnings.iter().any(|w| w.contains(&other) && w.contains("unprojected"))); +} + +#[tokio::test] +async fn apply_duplicate_acceptance_raises_slashing_alarm() { + let key = random_key(); + let vc1 = MockVc::holding(std::slice::from_ref(&key)); + let vc2 = MockVc::holding(std::slice::from_ref(&key)); + let (url1, url2) = (serve(vc1).await, serve(vc2).await); + + let env = env_for(std::slice::from_ref(&key), &[url1, url2]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(!report.ok()); + assert!( + report.errors.iter().any(|e| e.contains("DUPLICATE KEY") && e.contains("slashing")), + "{:?}", + report.errors + ); +} + +#[tokio::test] +async fn apply_403_is_config_file_managed_warning() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + vc.post_status.insert(key.clone(), 403); + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!( + report.warnings.iter().any(|w| w.contains("config-file-managed")), + "{:?}", + report.warnings + ); + // not accepted anywhere -> still an error overall + assert!(!report.ok()); +} + +#[tokio::test] +async fn prune_posts_exactly_empty_object() { + let (projected, stale) = (random_key(), random_key()); + let vc = MockVc::holding(&[projected.clone(), stale.clone()]); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&projected), &[url]); + let opts = ApplyOptions { prune: true, ..Default::default() }; + let report = run_apply(&env.input, &env.overlay, &opts).await.unwrap(); + + let posts = vc.posts(); + let prune_posts: Vec<_> = posts.iter().filter(|(pk, _)| pk == &stale).collect(); + assert_eq!(prune_posts.len(), 1); + assert_eq!(prune_posts[0].1, "{}"); + assert_eq!(report.pruned.len(), 1); +} + +#[tokio::test] +async fn dry_run_and_emit_post_nothing() { + let key = random_key(); + let vc = MockVc::holding(std::slice::from_ref(&key)); + let url = serve(vc.clone()).await; + let env = env_for(std::slice::from_ref(&key), &[url]); + + let report = + run_apply(&env.input, &env.overlay, &ApplyOptions { dry_run: true, ..Default::default() }) + .await + .unwrap(); + assert!(report.ok()); + assert!(vc.posts().is_empty()); + + let emit_dir = tempfile::tempdir().unwrap(); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions { + emit_dir: Some(emit_dir.path().to_path_buf()), + ..Default::default() + }) + .await + .unwrap(); + assert!(report.ok()); + assert!(vc.posts().is_empty()); + assert!(emit_dir.path().join(format!("{key}.json")).exists()); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(emit_dir.path().join("manifest.json")).unwrap(), + ) + .unwrap(); + assert_eq!(manifest["keys"][0]["pubkey"], serde_json::json!(key)); +} + +/// Our projected doc for `key`, as the JSON value a VC would store. +fn projected_value(env: &TestEnv, key: &str) -> serde_json::Value { + serde_json::from_str(&projected_string(env, key)).unwrap() +} + +/// Our projected doc for `key`, serialized exactly as the tool POSTs it. +fn projected_string(env: &TestEnv, key: &str) -> String { + let projection = project(&env.input, &env.overlay).unwrap(); + let doc = projection.docs.iter().find(|(pk, _)| pk.to_string() == key).map(|(_, d)| d).unwrap(); + serde_json::to_string(doc).unwrap() +} + +fn third_party_entry(url: &str, auth_hex: &str) -> serde_json::Value { + serde_json::json!({ "url": url, "auth_data": auth_hex, "builder_pubkeys": [RELAY_PK_B] }) +} + +/// Collects the (url, auth_data) pairs in a POSTed builder_config body. +fn posted_entries(body: &str) -> Vec<(String, String)> { + let doc: serde_json::Value = serde_json::from_str(body).unwrap(); + doc["builders"] + .as_array() + .unwrap() + .iter() + .map(|e| { + (e["url"].as_str().unwrap().to_string(), e["auth_data"].as_str().unwrap().to_string()) + }) + .collect() +} + +// (a) Without the flag, a stored third-party entry is IGNORED: the POST body is +// our projection exactly, unchanged from today's full-replace behavior. +#[tokio::test] +async fn without_flag_post_body_is_projection_and_ignores_stored() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + let env = env_for(std::slice::from_ref(&key), &[]); + let mut stored = projected_value(&env, &key); + stored["builders"] + .as_array_mut() + .unwrap() + .push(third_party_entry("https://third-party.example.com", "0xc0ffee")); + vc.stored.insert(key.clone(), stored); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_apply(&env.input, &env.overlay, &ApplyOptions::default()).await.unwrap(); + assert!(report.ok(), "{:?}", report.errors); + + let posts = vc.posts(); + let posted = posts.iter().find(|(pk, _)| pk == &key).unwrap(); + assert_eq!(posted.1, projected_string(&env, &key)); +} + +// (b) With the flag, a pre-existing third-party entry survives the apply: the +// POSTed body contains BOTH our projected entries and theirs. +#[tokio::test] +async fn preserve_entries_keeps_third_party_entry() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + let env = env_for(std::slice::from_ref(&key), &[]); + let mut stored = projected_value(&env, &key); + stored["builders"] + .as_array_mut() + .unwrap() + .push(third_party_entry("https://third-party.example.com", "0xc0ffee")); + vc.stored.insert(key.clone(), stored); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let opts = ApplyOptions { preserve_entries: true, ..Default::default() }; + let report = run_apply(&env.input, &env.overlay, &opts).await.unwrap(); + assert!(report.ok(), "{:?}", report.errors); + + let posts = vc.posts(); + let posted = posts.iter().find(|(pk, _)| pk == &key).unwrap(); + let entries = posted_entries(&posted.1); + // our two projected entries plus the third party's, no more + assert_eq!(entries.len(), 3, "{entries:?}"); + assert!( + entries.iter().any(|(u, a)| u == "https://third-party.example.com" && a == "0xc0ffee"), + "{entries:?}" + ); + // both of ours (advertised URL) still present + assert_eq!(entries.iter().filter(|(u, _)| u == "https://cb.example.com").count(), 2); +} + +// (c) A stored third-party entry sharing OUR identity (url + auth_data) is +// REPLACED by ours, not duplicated -> no (url, auth_data) collision (no 400). +#[tokio::test] +async fn preserve_entries_collision_is_replaced_not_duplicated() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + let env = env_for(std::slice::from_ref(&key), &[]); + // stored = a doc whose entries share our identity but carry a foreign + // pubkey and a VC-resolved boost (simulating a resolved GET of our doc) + let mut stored = projected_value(&env, &key); + for entry in stored["builders"].as_array_mut().unwrap() { + entry["builder_pubkeys"] = serde_json::json!([RELAY_PK_A]); + entry["builder_boost_factor"] = serde_json::json!("100"); + } + vc.stored.insert(key.clone(), stored); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let opts = ApplyOptions { preserve_entries: true, ..Default::default() }; + let report = run_apply(&env.input, &env.overlay, &opts).await.unwrap(); + assert!(report.ok(), "{:?}", report.errors); + + let posts = vc.posts(); + let posted = posts.iter().find(|(pk, _)| pk == &key).unwrap(); + let entries = posted_entries(&posted.1); + // no identity duplicated: exactly our two projected entries + assert_eq!(entries.len(), 2, "{entries:?}"); + // ours win: the POST body equals our pure projection (resolved defaults + // dropped) + assert_eq!(posted.1, projected_string(&env, &key)); +} + +// (d) A merge that would exceed the KM 64-entry cap fails loudly with no POST. +#[tokio::test] +async fn preserve_entries_over_cap_fails_without_posting() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + let env = env_for(std::slice::from_ref(&key), &[]); + let mut stored = projected_value(&env, &key); + let builders = stored["builders"].as_array_mut().unwrap(); + // our 2 entries + 63 distinct third-party entries = 65 > 64 + for i in 0..63 { + builders.push(third_party_entry(&format!("https://third-{i}.example.com"), "0xabcdef")); + } + vc.stored.insert(key.clone(), stored); + let url = serve(vc.clone()).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let opts = ApplyOptions { preserve_entries: true, ..Default::default() }; + let report = run_apply(&env.input, &env.overlay, &opts).await.unwrap(); + + assert!(!report.ok()); + assert!( + report.errors.iter().any(|e| e.contains("exceeding the KM maximum")), + "{:?}", + report.errors + ); + // the merge aborts BEFORE any POST for this key + assert!(!vc.posts().iter().any(|(pk, _)| pk == &key), "{:?}", vc.posts()); +} + +#[tokio::test] +async fn check_reordered_uppercase_stored_doc_is_not_drift() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + + // the stored doc: same content as the projection, but entries REVERSED, + // auth_data hex UPPERCASED, and VC-resolved values for fields the + // projection omits + let env0 = env_for(std::slice::from_ref(&key), &[]); + let projection = project(&env0.input, &env0.overlay).unwrap(); + let projected_doc = projection.docs.values().next().unwrap(); + let mut stored = serde_json::to_value(projected_doc).unwrap(); + let builders = stored["builders"].as_array_mut().unwrap(); + builders.reverse(); + for entry in builders.iter_mut() { + let upper = entry["auth_data"].as_str().unwrap().to_uppercase().replacen("0X", "0x", 1); + entry["auth_data"] = serde_json::json!(upper); + // VC-side resolution fills fields the projection left out + entry["builder_boost_factor"] = serde_json::json!("100"); + } + stored["builder_boost_factor"] = serde_json::json!("100"); + vc.stored.insert(key.clone(), stored); + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_check(&env.input, &env.overlay).await.unwrap(); + assert!(!report.fails(Tier::Warn), "{:?}", report.findings); +} + +#[tokio::test] +async fn check_flags_drift_and_unroutable_auth_data() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + vc.stored.insert( + key.clone(), + serde_json::json!({ + "min_bid": "1", + "builders": [ + { "url": "https://cb.example.com", "auth_data": "0xdead" } + ] + }), + ); + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_check(&env.input, &env.overlay).await.unwrap(); + assert!( + report.findings.iter().any(|f| f.code == "unroutable-auth-data" && + f.tier == Tier::Warn && + f.msg.contains("served via the pipe")), + "{:?}", + report.findings + ); + assert!(report.findings.iter().any(|f| f.code == "drift" && f.tier == Tier::Warn)); + assert!(report.fails(Tier::Warn)); + assert!(!report.fails(Tier::Error)); +} + +#[tokio::test] +async fn check_errors_when_no_builder_config_route() { + let key = random_key(); + let mut vc = MockVc::holding(std::slice::from_ref(&key)); + vc.supports_builder_config = false; + let url = serve(vc).await; + + let env = env_for(std::slice::from_ref(&key), &[url]); + let report = run_check(&env.input, &env.overlay).await.unwrap(); + assert!( + report.findings.iter().any(|f| f.code == "no-builder-config-route" && + f.tier == Tier::Error && + f.msg.contains("#88")), + "{:?}", + report.findings + ); + assert!(report.fails(Tier::Error)); +} + +#[tokio::test] +async fn check_flags_duplicate_key_across_vcs() { + let key = random_key(); + let vc1 = MockVc::holding(std::slice::from_ref(&key)); + let vc2 = MockVc::holding(std::slice::from_ref(&key)); + let (url1, url2) = (serve(vc1).await, serve(vc2).await); + + let env = env_for(std::slice::from_ref(&key), &[url1, url2]); + let report = run_check(&env.input, &env.overlay).await.unwrap(); + assert!( + report.findings.iter().any(|f| f.code == "duplicate-key" && f.tier == Tier::Error), + "{:?}", + report.findings + ); +} diff --git a/crates/pbs/Cargo.toml b/crates/pbs/Cargo.toml index 7497aa93..92f53c06 100644 --- a/crates/pbs/Cargo.toml +++ b/crates/pbs/Cargo.toml @@ -5,6 +5,11 @@ publish = false rust-version.workspace = true version.workspace = true +[features] +# Test-only escape hatches (e.g. skipping the pipe SSRF target check so an e2e +# test can dial a local mock). Never enabled in a release build. +testing-flags = [] + [dependencies] alloy.workspace = true async-trait.workspace = true @@ -35,3 +40,6 @@ url.workspace = true uuid.workspace = true webpki-roots.workspace = true thiserror.workspace = true + +[dev-dependencies] +toml.workspace = true diff --git a/crates/pbs/src/constants.rs b/crates/pbs/src/constants.rs index 5976468d..837f97b5 100644 --- a/crates/pbs/src/constants.rs +++ b/crates/pbs/src/constants.rs @@ -2,6 +2,9 @@ pub const STATUS_ENDPOINT_TAG: &str = "status"; pub const REGISTER_VALIDATOR_ENDPOINT_TAG: &str = "register_validator"; pub const SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG: &str = "submit_blinded_block"; pub const GET_HEADER_ENDPOINT_TAG: &str = "get_header"; +pub const GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG: &str = "get_execution_payload_bid"; +pub const SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG: &str = "submit_builder_preferences"; +pub const SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG: &str = "submit_signed_beacon_block"; pub const RELOAD_ENDPOINT_TAG: &str = "reload"; /// For metrics recorded when a request times out @@ -19,6 +22,13 @@ pub const MAX_SIZE_SUBMIT_BLOCK_RESPONSE: usize = 20 * 1024 * 1024; /// 20 MiB, enough to process ~45000 registrations in one request pub const MAX_SIZE_REGISTER_VALIDATOR_REQUEST: usize = 20 * 1024 * 1024; +/// A Gloas `SignedBeaconBlock` carries the signed bid, not the execution +/// payload (that ships separately in the `ExecutionPayloadEnvelope`), so the +/// body is blinded-block-sized, not full-block-sized. This 20 MiB cap matches +/// the inbound blinded block limit (`MAX_SIZE_SUBMIT_BLOCK_RESPONSE`) as a +/// conservative ceiling; a real block is far smaller. +pub const MAX_SIZE_SUBMIT_SIGNED_BEACON_BLOCK: usize = MAX_SIZE_SUBMIT_BLOCK_RESPONSE; + /// 5 MiB, to account for max execution requests / commitments pub const MAX_SIZE_GET_HEADER_RESPONSE: usize = 5 * 1024 * 1024; diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index 98f8a2f1..866f0b9b 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -20,6 +20,36 @@ struct ErrorResponse { pub enum PbsClientError { #[error("no response from relays")] NoResponse, + /// ePBS submission fan-out where zero addressed builders accepted. 500, + /// not 502: neither endpoint's builder-specs response set contains 502 + /// (submitBuilderPreferences declares {202, 400, 401, 415, 500} - 415 added + /// in spec PR #165; submitSignedBeaconBlock declares {202, 400, 415, + /// 500}); 502 is not in either set. Legacy routes keep `NoResponse` -> 502. + #[error("no builder accepted the submission")] + NoBuilderResponse, + #[error("auth data does not match a configured builder")] + AuthDataMismatch, + /// The transient pipe was pointed at a builder URL that resolves into + /// loopback/private/link-local space (or one that could not be resolved at + /// all): an SSRF attempt or a misconfiguration aiming CB at an internal + /// host, so it is a bad request, never a target to dial. + #[error("pipe target resolves to a disallowed address")] + PipeTargetBlocked, + #[error("auth data is empty")] + EmptyAuthData, + #[error("missing or invalid timing headers")] + MissingTimingHeader, + #[error("auth slot does not match the request path")] + AuthSlotMismatch, + /// A lone addressed builder's own 400/401 from the preferences endpoint, + /// propagated so the proposer learns whether its auth data or its signature + /// was rejected (a blanket 500 would hide that). + #[error("the addressed builder rejected the request with {}", .0.as_u16())] + BuilderRejected(StatusCode), + #[error("auth signature verification failed")] + AuthSigVerify, + #[error("submitted block is not a Gloas block")] + NotGloasBlock, #[error("no payload from relays")] NoPayload, #[error("internal server error")] @@ -34,6 +64,15 @@ 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::PipeTargetBlocked => StatusCode::BAD_REQUEST, + PbsClientError::EmptyAuthData => StatusCode::BAD_REQUEST, + PbsClientError::MissingTimingHeader => StatusCode::BAD_REQUEST, + PbsClientError::AuthSlotMismatch => StatusCode::BAD_REQUEST, + PbsClientError::BuilderRejected(code) => *code, + PbsClientError::AuthSigVerify => StatusCode::UNAUTHORIZED, + PbsClientError::NotGloasBlock => StatusCode::BAD_REQUEST, PbsClientError::NoPayload => StatusCode::BAD_GATEWAY, PbsClientError::Internal => StatusCode::INTERNAL_SERVER_ERROR, PbsClientError::DecodeError(BodyDeserializeError::UnsupportedMediaType) => { @@ -50,10 +89,37 @@ 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 the value agreed with this builder".to_string() + } + PbsClientError::PipeTargetBlocked => { + "Invalid SignedBuilderRequestAuth: the addressed builder URL resolves to a disallowed address".to_string() + } + PbsClientError::EmptyAuthData => { + "Invalid SignedBuilderRequestAuth: auth.message.data must not be empty".to_string() + } + 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 submission with status {}", code.as_u16()) + } + PbsClientError::AuthSigVerify => { + "Invalid SignedBuilderRequestAuth: signature verification failed".to_string() + } + PbsClientError::NotGloasBlock => { + "Invalid signed beacon block: only Gloas blocks are supported".to_string() + } PbsClientError::NoPayload => "no payload from relays".to_string(), PbsClientError::Internal => "internal server error".to_string(), - PbsClientError::DecodeError(e) => format!("error decoding request: {e}"), - 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 +146,28 @@ mod test { PbsClientError::DecodeError(BodyDeserializeError::MissingVersionHeader).status_code(), StatusCode::BAD_REQUEST, ); + // The unrecognized-fork variant must stay 400: it sits under the + // variant-specific 415 arm, and only the DecodeError(_) catch-all + // routes it today + assert_eq!( + PbsClientError::DecodeError(BodyDeserializeError::InvalidVersionHeader( + "futurefork".to_string() + )) + .status_code(), + StatusCode::BAD_REQUEST, + ); + } + + #[test] + fn auth_errors_map_to_spec_status_codes() { + assert_eq!(PbsClientError::AuthSlotMismatch.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(PbsClientError::AuthSigVerify.status_code(), StatusCode::UNAUTHORIZED); + // A pipe target pointed at an internal address is a bad request, not a 5xx + assert_eq!(PbsClientError::PipeTargetBlocked.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + PbsClientError::DecodeError(BodyDeserializeError::MissingBody).status_code(), + StatusCode::BAD_REQUEST, + ); } #[tokio::test] diff --git a/crates/pbs/src/lib.rs b/crates/pbs/src/lib.rs index 8b4afdcf..35be3187 100644 --- a/crates/pbs/src/lib.rs +++ b/crates/pbs/src/lib.rs @@ -13,3 +13,6 @@ pub use constants::*; pub use mev_boost::*; pub use service::PbsService; pub use state::{BuilderApiState, PbsState, PbsStateGuard}; +// Test-only: let the e2e suite skip the pipe SSRF target check (dial a local mock). +#[cfg(feature = "testing-flags")] +pub use utils::set_skip_pipe_target_check; diff --git a/crates/pbs/src/metrics.rs b/crates/pbs/src/metrics.rs index 8ef049aa..473764d0 100644 --- a/crates/pbs/src/metrics.rs +++ b/crates/pbs/src/metrics.rs @@ -71,4 +71,16 @@ lazy_static! { &["relay_id"], PBS_METRICS_REGISTRY ).unwrap(); + + /// Relay responses CB rejected during validation, by reason. Deliberately + /// NOT a synthetic entry in `RELAY_STATUS_CODE`: the relay's HTTP status + /// was already counted there (usually a 200), and overloading a status + /// label with validation semantics is how the 555/556 bucketing confusion + /// started. A dropped response is an event of its own kind. + pub static ref RELAY_INVALID_RESPONSE: IntCounterVec = register_int_counter_vec_with_registry!( + "pbs_relay_invalid_response_total", + "Relay responses rejected by CB validation, by reason", + &["reason", "endpoint", "relay_id"], + PBS_METRICS_REGISTRY + ).unwrap(); } diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index 67fbad13..00c9a3da 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -802,7 +802,7 @@ mod tests { #[test] fn test_validate_signature() { - let secret_key = BlsSecretKey::test_random(); + let secret_key = BlsSecretKey::random(); let pubkey = secret_key.public_key(); let wrong_pubkey = BlsPublicKeyBytes::test_random(); let wrong_signature = BlsSignature::test_random(); @@ -858,7 +858,7 @@ mod tests { ForkName::Fulu => {} // Skip future forks - ForkName::Gloas => continue, + ForkName::Gloas | ForkName::Heze => continue, } // Load get_header JSON from test data diff --git a/crates/pbs/src/mev_boost/get_header_ws.rs b/crates/pbs/src/mev_boost/get_header_ws.rs index afebec17..152dde57 100644 --- a/crates/pbs/src/mev_boost/get_header_ws.rs +++ b/crates/pbs/src/mev_boost/get_header_ws.rs @@ -66,6 +66,7 @@ fn fork_from_wire(byte: u8) -> Option { 5 => ForkName::Electra, 6 => ForkName::Fulu, 7 => ForkName::Gloas, + 8 => ForkName::Heze, _ => return None, }) } diff --git a/crates/pbs/src/routes/builder_preferences.rs b/crates/pbs/src/routes/builder_preferences.rs new file mode 100644 index 00000000..c3ad41d5 --- /dev/null +++ b/crates/pbs/src/routes/builder_preferences.rs @@ -0,0 +1,339 @@ +use axum::{ + body::Bytes, + extract::{Path, State}, + http::HeaderMap, + response::IntoResponse, +}; +use cb_common::{ + pbs::{ + BuilderPreferencesRequest, RelayClient, SignedBuilderRequestAuth, + SubmitBuilderPreferencesParams, error::PbsError, + }, + types::Chain, + wire::{decode_versioned_request_body, get_user_agent}, +}; +use reqwest::StatusCode; +use ssz::Encode; +use tracing::{debug, error, info}; + +use crate::{ + PbsStateGuard, + constants::SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, + error::PbsClientError, + state::{BuilderApiState, PbsState}, + utils::{ + epbs_base_send_headers, join_detached_sends, log_mux_selection, post_ssz_expect_accepted, + record_beacon_status, record_client_error, record_request_failure, + resolve_addressed_relays, validate_auth_data, verify_auth_signature, + }, +}; + +/// The body is the required `BuilderPreferencesRequest`; like the bid +/// endpoint it is fork-versioned per builder-specs, and `Eth-Consensus-Version` +/// is required for JSON and SSZ alike +pub async fn handle_submit_builder_preferences( + State(state): State>, + req_headers: HeaderMap, + Path(params): Path, + body: Bytes, +) -> Result { + let request = + decode_versioned_request_body::(&req_headers, &body) + .map_err(|err| record_client_error(err, SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG))?; + tracing::Span::current().record("validator", tracing::field::debug(¶ms.proposer_pubkey)); + tracing::Span::current().record("slot", request.auth.message.slot.as_u64()); + + let state = state.read().clone(); + let ua = get_user_agent(&req_headers); + info!( + ua, + slot = %request.auth.message.slot, + max_execution_payment = request.preferences.max_execution_payment, + "new request" + ); + + match submit_builder_preferences(params, request, req_headers, state).await { + Ok(()) => { + 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 +/// Ok(()) if at least one addressed builder accepted (-> 202). +pub async fn submit_builder_preferences( + params: SubmitBuilderPreferencesParams, + request: BuilderPreferencesRequest, + req_headers: HeaderMap, + state: PbsState, +) -> Result<(), PbsClientError> { + let (pbs_config, relays, maybe_mux_id) = state.mux_config_and_relays(¶ms.proposer_pubkey); + + log_mux_selection(maybe_mux_id, relays.len(), ¶ms.proposer_pubkey); + + // Validate before any outbound work so a rejected request costs nothing + validate_preferences_auth( + &request.auth, + ¶ms, + state.config.chain, + pbs_config.verify_builder_request_auth, + )?; + + let relays = resolve_addressed_relays( + relays, + request.auth.message.data.as_ref(), + &pbs_config.advertised_urls, + &state.pipe_client, + ) + .await?; + + let send_headers = epbs_base_send_headers(&req_headers)?; + + // The builder decodes what the proposer signed either way, and SSZ is the + // faster wire format on the relay hop; encoded once, shared by every send + let body = Bytes::from(request.as_ssz_bytes()); + + // Preferences are submitted an epoch ahead, so they share the registration + // timeout rather than the block-production one + let timeout_ms = pbs_config.timeout_register_validator_ms; + + let results = join_detached_sends(relays.iter().map(|relay| { + send_one_submit_builder_preferences( + params.proposer_pubkey.clone(), + body.clone(), + relay.clone(), + send_headers.clone(), + timeout_ms, + ) + })) + .await; + let mut accepted = 0; + let mut lone_rejection = None; + for (res, relay) in results.into_iter().zip(relays.iter()) { + let relay_id = relay.id.as_str(); + match res { + Ok(()) => accepted += 1, + Err(err) if err.is_timeout() => error!(err = "Timed Out", relay_id), + Err(err) => { + // Only a single addressed builder's verdict is unambiguous enough + // to hand back to the proposer + if relays.len() == 1 { + lone_rejection = err.relay_status_code(); + } + error!(%err, relay_id) + } + } + } + + // One accepting builder is a successful submission: the others are separate + // destinations, not replicas, and the proposer addressed each by auth data + if accepted == 0 { + return Err(match lone_rejection { + Some(400) => PbsClientError::BuilderRejected(StatusCode::BAD_REQUEST), + Some(401) => PbsClientError::BuilderRejected(StatusCode::UNAUTHORIZED), + _ => PbsClientError::NoBuilderResponse, + }); + } + + info!(accepted, addressed = relays.len(), "builder preferences submitted"); + Ok(()) +} + +/// Validates the caller's `SignedBuilderRequestAuth`. CB is a blind pipe for +/// preferences: it does not gate on the auth slot. Freshness (rejecting a stale +/// or replayed submission that would roll a proposer's preferences back) is the +/// builder's call, not the relay's, so CB forwards regardless of slot age. All +/// that is required here is `auth.message.data` (non-empty; which builder it +/// addresses is the demux's job, `match_relays_by_auth_data`) and, when +/// enabled, the request-auth signature. +fn validate_preferences_auth( + auth: &SignedBuilderRequestAuth, + params: &SubmitBuilderPreferencesParams, + chain: Chain, + verify_signature: bool, +) -> Result<(), PbsClientError> { + validate_auth_data(auth)?; + + verify_auth_signature(¶ms.proposer_pubkey, auth, chain, verify_signature) +} + +async fn send_one_submit_builder_preferences( + proposer_pubkey: cb_common::types::BlsPublicKey, + body: Bytes, + relay: RelayClient, + headers: HeaderMap, + timeout_ms: u64, +) -> Result<(), PbsError> { + let url = relay.submit_builder_preferences_url(&proposer_pubkey)?; + + let request_latency = post_ssz_expect_accepted( + &relay, + url, + body, + headers, + timeout_ms, + SUBMIT_BUILDER_PREFERENCES_ENDPOINT_TAG, + ) + .await?; + + debug!(relay_id = relay.id.as_ref(), latency = ?request_latency, "preferences accepted"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use cb_common::{ + pbs::{BuilderPreferences, BuilderRequestAuth}, + types::BlsSignature, + utils::utcnow_sec, + wire::{BodyDeserializeError, CONSENSUS_VERSION_HEADER}, + }; + + use super::*; + + fn current_slot(chain: Chain) -> u64 { + (utcnow_sec() - chain.genesis_time_sec()) / chain.slot_time_sec() + } + + fn sample_request() -> BuilderPreferencesRequest { + BuilderPreferencesRequest { + auth: SignedBuilderRequestAuth { + message: BuilderRequestAuth { + data: Default::default(), + slot: lh_types::Slot::new(3), + }, + signature: BlsSignature::empty(), + }, + preferences: BuilderPreferences { max_execution_payment: 7 }, + } + } + + // Empty `auth.message.data` is rejected before sigverify, so it cannot slip + // through a catch-all relay match. Guards the wiring of the shared + // `validate_auth_data` into this endpoint. + #[test] + fn validate_preferences_auth_rejects_empty_data() { + use cb_common::types::BlsSecretKey; + + let chain = Chain::Hoodi; + let params = + SubmitBuilderPreferencesParams { proposer_pubkey: BlsSecretKey::random().public_key() }; + let empty = SignedBuilderRequestAuth { + message: BuilderRequestAuth { + data: Default::default(), + slot: lh_types::Slot::new(current_slot(chain)), + }, + signature: BlsSignature::empty(), + }; + for verify in [false, true] { + assert!(matches!( + validate_preferences_auth(&empty, ¶ms, chain, verify), + Err(PbsClientError::EmptyAuthData) + )); + } + } + + #[test] + fn decode_rejects_an_empty_body() { + let err = decode_versioned_request_body::( + &HeaderMap::new(), + &Bytes::new(), + ) + .expect_err("an empty body is not a request"); + assert!(matches!(err, BodyDeserializeError::MissingBody)); + } + + /// This endpoint's no-preference default is SSZ, not the shared JSON one, + /// and `Eth-Consensus-Version` is required regardless of encoding. + #[test] + fn decode_defaults_to_ssz_without_a_content_type() { + let body = Bytes::from(sample_request().as_ssz_bytes()); + + // Missing the header, the SSZ-default body is rejected, not misparsed + let err = + decode_versioned_request_body::(&HeaderMap::new(), &body) + .expect_err("ssz without the version header must be rejected"); + assert!(matches!(err, BodyDeserializeError::MissingVersionHeader)); + + let mut headers = HeaderMap::new(); + headers.insert(CONSENSUS_VERSION_HEADER, axum::http::HeaderValue::from_static("gloas")); + let decoded = decode_versioned_request_body::(&headers, &body) + .expect("ssz body decodes without a content type"); + assert_eq!(decoded.preferences.max_execution_payment, 7); + assert_eq!(decoded.auth.message.slot.as_u64(), 3); + + // gloas is the ONLY supported value: fulu is rejected + // at the wire layer like every other deprecated fork + let mut headers = HeaderMap::new(); + headers.insert(CONSENSUS_VERSION_HEADER, axum::http::HeaderValue::from_static("fulu")); + let err = decode_versioned_request_body::(&headers, &body) + .expect_err("fulu must be rejected on the gloas-only endpoints"); + assert!(matches!(err, BodyDeserializeError::InvalidVersionHeader(ref v) if v == "fulu")); + + // A DEPRECATED fork name (recognized by lighthouse, outside the gloas-only + // window) is rejected as unsupported + let mut headers = HeaderMap::new(); + headers.insert(CONSENSUS_VERSION_HEADER, axum::http::HeaderValue::from_static("electra")); + let err = decode_versioned_request_body::(&headers, &body) + .expect_err("a deprecated fork must be rejected"); + assert!(matches!(err, BodyDeserializeError::InvalidVersionHeader(ref v) if v == "electra")); + + // Case is folded before the window check (lighthouse FromStr + // lowercases), so an uppercase spelling of the one legal value passes. + // Pinned: if the spec is ever read as lowercase-only, this is the + // deliberate place to change it. + let mut headers = HeaderMap::new(); + headers.insert(CONSENSUS_VERSION_HEADER, axum::http::HeaderValue::from_static("GLOAS")); + decode_versioned_request_body::(&headers, &body) + .expect("case-insensitive gloas is accepted"); + } + + /// builder-specs marks `Eth-Consensus-Version` required on this endpoint + /// for JSON and SSZ alike (builder-specs #165): JSON without it is a 400, + /// not a best-effort decode. Since spec PR #165 the same rule covers + /// `submitSignedBeaconBlock` too — no endpoint is lenient. + #[test] + fn decode_rejects_json_without_the_version_header() { + let body = Bytes::from(serde_json::to_vec(&sample_request()).unwrap()); + + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); + let err = decode_versioned_request_body::(&headers, &body) + .expect_err("json without the version header must be rejected"); + assert!(matches!(err, BodyDeserializeError::MissingVersionHeader)); + + headers.insert(CONSENSUS_VERSION_HEADER, axum::http::HeaderValue::from_static("gloas")); + decode_versioned_request_body::(&headers, &body) + .expect("the same json body decodes once the header is present"); + } + + /// An unrecognized fork name is a 400 per builder-specs, and the error + /// names the value rather than claiming the header is missing. + #[test] + fn decode_rejects_an_unrecognized_fork_value() { + let request = sample_request(); + for (ct, body) in [ + ("application/json", Bytes::from(serde_json::to_vec(&request).unwrap())), + ("application/octet-stream", Bytes::from(request.as_ssz_bytes())), + ] { + let mut headers = HeaderMap::new(); + headers + .insert(axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static(ct)); + headers.insert( + CONSENSUS_VERSION_HEADER, + axum::http::HeaderValue::from_static("futurefork"), + ); + let err = decode_versioned_request_body::(&headers, &body) + .expect_err("an unrecognized fork value must be rejected"); + assert!( + matches!(err, BodyDeserializeError::InvalidVersionHeader(ref v) if v == "futurefork"), + "{ct}: wrong error: {err}" + ); + } + } +} diff --git a/crates/pbs/src/routes/execution_payload_bid.rs b/crates/pbs/src/routes/execution_payload_bid.rs new file mode 100644 index 00000000..68b913df --- /dev/null +++ b/crates/pbs/src/routes/execution_payload_bid.rs @@ -0,0 +1,1238 @@ +use std::{sync::Arc, time::Duration}; + +use alloy::{ + consensus::BlockHeader, + primitives::{B256, U256, utils::format_ether}, + providers::Provider, + rpc::types::Block, +}; +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, HeaderValue}, + response::{IntoResponse, Response}, +}; +use cb_common::{ + config::PbsConfig, + pbs::{ + DEFAULT_BID_POLL_TIMEOUT_MS, ForkName, GetExecutionPayloadBidInfo, + GetExecutionPayloadBidParams, GetExecutionPayloadBidResponse, HEADER_START_TIME_UNIX_MS, + HEADER_TIMEOUT_MS, RelayClient, SignedBuilderRequestAuth, SignedExecutionPayloadBid, + error::{PbsError, ValidationError}, + }, + types::Chain, + utils::{ms_into_slot, utcnow_ms}, + wire::{ + CONSENSUS_VERSION_HEADER, EncodingType, OUTBOUND_ACCEPT_JSON_FIRST, + OUTBOUND_ACCEPT_SSZ_FIRST, decode_versioned_request_body, get_accept_types_with_default, + get_user_agent, parse_response_encoding_and_fork, safe_read_http_response, + }, +}; +use futures::future::join_all; +use parking_lot::RwLock; +use reqwest::{ + StatusCode, + header::{ACCEPT, CONTENT_TYPE}, +}; +use ssz::{Decode, Encode}; +use tokio::time::sleep; +use tracing::{Instrument, debug, error, info, warn}; +use url::Url; + +use crate::{ + PbsStateGuard, + constants::{ + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, MAX_SIZE_GET_HEADER_RESPONSE, TIMEOUT_ERROR_CODE, + }, + error::PbsClientError, + metrics::{RELAY_HEADER_VALUE, RELAY_LAST_SLOT}, + state::{BuilderApiState, PbsState}, + utils::{ + check_gas_limit, epbs_base_send_headers, log_mux_selection, record_beacon_status, + record_client_error, record_request_failure, resolve_addressed_relays, send_to_relay, + validate_auth_data, verify_auth_signature, + }, +}; + +/// The body is the required `SignedBuilderRequestAuth`; builder-specs +/// fork-versions the request wire type, and `Eth-Consensus-Version` is required +/// for JSON and SSZ alike (builder-specs #165). +pub async fn handle_get_execution_payload_bid( + State(state): State>, + req_headers: HeaderMap, + Path(params): Path, + body: Bytes, +) -> Result { + // Count decode rejections: a client broken by the strict header rule must + // show up as a 400 spike on this endpoint, not vanish from the counter + let body = Arc::new( + decode_versioned_request_body::(&req_headers, &body) + .map_err(|err| record_client_error(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG))?, + ); + tracing::Span::current().record("slot", params.slot); + tracing::Span::current().record("parent_hash", tracing::field::debug(params.parent_hash)); + tracing::Span::current().record("parent_root", tracing::field::debug(params.parent_root)); + tracing::Span::current().record("validator", tracing::field::debug(¶ms.proposer_pubkey)); + + let state = state.read().clone(); + + let ua = get_user_agent(&req_headers); + let ms_into_slot = ms_into_slot(params.slot, state.config.chain); + + // No-preference (absent Accept / wildcard) defaults to SSZ; an explicit + // Accept header is obeyed. Parsed once here and threaded through: it picks + // both the relay-side Accept and the response encoding. + let response_encoding = get_accept_types_with_default(&req_headers, EncodingType::Ssz) + .inspect_err(|err| error!(%err, "error parsing accept header")) + .map_err(|err| record_client_error(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG))? + .primary; + + info!(ua, ms_into_slot, "new request"); + + match get_execution_payload_bid( + params, + body, + req_headers, + response_encoding, + ms_into_slot, + state, + ) + .await + { + Ok(Some(max_bid)) => { + encode_bid_response(max_bid, response_encoding, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG) + } + Ok(None) => { + // spec: return 204 if request is valid but no bid available + info!("no header available for slot"); + record_beacon_status("204", GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG); + Ok(StatusCode::NO_CONTENT.into_response()) + } + Err(err) => Err(record_request_failure(err, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG)), + } +} + +/// Encodes a winning bid into the 200 response for the caller's negotiated +/// encoding, stamping the required `Eth-Consensus-Version` header and counting +/// the returned status. +fn encode_bid_response( + max_bid: GetExecutionPayloadBidResponse, + response_encoding: EncodingType, + endpoint: &str, +) -> Result { + info!( + trustless_bid_eth = format_gwei_as_eth(max_bid.value()), + execution_payment_eth = format_gwei_as_eth(max_bid.execution_payment()), + block_hash = %max_bid.block_hash(), + builder_index = max_bid.builder_index(), + "received header" + ); + + // Eth-Consensus-Version is required on the 200 for both encodings + let consensus_version_header = HeaderValue::from_str(&max_bid.version.to_string()) + .expect("fork name is always a valid header value"); + + record_beacon_status("200", endpoint); + let mut res = match response_encoding { + EncodingType::Ssz => { + let mut res = max_bid.data.as_ssz_bytes().into_response(); + res.headers_mut().insert(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()); + res + } + EncodingType::Json => axum::Json(max_bid).into_response(), + }; + res.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + Ok(res) +} + +/// Implements https://ethereum.github.io/builder-specs/?urls.primaryName=dev#/Builder/getExecutionPayloadBid +/// Some(bid) if a relay serves one (-> 200), None if none do (-> 204); errors +/// with Internal (-> 500). +pub async fn get_execution_payload_bid( + params: GetExecutionPayloadBidParams, + body: Arc, + req_headers: HeaderMap, + response_encoding: EncodingType, + ms_into_slot: u64, + 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); + + // Validate before any outbound work so a rejected request costs nothing + validate_builder_request_auth( + &body, + ¶ms, + state.config.chain, + pbs_config.verify_builder_request_auth, + )?; + + let parent_block = Arc::new(RwLock::new(None)); + if state.extra_validation_enabled() && + let Some(rpc_url) = pbs_config.rpc_url.clone() + { + tokio::spawn( + fetch_parent_block(rpc_url, params.parent_hash, parent_block.clone()).in_current_span(), + ); + } + + let relays = resolve_addressed_relays( + relays, + body.message.data.as_ref(), + &pbs_config.advertised_urls, + &state.pipe_client, + ) + .await?; + + // The proposer's own deadline (Date-Milliseconds + X-Timeout-Ms) tells CB + // exactly when the beacon node will stop waiting, so CB derives its timeout + // from that live value rather than a static, drift-prone config. It reserves + // proposer_deadline_buffer_ms for the winning bid's return trip to the BN and + // the BN's own selection/assembly, and asks the builder for the rest. Legacy + // timeout_get_header_ms and late_in_slot_time_ms are NOT consulted here: they + // exist for the get_header path, which carries no X-Timeout-Ms. + let budget_ms = request_budget_ms(&req_headers, utcnow_ms())?; + // Clamp the proposer-supplied X-Timeout-Ms to one slot: it has no upper + // bound on the wire, and a huge value would pin an outbound relay connection + // open and grow the timing-games ladder without limit. + let budget_ms = budget_ms.min(state.config.chain.slot_time_sec().saturating_mul(1000)); + let max_timeout_ms = budget_ms.saturating_sub(pbs_config.proposer_deadline_buffer_ms); + debug!( + budget_ms, + buffer_ms = pbs_config.proposer_deadline_buffer_ms, + max_timeout_ms, + "ePBS bid request budget" + ); + + // Zero budget = the proposer's own deadline (minus the buffer) has already + // passed, so any bid would land too late for the beacon node to use. Return + // 204 without a doomed relay call. This honors the PROPOSER's deadline, not a + // CB-imposed late-in-slot cutoff, so it is not the preemptive skip the bid + // path used to do. + if max_timeout_ms == 0 { + warn!(budget_ms, "proposer deadline reached, no time to solicit a bid"); + return Ok(None); + } + + // prepare headers, except for start time which is set in + // `send_one_get_execution_payload_bid` + let mut send_headers = epbs_base_send_headers(&req_headers)?; + + // Ask the relay for the format the BN wants first, avoiding a + // decode->re-encode, but always offer the other encoding as fallback so a + // format-limited relay still returns a bid. + let relay_accept = match response_encoding { + EncodingType::Ssz => &OUTBOUND_ACCEPT_SSZ_FIRST, + EncodingType::Json => &OUTBOUND_ACCEPT_JSON_FIRST, + }; + send_headers.insert(ACCEPT, relay_accept.clone()); + + let caps: Vec = relays.iter().map(|relay| ranking_cap_gwei(relay, pbs_config)).collect(); + let mut handles = Vec::with_capacity(relays.len()); + for (relay, &cap) in relays.iter().zip(&caps) { + handles.push( + send_timed_get_execution_payload_bid( + params.clone(), + body.clone(), + relay.clone(), + send_headers.clone(), + ms_into_slot, + max_timeout_ms, + cap, + ValidationContext { + extra_validation_enabled: state.extra_validation_enabled(), + parent_block: parent_block.clone(), + }, + ) + .in_current_span(), + ); + } + + let results = join_all(handles).await; + let mut relay_bids = Vec::with_capacity(relays.len()); + for ((res, relay), &cap) in results.into_iter().zip(relays.iter()).zip(&caps) { + let relay_id = relay.id.as_str(); + + match res { + Ok(Some(res)) => { + RELAY_LAST_SLOT.with_label_values(&[relay_id]).set(params.slot as i64); + // value() is already gwei (the gauge is labelled gwei), so it is set unscaled + RELAY_HEADER_VALUE.with_label_values(&[relay_id]).set(res.value() as i64); + + relay_bids.push((relay_id, res, cap)) + } + Ok(None) => {} + Err(err) if err.is_timeout() => error!(err = "Timed Out", relay_id), + Err(err) => error!(%err, relay_id), + } + } + + let max_bid = select_max_bid(relay_bids); + + if let Some((winning_relay_id, ref bid)) = max_bid { + info!( + relay_id = winning_relay_id, + bid_eth = format_gwei_as_eth(total_payment(bid)), + trustless_bid_eth = format_gwei_as_eth(bid.value()), + execution_payment_eth = format_gwei_as_eth(bid.execution_payment()), + block_hash = %bid.block_hash(), + "auction winner" + ); + } + + Ok(max_bid.map(|(_, bid)| bid)) +} + +/// Timeout for one bid poll. Every poll shares the proposer's deadline, so an +/// early poll is bounded to land a bid in hand while there is still time to use +/// it; only the last poll holds for the full remainder. +fn poll_call_timeout_ms(timeout_left_ms: u64, poll_timeout_ms: u64, is_last: bool) -> u64 { + if is_last { timeout_left_ms } else { poll_timeout_ms.min(timeout_left_ms) } +} + +struct RungBudget { + call_timeout_ms: u64, + is_last: bool, +} + +/// One rung's slice of the shared budget, derived from the absolute deadline +/// at the moment the rung fires. Nominal bookkeeping (budget minus one cadence +/// step per rung) drifts optimistic because a rung costs cadence PLUS +/// scheduling delay; anchoring on the deadline means a rung, in particular the +/// last one, is never granted more than truly remains. +fn rung_budget( + deadline_ms: u64, + now_ms: u64, + send_freq_ms: u64, + poll_timeout_ms: u64, +) -> RungBudget { + let remaining_ms = deadline_ms.saturating_sub(now_ms); + let is_last = remaining_ms <= send_freq_ms; + RungBudget { + call_timeout_ms: poll_call_timeout_ms(remaining_ms, poll_timeout_ms, is_last), + is_last, + } +} + +/// Pre-ladder wait for `target_first_request_ms` (0 when the slot is already +/// past the target). `None` means the target sits at or beyond the proposer's +/// deadline, so every poll would go out with a 0ms timeout: the relay must be +/// skipped instead of being sent requests that cannot succeed. +fn target_first_request_delay_ms(target_ms: u64, ms_into_slot: u64, budget_ms: u64) -> Option { + let delay = target_ms.saturating_sub(ms_into_slot); + if delay >= budget_ms { None } else { Some(delay) } +} + +/// Milliseconds left to serve this request, from the proposer's required timing +/// headers. `X-Timeout-Ms` is measured from `Date-Milliseconds`, so the +/// deadline is absolute and survives transit delay. It is also clamped to +/// `now + X-Timeout-Ms` so a proposer whose clock runs ahead cannot hand out +/// more time than it meant to. Returns 0 when the deadline has already passed. +fn request_budget_ms(req_headers: &HeaderMap, now_ms: u64) -> Result { + fn header_u64(req_headers: &HeaderMap, name: &str) -> Result { + req_headers + .get(name) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .ok_or(PbsClientError::MissingTimingHeader) + } + + let sent_at_ms = header_u64(req_headers, HEADER_START_TIME_UNIX_MS)?; + let timeout_ms = header_u64(req_headers, HEADER_TIMEOUT_MS)?; + if timeout_ms == 0 { + return Err(PbsClientError::MissingTimingHeader); + } + + let until_deadline = sent_at_ms.saturating_add(timeout_ms).saturating_sub(now_ms); + Ok(until_deadline.min(timeout_ms)) +} + +/// Validates the caller's `SignedBuilderRequestAuth` against the request path. +/// The `auth.message.data` must be non-empty; which builder it addresses is the +/// demux's job (`match_relays_by_auth_data`). The slot must match the request +/// path, plus the signature when `verify_builder_request_auth` is on. The +/// downstream builder verifies the signature regardless, which is why the +/// crypto is opt-in. +fn validate_builder_request_auth( + auth: &SignedBuilderRequestAuth, + params: &GetExecutionPayloadBidParams, + chain: Chain, + verify_signature: bool, +) -> Result<(), PbsClientError> { + validate_auth_data(auth)?; + + if auth.message.slot.as_u64() != params.slot { + warn!(auth_slot = %auth.message.slot, path_slot = params.slot, "auth slot mismatch"); + return Err(PbsClientError::AuthSlotMismatch); + } + + verify_auth_signature(¶ms.proposer_pubkey, auth, chain, verify_signature) +} + +fn total_payment(bid: &impl GetExecutionPayloadBidInfo) -> u64 { + bid.value().saturating_add(bid.execution_payment()) +} + +/// Bid amounts are denominated in gwei, but `format_ether` expects wei; scale +/// up before formatting so the human-readable `_eth` log fields are correct. +fn format_gwei_as_eth(gwei: u64) -> String { + format_ether(U256::from(gwei) * U256::from(1_000_000_000u64)) +} + +/// The execution-payment cap used when ranking a relay's bids: the per-relay +/// override, else the global config value (default u64::MAX = unclamped). +fn ranking_cap_gwei(relay: &RelayClient, pbs_config: &PbsConfig) -> u64 { + relay + .config + .max_execution_payment_gwei + .or(pbs_config.max_execution_payment_gwei) + .unwrap_or(u64::MAX) +} + +/// A bid's ranking value per beacon-APIs #630: the BN values a bid at +/// `value + min(execution_payment, max_execution_payment)` (the cap CLAMPS the +/// trusted payment, it does not reject the bid). CB returns a single winner, +/// so it must rank with the same clamp or its winner can disagree with the +/// BN's valuation. +fn ranking_payment(bid: &impl GetExecutionPayloadBidInfo, cap_gwei: u64) -> u64 { + bid.value().saturating_add(bid.execution_payment().min(cap_gwei)) +} + +/// The winner among `(relay id, bid, that relay's execution-payment cap in +/// gwei)`, ranked by [`ranking_payment`]. +fn select_max_bid(bids: Vec<(&str, I, u64)>) -> Option<(&str, I)> { + bids.into_iter() + .max_by_key(|(_, bid, cap_gwei)| ranking_payment(bid, *cap_gwei)) + .map(|(label, bid, _)| (label, bid)) +} + +/// Fetch the parent block from the RPC URL for extra validation of the header. +/// Extra validation will be skipped if: +/// - relay returns header before parent block is fetched +/// - parent block is not found, eg because of a RPC delay +async fn fetch_parent_block( + rpc_url: Url, + parent_hash: B256, + parent_block: Arc>>, +) { + let provider = alloy::providers::ProviderBuilder::new().connect_http(rpc_url).to_owned(); + + debug!(%parent_hash, "fetching parent block"); + + match provider.get_block_by_hash(parent_hash).await { + Ok(maybe_block) => { + debug!(block_found = maybe_block.is_some(), "fetched parent block"); + let mut guard = parent_block.write(); + *guard = maybe_block; + } + Err(err) => { + warn!(%err, %parent_hash, "failed to fetch parent block, skipping extra validation"); + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn send_timed_get_execution_payload_bid( + params: GetExecutionPayloadBidParams, + body: Arc, + relay: RelayClient, + headers: HeaderMap, + ms_into_slot: u64, + timeout_left_ms: u64, + ranking_cap_gwei: u64, + validation: ValidationContext, +) -> Result, PbsError> { + let url = relay.get_execution_payload_bid_url( + params.slot, + ¶ms.parent_hash, + ¶ms.parent_root, + ¶ms.proposer_pubkey, + )?; + + // The proposer's deadline is absolute (same clock basis as + // `request_budget_ms`); every budget below is derived from it at the moment + // it is needed, so sleep and scheduling drift can never over-grant. + let deadline_ms = utcnow_ms().saturating_add(timeout_left_ms); + + if relay.config.enable_timing_games { + if let Some(target_ms) = relay.config.target_first_request_ms { + let Some(delay) = + target_first_request_delay_ms(target_ms, ms_into_slot, timeout_left_ms) + else { + warn!( + relay_id = relay.id.as_ref(), + target_ms, + ms_into_slot, + budget_ms = timeout_left_ms, + "TG: target_first_request_ms exceeds the request budget, skipping relay" + ); + return Ok(None); + }; + if delay > 0 { + debug!( + relay_id = relay.id.as_ref(), + target_ms, ms_into_slot, "TG: waiting to send first header request" + ); + sleep(Duration::from_millis(delay)).await; + } else { + debug!( + relay_id = relay.id.as_ref(), + target_ms, ms_into_slot, "TG: request already late enough in slot" + ); + } + } + + if let Some(send_freq_ms) = relay.config.frequency_get_header_ms { + let mut handles = Vec::new(); + + debug!( + relay_id = relay.id.as_ref(), + send_freq_ms, + budget_left_ms = deadline_ms.saturating_sub(utcnow_ms()), + "TG: sending multiple header requests" + ); + + // Bounded early polls land a floor of progressively better bids (see + // poll_call_timeout_ms). + let poll_timeout_ms = + relay.config.bid_poll_timeout_ms.unwrap_or(DEFAULT_BID_POLL_TIMEOUT_MS); + + loop { + let rung = rung_budget(deadline_ms, utcnow_ms(), send_freq_ms, poll_timeout_ms); + // Drift can consume the remainder before a trailing rung fires; + // a 0ms poll cannot succeed, so stop once something is in flight + if rung.call_timeout_ms == 0 && !handles.is_empty() { + break; + } + let params = params.clone(); + handles.push(tokio::spawn( + send_one_get_execution_payload_bid( + params, + body.clone(), + relay.clone(), + RequestContext { + timeout_ms: rung.call_timeout_ms, + url: url.clone(), + headers: headers.clone(), + }, + validation.clone(), + ) + .in_current_span(), + )); + + if rung.is_last { + break; + } + sleep(Duration::from_millis(send_freq_ms)).await; + } + + let results = join_all(handles).await; + let mut n_headers = 0; + let mut served_no_bid = false; + + let bids: Vec<_> = results + .into_iter() + .filter_map(|res| { + // ignore join error and timeouts, log other errors + res.ok().and_then(|inner_res| match inner_res { + Ok(Some(header)) => { + n_headers += 1; + Some((relay.id.as_str(), header, ranking_cap_gwei)) + } + // a 204 is the relay answering "no bid", not failing + Ok(None) => { + served_no_bid = true; + None + } + Err(err) if err.is_timeout() => None, + Err(err) => { + error!(relay_id = relay.id.as_ref(),%err, "TG: error sending header request"); + None + } + }) + }) + .collect(); + + // Pick the highest total payment across this relay's in-flight responses + if let Some((_, header)) = select_max_bid(bids) { + debug!(relay_id = relay.id.as_ref(), n_headers, "TG: received headers from relay"); + return Ok(Some(header)); + } else if served_no_bid { + // Answered, just with nothing to offer: same result as the single-request path + debug!(relay_id = relay.id.as_ref(), "TG: relay served no bid"); + return Ok(None); + } else { + // all requests failed + warn!(relay_id = relay.id.as_ref(), "TG: no headers received"); + + return Err(PbsError::RelayResponse { + error_msg: "no headers received".to_string(), + code: TIMEOUT_ERROR_CODE, + }); + } + } + } + + send_one_get_execution_payload_bid( + params, + body, + relay, + RequestContext { timeout_ms: deadline_ms.saturating_sub(utcnow_ms()), url, headers }, + validation, + ) + .await +} + +struct RequestContext { + url: Url, + timeout_ms: u64, + headers: HeaderMap, +} + +#[derive(Clone)] +struct ValidationContext { + extra_validation_enabled: bool, + parent_block: Arc>>, +} + +async fn send_one_get_execution_payload_bid( + params: GetExecutionPayloadBidParams, + body: Arc, + relay: RelayClient, + mut req_config: RequestContext, + validation: ValidationContext, +) -> Result, PbsError> { + req_config.headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(utcnow_ms())); + + // The timeout header indicating how long a relay has to respond, so they can + // minimize timing games without losing the bid + req_config.headers.insert(HEADER_TIMEOUT_MS, HeaderValue::from(req_config.timeout_ms)); + + // This is a new endpoint, so every builder is expected to implement SSZ; we + // therefore send the request body in SSZ (the most performant encoding) + // unconditionally rather than negotiating it. The auth is forwarded + // byte-for-byte so the builder verifies what the validator signed. The + // response encoding still honors what the beacon node asked for via its + // Accept header. + let request = relay + .client + .post(req_config.url) + .timeout(Duration::from_millis(req_config.timeout_ms)) + .headers(req_config.headers) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(body.as_ssz_bytes()); + let (res, request_latency) = + send_to_relay(request, &relay, GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG).await?; + let code = res.status(); + + // Parse the negotiated Content-Type (and optional fork) before the body is + // consumed. Only successful responses carry a meaningful encoding; on + // non-success we fall through to safe_read_http_response's NonSuccess error, + // so these values are never consumed. + let (content_type, fork) = if code.is_success() { + parse_response_encoding_and_fork(res.headers(), code.as_u16())? + } else { + (EncodingType::Json, None) + }; + + let response_bytes = safe_read_http_response(res, MAX_SIZE_GET_HEADER_RESPONSE).await?; + let header_size_bytes = response_bytes.len(); + if code == StatusCode::NO_CONTENT { + debug!( + relay_id = relay.id.as_ref(), + ?code, + latency = ?request_latency, + response = ?response_bytes, + "no header from relay" + ); + return Ok(None); + } + + let get_header_response = match content_type { + EncodingType::Json => serde_json::from_slice::( + &response_bytes, + ) + .map_err(|err| PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(&response_bytes).into_owned(), + })?, + EncodingType::Ssz => { + // SSZ requires the fork from Eth-Consensus-Version; its absence is a + // relay protocol violation. + let fork = fork.ok_or_else(|| PbsError::RelayResponse { + error_msg: "relay did not provide consensus version header for ssz payload" + .to_string(), + code: code.as_u16(), + })?; + let data = + SignedExecutionPayloadBid::from_ssz_bytes(&response_bytes).map_err(|err| { + PbsError::SSZDecode { + err: format!("error decoding relay payload: {err:?}"), + fork, + } + })?; + GetExecutionPayloadBidResponse { version: fork, data, metadata: Default::default() } + } + }; + + // The endpoint serves Gloas bids only, and `version` is stamped verbatim + // onto CB's 200 to the BN - so a relay claiming any other fork is a bad + // relay response (this relay contributes no bid), not something to forward + if get_header_response.version != ForkName::Gloas { + crate::utils::record_invalid_relay_response( + "wrong_fork", + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, + &relay.id, + ); + return Err(PbsError::RelayResponse { + error_msg: format!( + "relay served a {} bid on the gloas-only bid endpoint", + get_header_response.version + ), + code: code.as_u16(), + }); + } + + debug!( + relay_id = relay.id.as_ref(), + header_size_bytes, + latency = ?request_latency, + version =? get_header_response.version, + bid_eth = format_gwei_as_eth(get_header_response.data.message.value + get_header_response.data.message.execution_payment), + trustless_bid_eth = format_gwei_as_eth(get_header_response.data.message.value), + execution_payment_eth = format_gwei_as_eth(get_header_response.data.message.execution_payment), + block_hash = %get_header_response.data.message.block_hash, + "received new header" + ); + + let header_info = HeaderInfo { + block_hash: get_header_response.block_hash(), + parent_hash: get_header_response.parent_hash(), + parent_root: get_header_response.parent_root(), + slot: get_header_response.slot(), + gas_limit: get_header_response.gas_limit(), + }; + + validate_header_data(&header_info, ¶ms).inspect_err(|_| { + crate::utils::record_invalid_relay_response( + "header_validation", + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, + &relay.id, + ); + })?; + + if validation.extra_validation_enabled { + let parent_block = validation.parent_block.read(); + if let Some(parent_block) = parent_block.as_ref() { + extra_validation(parent_block, &header_info, ¶ms).inspect_err(|_| { + crate::utils::record_invalid_relay_response( + "extra_validation", + GET_EXECUTION_PAYLOAD_BID_ENDPOINT_TAG, + &relay.id, + ); + })?; + } else { + warn!( + relay_id = relay.id.as_ref(), + "parent block not found, skipping extra validation" + ); + } + } + + Ok(Some(get_header_response)) +} + +struct HeaderInfo { + block_hash: B256, + parent_hash: B256, + parent_root: B256, + slot: u64, + gas_limit: u64, +} + +// No min_bid or execution-payment-cap check here: on the ePBS path the BN is +// the enforcer (beacon-APIs #630 MUST-rejects below the per-key min_bid and +// CLAMPS the payment at max_execution_payment); a CB-side copy would over- +// reject bids the BN would still consider. The cap survives only as a ranking +// clamp (see `ranking_payment`). +fn validate_header_data( + header_info: &HeaderInfo, + params: &GetExecutionPayloadBidParams, +) -> Result<(), ValidationError> { + if header_info.block_hash == B256::ZERO { + return Err(ValidationError::EmptyBlockhash); + } + + if params.parent_hash != header_info.parent_hash { + return Err(ValidationError::ParentHashMismatch { + expected: params.parent_hash, + got: header_info.parent_hash, + }); + } + + if params.parent_root != header_info.parent_root { + return Err(ValidationError::ParentRootMismatch { + expected: params.parent_root, + got: header_info.parent_root, + }); + } + + if params.slot != header_info.slot { + return Err(ValidationError::SlotNumberMismatch { + expected: params.slot, + got: header_info.slot, + }); + } + + Ok(()) +} + +fn extra_validation( + parent_block: &Block, + header_info: &HeaderInfo, + params: &GetExecutionPayloadBidParams, +) -> Result<(), ValidationError> { + if parent_block.hash() != params.parent_hash { + return Err(ValidationError::ParentHashMismatch { + got: parent_block.header.parent_hash, + expected: params.parent_hash, + }); + }; + + let Some(parent_root) = parent_block.header.parent_beacon_block_root() else { + tracing::error!("parent block is missing parent_beacon_block_root"); + return Err(ValidationError::EmptyParentRoot); + }; + + if parent_root != params.parent_root { + return Err(ValidationError::ParentRootMismatch { + got: parent_root, + expected: params.parent_root, + }); + } + + // TODO potentially check builder index -> pubkey mapping + + if !check_gas_limit(header_info.gas_limit, parent_block.header.gas_limit) { + return Err(ValidationError::GasLimit { + parent: parent_block.header.gas_limit, + header: header_info.gas_limit, + }); + }; + + Ok(()) +} + +#[cfg(test)] +mod tests { + + use alloy::primitives::{B256, aliases::B32}; + use cb_common::{ + constants::{DOMAIN_BUILDER_REQUEST_AUTH, GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, + pbs::{BuilderRequestAuth, error::ValidationError}, + signature::{ + builder_request_auth_domain, compute_domain, compute_domain_with_fork_version, + sign_builder_request_auth_root, sign_execution_payload_bid_root, + }, + types::{BlsSecretKey, BlsSignature, Chain}, + utils::TestRandomSeed, + wire::BodyDeserializeError, + }; + use lh_types::Slot; + use tree_hash::TreeHash; + + use super::{validate_header_data, *}; + + #[test] + fn test_validate_header() { + let slot = 5; + let parent_hash = B256::from_slice(&[1; 32]); + let parent_root = B256::from_slice(&[2; 32]); + let secret_key = BlsSecretKey::random(); + let pubkey = secret_key.public_key(); + + let mock_params = GetExecutionPayloadBidParams { + slot, + parent_hash, + parent_root, + proposer_pubkey: pubkey, + }; + + let mut mock_header_data = HeaderInfo { + block_hash: B256::default(), + parent_hash: B256::default(), + parent_root: B256::default(), + slot: 0, + gas_limit: 0, + }; + + assert_eq!( + validate_header_data(&mock_header_data, &mock_params), + Err(ValidationError::EmptyBlockhash) + ); + + mock_header_data.block_hash.0[1] = 1; + + assert_eq!( + validate_header_data(&mock_header_data, &mock_params), + Err(ValidationError::ParentHashMismatch { + expected: mock_params.parent_hash, + got: B256::default() + }) + ); + + mock_header_data.parent_hash = parent_hash; + + assert_eq!( + validate_header_data(&mock_header_data, &mock_params), + Err(ValidationError::ParentRootMismatch { + expected: mock_params.parent_root, + got: B256::default() + }) + ); + + mock_header_data.parent_root = parent_root; + + assert_eq!( + validate_header_data(&mock_header_data, &mock_params), + Err(ValidationError::SlotNumberMismatch { expected: slot, got: 0 }) + ); + + mock_header_data.slot = slot; + + // All request-derived fields now agree, so the header validates. + validate_header_data(&mock_header_data, &mock_params).unwrap(); + } + + fn test_auth(slot: u64, signature: BlsSignature) -> SignedBuilderRequestAuth { + SignedBuilderRequestAuth { + // Non-empty so it clears the empty-data guard; the value itself is the + // demux's input, exercised elsewhere, not this validator's slot/sig path + message: BuilderRequestAuth { + data: vec![0x01].try_into().unwrap(), + slot: Slot::new(slot), + }, + signature, + } + } + + // Empty `auth.message.data` is rejected before the slot/sig checks, so it + // cannot slip through a catch-all relay match. Guards the wiring of the + // shared `validate_auth_data` into this endpoint. + #[test] + fn validate_builder_request_auth_rejects_empty_data() { + let chain = Chain::Hoodi; + let slot = 5; + let params = GetExecutionPayloadBidParams { + slot, + parent_hash: B256::ZERO, + parent_root: B256::ZERO, + proposer_pubkey: BlsSecretKey::random().public_key(), + }; + let empty = SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: Default::default(), slot: Slot::new(slot) }, + signature: BlsSignature::empty(), + }; + for verify in [false, true] { + assert!(matches!( + validate_builder_request_auth(&empty, ¶ms, chain, verify), + Err(PbsClientError::EmptyAuthData) + )); + } + } + + // An empty body is as invalid as a malformed one: the spec requires the auth + #[test] + fn test_decode_builder_request_auth_rejects_empty_body() { + assert!(matches!( + decode_versioned_request_body::( + &HeaderMap::new(), + &Bytes::new() + ), + Err(BodyDeserializeError::MissingBody) + )); + } + + #[test] + fn test_poll_call_timeout_ms() { + // Early polls are bounded so a bid lands while there is time to use it, + // even when the deadline is far away + assert_eq!(poll_call_timeout_ms(4000, 1000, false), 1000); + // The last poll holds for everything that is left + assert_eq!(poll_call_timeout_ms(4000, 1000, true), 4000); + // Never promise more time than remains before the shared deadline + assert_eq!(poll_call_timeout_ms(600, 1000, false), 600); + // A budget shorter than the poll timeout degrades to today's behavior: + // one poll that holds until the deadline + assert_eq!(poll_call_timeout_ms(800, 1000, true), 800); + } + + #[test] + fn test_rung_budget_tracks_the_real_clock_not_nominal_cadence() { + // Deadline 1000ms from the clock basis, 300ms cadence, 700ms poll bound. + // First rung fires on time: bounded early rung, plenty of budget left + let rung = rung_budget(1000, 0, 300, 700); + assert!(!rung.is_last); + assert_eq!(rung.call_timeout_ms, 700); + + // By the second rung, drift has burned 400ms of wall clock while + // nominal bookkeeping would claim only one 300ms cadence step: the + // budget must reflect the real 600ms remaining, not the nominal 700 + let rung = rung_budget(1000, 400, 300, 700); + assert!(!rung.is_last); + assert_eq!(rung.call_timeout_ms, 600); + + // The last rung carries exactly what truly remains, never the nominal + // remainder (which would be 1000 - 2 * 300 = 400 here) + let rung = rung_budget(1000, 800, 300, 700); + assert!(rung.is_last); + assert_eq!(rung.call_timeout_ms, 200); + + // Drift past the deadline leaves nothing to grant + let rung = rung_budget(1000, 1100, 300, 700); + assert!(rung.is_last); + assert_eq!(rung.call_timeout_ms, 0); + } + + #[test] + fn test_target_first_request_delay_ms() { + // Already past the target: fire immediately + assert_eq!(target_first_request_delay_ms(200, 300, 1000), Some(0)); + // Normal case: wait out the remainder of the target + assert_eq!(target_first_request_delay_ms(500, 100, 1000), Some(400)); + // Target slightly under the budget: the reduced remainder still buys a + // real poll (1ms here, granted by the deadline math after the sleep) + assert_eq!(target_first_request_delay_ms(999, 0, 1000), Some(999)); + assert_eq!(rung_budget(1000, 999, 300, 700).call_timeout_ms, 1); + // Target consumes the whole budget: skip the relay, never a 0ms poll + assert_eq!(target_first_request_delay_ms(1000, 0, 1000), None); + assert_eq!(target_first_request_delay_ms(5000, 0, 1000), None); + assert_eq!(target_first_request_delay_ms(600, 100, 400), None); + } + + #[test] + fn test_request_budget_ms() { + let headers = |sent: u64, timeout: u64| { + let mut h = HeaderMap::new(); + h.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(sent)); + h.insert(HEADER_TIMEOUT_MS, HeaderValue::from(timeout)); + h + }; + let now = 1_000_000; + + // Transit delay eats the budget: the deadline is absolute + assert_eq!(request_budget_ms(&headers(now, 1000), now).unwrap(), 1000); + assert_eq!(request_budget_ms(&headers(now - 400, 1000), now).unwrap(), 600); + + // A deadline already in the past leaves nothing + assert_eq!(request_budget_ms(&headers(now - 5000, 1000), now).unwrap(), 0); + + // A proposer clock running ahead cannot grant more than it advertised + assert_eq!(request_budget_ms(&headers(now + 10_000, 1000), now).unwrap(), 1000); + + // Both headers are required, and a zero timeout is not a valid request + for h in [ + HeaderMap::new(), + { + let mut h = HeaderMap::new(); + h.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(now)); + h + }, + { + let mut h = HeaderMap::new(); + h.insert(HEADER_TIMEOUT_MS, HeaderValue::from(1000u64)); + h + }, + headers(now, 0), + { + let mut h = HeaderMap::new(); + h.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from_static("soon")); + h.insert(HEADER_TIMEOUT_MS, HeaderValue::from(1000u64)); + h + }, + ] { + assert!(matches!(request_budget_ms(&h, now), Err(PbsClientError::MissingTimingHeader))); + } + } + + // The auth domain is NOT fork-versioned: it must equal the spec's + // compute_domain(DOMAIN_BUILDER_REQUEST_AUTH), i.e. genesis fork version and a + // zero root. A sign/verify round trip cannot catch a wrong domain, so pin + // it. + #[test] + fn test_builder_request_auth_domain_is_not_fork_versioned() { + for chain in [Chain::Mainnet, Chain::Hoodi, Chain::Holesky] { + assert_eq!( + builder_request_auth_domain(chain), + compute_domain(chain, &B32::from(DOMAIN_BUILDER_REQUEST_AUTH)), + ); + // A fork-versioned domain would differ; that is the bug this guards + assert_ne!( + builder_request_auth_domain(chain), + compute_domain_with_fork_version( + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + &B32::from(DOMAIN_BUILDER_REQUEST_AUTH), + ), + ); + } + // Chains are separated by their genesis fork version + assert_ne!( + builder_request_auth_domain(Chain::Mainnet), + builder_request_auth_domain(Chain::Hoodi) + ); + } + + #[test] + fn test_validate_builder_request_auth() { + let chain = Chain::Hoodi; + let secret_key = BlsSecretKey::random(); + let pubkey = secret_key.public_key(); + let slot = 5; + let params = GetExecutionPayloadBidParams { + slot, + parent_hash: B256::ZERO, + parent_root: B256::ZERO, + proposer_pubkey: pubkey, + }; + + // Slot mismatch is a 400 whether or not sigverify is on + for verify in [false, true] { + assert!(matches!( + validate_builder_request_auth( + &test_auth(slot + 1, BlsSignature::empty()), + ¶ms, + chain, + verify + ), + Err(PbsClientError::AuthSlotMismatch) + )); + } + + // With verification off a bad signature passes through to the builder + let bad = test_auth(slot, BlsSignature::test_random()); + validate_builder_request_auth(&bad, ¶ms, chain, false).unwrap(); + assert!(matches!( + validate_builder_request_auth(&bad, ¶ms, chain, true), + Err(PbsClientError::AuthSigVerify) + )); + + // The bid domain must not be accepted for a request auth + let message = test_auth(slot, BlsSignature::empty()).message; + let bid_domain_sig = sign_execution_payload_bid_root( + &secret_key, + &message.tree_hash_root(), + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ); + assert!(matches!( + validate_builder_request_auth(&test_auth(slot, bid_domain_sig), ¶ms, chain, true), + Err(PbsClientError::AuthSigVerify) + )); + + // A signature made for another chain must not verify here + let other_chain_sig = + sign_builder_request_auth_root(&secret_key, &message.tree_hash_root(), Chain::Mainnet); + assert!(matches!( + validate_builder_request_auth(&test_auth(slot, other_chain_sig), ¶ms, chain, true), + Err(PbsClientError::AuthSigVerify) + )); + + let good_sig = + sign_builder_request_auth_root(&secret_key, &message.tree_hash_root(), chain); + validate_builder_request_auth(&test_auth(slot, good_sig), ¶ms, chain, true).unwrap(); + } + + struct MockBid { + value: u64, + execution_payment: u64, + } + + impl GetExecutionPayloadBidInfo for MockBid { + fn block_hash(&self) -> B256 { + B256::default() + } + fn parent_hash(&self) -> B256 { + B256::default() + } + fn parent_root(&self) -> B256 { + B256::default() + } + fn value(&self) -> u64 { + self.value + } + fn execution_payment(&self) -> u64 { + self.execution_payment + } + fn builder_index(&self) -> u64 { + 0 + } + fn slot(&self) -> u64 { + 0 + } + fn gas_limit(&self) -> u64 { + 0 + } + } + + // The winning bid is the one paying the proposer the most in TOTAL: + // value + execution_payment (the builder commits to pay the sum), not + // the highest trustless value alone. + #[test] + fn test_select_max_bid_by_total_payment() { + let bids = vec![ + ("value_winner", MockBid { value: 6, execution_payment: 0 }, u64::MAX), + ("total_winner", MockBid { value: 5, execution_payment: 10 }, u64::MAX), + ]; + let (winner, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner, "total_winner"); + + // A saturating sum must not misrank a near-overflow bid + let bids = vec![ + ("honest", MockBid { value: 7, execution_payment: 0 }, u64::MAX), + ("overflow", MockBid { value: u64::MAX, execution_payment: u64::MAX }, u64::MAX), + ]; + let (winner, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner, "overflow"); + } + + // Ranking clamps the execution payment at the relay's cap (beacon-APIs + // #630): a bid over-claiming a huge trusted payment behind a low cap must + // lose to a moderate honest bid the BN would value higher. + #[test] + fn test_select_max_bid_clamps_execution_payment_at_relay_cap() { + let bids = vec![ + // ranks at 5 + min(1_000_000, 10) = 15 + ("overclaimer", MockBid { value: 5, execution_payment: 1_000_000 }, 10), + // ranks at 20 + 0 = 20 + ("honest", MockBid { value: 20, execution_payment: 0 }, u64::MAX), + ]; + let (winner, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner, "honest"); + + // The default cap (u64::MAX) leaves ranking unclamped: the same + // over-claimed payment wins on its full total + let bids = vec![ + ("overclaimer", MockBid { value: 5, execution_payment: 1_000_000 }, u64::MAX), + ("honest", MockBid { value: 20, execution_payment: 0 }, u64::MAX), + ]; + let (winner, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner, "overclaimer"); + } + + // Per-relay in-flight aggregation (timing games) must pick the highest + // TOTAL payment, not the latest-started response. + #[test] + fn test_inflight_selection_prefers_max_total_not_latest() { + // Max total is neither first nor last, and the later-started response + // pays LESS: this fails both latest-wins and first-wins. + let bids = vec![ + ("late", MockBid { value: 3, execution_payment: 1 }, u64::MAX), // total 4 + ("early", MockBid { value: 10, execution_payment: 5 }, u64::MAX), // total 15 (winner) + ("mid", MockBid { value: 6, execution_payment: 2 }, u64::MAX), // total 8 + ]; + let (winner, _) = select_max_bid(bids).unwrap(); + assert_eq!(winner, "early", "must pick highest total, not latest- or first-started"); + } +} diff --git a/crates/pbs/src/routes/mod.rs b/crates/pbs/src/routes/mod.rs index 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..f48fca7c --- /dev/null +++ b/crates/pbs/src/routes/submit_signed_beacon_block.rs @@ -0,0 +1,138 @@ +use axum::{body::Bytes, extract::State, http::HeaderMap, response::IntoResponse}; +use cb_common::{ + pbs::is_gloas, + wire::{ + BodyDeserializeError, EncodingType, content_type_encoding_with_default, + decode_signed_beacon_block, get_user_agent, require_consensus_version_header, + }, +}; +use reqwest::StatusCode; +use ssz::Encode; +use tracing::{info, warn}; + +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, + }, +}; + +/// POST /eth/v1/builder/beacon_blocks (submitSignedBeaconBlock). +/// `Eth-Consensus-Version` is required (spec PR #165) and names the block's +/// fork. By default CB is a blind pipe: it forwards the block bytes to every +/// builder WITHOUT decoding them, because block validity is the builder's job +/// (builder-specs: an invalid block MUST be rejected by the builder). Set +/// `strict_block_decode` to have CB decode the block and 400 a non-gloas or +/// undecodable reveal itself. +pub async fn handle_submit_signed_beacon_block( + State(state): State>, + req_headers: HeaderMap, + body: Bytes, +) -> Result { + let 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)), + } +} + +/// Decides the SSZ body + fork to forward (blind by default, decoding only +/// under `strict_block_decode`) and broadcasts to every configured builder. CB +/// keeps no auction state, so it forwards to all relays to improve inclusion, +/// additive to the beacon node's own p2p gossip. Ok(()) means at least one +/// builder accepted with a 202. +pub async fn submit_signed_beacon_block( + body: Bytes, + req_headers: HeaderMap, + state: PbsState, +) -> Result<(), PbsClientError> { + let strict = state.pbs_config().strict_block_decode; + let ua = get_user_agent(&req_headers); + + // Eth-Consensus-Version is spec-required here; under strict decode it also + // selects the SSZ variant. Only Gloas is accepted, which is the fork the + // outbound headers already carry. + require_consensus_version_header(&req_headers)?; + + let (out_body, slot) = if strict { + // Strict: CB decodes and rejects a malformed or non-gloas reveal itself. + let block = decode_signed_beacon_block(&req_headers, &body)?; + if !is_gloas(&block) { + return Err(PbsClientError::NotGloasBlock); + } + let slot = block.slot().as_u64(); + (Bytes::from(block.as_ssz_bytes()), Some(slot)) + } else { + // Blind pipe: forward the bytes without parsing (the builder validates). + // The outbound is always SSZ, so the reveal must be SSZ; a JSON or + // otherwise non-SSZ reveal would be forwarded mislabeled as octet-stream + // and fail opaquely downstream, so reject it up front with 415. The + // Content-Type defaults to JSON when absent (builder-specs), so an + // unlabeled reveal is treated as JSON and rejected, not assumed SSZ. + if body.is_empty() { + return Err(BodyDeserializeError::MissingBody.into()); + } + if content_type_encoding_with_default(&req_headers, EncodingType::Json)? != + EncodingType::Ssz + { + return Err(BodyDeserializeError::UnsupportedMediaType.into()); + } + (body, None) + }; + + if let Some(slot) = slot { + tracing::Span::current().record("slot", slot); + } + info!(ua, ?slot, strict, "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(), out_body.clone(), send_headers.clone()); + async move { + // Every builder implements SSZ for this new endpoint, so the block is + // forwarded in SSZ (the fork travels in Eth-Consensus-Version). + let url = relay.submit_signed_beacon_block_url()?; + post_ssz_expect_accepted( + &relay, + url, + body, + headers, + timeout_ms, + SUBMIT_SIGNED_BEACON_BLOCK_ENDPOINT_TAG, + ) + .await?; + Ok(()) + } + })) + .await; + let accepted = results + .into_iter() + .zip(relays.iter()) + .filter(|(res, relay)| match res { + Ok(()) => true, + Err(err) => { + // Non-winning builders reject by design; only the auction winner + // accepts, so a rejection here may be expected + warn!(relay_id = relay.id.as_ref(), %err, "builder did not accept the block; may be expected, only the winner accepts"); + false + } + }) + .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..d09bb0d2 100644 --- a/crates/pbs/src/service.rs +++ b/crates/pbs/src/service.rs @@ -30,7 +30,18 @@ pub struct PbsService; impl PbsService { pub async fn run>(state: PbsState) -> Result<()> { - let addr = state.config.endpoint; + let listener = TcpListener::bind(state.config.endpoint).await?; + Self::run_with_listener::(state, listener).await + } + + /// Serve from an already-bound listener. Prefer this in tests: discovering + /// a free port, dropping the listener, then rebinding leaves a window + /// where another process can take the port before the server binds it. + pub async fn run_with_listener>( + state: PbsState, + listener: TcpListener, + ) -> Result<()> { + let addr = listener.local_addr()?; info!(version = COMMIT_BOOST_VERSION, commit_hash = COMMIT_BOOST_COMMIT, ?addr, chain =? state.config.chain, "starting PBS service"); // Check if refreshing registry muxes is required @@ -44,7 +55,6 @@ impl PbsService { let config_path = state.config_path.clone(); let state: Arc>> = RwLock::new(state).into(); let app = create_app_router::(state.clone()); - let listener = TcpListener::bind(addr).await?; let task = tokio::spawn( diff --git a/crates/pbs/src/state.rs b/crates/pbs/src/state.rs index bd683e5f..cad88d29 100644 --- a/crates/pbs/src/state.rs +++ b/crates/pbs/src/state.rs @@ -1,11 +1,13 @@ use std::{path::PathBuf, sync::Arc}; use cb_common::{ + DEFAULT_REQUEST_TIMEOUT, config::{PbsConfig, PbsModuleConfig}, - pbs::RelayClient, + pbs::{HEADER_VERSION_KEY, HEADER_VERSION_VALUE, RelayClient}, types::BlsPublicKey, }; use parking_lot::RwLock; +use reqwest::header::{HeaderMap, HeaderValue}; pub trait BuilderApiState: Clone + Sync + Send + 'static {} impl BuilderApiState for () {} @@ -21,17 +23,46 @@ pub struct PbsState { pub config: Arc, /// Path of the config file, for watching changes pub config_path: Arc, + /// One process-wide HTTP client the ePBS transient pipe reuses across + /// dials. Configured relays build their client once at load; the pipe + /// would otherwise pay a cold client (pool + TLS) build on every + /// request, so it shares this one instead. Cloning a `reqwest::Client` + /// is a cheap Arc bump. + pub pipe_client: reqwest::Client, /// Opaque extra data for library use pub data: S, } +/// Builds the shared pipe client the same way [`RelayClient::new`] builds its +/// own: the CommitBoost version header as a default header and the shared +/// request timeout. +fn build_pipe_client() -> reqwest::Client { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_VERSION_KEY, HeaderValue::from_static(HEADER_VERSION_VALUE)); + reqwest::Client::builder() + .default_headers(headers) + .timeout(DEFAULT_REQUEST_TIMEOUT) + .build() + .expect("a static default header and timeout always build a valid reqwest client") +} + impl PbsState<()> { pub fn new(config: PbsModuleConfig, config_path: PathBuf) -> Self { - Self { config: Arc::new(config), config_path: Arc::new(config_path), data: () } + Self { + config: Arc::new(config), + config_path: Arc::new(config_path), + pipe_client: build_pipe_client(), + data: (), + } } pub fn with_data(self, data: S) -> PbsState { - PbsState { data, config: self.config, config_path: self.config_path } + PbsState { + data, + config: self.config, + config_path: self.config_path, + pipe_client: self.pipe_client, + } } } diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index 782ae79b..a1605459 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -1,11 +1,198 @@ +use std::{ + future::Future, + sync::{ + OnceLock, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +use cb_common::{ + config::{GetHeaderTransport, RelayConfig}, + pbs::{ForkName, RelayClient, RelayEntry, SignedBuilderRequestAuth, error::PbsError}, + signature::verify_builder_request_auth_signature, + types::{BlsPublicKey, BlsSecretKey, Chain}, + wire::{ + CONSENSUS_VERSION_HEADER, EncodingType, get_user_agent_with_version, + safe_read_http_response, + }, +}; +use futures::future::join_all; +use reqwest::{ + StatusCode, + header::{CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT}, +}; +use tracing::{Instrument, debug, error, warn}; +use url::Url; + +use crate::{ + constants::{MAX_SIZE_DEFAULT, TIMEOUT_ERROR_CODE_STR}, + error::PbsClientError, + metrics::{RELAY_LATENCY, RELAY_STATUS_CODE}, +}; + +/// Sends one already-built relay request, recording the per-relay metrics +/// shared by all three ePBS endpoints, and returns the response and its latency +/// so the caller can read/decode the body itself. `tag` is the per-endpoint +/// metric label. Callers build their own `RequestBuilder` because the requests +/// legitimately differ (bid sets a per-call timeout and timing headers). +pub(crate) async fn send_to_relay( + req: reqwest::RequestBuilder, + relay: &RelayClient, + tag: &str, +) -> Result<(reqwest::Response, Duration), PbsError> { + let start_request = Instant::now(); + let res = match req.send().await { + Ok(res) => res, + Err(err) => { + RELAY_STATUS_CODE.with_label_values(&[TIMEOUT_ERROR_CODE_STR, tag, &relay.id]).inc(); + return Err(err.into()); + } + }; + + let request_latency = start_request.elapsed(); + RELAY_LATENCY.with_label_values(&[tag, &relay.id]).observe(request_latency.as_secs_f64()); + + let code = res.status(); + RELAY_STATUS_CODE.with_label_values(&[code.as_str(), tag, &relay.id]).inc(); + + Ok((res, request_latency)) +} + +/// Count a request-rejection in `BEACON_NODE_STATUS` before it short-circuits +/// the handler. Without this a client broken by e.g. the strict +/// `Eth-Consensus-Version` rule or a bad `Accept` header VANISHES from the +/// endpoint counter instead of showing up as a 4xx spike - the exact signal an +/// operator needs during a rollout. +pub(crate) fn record_client_error( + err: impl Into, + endpoint: &str, +) -> PbsClientError { + let err = err.into(); + crate::metrics::BEACON_NODE_STATUS + .with_label_values(&[err.status_code().as_str(), endpoint]) + .inc(); + err +} + +/// Records the HTTP status CB returned to the beacon node for one request on an +/// ePBS endpoint. One home for the `(status, endpoint)` label pair the three +/// handlers all bump. +pub(crate) fn record_beacon_status(code: &str, endpoint: &str) { + crate::metrics::BEACON_NODE_STATUS.with_label_values(&[code, endpoint]).inc(); +} + +/// Logs and counts a failed ePBS request before it is returned to the beacon +/// node. A 4xx is the caller's fault, not CB's: only a 5xx is an error. +pub(crate) fn record_request_failure(err: PbsClientError, endpoint: &str) -> PbsClientError { + if err.status_code().is_server_error() { + error!(%err, "{endpoint} failed"); + } else { + warn!(%err, "{endpoint} failed"); + } + record_beacon_status(err.status_code().as_str(), endpoint); + err +} + +/// Fans `sends` out on detached tasks and waits for all of them: a BN +/// disconnect must not cancel in-flight writes mid-fan-out, leaving some +/// builders with the data and others without. +pub(crate) async fn join_detached_sends( + sends: impl IntoIterator, +) -> Vec> +where + F: Future> + Send + 'static, +{ + let handles: Vec<_> = + sends.into_iter().map(|send| tokio::spawn(send.in_current_span())).collect(); + join_all(handles) + .await + .into_iter() + .map(|joined| joined.unwrap_or_else(|err| Err(PbsError::TokioJoinError(err)))) + .collect() +} + +/// Logs which relay set an ePBS demux request resolved to (a mux's relays or +/// the default set), shared by the bid and preferences endpoints. +pub(crate) fn log_mux_selection( + maybe_mux_id: Option<&str>, + relay_count: usize, + pubkey: &BlsPublicKey, +) { + match maybe_mux_id { + Some(mux_id) => { + debug!(mux_id, relays = relay_count, pubkey = %pubkey, "using mux config") + } + None => debug!(relays = relay_count, pubkey = %pubkey, "using default config"), + } +} + +/// Count a relay response that CB rejected during validation, by reason (see +/// `RELAY_INVALID_RESPONSE` for why this is a separate signal from the relay's +/// HTTP status). +pub(crate) fn record_invalid_relay_response(reason: &str, endpoint: &str, relay_id: &str) { + crate::metrics::RELAY_INVALID_RESPONSE.with_label_values(&[reason, endpoint, relay_id]).inc(); +} + +/// POSTs an SSZ body to a builder and enforces the ePBS write-endpoint +/// contract: 202 Accepted is the only success. The response body is read (and +/// capped) then discarded - a builder is untrusted and must not stream an +/// unbounded error body into memory or the logs. Returns the request latency. +/// Shared by `submitBuilderPreferences` and `submitSignedBeaconBlock`. +pub(crate) async fn post_ssz_expect_accepted( + relay: &RelayClient, + url: Url, + body: impl Into, + headers: HeaderMap, + timeout_ms: u64, + tag: &str, +) -> Result { + let req = relay + .client + .post(url) + .timeout(Duration::from_millis(timeout_ms)) + .headers(headers) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(body); + let (res, latency) = send_to_relay(req, relay, tag).await?; + let code = res.status(); + safe_read_http_response(res, MAX_SIZE_DEFAULT).await?; + if code != StatusCode::ACCEPTED { + return Err(PbsError::RelayResponse { + error_msg: "expected 202".to_string(), + code: code.as_u16(), + }); + } + Ok(latency) +} + +/// Base outbound headers shared by the ePBS endpoints: the versioned +/// `User-Agent` and `Eth-Consensus-Version`. All three relay hops send SSZ +/// bodies of fork-versioned wire types, so the builder needs the fork header, +/// and all three are Gloas-only. Callers add their endpoint-specific headers +/// (bid adds `Accept` and the timing headers). +pub(crate) fn epbs_base_send_headers(req_headers: &HeaderMap) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + USER_AGENT, + get_user_agent_with_version(req_headers).map_err(|_| PbsClientError::Internal)?, + ); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&ForkName::Gloas.to_string()) + .expect("fork name is always a valid header value"), + ); + Ok(headers) +} + const GAS_LIMIT_ADJUSTMENT_FACTOR: u64 = 1024; const GAS_LIMIT_MINIMUM: u64 = 5_000; /// Validates the gas limit against the parent gas limit, according to the /// execution spec https://github.com/ethereum/execution-specs/blob/98d6ddaaa709a2b7d0cd642f4cfcdadc8c0808e1/src/ethereum/cancun/fork.py#L1118-L1154 -pub fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { +pub(crate) fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { let max_adjustment_delta = parent_gas_limit / GAS_LIMIT_ADJUSTMENT_FACTOR; - if gas_limit >= parent_gas_limit + max_adjustment_delta { + if gas_limit >= parent_gas_limit.saturating_add(max_adjustment_delta) { return false; } @@ -19,3 +206,632 @@ pub fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { true } + +/// A zero-length `auth.message.data` is invalid per builder-specs +/// `types/gloas/request_auth.yaml` (pattern `{1,4096}`, "A zero-length `data` +/// is invalid"). It addresses no builder, so it must be rejected up front +/// rather than slip through a catch-all relay match in +/// [`match_relays_by_auth_data`]. Shared by both ePBS request-auth validators. +pub(crate) fn validate_auth_data(auth: &SignedBuilderRequestAuth) -> Result<(), PbsClientError> { + if auth.message.data.is_empty() { + warn!("auth data is empty"); + return Err(PbsClientError::EmptyAuthData); + } + + Ok(()) +} + +/// Verifies the request auth signature when `verify_signature` is on. The +/// downstream builder verifies it regardless, which is why the crypto is +/// opt-in. Shared by the request-auth validators of both ePBS endpoints; the +/// slot rule differs between them and stays with each caller. +pub(crate) fn verify_auth_signature( + pubkey: &BlsPublicKey, + auth: &SignedBuilderRequestAuth, + chain: Chain, + verify_signature: bool, +) -> Result<(), PbsClientError> { + if verify_signature && + !verify_builder_request_auth_signature(pubkey, &auth.message, &auth.signature, chain) + { + warn!(pubkey = %pubkey, "auth signature verification failed"); + return Err(PbsClientError::AuthSigVerify); + } + + Ok(()) +} + +/// Selects the relays an ePBS request is addressed to. +/// +/// Each `getExecutionPayloadBid` or `submitBuilderPreferences` call is for one +/// builder, designated by the caller's `auth.message.data`. Two layers, most +/// specific first: +/// +/// 1. A relay with `expected_auth_data` configured matches only that exact byte +/// string. This is the authoritative form for bilateral agreements where the +/// data is a shared secret rather than a URL. +/// 2. Otherwise, data carrying a builder URL (see [`decode_auth_data_url`]) +/// matches the relays whose configured URL it names. Comparison ignores +/// userinfo, so a bare URL matches a relay entry that embeds its pubkey. +/// +/// Data matching nothing selects no relay: CB then has no builder to proxy to +/// and the caller must get the same DataMismatch 400 a builder would return. +/// The result is usually one relay, several when multiple builders are +/// configured behind the same agreement. Comparing bids across different +/// builders is the beacon node's job across its per-entry calls; within the +/// matched set the winner is the highest total payment. +pub(crate) fn match_relays_by_auth_data<'a>( + relays: &'a [RelayClient], + received_data: &[u8], +) -> Vec<&'a RelayClient> { + let data_url = decode_auth_data_url(received_data); + relays + .iter() + .filter(|relay| { + if let Some(expected) = &relay.config.expected_auth_data { + return received_data == expected.as_ref(); + } + match &data_url { + Some(url) => url_matches(&relay.config.entry.url, url), + None => false, + } + }) + .collect() +} + +/// Resolves the relays an ePBS demux request is sent to: the configured relays +/// whose auth data matches (see [`match_relays_by_auth_data`]), or, when none +/// match, a single transient pipe relay dialing the builder URL the auth data +/// names (self-URL guarded, see [`transient_pipe_relay`]). Shared by the bid +/// and preferences endpoints so their demux cannot diverge. +pub(crate) async fn resolve_addressed_relays( + relays: &[RelayClient], + auth_data: &[u8], + advertised_urls: &[Url], + pipe_client: &reqwest::Client, +) -> Result, PbsClientError> { + let matched = match_relays_by_auth_data(relays, auth_data); + if matched.is_empty() { + Ok(vec![transient_pipe_relay(auth_data, advertised_urls, pipe_client).await?]) + } else { + Ok(matched.into_iter().cloned().collect()) + } +} + +/// Extracts a builder URL from `auth.message.data` using Commit-Boost's +/// purely additive convention: the UTF-8 bytes of the builder's URL, optionally +/// followed by a NUL byte and opaque extra bytes. Data without extra bytes is +/// byte-identical to the spec's nothing-agreed default, so parties using the +/// default need no change; NUL cannot appear in a URL, so the split is +/// unambiguous. Returns None for opaque data carrying no URL. +pub(crate) fn decode_auth_data_url(data: &[u8]) -> Option { + let url_bytes = match data.iter().position(|&b| b == 0) { + Some(i) => &data[..i], + None => data, + }; + std::str::from_utf8(url_bytes).ok().and_then(|s| Url::parse(s).ok()) +} + +/// A process-lifetime placeholder pubkey for pipe relays. This value is never +/// read: bid sigverify is skipped for the pipe (see the rationale at the call +/// site in `execution_payload_bid.rs`). A single lazily-built valid BLS point +/// avoids a keygen on every unmatched pipe request. +fn pipe_relay_placeholder_pubkey() -> BlsPublicKey { + static PLACEHOLDER: OnceLock = OnceLock::new(); + PLACEHOLDER.get_or_init(|| BlsSecretKey::random().public_key()).clone() +} + +/// Builds the transient client the ePBS pipe dials when `auth.message.data` +/// names a builder URL no configured relay serves: CB is a pure pipe and +/// routes the request to the builder the proposer's signed auth data names +/// (trust is the VC's job via its KM `builder_pubkeys`). +/// +/// Fail-closed self-URL guard: an unconfigured key's auth data defaults to +/// CB's own URL, so a decoded URL matching an `advertised_urls` entry - or an +/// empty `advertised_urls`, which cannot rule that out - is an +/// `AuthDataMismatch`, never a self-dial. Data carrying no URL at all names +/// no builder and mismatches as before. +pub(crate) async fn transient_pipe_relay( + received_data: &[u8], + advertised_urls: &[Url], + pipe_client: &reqwest::Client, +) -> Result { + let Some(url) = decode_auth_data_url(received_data) else { + return Err(PbsClientError::AuthDataMismatch); + }; + if advertised_urls.is_empty() { + // Fail closed; warned once per process here and once at startup in + // load_pbs_config, never per request + static WARNED: AtomicBool = AtomicBool::new(false); + if !WARNED.swap(true, Ordering::Relaxed) { + warn!(%url, "advertised_urls is unset: the ePBS transient pipe is disabled, not forwarding to this proposer-addressed builder; set advertised_urls to CB's advertised URL(s) to enable it"); + } + return Err(PbsClientError::AuthDataMismatch); + } + if advertised_urls.iter().any(|own| url_matches(own, &url)) { + warn!(%url, "auth data URL matches CB's own advertised URL, not self-dialing"); + return Err(PbsClientError::AuthDataMismatch); + } + + // SSRF guard: the URL comes straight from untrusted auth data, so refuse a + // target that resolves into loopback/private/link-local space before + // building the client. We resolve-then-dial and do NOT pin the resolved IP, + // so a DNS rebind between this lookup and the dial can still slip through; + // that TOCTOU window is an accepted v1 limitation, not a plugged hole. The + // check is always compiled and always runs in production; it is skippable + // only under the `testing-flags` feature, so an e2e test can dial a local + // mock builder on an address this guard would otherwise block. + if pipe_target_check_enabled() { + let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) else { + warn!(%url, "pipe target has no resolvable host/port, refusing to dial"); + return Err(PbsClientError::PipeTargetBlocked); + }; + let resolved = tokio::net::lookup_host((host, port)).await.map_err(|err| { + // Fail closed: a target we cannot resolve is a target we cannot verify. + warn!(%url, %err, "pipe target DNS resolution failed, refusing to dial"); + PbsClientError::PipeTargetBlocked + })?; + let mut resolved_any = false; + for addr in resolved { + resolved_any = true; + if ip_is_disallowed(addr.ip()) { + warn!(%url, "pipe target resolves to a disallowed (loopback/private/link-local) address, refusing to dial"); + return Err(PbsClientError::PipeTargetBlocked); + } + } + if !resolved_any { + // Fail closed: no address to check is no address we verified. + warn!(%url, "pipe target resolved to no addresses, refusing to dial"); + return Err(PbsClientError::PipeTargetBlocked); + } + } + + let config = RelayConfig { + entry: RelayEntry { + id: PIPE_RELAY_ID.to_string(), + pubkey: pipe_relay_placeholder_pubkey(), + url, + }, + id: None, + headers: None, + get_params: None, + get_header: GetHeaderTransport::Http, + enable_timing_games: false, + target_first_request_ms: None, + frequency_get_header_ms: None, + bid_poll_timeout_ms: None, + validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: None, + }; + RelayClient::with_client(config, pipe_client.clone()).map_err(|err| { + warn!(%err, "failed to build the pipe relay client"); + PbsClientError::Internal + }) +} + +/// The fixed `relay_id` metric label for every transient pipe request. The pipe +/// dials attacker-influenced hosts, so a per-host label would be an unbounded +/// Prometheus cardinality vector; one constant collapses them into a single +/// series (the real URL is still dialed, only the label is the sentinel). +const PIPE_RELAY_ID: &str = "pipe"; + +/// Whether the pipe SSRF target check runs. Always true in a normal build; only +/// the `testing-flags` feature can turn it off, and only via a thread-local a +/// test sets, so an e2e test can dial a local mock builder. +fn pipe_target_check_enabled() -> bool { + #[cfg(feature = "testing-flags")] + { + !SKIP_PIPE_TARGET_CHECK.with(|f| f.get()) + } + #[cfg(not(feature = "testing-flags"))] + { + true + } +} + +#[cfg(feature = "testing-flags")] +thread_local! { + static SKIP_PIPE_TARGET_CHECK: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// TEST-ONLY (`testing-flags`): skip the pipe SSRF target check so an e2e test +/// can dial a local mock builder on a loopback/unspecified address the guard +/// would otherwise block. Never compiled into a release binary. +#[cfg(feature = "testing-flags")] +pub fn set_skip_pipe_target_check(val: bool) { + SKIP_PIPE_TARGET_CHECK.with(|f| f.set(val)); +} + +/// True for an address the ePBS pipe must never dial: loopback and unspecified +/// in both families, plus the IPv4 private/link-local/broadcast ranges and the +/// IPv6 unique-local (`fc00::/7`) and link-local (`fe80::/10`) ranges. A +/// v4-mapped IPv6 address (`::ffff:a.b.c.d`) is unwrapped and re-checked as +/// IPv4 so an internal target cannot hide behind the mapped form. +fn ip_is_disallowed(ip: std::net::IpAddr) -> bool { + use std::net::IpAddr; + if ip.is_loopback() || ip.is_unspecified() { + return true; + } + match ip { + IpAddr::V4(v4) => { + let oct = v4.octets(); + v4.is_private() || v4.is_link_local() || v4.is_broadcast() || + // RFC 6598 CGNAT 100.64.0.0/10, which is_private() does not cover + // but can front ISP / k8s-CNI internal infrastructure. + (oct[0] == 100 && oct[1] & 0xc0 == 0x40) + } + IpAddr::V6(v6) => { + if let Some(v4) = v6.to_ipv4_mapped() { + return ip_is_disallowed(IpAddr::V4(v4)); + } + let seg = v6.octets(); + // fc00::/7 unique-local, fe80::/10 link-local + seg[0] & 0xfe == 0xfc || u16::from_be_bytes([seg[0], seg[1]]) & 0xffc0 == 0xfe80 + } + } +} + +/// Compares two URLs without checking userinfo/path/queries/frags. A relay +/// entry URL embeds the relay pubkey as userinfo, so full equality would never +/// match a bare builder URL. +pub(crate) fn url_matches(a: &Url, b: &Url) -> bool { + // A trailing dot marks a fully-qualified host that resolves to the same + // host as its dotless form; canonicalize so it cannot slip the self-URL + // guard in `transient_pipe_relay`. + fn host_canonical(url: &Url) -> Option<&str> { + url.host_str().map(|host| host.strip_suffix('.').unwrap_or(host)) + } + a.scheme() == b.scheme() && + host_canonical(a) == host_canonical(b) && + a.port_or_known_default() == b.port_or_known_default() +} + +#[cfg(test)] +mod tests { + use cb_common::{ + config::{GetHeaderTransport, RelayConfig}, + pbs::RelayEntry, + types::BlsSecretKey, + }; + + use super::*; + + fn test_relay(url: &str, expected_auth_data: Option<&[u8]>) -> RelayClient { + let entry = RelayEntry { + id: url.to_string(), + pubkey: BlsSecretKey::random().public_key().into(), + url: Url::parse(url).unwrap(), + }; + let mut config = RelayConfig { + entry, + id: None, + headers: None, + get_params: None, + get_header: GetHeaderTransport::Http, + enable_timing_games: false, + target_first_request_ms: None, + frequency_get_header_ms: None, + bid_poll_timeout_ms: None, + validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: None, + }; + config.expected_auth_data = expected_auth_data.map(|d| d.to_vec().into()); + RelayClient::new(config).unwrap() + } + + #[test] + fn match_relays_configured_data_never_matches_empty() { + let relays = vec![test_relay("http://a.example.com", Some(&[0xaa]))]; + // An empty `data` field must not satisfy a relay that declared its data + assert!(match_relays_by_auth_data(&relays, &[]).is_empty()); + } + + #[test] + fn validate_auth_data_requires_nonempty_data() { + use cb_common::{pbs::BuilderRequestAuth, types::BlsSignature}; + use lh_types::Slot; + + let with_data = |data: Vec| SignedBuilderRequestAuth { + message: BuilderRequestAuth { data: data.try_into().unwrap(), slot: Slot::new(1) }, + signature: BlsSignature::empty(), + }; + + // Zero-length data is invalid per builder-specs request_auth.yaml ({1,4096}) + assert!(matches!( + validate_auth_data(&with_data(vec![])), + Err(PbsClientError::EmptyAuthData) + )); + assert!(validate_auth_data(&with_data(vec![0xaa])).is_ok()); + } + + #[test] + fn match_relays_prefers_configured_auth_data() { + let relays = vec![ + test_relay("http://a.example.com", Some(&[0xaa])), + test_relay("http://b.example.com", Some(&[0xbb])), + ]; + let matched = match_relays_by_auth_data(&relays, &[0xbb]); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].config.entry.url.host_str(), Some("b.example.com")); + // Data matching no configured value selects none, even though the data + // is a URL naming a configured relay: configured bytes take precedence + assert!(match_relays_by_auth_data(&relays, b"http://a.example.com").is_empty()); + } + + // Contract vectors: external KM projection tooling round-trips auth_data + // through this demux, so the behaviors below are a compatibility contract, + // not incidental implementation detail. + + // A relay entry URL embeds its pubkey as userinfo and may omit the default + // port; a bare builder URL in auth_data must still match it. + #[test] + fn match_relays_contract_userinfo_and_default_port_ignored() { + let relays = vec![test_relay("https://0xdeadbeef@builder.example.com", None)]; + assert_eq!(match_relays_by_auth_data(&relays, b"https://builder.example.com").len(), 1); + assert_eq!(match_relays_by_auth_data(&relays, b"https://builder.example.com:443").len(), 1); + assert!(match_relays_by_auth_data(&relays, b"https://builder.example.com:8443").is_empty()); + } + + // Cross-form collision: relay A's `expected_auth_data` equals relay B's URL + // bytes. For A the exact-byte layer decides (its own URL never enters into + // it); B, with no configured bytes, still matches by URL. The matched SET + // is {A, B}: configured bytes take precedence per relay, they do not + // subtract other relays' URL matches. + #[test] + fn match_relays_contract_cross_form_collision() { + let relays = vec![ + test_relay("http://a.example.com", Some(b"http://b.example.com")), + test_relay("http://b.example.com", None), + ]; + let matched = match_relays_by_auth_data(&relays, b"http://b.example.com"); + let hosts: Vec<_> = + matched.iter().map(|r| r.config.entry.url.host_str().unwrap()).collect(); + assert_eq!(hosts, vec!["a.example.com", "b.example.com"]); + // A's own URL no longer matches anything: configured bytes replace + // URL-derived matching for that relay (layer-1 precedence) + assert!(match_relays_by_auth_data(&relays, b"http://a.example.com").is_empty()); + } + + // Matched-SET semantics: one auth_data may select several relays (multiple + // builders behind one agreement); the winner is picked later by payment. + #[test] + fn match_relays_contract_shared_auth_data_matches_all() { + let relays = vec![ + test_relay("http://a.example.com", Some(&[0xcc])), + test_relay("http://b.example.com", Some(&[0xcc])), + ]; + let matched = match_relays_by_auth_data(&relays, &[0xcc]); + assert_eq!(matched.len(), 2); + } + + #[test] + fn match_relays_url_fallback_and_unmatched_selects_none() { + let relays = vec![ + test_relay("http://a.example.com", None), + test_relay("http://b.example.com", None), + ]; + // URL-carrying data selects the named relay only + let matched = match_relays_by_auth_data(&relays, b"http://a.example.com"); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].config.entry.url.host_str(), Some("a.example.com")); + // NUL-suffixed extra bytes route identically + let mut with_extra = b"http://a.example.com".to_vec(); + with_extra.push(0); + with_extra.extend_from_slice(&[0xde, 0xad]); + assert_eq!(match_relays_by_auth_data(&relays, &with_extra).len(), 1); + // A URL naming nothing configured selects none + assert!(match_relays_by_auth_data(&relays, b"http://z.example.com").is_empty()); + // Opaque non-URL data names nothing: no catch-all, no relay + assert!(match_relays_by_auth_data(&relays, &[0xde, 0xad]).is_empty()); + // Empty data carries no URL either + assert!(match_relays_by_auth_data(&relays, &[]).is_empty()); + } + + // The pipe never dials blind: with no advertised_urls the self-URL guard + // cannot rule out CB's own URL (an unconfigured key's auth data defaults + // to it), so it fails closed with the same mismatch a builder would return. + #[tokio::test] + async fn transient_pipe_relay_fails_closed_without_advertised_urls() { + assert!(matches!( + transient_pipe_relay(b"http://builder.example.com", &[], &reqwest::Client::new()).await, + Err(PbsClientError::AuthDataMismatch) + )); + } + + // A decoded URL naming CB itself is never dialed: matching follows + // `url_matches`, so userinfo/path/default-port variants still guard. + #[tokio::test] + async fn transient_pipe_relay_guards_own_advertised_urls() { + let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; + for own in [ + "http://cb.example.com:18550", + "http://cb.example.com:18550/eth/v1/builder", + "http://0xdeadbeef@cb.example.com:18550", + ] { + assert!( + matches!( + transient_pipe_relay(own.as_bytes(), &advertised, &reqwest::Client::new()) + .await, + Err(PbsClientError::AuthDataMismatch) + ), + "{own} must not be dialed" + ); + } + } + + // Data carrying no URL names no builder: mismatch, no dial. + #[tokio::test] + async fn transient_pipe_relay_rejects_non_url_data() { + let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; + for data in [&[0xde, 0xad][..], b"not a url", &[]] { + assert!(matches!( + transient_pipe_relay(data, &advertised, &reqwest::Client::new()).await, + Err(PbsClientError::AuthDataMismatch) + )); + } + } + + // A decodable, non-self URL gets a transient client carrying no configured + // relay's headers and no per-relay cap (the global default applies), so + // pipe bids rank unclamped and leak no credentials. A literal public IP host + // is used so the SSRF resolve step needs no network DNS. The metric id is + // the fixed `pipe` sentinel, not the untrusted host. + #[tokio::test] + async fn transient_pipe_relay_builds_a_bare_client() { + let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; + let mut data = b"http://1.1.1.1:8551".to_vec(); + data.push(0); + data.extend_from_slice(&[0xde, 0xad]); + + let relay = + transient_pipe_relay(&data, &advertised, &reqwest::Client::new()).await.unwrap(); + assert_eq!(relay.config.entry.url.as_str(), "http://1.1.1.1:8551/"); + assert_eq!(relay.id.as_str(), "pipe"); + assert!(relay.config.headers.is_none()); + assert!(relay.config.max_execution_payment_gwei.is_none()); + assert!(relay.config.expected_auth_data.is_none()); + assert!(!relay.config.enable_timing_games); + } + + // The SSRF guard refuses a target that resolves into loopback/private space + // even when the URL decodes and is not a self-URL. Literal-IP hosts keep the + // resolve step off the network. + #[tokio::test] + async fn transient_pipe_relay_rejects_disallowed_ip_targets() { + let advertised = vec![Url::parse("http://cb.example.com:18550").unwrap()]; + for host in ["http://127.0.0.1:8551", "http://10.0.0.1:8551"] { + assert!( + matches!( + transient_pipe_relay(host.as_bytes(), &advertised, &reqwest::Client::new()) + .await, + Err(PbsClientError::PipeTargetBlocked) + ), + "{host} must be refused as an internal target" + ); + } + // A public literal IP is not rejected by the IP check (it builds a client) + assert!( + transient_pipe_relay(b"http://1.1.1.1:8551", &advertised, &reqwest::Client::new()) + .await + .is_ok() + ); + } + + // The disallow predicate covers loopback/unspecified/private/link-local in + // both families, and unwraps a v4-mapped v6 so an internal target cannot + // hide behind `::ffff:a.b.c.d`. + #[test] + fn ip_is_disallowed_table() { + use std::net::IpAddr; + let dis = |s: &str| ip_is_disallowed(s.parse::().unwrap()); + // loopback / unspecified, both families + assert!(dis("127.0.0.1")); + assert!(dis("0.0.0.0")); + assert!(dis("::1")); + assert!(dis("::")); + // private v4 + assert!(dis("10.0.0.1")); + assert!(dis("192.168.1.1")); + assert!(dis("172.16.0.1")); + // link-local v4 and the v4 broadcast + assert!(dis("169.254.1.1")); + assert!(dis("255.255.255.255")); + // RFC 6598 CGNAT 100.64.0.0/10 (edges), but not 100.x outside the /10 + assert!(dis("100.64.0.1")); + assert!(dis("100.127.255.254")); + assert!(!dis("100.63.0.1")); + assert!(!dis("100.128.0.1")); + // v6 unique-local (fc00::/7) and link-local (fe80::/10) + assert!(dis("fc00::1")); + assert!(dis("fd12:3456::1")); + assert!(dis("fe80::1")); + assert!(dis("febf::1")); + // v4-mapped internal addresses are unwrapped and caught + assert!(dis("::ffff:127.0.0.1")); + assert!(dis("::ffff:10.0.0.1")); + // public addresses pass, in both families and via the v4-mapped form + assert!(!dis("1.1.1.1")); + assert!(!dis("8.8.8.8")); + assert!(!dis("2606:4700:4700::1111")); + assert!(!dis("::ffff:1.1.1.1")); + } + + // The placeholder pubkey is stable across pipe requests: one process-wide + // point rather than a fresh keygen per unmatched request. + #[test] + fn pipe_relay_placeholder_pubkey_is_stable() { + assert_eq!(pipe_relay_placeholder_pubkey(), pipe_relay_placeholder_pubkey()); + } + + #[test] + fn url_matches_ignores_userinfo_and_default_port() { + let u = |s: &str| Url::parse(s).unwrap(); + // A bare builder URL matches a configured relay whose URL embeds the + // relay pubkey as userinfo and omits the default port. + assert!(url_matches( + &u("https://0xdeadbeef@builder.example.com"), + &u("https://builder.example.com") + )); + assert!(url_matches( + &u("https://builder.example.com:443"), + &u("https://builder.example.com") + )); + assert!(!url_matches(&u("http://a.com"), &u("https://a.com"))); + assert!(!url_matches(&u("https://a.com"), &u("https://b.com"))); + assert!(!url_matches(&u("http://a.com:8001"), &u("http://a.com:8002"))); + // A fully-qualified trailing-dot host matches its dotless form, so it + // cannot be used to slip the self-URL guard. + assert!(url_matches(&u("https://cb.example.com."), &u("https://cb.example.com"))); + assert!(url_matches(&u("https://cb.example.com"), &u("https://cb.example.com."))); + } + + #[test] + fn decode_auth_data_url_variants() { + // raw UTF-8 URL bytes (the spec's nothing-agreed default) + let url = decode_auth_data_url(b"https://builder.example.com").unwrap(); + assert_eq!(url.host_str(), Some("builder.example.com")); + // NUL-suffixed extra bytes decode to the same URL: purely additive + let mut with_extra = b"https://builder.example.com".to_vec(); + with_extra.push(0); + with_extra.extend_from_slice(&[0xde, 0xad]); + let url = decode_auth_data_url(&with_extra).unwrap(); + assert_eq!(url.host_str(), Some("builder.example.com")); + // empty extra after the NUL is also valid and identical + let url = decode_auth_data_url(b"https://builder.example.com\x00").unwrap(); + assert_eq!(url.host_str(), Some("builder.example.com")); + // opaque non-URL bytes carry no routing, with or without a NUL + assert!(decode_auth_data_url(&[0xde, 0xad, 0xbe, 0xef]).is_none()); + assert!(decode_auth_data_url(&[0xde, 0x00, 0xad]).is_none()); + assert!(decode_auth_data_url(b"not a url").is_none()); + } + + /// Same label-order pin as the beacon-node counter: all three labels are + /// &str, so a permuted order compiles and silently writes another series. + #[test] + fn record_invalid_relay_response_lands_in_relay_invalid_response() { + const TAG: &str = "invalid-relay-response-unit-test"; + + let before = crate::metrics::RELAY_INVALID_RESPONSE + .with_label_values(&["wrong_fork", TAG, "relay-x"]) + .get(); + record_invalid_relay_response("wrong_fork", TAG, "relay-x"); + let after = crate::metrics::RELAY_INVALID_RESPONSE + .with_label_values(&["wrong_fork", TAG, "relay-x"]) + .get(); + assert_eq!(after, before + 1); + } + + #[test] + fn record_client_error_lands_in_beacon_node_status() { + const TAG: &str = "record-client-error-unit-test"; + + let before = crate::metrics::BEACON_NODE_STATUS.with_label_values(&["400", TAG]).get(); + let err = + record_client_error(cb_common::wire::BodyDeserializeError::MissingVersionHeader, TAG); + assert_eq!(err.status_code(), reqwest::StatusCode::BAD_REQUEST); + let after = crate::metrics::BEACON_NODE_STATUS.with_label_values(&["400", TAG]).get(); + assert_eq!(after, before + 1, "the 400 must count under (status, endpoint)"); + } +} diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index 710d7ea6..807057fe 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -94,6 +94,16 @@ impl SigningService { return Ok(()); } + let listener = tokio::net::TcpListener::bind(config.endpoint).await?; + Self::run_with_listener(config, listener).await + } + + /// Serve from an already-bound listener. Prefer this in tests to avoid a + /// port rebind race (see `PbsService::run_with_listener`). + pub async fn run_with_listener( + config: StartSignerConfig, + listener: tokio::net::TcpListener, + ) -> eyre::Result<()> { let module_ids: Vec = config.mod_signing_configs.keys().cloned().map(Into::into).collect(); @@ -169,16 +179,17 @@ impl SigningService { } }); + let std_listener = listener.into_std()?; let server_result = if let Some(tls_config) = config.tls_certificates { let tls_config = RustlsConfig::from_pem(tls_config.0, tls_config.1).await?; - axum_server::bind_rustls(config.endpoint, tls_config) + axum_server::tls_rustls::from_tcp_rustls(std_listener, tls_config) .serve( signer_app.merge(admin_app).into_make_service_with_connect_info::(), ) .await } else { warn!("Running in insecure HTTP mode, no TLS certificates provided"); - axum_server::bind(config.endpoint) + axum_server::from_tcp(std_listener) .serve( signer_app.merge(admin_app).into_make_service_with_connect_info::(), ) diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 646f4994..77206686 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -8,7 +8,7 @@ version.workspace = true alloy.workspace = true axum.workspace = true cb-common.workspace = true -cb-pbs.workspace = true +cb-pbs = { workspace = true, features = ["testing-flags"] } cb-signer.workspace = true eyre.workspace = true ethereum_ssz.workspace = true @@ -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..23fd408f 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -21,16 +21,21 @@ use axum::{ routing::{get, post}, }; use cb_common::{ + constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, pbs::{ BUILDER_V1_API_PATH, BUILDER_V2_API_PATH, BlobsBundle, BuilderBid, BuilderBidFulu, - ExecutionPayloadElectra, ExecutionPayloadHeaderFulu, ExecutionRequests, ForkName, - GET_HEADER_PATH, GET_STATUS_PATH, GetHeaderParams, GetHeaderResponse, GetPayloadInfo, - PayloadAndBlobs, REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SignedBuilderBid, - SubmitBlindedBlockResponse, + BuilderPreferencesRequest, ExecutionPayloadBid, ExecutionPayloadElectra, + ExecutionPayloadHeaderFulu, ForkName, ForkVersionDecode, GET_EXECUTION_PAYLOAD_BID_PATH, + GET_HEADER_PATH, GET_STATUS_PATH, GetExecutionPayloadBidResponse, GetHeaderParams, + GetHeaderResponse, GetPayloadInfo, HEADER_TIMEOUT_MS, PayloadAndBlobs, + REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SUBMIT_BUILDER_PREFERENCES_PATH, + SUBMIT_SIGNED_BEACON_BLOCK_PATH, SignedBeaconBlock, SignedBuilderBid, + SignedBuilderRequestAuth, SignedExecutionPayloadBid, SubmitBlindedBlockResponse, }, - signature::sign_builder_root, - types::{BlsSecretKey, Chain}, - utils::{TestRandomSeed, timestamp_of_slot_start_sec}, + signature::{sign_builder_root, sign_execution_payload_bid_root}, + signer::random_secret, + types::{BlsPublicKey, BlsSecretKey, Chain}, + utils::{TestRandomSeed, timestamp_of_slot_start_sec, utcnow_ms}, wire::{ CONSENSUS_VERSION_HEADER, EncodingType, deserialize_body, get_accept_types, get_consensus_version_header, get_content_type, @@ -40,9 +45,9 @@ use cb_pbs::{ GET_HEADER_ENDPOINT_TAG, MAX_SIZE_SUBMIT_BLOCK_RESPONSE, REGISTER_VALIDATOR_ENDPOINT_TAG, STATUS_ENDPOINT_TAG, SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, }; -use lh_types::KzgProof; +use lh_types::{KzgProof, Slot}; use reqwest::header::{ACCEPT, CONTENT_TYPE}; -use ssz::Encode; +use ssz::{Decode, Encode}; use tokio::net::TcpListener; use tracing::{debug, error}; use tree_hash::TreeHash; @@ -65,6 +70,15 @@ pub async fn start_mock_relay_service_with_listener( Ok(()) } +/// One inbound `getExecutionPayloadBid` request, recorded on arrival. Both +/// fields are captured under a single lock so the two derived sequences cannot +/// interleave differently when polls overlap. +struct BidRequestRecord { + /// `X-Timeout-Ms` the caller granted this request + timeout_ms: u64, + arrival_ms: u64, +} + pub struct MockRelayState { pub chain: Chain, pub signer: BlsSecretKey, @@ -93,6 +107,33 @@ pub struct MockRelayState { received_get_status: Arc, received_register_validator: Arc, received_submit_block: Arc, + received_execution_payload_bid: Arc, + received_builder_preferences: Arc, + received_signed_beacon_block: Arc, + /// `slot` of the last signed beacon block forwarded, decoded from the SSZ + /// body PBS sent, so a test can assert the block survived the hop + received_block_slot: RwLock>, + /// `block_hash` the last forwarded block committed to in its bid + received_block_committed_hash: RwLock>, + /// The last `BuilderPreferencesRequest` submitted, so a test can assert + /// both the preferences and the auth were forwarded unchanged + received_preferences: RwLock>, + /// The `{proposer_pubkey}` path segment of the last preferences submission + received_preferences_pubkey: RwLock>, + /// Bid requests answered after `bid_delay_ms` elapsed. A poll the caller + /// timed out on is cancelled during the delay and never lands here, so this + /// counts the polls that actually got a response. + served_execution_payload_bid: Arc, + /// Every inbound bid request, in arrival order + received_bid_requests: RwLock>, + /// Each successive bid request serves this many more gwei than the last, + /// so a test can tell which poll's bid won + improving_bid_step_gwei: Option, + /// Hold every bid request this long before answering, simulating a builder + /// that sits on a request instead of answering promptly + bid_delay_ms: Option, + /// The last `SignedBuilderRequestAuth` forwarded on a bid request + received_auth: RwLock>, response_override: RwLock>, bid_value: RwLock, /// The raw `Accept` header PBS sent on the most recent get_header request, @@ -101,6 +142,21 @@ pub struct MockRelayState { /// Api key header seen per endpoint tag, so a test can assert the relay's /// configured key rides on every request PBS sends it. api_keys_seen: RwLock>, + /// Served as `bid.value` / `bid.execution_payment` by + /// `handle_get_execution_payload_bid` + trustless_bid_gwei: u64, // default 10 + trusted_bid_gwei: u64, + epbs_no_bid: bool, + epbs_invalid_signature: bool, + epbs_wrong_parent_hash: bool, + epbs_wrong_parent_root: bool, + /// When true, `handle_get_execution_payload_bid` omits the + /// `Eth-Consensus-Version` header on an SSZ 200. Drives the PBS error path + /// for an SSZ bid response that lacks the fork header. + epbs_omit_consensus_version: bool, + /// When true, the bid 200 claims `capella` instead of gloas (header on + /// SSZ, body `version` on JSON). Drives the PBS gloas-only response check. + epbs_wrong_fork: bool, } impl MockRelayState { @@ -119,6 +175,66 @@ impl MockRelayState { pub fn received_submit_block(&self) -> u64 { self.received_submit_block.load(Ordering::Relaxed) } + pub fn received_execution_payload_bid(&self) -> u64 { + self.received_execution_payload_bid.load(Ordering::Relaxed) + } + pub fn served_execution_payload_bid(&self) -> u64 { + self.served_execution_payload_bid.load(Ordering::Relaxed) + } + pub fn received_builder_preferences(&self) -> u64 { + self.received_builder_preferences.load(Ordering::Relaxed) + } + pub fn received_signed_beacon_block(&self) -> u64 { + self.received_signed_beacon_block.load(Ordering::Relaxed) + } + /// `slot` of the last signed beacon block PBS forwarded + pub fn received_block_slot(&self) -> Option { + *self.received_block_slot.read().unwrap() + } + /// `block_hash` the last forwarded block committed to + pub fn received_block_committed_hash(&self) -> Option { + *self.received_block_committed_hash.read().unwrap() + } + + /// `max_execution_payment` of the last submitted preferences + pub fn received_max_execution_payment(&self) -> Option { + self.received_preferences + .read() + .unwrap() + .as_ref() + .map(|r| r.preferences.max_execution_payment) + } + + /// The `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() + } + + /// `X-Timeout-Ms` of every inbound bid request, in arrival order: the shape + /// of the poll ladder as the builder saw it. + pub fn received_bid_timeouts(&self) -> Vec { + self.received_bid_requests.read().unwrap().iter().map(|r| r.timeout_ms).collect() + } + + /// Arrival time of every inbound bid request, in arrival order, for + /// asserting the poll cadence. + pub fn received_bid_arrivals_ms(&self) -> Vec { + self.received_bid_requests.read().unwrap().iter().map(|r| r.arrival_ms).collect() + } + pub fn received_auth_data(&self) -> Option> { + self.received_auth.read().unwrap().as_ref().map(|a| a.message.data.to_vec()) + } + + /// The full `SignedBuilderRequestAuth` the relay saw, so a test can assert + /// the signature was forwarded byte-for-byte. + pub fn received_auth(&self) -> Option { + self.received_auth.read().unwrap().clone() + } pub fn large_body(&self) -> bool { self.large_body } @@ -166,10 +282,30 @@ impl MockRelayState { received_get_status: Default::default(), received_register_validator: Default::default(), received_submit_block: Default::default(), + received_execution_payload_bid: Default::default(), + received_builder_preferences: Default::default(), + received_signed_beacon_block: Default::default(), + received_block_slot: RwLock::new(None), + received_block_committed_hash: RwLock::new(None), + received_preferences: RwLock::new(None), + received_preferences_pubkey: RwLock::new(None), + served_execution_payload_bid: Default::default(), + received_bid_requests: RwLock::new(Vec::new()), + improving_bid_step_gwei: None, + bid_delay_ms: None, + received_auth: RwLock::new(None), response_override: RwLock::new(None), bid_value: RwLock::new(U256::from(10)), received_get_header_accept: RwLock::new(None), api_keys_seen: RwLock::new(HashMap::new()), + trustless_bid_gwei: 10, + trusted_bid_gwei: 0, + epbs_no_bid: false, + epbs_invalid_signature: false, + epbs_wrong_parent_hash: false, + epbs_wrong_parent_root: false, + epbs_omit_consensus_version: false, + epbs_wrong_fork: false, supported_content_types: Arc::new( [EncodingType::Json, EncodingType::Ssz].iter().cloned().collect(), ), @@ -218,6 +354,74 @@ impl MockRelayState { pub fn with_submit_block_version(self, fork: ForkName) -> Self { Self { submit_block_version_override: Some(fork), ..self } } + + pub fn with_trustless_bid_gwei(self, value: u64) -> Self { + Self { trustless_bid_gwei: value, ..self } + } + + pub fn with_trusted_bid_gwei(self, execution_payment: u64) -> Self { + Self { trusted_bid_gwei: execution_payment, ..self } + } + + pub fn with_no_epbs_bid(self) -> Self { + Self { epbs_no_bid: true, ..self } + } + + /// Signs the served bid with a throwaway key + pub fn with_epbs_invalid_signature(self) -> Self { + Self { epbs_invalid_signature: true, ..self } + } + + pub fn with_epbs_wrong_parent_hash(self) -> Self { + Self { epbs_wrong_parent_hash: true, ..self } + } + + pub fn with_epbs_wrong_parent_root(self) -> Self { + Self { epbs_wrong_parent_root: true, ..self } + } + + /// Restrict this relay to SSZ responses on the bid endpoint, so the bid + /// 200 is served as SSZ regardless of the caller's fallback preference. + pub fn with_ssz_only_response(self) -> Self { + Self { + supported_content_types: Arc::new([EncodingType::Ssz].into_iter().collect()), + ..self + } + } + + /// Restrict this relay to JSON responses on the bid endpoint. + pub fn with_json_only_response(self) -> Self { + Self { + supported_content_types: Arc::new([EncodingType::Json].into_iter().collect()), + ..self + } + } + + /// Serve a strictly better bid on each successive bid request: the nth + /// request (0-indexed) is worth `trustless_bid_gwei + n * step_gwei`. Lets + /// a test prove WHICH poll of a ladder produced the winning bid. + pub fn with_improving_bids(self, step_gwei: u64) -> Self { + Self { improving_bid_step_gwei: Some(step_gwei), ..self } + } + + /// Hold every bid request `delay_ms` before answering. Polls whose + /// `X-Timeout-Ms` is shorter than this are dropped by the caller, which is + /// what makes the early rungs of the ladder observable. + pub fn with_bid_delay_ms(self, delay_ms: u64) -> Self { + Self { bid_delay_ms: Some(delay_ms), ..self } + } + + /// Serve an SSZ bid 200 WITHOUT the `Eth-Consensus-Version` header, to + /// exercise the PBS missing-fork error path on the outbound SSZ decode. + pub fn with_epbs_omit_consensus_version(self) -> Self { + Self { epbs_omit_consensus_version: true, ..self } + } + + /// Label the bid 200 `capella` instead of gloas, to exercise the PBS + /// gloas-only response check (the bid must be dropped, not forwarded). + pub fn with_epbs_wrong_fork(self) -> Self { + Self { epbs_wrong_fork: true, ..self } + } } pub fn mock_relay_app_router(state: Arc) -> Router { @@ -225,7 +429,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 +464,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 +473,145 @@ pub fn mock_signed_builder_bid( SignedBuilderBid { message, signature } } +async fn handle_get_execution_payload_bid( + State(state): State>, + Path((slot, parent_hash, parent_root, _pubkey)): Path<(u64, B256, B256, BlsPublicKey)>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Response { + let request_index = state.received_execution_payload_bid.fetch_add(1, Ordering::Relaxed); + state.received_bid_requests.write().unwrap().push(BidRequestRecord { + timeout_ms: headers + .get(HEADER_TIMEOUT_MS) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) + .unwrap_or_default(), + arrival_ms: utcnow_ms(), + }); + // Decode the optional request auth the way a real builder does: Content-Type + // selects JSON vs SSZ. The wire type is fork-versioned per builder-specs, + // so the SSZ form additionally requires Eth-Consensus-Version — PBS always + // forwards SSZ, making this the assertion that the header arrives as gloas. + if !body.is_empty() { + let auth = match get_content_type(&headers) { + EncodingType::Ssz => { + if get_consensus_version_header(&headers) != Some(ForkName::Gloas) { + return ( + StatusCode::BAD_REQUEST, + "missing Eth-Consensus-Version header".to_string(), + ) + .into_response(); + } + 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; + } + state.served_execution_payload_bid.fetch_add(1, Ordering::Relaxed); + + if state.epbs_no_bid { + return StatusCode::NO_CONTENT.into_response(); + } + + let served_parent_hash = + if state.epbs_wrong_parent_hash { B256::repeat_byte(0xab) } else { parent_hash }; + let served_parent_root = + if state.epbs_wrong_parent_root { B256::repeat_byte(0xcd) } else { parent_root }; + + let mut block_hash = B256::ZERO; + block_hash.0[0] = 1; + + let message = ExecutionPayloadBid { + parent_block_hash: served_parent_hash.into(), + parent_block_root: served_parent_root, + block_hash: block_hash.into(), + gas_limit: 30_000_000, + builder_index: 42, + slot: Slot::new(slot), + value: state.trustless_bid_gwei.saturating_add( + state.improving_bid_step_gwei.unwrap_or(0).saturating_mul(request_index), + ), + execution_payment: state.trusted_bid_gwei, + ..Default::default() + }; + + let wrong_signer = state.epbs_invalid_signature.then(random_secret); + let signature = sign_execution_payload_bid_root( + wrong_signer.as_ref().unwrap_or(&state.signer), + &message.tree_hash_root(), + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ); + let data = SignedExecutionPayloadBid { message, signature }; + let served_fork = if state.epbs_wrong_fork { ForkName::Capella } else { ForkName::Gloas }; + + // Negotiate the RESPONSE encoding from the forwarded Accept, mirroring + // handle_get_header: honor supported_content_types + the caller's Accept. + 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: served_fork, + 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(&served_fork.to_string()).unwrap(), + ); + } + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_str(&content_type.to_string()).unwrap()); + response +} + async fn handle_get_header( State(state): State>, Path(GetHeaderParams { parent_hash, slot, .. }): Path, @@ -351,6 +698,69 @@ async fn handle_get_status( StatusCode::OK } +/// Decodes the submission the way a real builder does (Content-Type selects +/// JSON vs SSZ), records it, and 202s unless the test overrode the response. +async fn handle_submit_builder_preferences( + Path(proposer_pubkey): Path, + headers: HeaderMap, + State(state): State>, + body: axum::body::Bytes, +) -> Response { + state.received_builder_preferences.fetch_add(1, Ordering::Relaxed); + // A real builder keys preferences by proposer, so the path segment PBS sent + // is part of what a test must be able to assert + *state.received_preferences_pubkey.write().unwrap() = Some(proposer_pubkey); + + let decoded = match get_content_type(&headers) { + EncodingType::Json => serde_json::from_slice::(&body).ok(), + // The wire type is fork-versioned per builder-specs, so a real builder + // requires Eth-Consensus-Version on the SSZ form — PBS always forwards + // SSZ, making this the assertion that the header arrives as gloas. + EncodingType::Ssz => get_consensus_version_header(&headers) + .filter(|fork| *fork == ForkName::Gloas) + .and_then(|_| BuilderPreferencesRequest::from_ssz_bytes(&body).ok()), + }; + let Some(request) = decoded else { + return StatusCode::BAD_REQUEST.into_response(); + }; + *state.received_preferences.write().unwrap() = Some(request); + + if let Some(status) = state.response_override.read().unwrap().as_ref() { + return (*status).into_response(); + } + + StatusCode::ACCEPTED.into_response() +} + +/// Decodes the forwarded block (PBS always sends SSZ with +/// `Eth-Consensus-Version`), records its slot and committed bid hash, and 202s +/// unless the test overrode the response. +async fn handle_submit_signed_beacon_block( + headers: HeaderMap, + State(state): State>, + body: axum::body::Bytes, +) -> Response { + state.received_signed_beacon_block.fetch_add(1, Ordering::Relaxed); + + if let Some(fork) = get_consensus_version_header(&headers) && + let Ok(block) = SignedBeaconBlock::from_ssz_bytes_by_fork(&body, fork) + { + *state.received_block_slot.write().unwrap() = Some(block.slot().as_u64()); + *state.received_block_committed_hash.write().unwrap() = match &block { + lh_types::SignedBeaconBlock::Gloas(b) => { + Some(b.message.body.signed_execution_payload_bid.message.block_hash.0) + } + _ => None, + }; + } + + if let Some(status) = state.response_override.read().unwrap().as_ref() { + return (*status).into_response(); + } + + StatusCode::ACCEPTED.into_response() +} + async fn handle_register_validator( State(state): State>, headers: HeaderMap, diff --git a/tests/src/mock_ssv_node.rs b/tests/src/mock_ssv_node.rs index 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..6605a1e5 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,123 @@ 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: + /// that header is what bounds the whole bid poll ladder. + #[allow(clippy::too_many_arguments)] + pub async fn do_get_execution_payload_bid_with_timeout( + &self, + slot: u64, + parent_hash: B256, + parent_root: B256, + pubkey: Option, + auth: Option<&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 fa71d5fa..964b58d4 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -3,6 +3,7 @@ use std::{ net::{Ipv4Addr, SocketAddr}, path::PathBuf, sync::{Arc, Once}, + time::Duration, }; use alloy::primitives::{B256, U256}; @@ -14,26 +15,41 @@ 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}, + signature::sign_builder_request_auth_root, + signer::{SignerLoader, random_secret}, + types::{BlsPublicKey, BlsSecretKey, BlsSignature, Chain, ModuleId}, utils::{bls_pubkey_from_hex, default_host}, }; +use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use eyre::Result; +use lh_types::Slot; use rcgen::generate_simple_self_signed; +use reqwest::StatusCode; +use tree_hash::TreeHash; use url::Url; +use crate::{ + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, + mock_validator::MockValidator, +}; + pub const HEADER_API_KEY: &str = "x-api-key"; pub const API_KEY: &str = "123e4567-e89b-12d3-a456-426614174000"; /// Distinct from [`API_KEY`], which `MockValidator` also sends to PBS: a relay /// that sees this one can only have got it from its own config. pub const RELAY_API_KEY: &str = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"; +/// The auth data the default mock relay declares and most ePBS tests send. +/// Unmatched auth data is a 400 (no catch-all), so a relay must declare the +/// data it serves for opaque-data tests to route. +pub const TEST_AUTH_DATA: &[u8] = &[0xde, 0xad]; +/// 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}") } -/// Bind to port 0 and let the OS assign an unused ephemeral port. pub async fn get_free_listener() -> tokio::net::TcpListener { tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap() } @@ -59,7 +75,10 @@ fn mock_relay_config(port: u16, pubkey: BlsPublicKey) -> Result { enable_timing_games: false, target_first_request_ms: None, frequency_get_header_ms: None, + bid_poll_timeout_ms: None, validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: Some(TEST_AUTH_DATA.to_vec().into()), }) } @@ -67,6 +86,53 @@ pub fn generate_mock_relay(port: u16, pubkey: BlsPublicKey) -> Result Result { + let mut relay = generate_mock_relay(port, pubkey)?; + let mut config = (*relay.config).clone(); + config.max_execution_payment_gwei = Some(max_execution_payment_gwei); + relay.config = std::sync::Arc::new(config); + Ok(relay) +} + +pub fn generate_mock_relay_with_timing_games( + port: u16, + pubkey: BlsPublicKey, + frequency_get_header_ms: u64, + bid_poll_timeout_ms: Option, +) -> Result { + let mut relay = generate_mock_relay(port, pubkey)?; + let mut config = (*relay.config).clone(); + config.enable_timing_games = true; + config.frequency_get_header_ms = Some(frequency_get_header_ms); + config.bid_poll_timeout_ms = bid_poll_timeout_ms; + relay.config = std::sync::Arc::new(config); + Ok(relay) +} + +pub fn generate_mock_relay_url_only(port: u16, pubkey: BlsPublicKey) -> Result { + let mut relay = generate_mock_relay(port, pubkey)?; + let mut config = (*relay.config).clone(); + config.expected_auth_data = None; + relay.config = std::sync::Arc::new(config); + Ok(relay) +} + +pub fn generate_mock_relay_with_auth_data( + port: u16, + pubkey: BlsPublicKey, + expected_auth_data: &[u8], +) -> Result { + let mut relay = generate_mock_relay(port, pubkey)?; + let mut config = (*relay.config).clone(); + config.expected_auth_data = Some(expected_auth_data.to_vec().into()); + relay.config = std::sync::Arc::new(config); + Ok(relay) +} + pub fn generate_mock_relay_with_batch_size( port: u16, pubkey: BlsPublicKey, @@ -118,8 +184,13 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { timeout_register_validator_ms: u64::MAX, skip_sigverify: false, min_bid_wei: U256::ZERO, + // matches the config default: the cap is a ranking clamp, unset = unclamped + max_execution_payment_gwei: None, late_in_slot_time_ms: u64::MAX, + proposer_deadline_buffer_ms: 0, extra_validation_enabled: false, + strict_block_decode: false, + verify_builder_request_auth: false, ssv_node_api_url: Url::parse("http://localhost:0").unwrap(), ssv_public_api_url: Url::parse("http://localhost:0").unwrap(), @@ -128,6 +199,9 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { register_validator_retry_limit: u32::MAX, validator_registration_batch_size: None, mux_registry_refresh_interval_seconds: 5, + advertised_urls: vec![], + min_bid_p2p_wei: None, + builder_boost_factor_p2p: None, } } @@ -230,3 +304,128 @@ 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; the signature is only verified when +/// `verify_builder_request_auth` is on, so an empty one suffices elsewhere. +pub fn opaque_auth(data: &[u8], slot: u64) -> SignedBuilderRequestAuth { + SignedBuilderRequestAuth { + message: BuilderRequestAuth { + data: ssz_types::VariableList::new(data.to_vec()) + .expect("data fits in MAX_BUILDER_AUTH_DATA_SIZE"), + slot: Slot::new(slot), + }, + signature: BlsSignature::empty(), + } +} + +/// Same, but signed under the spec's `DOMAIN_BUILDER_REQUEST_AUTH` by +/// `secret_key`. +pub fn signed_auth( + secret_key: &BlsSecretKey, + data: &[u8], + slot: u64, + chain: Chain, +) -> SignedBuilderRequestAuth { + let mut auth = opaque_auth(data, slot); + auth.signature = + sign_builder_request_auth_root(secret_key, &auth.message.tree_hash_root(), chain); + auth +} + +/// Starts a mock relay on a free port, returning its state and port. The +/// building block of every PBS boot below, and on its own the way a test gets a +/// builder that PBS does NOT know about (the ePBS pipe target). +pub async fn spawn_mock_relay(state: MockRelayState) -> Result<(Arc, u16)> { + let listener = get_free_listener().await; + let port = listener.local_addr()?.port(); + let state = Arc::new(state); + tokio::spawn(start_mock_relay_service_with_listener(state.clone(), listener)); + Ok((state, port)) +} + +/// Boot PBS in front of already-spawned mock relays, letting the test shape +/// the PBS config first. Readiness is awaited: relay_check makes a 200 on +/// /status mean the whole chain is up. +pub async fn setup_pbs( + chain: Chain, + relays: Vec, + tweak: impl FnOnce(&mut PbsConfig), +) -> 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)) +} + +/// Like [`setup_relays`], but each relay declares the `expected_auth_data` it +/// serves, so tests can address one builder among several. +pub async fn setup_relays_with_auth_data( + chain: Chain, + states: Vec<(MockRelayState, &[u8])>, +) -> Result<(MockValidator, Vec>)> { + let mut relays = Vec::with_capacity(states.len()); + let mut arc_states = Vec::with_capacity(states.len()); + for (state, auth_data) in states { + let (state, port) = spawn_mock_relay(state).await?; + relays.push(generate_mock_relay_with_auth_data( + port, + state.signer.public_key(), + auth_data, + )?); + 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..b8858aeb 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -64,9 +64,13 @@ async fn test_cfg_file_update() -> Result<()> { timeout_register_validator_ms: 3000, skip_sigverify: true, min_bid_wei: U256::ZERO, + max_execution_payment_gwei: None, + verify_builder_request_auth: false, late_in_slot_time_ms: u64::MAX / 2, /* serde gets very upset about serializing u64::MAX * or anything close to it */ + proposer_deadline_buffer_ms: 0, extra_validation_enabled: false, + strict_block_decode: false, rpc_url: None, ssv_node_api_url: Url::parse("http://example.com").unwrap(), ssv_public_api_url: Url::parse("http://example.com").unwrap(), @@ -74,6 +78,9 @@ async fn test_cfg_file_update() -> Result<()> { register_validator_retry_limit: 3, validator_registration_batch_size: None, mux_registry_refresh_interval_seconds: 384, + advertised_urls: vec![], + min_bid_p2p_wei: None, + builder_boost_factor_p2p: None, }; let cb_config = CommitBoostConfig { chain, @@ -91,11 +98,14 @@ async fn test_cfg_file_update() -> Result<()> { id: Some(relay1.id.to_string()), enable_timing_games: false, frequency_get_header_ms: None, + bid_poll_timeout_ms: None, get_params: None, get_header: GetHeaderTransport::Http, headers: None, target_first_request_ms: None, validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: None, entry: RelayEntry { id: relay1.id.to_string(), url: Url::parse(&format!("http://localhost:{relay1_port}"))?, @@ -114,8 +124,7 @@ async fn test_cfg_file_update() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![relay1.clone()]); let state = PbsState::new(config, config_path.clone()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers - extra time for the file watcher tokio::time::sleep(Duration::from_millis(1000)).await; @@ -145,11 +154,14 @@ async fn test_cfg_file_update() -> Result<()> { id: Some(relay2_id.clone()), enable_timing_games: false, frequency_get_header_ms: None, + bid_poll_timeout_ms: None, get_params: None, get_header: GetHeaderTransport::Http, headers: None, target_first_request_ms: None, validator_registration_batch_size: None, + max_execution_payment_gwei: None, + expected_auth_data: None, entry: RelayEntry { id: relay2_id, url: Url::parse(&format!("http://{pubkey}@localhost:{relay2_port}"))?, diff --git a/tests/tests/pbs_get_execution_payload_bid.rs b/tests/tests/pbs_get_execution_payload_bid.rs new file mode 100644 index 00000000..e6719263 --- /dev/null +++ b/tests/tests/pbs_get_execution_payload_bid.rs @@ -0,0 +1,1629 @@ +use std::sync::Arc; + +use alloy::primitives::{B256, U256}; +use cb_common::{ + constants::{GENESIS_VALIDATORS_ROOT, GLOAS_FORK_VERSION}, + pbs::{ + DEFAULT_BID_POLL_TIMEOUT_MS, GetExecutionPayloadBidInfo, GetExecutionPayloadBidResponse, + HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, SignedBuilderRequestAuth, + SignedExecutionPayloadBid, + }, + signature::sign_execution_payload_bid_root, + signer::random_secret, + types::Chain, + utils::utcnow_ms, + wire::{CONSENSUS_VERSION_HEADER, EncodingType}, +}; +use cb_tests::{ + mock_relay::MockRelayState, + mock_validator::MockValidator, + utils::{ + TEST_PROPOSER_PUBKEY, generate_mock_relay, generate_mock_relay_url_only, + generate_mock_relay_with_auth_data, generate_mock_relay_with_max_payment, + generate_mock_relay_with_timing_games, opaque_auth, setup_pbs, setup_relay, setup_relays, + setup_relays_with_auth_data, signed_auth, spawn_mock_relay, + }, +}; +use eyre::Result; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::{Decode, Encode}; +use tracing::info; +use tree_hash::TreeHash; + +const TEST_SLOT: u64 = 100; + +/// The request most tests send: a JSON-accept bid request for `TEST_SLOT` on +/// a zero parent hash/root, carrying `auth`. +async fn get_json_bid( + mock_validator: &MockValidator, + auth: &SignedBuilderRequestAuth, +) -> Result { + mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(auth), vec![ + EncodingType::Json, + ]) + .await +} + +/// The literal spec URL of the bid endpoint for `TEST_SLOT`, for the tests that +/// build their request by hand (bare-URL shape, missing headers, raw bodies). +fn bid_url(mock_validator: &MockValidator) -> String { + format!( + "{}eth/v1/builder/execution_payload_bid/{}/{}/{}/{}", + mock_validator.comm_boost.config.entry.url, + TEST_SLOT, + B256::ZERO, + B256::ZERO, + TEST_PROPOSER_PUBKEY, + ) +} + +/// Test requesting a bid with a single default relay +#[tokio::test] +async fn test_get_execution_payload_bid() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret())], + StatusCode::OK, + &[1], + Some(10), + 0, + ) + .await +} + +/// Test that the relay returning 204 (no bid) results in a 204 from PBS +#[tokio::test] +async fn test_get_execution_payload_bid_no_bid() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_no_epbs_bid()], + StatusCode::NO_CONTENT, + &[1], + None, + 0, + ) + .await +} + +/// A bid signed with the wrong key is forwarded unchanged, not dropped: the +/// ePBS bid path is a blind pipe. The beacon node verifies the bid's +/// builder_index against the on-chain builder registry and collateral, so CB +/// does not verify the signature. +#[tokio::test] +async fn test_get_execution_payload_bid_invalid_signature_is_forwarded() -> Result<()> { + test_get_execution_payload_bid_impl_opts( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_epbs_invalid_signature()], + StatusCode::OK, + &[1], + Some(10), + 0, + false, + ) + .await +} + +/// Test that a bid with a mismatched parent hash is dropped +#[tokio::test] +async fn test_get_execution_payload_bid_wrong_parent_hash() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_epbs_wrong_parent_hash()], + StatusCode::NO_CONTENT, + &[1], + None, + 0, + ) + .await +} + +/// Test that a bid with a mismatched parent root is dropped +#[tokio::test] +async fn test_get_execution_payload_bid_wrong_parent_root() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_epbs_wrong_parent_root()], + StatusCode::NO_CONTENT, + &[1], + None, + 0, + ) + .await +} + +/// `max_execution_payment_gwei` is a ranking clamp, not a reject: even the +/// strictest cap (0) passes a bid carrying an execution payment through +/// validation. The BN enforces the cap by clamping (beacon-APIs #630) +#[tokio::test] +async fn test_get_execution_payload_bid_execution_payment_over_cap_accepted() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_trusted_bid_gwei(1)], + StatusCode::OK, + &[1], + Some(10), + 0, + ) + .await +} + +#[tokio::test] +async fn test_get_execution_payload_bid_highest_wins() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![ + MockRelayState::new(Chain::Hoodi, random_secret()).with_trustless_bid_gwei(10), + MockRelayState::new(Chain::Hoodi, random_secret()).with_trustless_bid_gwei(42), + ], + StatusCode::OK, + &[1, 1], + Some(42), + 0, + ) + .await +} + +/// Test that a bid with an execution payment within the configured +/// `max_execution_payment_gwei` is accepted +#[tokio::test] +async fn test_get_execution_payload_bid_execution_payment_within_cap() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![MockRelayState::new(Chain::Hoodi, random_secret()).with_trusted_bid_gwei(5)], + StatusCode::OK, + &[1], + None, + 10, + ) + .await +} + +/// Test that selection is by TOTAL payment: trustless 5 + payment 10 beats +/// trustless 10 + payment 0. Asserting value == 5 proves the total won. +#[tokio::test] +async fn test_get_execution_payload_bid_highest_total_payment_wins() -> Result<()> { + test_get_execution_payload_bid_impl( + vec![ + MockRelayState::new(Chain::Hoodi, random_secret()).with_trustless_bid_gwei(10), + MockRelayState::new(Chain::Hoodi, random_secret()) + .with_trustless_bid_gwei(5) + .with_trusted_bid_gwei(10), + ], + StatusCode::OK, + &[1, 1], + Some(5), + 10, + ) + .await +} + +/// `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(&[0xde, 0xad], 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(()) +} + +/// The caller's auth data designates the downstream: only the relay whose +/// `expected_auth_data` matches is contacted, and its bid is returned. +#[tokio::test] +async fn test_get_execution_payload_bid_demux_routes_by_auth_data() -> Result<()> { + let chain = Chain::Hoodi; + let data_a = [0xaa, 0x01]; + let data_b = [0xbb, 0x02]; + let (mock_validator, states) = setup_relays_with_auth_data(chain, vec![ + (MockRelayState::new(chain, random_secret()), &data_a), + (MockRelayState::new(chain, random_secret()), &data_b), + ]) + .await?; + + let auth = opaque_auth(&data_a, TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(states[0].received_execution_payload_bid(), 1); + assert_eq!(states[1].received_execution_payload_bid(), 0); + Ok(()) +} + +/// Auth data carrying a builder URL (raw UTF-8 bytes, the spec default) routes +/// to the relay whose configured URL matches, ignoring the entry's userinfo. +#[tokio::test] +async fn test_get_execution_payload_bid_demux_by_url_bytes() -> Result<()> { + let chain = Chain::Hoodi; + let mut relays = Vec::new(); + let mut states = Vec::new(); + let mut urls = Vec::new(); + for _ in 0..2 { + let (state, port) = spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + // No expected_auth_data: these relays are addressed by URL-carrying data + relays.push(generate_mock_relay_url_only(port, state.signer.public_key())?); + urls.push(format!("http://0.0.0.0:{port}/")); + states.push(state); + } + let mock_validator = setup_pbs(chain, relays, |_| {}).await?; + + // data = UTF-8 bytes of relay-0's URL + let auth = opaque_auth(urls[0].as_bytes(), TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(states[0].received_execution_payload_bid(), 1); + assert_eq!(states[1].received_execution_payload_bid(), 0); + + // URL bytes with a NUL-suffixed extra payload route the same way + let mut with_extra = urls[1].as_bytes().to_vec(); + with_extra.push(0); + with_extra.extend_from_slice(&[0xde, 0xad]); + let auth = opaque_auth(&with_extra, TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(states[0].received_execution_payload_bid(), 1); + assert_eq!(states[1].received_execution_payload_bid(), 1); + + // a URL matching no configured relay is a 400, nothing contacted + let auth = opaque_auth(b"https://unknown-builder.example:9999/", TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(states[0].received_execution_payload_bid(), 1); + assert_eq!(states[1].received_execution_payload_bid(), 1); + Ok(()) +} + +/// PIPE: auth data naming a builder URL outside CB's config dials it via a +/// transient client and returns its bid. The bid is signed by the builder's +/// own key, which no configured relay entry carries, and CB forwards it +/// unchanged (blind pipe: the ePBS path does not verify the bid signature). +#[tokio::test] +async fn test_get_execution_payload_bid_pipe_dials_unconfigured_builder() -> Result<()> { + // This test's mock builder binds to a local (unspecified/loopback) address the + // pipe's SSRF guard blocks; skip that check so the forward path is exercised. + cb_pbs::set_skip_pipe_target_check(true); + let chain = Chain::Hoodi; + // A configured relay, addressed by opaque bytes only + let (mock_validator, cfg_state) = setup_relay( + chain, + |cfg| cfg.advertised_urls = vec!["http://cb.self.example:18550".parse().unwrap()], + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &[0xaa]), + ) + .await?; + // The pipe builder runs but is NOT in CB's config + let (pipe_state, pipe_port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + + let pipe_url = format!("http://0.0.0.0:{pipe_port}/"); + let auth = opaque_auth(pipe_url.as_bytes(), TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(pipe_state.received_execution_payload_bid(), 1); + assert_eq!(cfg_state.received_execution_payload_bid(), 0, "only the piped builder is dialed"); + + // The pipe builder receives the auth verbatim and its bid comes back + // unchanged, still carrying its own signature + assert_eq!(pipe_state.received_auth_data(), Some(pipe_url.as_bytes().to_vec())); + let bid = serde_json::from_slice::(&res.bytes().await?)?; + let expected_sig = sign_execution_payload_bid_root( + &pipe_state.signer, + &bid.data.message.tree_hash_root(), + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ); + assert_eq!(bid.data.signature, expected_sig, "the piped bid must flow through unmodified"); + Ok(()) +} + +/// PIPE self-URL guard: auth data decoding to one of CB's `advertised_urls` +/// is a clean 400 and nothing is dialed - an unconfigured key's auth data +/// defaults to CB's own URL, which must not become a self-dial loop. +#[tokio::test] +async fn test_get_execution_payload_bid_pipe_self_url_not_dialed() -> Result<()> { + let chain = Chain::Hoodi; + // The mock stands in for whatever answers at CB's advertised URL: anything + // it receives means the guard failed and a dial went out + let (mock_state, port) = spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + let relay = generate_mock_relay_with_auth_data(port, mock_state.signer.public_key(), &[0xaa])?; + let self_url = format!("http://0.0.0.0:{port}/"); + let mock_validator = + setup_pbs(chain, vec![relay], |cfg| cfg.advertised_urls = vec![self_url.parse().unwrap()]) + .await?; + + let auth = opaque_auth(self_url.as_bytes(), TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "a self URL is never dialed"); + Ok(()) +} + +/// PIPE fail-closed: with `advertised_urls` unset the guard cannot verify the +/// URL is not CB's own, so an unmatched builder URL stays a 400 and nothing +/// is dialed - even though the named builder is alive. +#[tokio::test] +async fn test_get_execution_payload_bid_pipe_requires_advertised_urls() -> Result<()> { + let chain = Chain::Hoodi; + // get_pbs_config leaves advertised_urls empty + let (mock_validator, cfg_state) = setup_relay( + chain, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &[0xaa]), + ) + .await?; + let (pipe_state, pipe_port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + + let auth = opaque_auth(format!("http://0.0.0.0:{pipe_port}/").as_bytes(), TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(pipe_state.received_execution_payload_bid(), 0, "fail closed: no blind dial"); + assert_eq!(cfg_state.received_execution_payload_bid(), 0); + Ok(()) +} + +/// Auth data matching no configured relay is rejected with 400 and the spec +/// data-mismatch message; no relay is contacted. +#[tokio::test] +async fn test_get_execution_payload_bid_demux_no_match_400() -> Result<()> { + let (mock_validator, mock_state) = setup_relay( + Chain::Hoodi, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &[0xaa]), + ) + .await?; + + let auth = opaque_auth(&[0xbb], TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_execution_payload_bid(), 0); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert_eq!( + body["message"], + "Invalid SignedBuilderRequestAuth: auth.message.data does not match the value agreed with this builder" + ); + Ok(()) +} + +/// Opaque auth data matching no configured relay is a 400 with the builder's +/// data-mismatch message, and no relay receives anything: with auth data +/// required and unique per entry, CB has no builder to proxy to and answers as +/// a builder would. +#[tokio::test] +async fn test_get_execution_payload_bid_unmatched_opaque_auth_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay_url_only).await?; + + let auth = opaque_auth(&[0xcc], TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "no relay receives anything"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert_eq!( + body["message"], + "Invalid SignedBuilderRequestAuth: auth.message.data does not match the value agreed with this builder" + ); + Ok(()) +} + +/// An opaque (non-URL) auth body is forwarded to the relays verbatim. +#[tokio::test] +async fn test_get_execution_payload_bid_forwards_opaque_auth() -> Result<()> { + let data = vec![0xde, 0xad, 0xbe, 0xef]; + let (mock_validator, mock_state) = setup_relay( + Chain::Hoodi, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &data), + ) + .await?; + + let auth = opaque_auth(&data, TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + assert_eq!(mock_state.received_auth_data(), Some(data)); + Ok(()) +} + +/// The spec makes the auth body mandatory: a request without one is a 400 with +/// an ErrorMessage body, before any relay is queried. +#[tokio::test] +async fn test_get_execution_payload_bid_missing_auth_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, None, vec![ + EncodingType::Json, + ]) + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "no auth means no relay call"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert!( + body["message"].as_str().unwrap_or_default().contains("missing request body"), + "error body must name the missing body, got: {}", + body["message"] + ); + Ok(()) +} + +/// `auth.message.slot` must match the proposal slot in the request path. +#[tokio::test] +async fn test_get_execution_payload_bid_auth_slot_mismatch_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT + 1); + let res = 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(()) +} + +/// With `verify_builder_request_auth` on, a bad auth signature is a 401 and a +/// good one passes through to the relay. +#[tokio::test] +async fn test_get_execution_payload_bid_verify_builder_request_auth_enabled() -> Result<()> { + let secret_key = random_secret(); + let proposer_pubkey = secret_key.public_key(); + let (mock_validator, mock_state) = setup_relay( + Chain::Hoodi, + |cfg| cfg.verify_builder_request_auth = true, + generate_mock_relay, + ) + .await?; + + // An empty signature never verifies under DOMAIN_BUILDER_REQUEST_AUTH + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + Some(proposer_pubkey.clone()), + Some(&auth), + vec![EncodingType::Json], + ) + .await?; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "bad auth precedes relay calls"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 401); + assert_eq!(body["message"], "Invalid SignedBuilderRequestAuth: signature verification failed"); + + let auth = signed_auth(&secret_key, &[0xde, 0xad], TEST_SLOT, Chain::Hoodi); + let res = mock_validator + .do_get_execution_payload_bid( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + Some(proposer_pubkey), + Some(&auth), + vec![EncodingType::Json], + ) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + Ok(()) +} + +/// Both timing headers are required: the send time and the timeout are what +/// bound every downstream call, so a request missing either is a 400 and no +/// relay is contacted. +#[tokio::test] +async fn test_get_execution_payload_bid_missing_timing_headers_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + let body = opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes(); + + // no headers at all / only the send time / only the timeout / zero timeout + let cases: Vec> = vec![ + vec![], + vec![(HEADER_START_TIME_UNIX_MS, utcnow_ms().to_string())], + vec![(HEADER_TIMEOUT_MS, "1000".to_string())], + vec![ + (HEADER_START_TIME_UNIX_MS, utcnow_ms().to_string()), + (HEADER_TIMEOUT_MS, "0".to_string()), + ], + ]; + for headers in cases { + // Version header present throughout: only the timing headers vary + let mut req = mock_validator + .comm_boost + .client + .post(url.clone()) + .header("Eth-Consensus-Version", "gloas") + .body(body.clone()); + for (name, value) in &headers { + req = req.header(*name, value); + } + let res = req.send().await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "headers: {headers:?}"); + let json: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(json["code"], 400); + assert_eq!( + json["message"], + "Invalid request: Date-Milliseconds and X-Timeout-Ms headers are required" + ); + } + assert_eq!(mock_state.received_execution_payload_bid(), 0, "no relay call for a 400"); + Ok(()) +} + +/// A deadline that has already passed means there is no time to serve the +/// request: CB returns 204 rather than calling a relay it cannot beat. +#[tokio::test] +async fn test_get_execution_payload_bid_expired_deadline_204() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms() - 5_000) + .header(HEADER_TIMEOUT_MS, 1_000u64) + .header("Eth-Consensus-Version", "gloas") + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::NO_CONTENT); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "no relay call past the deadline"); + Ok(()) +} + +/// By default CB does not verify the auth signature: a bad one is forwarded to +/// the builder, which verifies it itself. +#[tokio::test] +async fn test_get_execution_payload_bid_bad_auth_signature_forwarded_by_default() -> Result<()> { + let proposer_pubkey = random_secret().public_key(); + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let data = vec![0xde, 0xad]; + let auth = opaque_auth(&data, TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + Some(proposer_pubkey), + Some(&auth), + vec![EncodingType::Json], + ) + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + // The spec requires message AND signature to reach the builder unchanged + let seen = mock_state.received_auth().expect("relay saw an auth"); + assert_eq!(seen.message.data.to_vec(), data); + assert_eq!(seen.message.slot, auth.message.slot); + assert_eq!(seen.signature, auth.signature, "signature must be forwarded byte-for-byte"); + Ok(()) +} + +#[tokio::test] +async fn test_get_execution_payload_bid_spec_url() -> Result<()> { + 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") + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + Ok(()) +} + +/// The bid response is served as SSZ when the caller sends +/// Accept: application/octet-stream, with Eth-Consensus-Version on the 200. +#[tokio::test] +async fn test_get_execution_payload_bid_ssz_response() -> Result<()> { + let (mock_validator, _) = setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![ + EncodingType::Ssz, + ]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + + let content_type = + res.headers().get(CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or_default(); + assert_eq!(content_type, EncodingType::Ssz.to_string(), "response must be SSZ"); + + let version = + res.headers().get("eth-consensus-version").and_then(|v| v.to_str().ok()).map(str::to_owned); + assert_eq!(version.as_deref(), Some("gloas"), "200 must set Eth-Consensus-Version"); + + let bid = SignedExecutionPayloadBid::from_ssz_bytes(&res.bytes().await?) + .expect("body must SSZ-decode to a SignedExecutionPayloadBid"); + assert_ne!(bid.message.block_hash.0, B256::ZERO); + assert_eq!(bid.message.slot.as_u64(), TEST_SLOT); + Ok(()) +} + +/// With NO Accept header the response defaults to SSZ (this endpoint is +/// SSZ-by-default): the 200 carries Content-Type: application/octet-stream and +/// the body SSZ-decodes to a SignedExecutionPayloadBid. Proves the +/// no-preference tiebreak is SSZ, not the legacy JSON default. +#[tokio::test] +async fn test_get_execution_payload_bid_no_accept_defaults_to_ssz() -> Result<()> { + let (mock_validator, _) = setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + // An empty accept vec makes MockValidator send NO Accept header at all. + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![]) + .await?; + assert_eq!(res.status(), StatusCode::OK); + + let content_type = + res.headers().get(CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or_default(); + assert_eq!(content_type, EncodingType::Ssz.to_string(), "no Accept header must default to SSZ"); + + let bid = SignedExecutionPayloadBid::from_ssz_bytes(&res.bytes().await?) + .expect("body must SSZ-decode to a SignedExecutionPayloadBid"); + assert_ne!(bid.message.block_hash.0, B256::ZERO); + assert_eq!(bid.message.slot.as_u64(), TEST_SLOT); + Ok(()) +} + +/// An explicit `Accept: application/json` is still obeyed even though the +/// endpoint defaults to SSZ when no preference is expressed. The 200 is JSON +/// and decodes to a GetExecutionPayloadBidResponse. The relay is JSON-only so +/// this also covers the JSON relay-leg decode path end to end. +#[tokio::test] +async fn test_get_execution_payload_bid_explicit_json_obeyed() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, _) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()).with_json_only_response(), + ]) + .await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::OK); + + let content_type = + res.headers().get(CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or_default(); + assert_eq!( + content_type, + EncodingType::Json.to_string(), + "explicit Accept: application/json must be obeyed over the SSZ default" + ); + + let decoded = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!(decoded.slot(), TEST_SLOT); + assert_ne!(decoded.block_hash(), B256::ZERO); + Ok(()) +} + +/// End-to-end outbound SSZ decode: the relay serves the bid as SSZ (with the +/// Eth-Consensus-Version header), PBS decodes it on the relay leg and returns +/// 200 with the correct bid. A decode failure would drop the only relay and +/// yield 204, so a 200 with matching fields proves the SSZ relay-response +/// decode path actually ran. +#[tokio::test] +async fn test_get_execution_payload_bid_relay_ssz_response_roundtrip() -> Result<()> { + // Relay serves ONLY SSZ, so PBS must decode the SSZ bid on the relay leg. + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()) + .with_ssz_only_response() + .with_trustless_bid_gwei(42), + ]) + .await?; + let mock_state = &states[0]; + + // BN asks for JSON; PBS decodes SSZ from the relay and re-encodes to JSON. + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + + let decoded = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!(decoded.slot(), TEST_SLOT); + assert_eq!(decoded.value(), 42, "bid value must survive the SSZ relay-leg round-trip"); + assert_ne!(decoded.block_hash(), B256::ZERO); + Ok(()) +} + +/// The relay serves an SSZ bid 200 WITHOUT the Eth-Consensus-Version header. +/// PBS cannot decode the (non-self-describing) SSZ without the fork, so it +/// surfaces a clean PbsError and drops the bid rather than returning a bogus +/// 200 or panicking. With a single relay this drop yields a 204 to the BN. +#[tokio::test] +async fn test_get_execution_payload_bid_relay_ssz_missing_version_header() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()) + .with_ssz_only_response() + .with_epbs_omit_consensus_version(), + ]) + .await?; + let mock_state = &states[0]; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![]) + .await?; + // The relay was contacted, but its undecodable SSZ bid was dropped. + assert_eq!(mock_state.received_execution_payload_bid(), 1); + // Never a bogus 200; the response exists (no panic). Single dropped relay -> + // 204. + assert_ne!(res.status(), StatusCode::OK, "an undecodable SSZ bid must not yield 200"); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + Ok(()) +} + +/// A relay that labels its bid with a non-Gloas fork on this Gloas-only +/// endpoint is a bad relay response: the bid is dropped, never forwarded to +/// the BN under the bogus fork. Single relay -> 204. +#[tokio::test] +async fn test_get_execution_payload_bid_relay_wrong_fork_dropped() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()).with_ssz_only_response().with_epbs_wrong_fork(), + ]) + .await?; + let mock_state = &states[0]; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![]) + .await?; + // The relay was contacted and served, so the 204 below proves the DROP, + // not an empty auction + assert_eq!(mock_state.received_execution_payload_bid(), 1); + assert_ne!(res.status(), StatusCode::OK, "a capella-labelled bid must not become CB's 200"); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + Ok(()) +} + +/// Same drop on the JSON path, where `version` comes from the relay's BODY +/// rather than the header - the two paths derive it differently, so both need +/// pinning. A bad relay response: the bid is dropped, never forwarded to +/// the BN under the bogus fork. Single relay -> 204. +#[tokio::test] +async fn test_get_execution_payload_bid_relay_wrong_fork_json_dropped() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()) + .with_json_only_response() + .with_epbs_wrong_fork(), + ]) + .await?; + let mock_state = &states[0]; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid(TEST_SLOT, B256::ZERO, B256::ZERO, None, Some(&auth), vec![]) + .await?; + // The relay was contacted and served, so the 204 below proves the DROP, + // not an empty auction + assert_eq!(mock_state.received_execution_payload_bid(), 1); + assert_ne!(res.status(), StatusCode::OK, "a capella-labelled bid must not become CB's 200"); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + Ok(()) +} + +/// An unsupported Accept type is rejected with 406 before any relay is queried +/// (a typed error, not a 500). +#[tokio::test] +async fn test_get_execution_payload_bid_unsupported_accept_406() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let url = bid_url(&mock_validator); + let res = mock_validator + .comm_boost + .client + .post(url) + .header("accept", "application/xml") + .header("Eth-Consensus-Version", "gloas") + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(mock_state.received_execution_payload_bid(), 0, "406 short-circuits before relays"); + Ok(()) +} + +/// An SSZ-encoded auth body (application/octet-stream) is decoded and forwarded +/// to the relay, same as JSON. +#[tokio::test] +async fn test_get_execution_payload_bid_ssz_auth_forwarded() -> Result<()> { + let data = vec![0xde, 0xad, 0xbe, 0xef]; + let (mock_validator, mock_state) = setup_relay( + Chain::Hoodi, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &data), + ) + .await?; + + let ssz_body = opaque_auth(&data, TEST_SLOT).as_ssz_bytes(); + let url = bid_url(&mock_validator); + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/octet-stream") + .header("Eth-Consensus-Version", "gloas") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(ssz_body) + .send() + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_auth_data(), Some(data), "relay must receive the decoded auth"); + Ok(()) +} + +/// A present-but-malformed auth body is rejected with 400 and an ErrorMessage +/// JSON body, before any relay is queried. +#[tokio::test] +async fn test_get_execution_payload_bid_malformed_auth_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let url = bid_url(&mock_validator); + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/json") + .body(vec![0xff, 0x00, 0x99]) + .send() + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!( + mock_state.received_execution_payload_bid(), + 0, + "malformed auth rejected before relays" + ); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert!( + body["message"].as_str().unwrap_or_default().contains("decoding"), + "error body must be an ErrorMessage describing the decode failure" + ); + Ok(()) +} + +/// Boot PBS in front of a single timing-games relay driven by `mock_state`, so +/// a test can observe the bid poll ladder from the builder's side. +async fn setup_timing_games_relay( + mock_state: MockRelayState, + frequency_get_header_ms: u64, + bid_poll_timeout_ms: Option, +) -> Result<(MockValidator, Arc)> { + let (mock_state, port) = spawn_mock_relay(mock_state).await?; + let relay = generate_mock_relay_with_timing_games( + port, + mock_state.signer.public_key(), + frequency_get_header_ms, + bid_poll_timeout_ms, + )?; + Ok((setup_pbs(Chain::Hoodi, vec![relay], |_| {}).await?, mock_state)) +} + +/// Request a bid advertising `budget_ms` as the proposer's `X-Timeout-Ms`. +async fn get_bid_with_budget( + mock_validator: &MockValidator, + budget_ms: u64, +) -> Result { + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + mock_validator + .do_get_execution_payload_bid_with_timeout( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + None, + Some(&auth), + vec![EncodingType::Json], + budget_ms, + ) + .await +} + +/// No poll may promise the builder more time than the shared deadline still has +/// left when it is sent. Poll `i` goes out one cadence step after poll `i - 1`, +/// so the budget left for it is at most `budget_ms - i * frequency_ms`. +fn assert_no_poll_overspends(timeouts: &[u64], budget_ms: u64, frequency_ms: u64) { + for (i, timeout) in timeouts.iter().enumerate() { + assert!( + timeout + i as u64 * frequency_ms <= budget_ms, + "poll {i} promised more than the deadline had left: {timeouts:?}" + ); + } +} + +/// The ladder's shape: with timing games on, a known cadence and a generous +/// deadline, every poll but the last carries the bounded `bid_poll_timeout_ms` +/// and the last one carries the whole remaining budget. +#[tokio::test] +async fn test_get_execution_payload_bid_ladder_timeout_shape() -> Result<()> { + const FREQ_MS: u64 = 500; + const POLL_TIMEOUT_MS: u64 = 100; + const BUDGET_MS: u64 = 2_000; + + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK); + + // The exact poll count is driven by real cadence sleeps, which only ever + // overrun under load, so assert the ladder SHAPE rather than a fixed count: + // every rung but the last is bounded by bid_poll_timeout_ms, and the last + // holds longer for the remaining budget. No poll may overspend the deadline. + let timeouts = mock_state.received_bid_timeouts(); + assert!( + timeouts.len() >= 2, + "the ladder must fire bounded rungs plus a final poll, got {timeouts:?}" + ); + let (last, bounded) = timeouts.split_last().unwrap(); + assert!( + bounded.iter().all(|timeout| *timeout == POLL_TIMEOUT_MS), + "every poll but the last must be bounded by bid_poll_timeout_ms: {timeouts:?}" + ); + assert!( + *last > POLL_TIMEOUT_MS, + "the last poll must hold longer than a bounded rung for the remaining budget, got {last}" + ); + assert_no_poll_overspends(&timeouts, BUDGET_MS, FREQ_MS); + + // A sleep never returns early, so arrivals only drift later; 10ms covers ms + // rounding + let arrivals = mock_state.received_bid_arrivals_ms(); + for pair in arrivals.windows(2) { + assert!( + pair[1].saturating_sub(pair[0]) + 10 >= FREQ_MS, + "polls must be spaced by the configured cadence: {arrivals:?}" + ); + } + Ok(()) +} + +/// Best-of across the whole ladder: with a builder improving its bid on every +/// poll, the returned bid is the LAST poll's, proving `select_max_bid` spans +/// every rung instead of returning the first one that landed. +#[tokio::test] +async fn test_get_execution_payload_bid_ladder_returns_best_poll() -> Result<()> { + const FREQ_MS: u64 = 400; + const POLL_TIMEOUT_MS: u64 = 300; + const BUDGET_MS: u64 = 1_200; + const STEP_GWEI: u64 = 7; + const BASE_GWEI: u64 = 10; + + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()) + .with_trustless_bid_gwei(BASE_GWEI) + .with_improving_bids(STEP_GWEI); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK); + + // This builder answers instantly, so every rung lands a bid and the last is the + // best + let polls = mock_state.received_execution_payload_bid(); + assert!(polls > 1, "the ladder must have fired more than one poll, got {polls}"); + + let decoded = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!( + decoded.value(), + BASE_GWEI + STEP_GWEI * (polls - 1), + "the winner must be the last poll's bid, not the first one to land" + ); + Ok(()) +} + +/// A builder that holds every request longer than `bid_poll_timeout_ms` times +/// out the early rungs, but the last poll holds for the full remainder, so the +/// run still yields a bid. +#[tokio::test] +async fn test_get_execution_payload_bid_ladder_slow_builder_still_bids() -> Result<()> { + const FREQ_MS: u64 = 500; + const POLL_TIMEOUT_MS: u64 = 100; + const DELAY_MS: u64 = 150; + const BUDGET_MS: u64 = 1_500; + + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()).with_bid_delay_ms(DELAY_MS); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK, "the last poll outlasts the builder's delay"); + + // Cadence sleeps only overrun under load, so the count can dip; assert the + // ladder shape (bounded early rungs plus a final poll that outlasts the + // builder's delay) rather than a fixed count. + let timeouts = mock_state.received_bid_timeouts(); + assert!( + timeouts.len() >= 2, + "the ladder must fire early rungs plus a final poll, got {timeouts:?}" + ); + let (last, early) = timeouts.split_last().unwrap(); + assert!( + early.iter().all(|timeout| *timeout < DELAY_MS), + "the early polls must expire before this builder answers: {timeouts:?}" + ); + assert!(*last > DELAY_MS, "the last poll must outlast the builder's delay, got {last}"); + assert_no_poll_overspends(&timeouts, BUDGET_MS, FREQ_MS); + Ok(()) +} + +/// The converse that motivates the ladder: when the builder answers inside +/// `bid_poll_timeout_ms`, the early rungs land bids in hand well before the +/// deadline instead of every poll being staked on the final instant. +#[tokio::test] +async fn test_get_execution_payload_bid_ladder_early_polls_land_bids() -> Result<()> { + const FREQ_MS: u64 = 400; + const POLL_TIMEOUT_MS: u64 = 300; + const DELAY_MS: u64 = 100; + const BUDGET_MS: u64 = 1_500; + + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()).with_bid_delay_ms(DELAY_MS); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK); + + // Each early poll has 200ms of slack over the builder's delay + let served = mock_state.served_execution_payload_bid(); + assert!(served > 1, "the early rungs must land bids, only {served} poll(s) were answered"); + let timeouts = mock_state.received_bid_timeouts(); + assert!( + timeouts.split_last().unwrap().1.iter().all(|timeout| *timeout > DELAY_MS), + "the early polls must outlast this builder's delay: {timeouts:?}" + ); + Ok(()) +} + +/// The proposer's deadline, not the cadence alone, sizes the ladder: a small +/// `X-Timeout-Ms` buys fewer polls than a large one, and no poll ever promises +/// more than the budget it was cut from. +#[tokio::test] +async fn test_get_execution_payload_bid_deadline_clamps_ladder() -> Result<()> { + const FREQ_MS: u64 = 200; + const POLL_TIMEOUT_MS: u64 = 100; + const SMALL_BUDGET_MS: u64 = 400; + const LARGE_BUDGET_MS: u64 = 1_600; + + let mut polls = Vec::new(); + for budget_ms in [SMALL_BUDGET_MS, LARGE_BUDGET_MS] { + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, budget_ms).await?; + assert_eq!(res.status(), StatusCode::OK, "budget {budget_ms}"); + + let timeouts = mock_state.received_bid_timeouts(); + assert_no_poll_overspends(&timeouts, budget_ms, FREQ_MS); + polls.push(timeouts); + } + + // 400ms of budget buys the first poll plus a last one for the remainder + assert_eq!(polls[0].len(), 2, "a tight deadline must cut the ladder short: {:?}", polls[0]); + assert!( + polls[1].len() > polls[0].len(), + "a larger deadline must buy more polls: {:?} vs {:?}", + polls[1], + polls[0] + ); + Ok(()) +} + +/// Degradation to the pre-ladder behavior: a budget shorter than +/// `bid_poll_timeout_ms` leaves no room to bound anything, so CB sends exactly +/// one poll carrying the whole budget. +#[tokio::test] +async fn test_get_execution_payload_bid_short_budget_single_poll() -> Result<()> { + const FREQ_MS: u64 = 1_000; + const BUDGET_MS: u64 = 300; + + // Default bid_poll_timeout_ms, which is larger than the whole budget here + const _: () = assert!(DEFAULT_BID_POLL_TIMEOUT_MS > BUDGET_MS); + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()); + let (mock_validator, mock_state) = setup_timing_games_relay(mock_state, FREQ_MS, None).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK); + + let timeouts = mock_state.received_bid_timeouts(); + assert_eq!(timeouts.len(), 1, "a budget under one cadence step is a single poll: {timeouts:?}"); + // The single poll gets the budget minus transit; 150ms of slack for transit + assert!( + timeouts[0] <= BUDGET_MS && timeouts[0] >= 150, + "the only poll must carry the whole budget, got {}", + timeouts[0] + ); + Ok(()) +} + +/// A relay's `bid_poll_timeout_ms` overrides the default bound on every poll +/// but the last. +#[tokio::test] +async fn test_get_execution_payload_bid_poll_timeout_override() -> Result<()> { + const FREQ_MS: u64 = 600; + const BUDGET_MS: u64 = 2_000; + const CUSTOM_POLL_TIMEOUT_MS: u64 = 150; + + for (configured, expected) in [ + (None, DEFAULT_BID_POLL_TIMEOUT_MS), + (Some(CUSTOM_POLL_TIMEOUT_MS), CUSTOM_POLL_TIMEOUT_MS), + ] { + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, configured).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::OK, "configured={configured:?}"); + + let timeouts = mock_state.received_bid_timeouts(); + assert!(timeouts.len() > 1, "configured={configured:?}, got {timeouts:?}"); + assert!( + timeouts.split_last().unwrap().1.iter().all(|timeout| *timeout == expected), + "configured={configured:?} must bound the early polls at {expected}: {timeouts:?}" + ); + } + Ok(()) +} + +/// A relay with nothing to offer answers 204 on every poll. That is an answer, +/// not a failure, so the ladder must surface it exactly like the single-request +/// path does rather than reporting the relay as timed out. +#[tokio::test] +async fn test_get_execution_payload_bid_ladder_no_bid_is_204() -> Result<()> { + const FREQ_MS: u64 = 300; + const POLL_TIMEOUT_MS: u64 = 200; + const BUDGET_MS: u64 = 1_000; + + let mock_state = MockRelayState::new(Chain::Hoodi, random_secret()).with_no_epbs_bid(); + let (mock_validator, mock_state) = + setup_timing_games_relay(mock_state, FREQ_MS, Some(POLL_TIMEOUT_MS)).await?; + + let res = get_bid_with_budget(&mock_validator, BUDGET_MS).await?; + assert_eq!(res.status(), StatusCode::NO_CONTENT, "a 204 from every poll must stay a 204"); + + let polls = mock_state.received_execution_payload_bid(); + assert!(polls > 1, "the ladder must have polled more than once, got {polls}"); + Ok(()) +} + +/// One of two relays errors on the bid endpoint; the other still serves a valid +/// bid, so the request is a 200 carrying the surviving relay's bid. Guards +/// against a partial failure aborting the join or poisoning `select_max_bid`. +#[tokio::test] +async fn test_get_execution_payload_bid_one_relay_fails_other_wins_200() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + // The first relay errors; the second serves the default valid bid + states[0].set_response_override(StatusCode::INTERNAL_SERVER_ERROR); + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::OK, "a surviving relay still wins the auction"); + + let decoded = serde_json::from_slice::(&res.bytes().await?)?; + let object_root = decoded.data.message.tree_hash_root(); + assert_eq!( + decoded.data.signature, + sign_execution_payload_bid_root( + &states[1].signer, + &object_root, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ), + "the winning bid must be the surviving relay's" + ); + assert_ne!( + decoded.data.signature, + sign_execution_payload_bid_root( + &states[0].signer, + &object_root, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ), + "the failed relay must not have won" + ); + Ok(()) +} + +/// Every relay erroring on the bid endpoint degrades to 204 (no bid), never a +/// 502: this endpoint has no bad-gateway path, a dead or erroring relay simply +/// contributes no bid. This is the documented contract. +#[tokio::test] +async fn test_get_execution_payload_bid_all_relays_fail_204_not_502() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + for state in &states { + state.set_response_override(StatusCode::INTERNAL_SERVER_ERROR); + } + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!( + res.status(), + StatusCode::NO_CONTENT, + "all relays erroring is a no-bid 204, not a 502" + ); + assert_eq!(states[0].received_execution_payload_bid(), 1, "every relay is asked"); + assert_eq!(states[1].received_execution_payload_bid(), 1, "every relay is asked"); + Ok(()) +} + +/// An unsupported request `Content-Type` is a 415 before any relay is queried, +/// mirroring the preferences endpoint. +#[tokio::test] +async fn test_get_execution_payload_bid_unsupported_content_type_415() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "text/plain") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!( + mock_state.received_execution_payload_bid(), + 0, + "415 short-circuits before any relay call" + ); + Ok(()) +} + +/// An SSZ auth body missing `Eth-Consensus-Version` is a 400: builder-specs +/// fork-versions the request wire type, so the header is required to accept the +/// SSZ form (and the same request with the header is served). +#[tokio::test] +async fn test_get_execution_payload_bid_ssz_missing_version_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + // Note: SSZ Content-Type but no Eth-Consensus-Version + let res = mock_validator + .comm_boost + .client + .post(url.clone()) + .header(CONTENT_TYPE, "application/octet-stream") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!( + mock_state.received_execution_payload_bid(), + 0, + "an undecodable request must not forward" + ); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + + // The identical request carrying the literal spec header is served + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/octet-stream") + .header("Eth-Consensus-Version", "gloas") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(opaque_auth(&[0xde, 0xad], TEST_SLOT).as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(mock_state.received_execution_payload_bid(), 1); + Ok(()) +} + +/// builder-specs marks `Eth-Consensus-Version` required for JSON and SSZ alike +/// on this endpoint (builder-specs #165): a JSON auth body without it is a +/// 400, superseding the earlier best-effort policy. +#[tokio::test] +async fn test_get_execution_payload_bid_json_no_version_400() -> Result<()> { + let (mock_validator, mock_state) = + setup_relay(Chain::Hoodi, |_| {}, generate_mock_relay).await?; + + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + // No Eth-Consensus-Version: required regardless of encoding + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/json") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(serde_json::to_vec(&opaque_auth(&[0xde, 0xad], TEST_SLOT))?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("missing consensus version"), + "the 400 must name the missing header: {body}" + ); + + // A deprecated fork name (outside the gloas-only window) is also a 400, + // and the error echoes the VALUE rather than claiming the header is missing + let url = mock_validator.comm_boost.get_execution_payload_bid_url( + TEST_SLOT, + &B256::ZERO, + &B256::ZERO, + &random_secret().public_key(), + )?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/json") + .header(CONSENSUS_VERSION_HEADER, "electra") + .header(HEADER_START_TIME_UNIX_MS, utcnow_ms()) + .header(HEADER_TIMEOUT_MS, 60_000u64) + .body(serde_json::to_vec(&opaque_auth(&[0xde, 0xad], TEST_SLOT))?) + .send() + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("electra"), + "the 400 must echo the unsupported value: {body}" + ); + // Rejected at the wire layer: the request never reaches a relay + assert_eq!(mock_state.received_execution_payload_bid(), 0); + Ok(()) +} + +/// Without timing games, a relay slower than the proposer's `X-Timeout-Ms` is +/// dropped on the single-poll `send_one_*` path, so the request degrades to 204 +/// rather than waiting the relay out. +#[tokio::test] +async fn test_get_execution_payload_bid_slow_relay_times_out_204() -> Result<()> { + const DELAY_MS: u64 = 600; + const BUDGET_MS: u64 = 200; + + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()).with_bid_delay_ms(DELAY_MS), + ]) + .await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = mock_validator + .do_get_execution_payload_bid_with_timeout( + TEST_SLOT, + B256::ZERO, + B256::ZERO, + None, + Some(&auth), + vec![EncodingType::Json], + BUDGET_MS, + ) + .await?; + assert_eq!( + res.status(), + StatusCode::NO_CONTENT, + "a relay slower than the deadline is dropped, not awaited" + ); + assert_eq!(states[0].received_execution_payload_bid(), 1, "the relay was still contacted"); + Ok(()) +} + +/// A per-relay `max_execution_payment_gwei` cap clamps that relay's bids in +/// RANKING: a bid with a huge execution payment behind a low cap ranks at +/// `value + cap` and loses to a moderate honest bid, matching the BN's +/// clamped valuation (beacon-APIs #630). Without the per-relay cap the same +/// over-claimed bid wins on its full total, isolating the clamp as the cause. +#[tokio::test] +async fn test_get_execution_payload_bid_per_relay_cap_clamps_ranking() -> Result<()> { + const RELAY_CAP_GWEI: u64 = 5; + const OVERCLAIMED_TRUSTED_GWEI: u64 = 1_000; + const OVERCLAIMER_TRUSTLESS_GWEI: u64 = 5; + const HONEST_TRUSTLESS_GWEI: u64 = 20; + + // (overclaimer's per-relay cap, expected winning trustless value) + for (relay_cap, expected_value) in [ + // unclamped: 5 + 1000 beats 20 + (None, OVERCLAIMER_TRUSTLESS_GWEI), + // clamped: 5 + min(1000, 5) = 10 loses to 20 + (Some(RELAY_CAP_GWEI), HONEST_TRUSTLESS_GWEI), + ] { + let chain = Chain::Hoodi; + let (overclaimer_state, overclaimer_port) = spawn_mock_relay( + MockRelayState::new(chain, random_secret()) + .with_trustless_bid_gwei(OVERCLAIMER_TRUSTLESS_GWEI) + .with_trusted_bid_gwei(OVERCLAIMED_TRUSTED_GWEI), + ) + .await?; + let overclaimer_pubkey = overclaimer_state.signer.public_key(); + let overclaimer_relay = match relay_cap { + Some(cap) => { + generate_mock_relay_with_max_payment(overclaimer_port, overclaimer_pubkey, cap)? + } + None => generate_mock_relay(overclaimer_port, overclaimer_pubkey)?, + }; + let (honest_state, honest_port) = spawn_mock_relay( + MockRelayState::new(chain, random_secret()) + .with_trustless_bid_gwei(HONEST_TRUSTLESS_GWEI), + ) + .await?; + let honest_relay = generate_mock_relay(honest_port, honest_state.signer.public_key())?; + let mock_validator = + setup_pbs(chain, vec![overclaimer_relay, honest_relay], |_| {}).await?; + + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), StatusCode::OK, "relay_cap={relay_cap:?}"); + assert_eq!(overclaimer_state.received_execution_payload_bid(), 1); + assert_eq!(honest_state.received_execution_payload_bid(), 1); + + let res = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!(res.value(), expected_value, "relay_cap={relay_cap:?}"); + } + Ok(()) +} + +async fn test_get_execution_payload_bid_impl( + relay_states: Vec, + expected_code: StatusCode, + expected_relay_counts: &[u64], + expected_value: Option, + max_execution_payment_gwei: u64, +) -> Result<()> { + test_get_execution_payload_bid_impl_opts( + relay_states, + expected_code, + expected_relay_counts, + expected_value, + max_execution_payment_gwei, + true, + ) + .await +} + +async fn test_get_execution_payload_bid_impl_opts( + relay_states: Vec, + expected_code: StatusCode, + expected_relay_counts: &[u64], + expected_value: Option, + max_execution_payment_gwei: u64, + require_relay_signature: bool, +) -> Result<()> { + let chain = Chain::Hoodi; + let mut relays = Vec::new(); + let mut states = Vec::new(); + for state in relay_states { + let (state, port) = spawn_mock_relay(state).await?; + relays.push(generate_mock_relay(port, state.signer.public_key())?); + states.push(state); + } + let mock_validator = setup_pbs(chain, relays, |pbs_config| { + pbs_config.max_execution_payment_gwei = Some(max_execution_payment_gwei) + }) + .await?; + + info!("Sending get execution payload bid"); + let auth = opaque_auth(&[0xde, 0xad], TEST_SLOT); + let res = get_json_bid(&mock_validator, &auth).await?; + assert_eq!(res.status(), expected_code); + for (state, expected) in states.iter().zip(expected_relay_counts) { + assert_eq!(state.received_execution_payload_bid(), *expected); + } + if expected_code != StatusCode::OK { + return Ok(()); + } + + // Eth-Consensus-Version required on 200 + let version_header = + res.headers().get("eth-consensus-version").and_then(|v| v.to_str().ok()).map(str::to_owned); + assert_eq!( + version_header.as_deref(), + Some("gloas"), + "200 response must set Eth-Consensus-Version: gloas" + ); + + // Get the data + let res = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!(res.version.to_string(), "gloas"); + assert_eq!(res.slot(), TEST_SLOT); + assert_eq!(res.parent_hash(), B256::ZERO); + assert_eq!(res.parent_root(), B256::ZERO); + assert_ne!(res.block_hash(), B256::ZERO); + if let Some(expected_value) = expected_value { + assert_eq!(res.value(), expected_value); + } + // The winning bid must be signed by one of the configured relays, unless the + // case deliberately forwards a mis-signed bid (blind pipe: CB does not verify + // the bid signature; the beacon node does). + if require_relay_signature { + let object_root = res.data.message.tree_hash_root(); + assert!( + states.iter().any(|s| sign_execution_payload_bid_root( + &s.signer, + &object_root, + GLOAS_FORK_VERSION, + GENESIS_VALIDATORS_ROOT.into(), + ) == res.data.signature), + "bid signature does not match any configured relay" + ); + } + Ok(()) +} 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 cd2ab51d..68439e71 100644 --- a/tests/tests/pbs_get_status.rs +++ b/tests/tests/pbs_get_status.rs @@ -3,9 +3,11 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; use cb_common::{signer::random_secret, types::Chain}; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, + utils::{ + generate_mock_relay, get_free_listener, get_pbs_config, setup_test_env, to_pbs_config, + }, }; use eyre::Result; use reqwest::StatusCode; @@ -18,21 +20,24 @@ async fn test_get_status() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3500; - let relay_0_port = pbs_port + 1; - let relay_1_port = pbs_port + 2; + let pbs_listener = get_free_listener().await; + let relay_0_listener = get_free_listener().await; + let relay_1_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_0_port = relay_0_listener.local_addr()?.port(); + let relay_1_port = relay_1_listener.local_addr()?.port(); let relays = vec![ generate_mock_relay(relay_0_port, pubkey.clone())?, generate_mock_relay(relay_1_port, pubkey)?, ]; let mock_state = Arc::new(MockRelayState::new(chain, signer)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_0_port)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_1_port)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_0_listener)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_1_listener)); let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -54,18 +59,21 @@ async fn test_get_status_returns_502_if_relay_down() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3600; - let relay_port = pbs_port + 1; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr()?.port(); + let relay_port = relay_listener.local_addr()?.port(); + // Release the relay's port so nothing listens on it: the relay is down + drop(relay_listener); let relays = vec![generate_mock_relay(relay_port, pubkey)?]; let mock_state = Arc::new(MockRelayState::new(chain, signer)); // Don't start the relay - // tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config, PathBuf::new()); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index a34e5f86..dc84822f 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -39,8 +39,7 @@ async fn test_ssv_public_network_fetch() -> Result<()> { // Start the mock server let listener = get_free_listener().await; let port = listener.local_addr().unwrap().port(); - drop(listener); - let server_handle = create_mock_public_ssv_server(port, None).await?; + let server_handle = create_mock_public_ssv_server(listener, None).await?; let url = Url::parse(&format!("http://localhost:{port}/api/v4/test_chain/validators/in_operator/1")) .unwrap(); @@ -79,9 +78,8 @@ async fn test_ssv_network_fetch_big_data() -> Result<()> { // Start the mock server let listener = get_free_listener().await; let port = listener.local_addr().unwrap().port(); - drop(listener); let server_handle = - cb_tests::mock_ssv_public::create_mock_public_ssv_server(port, None).await?; + cb_tests::mock_ssv_public::create_mock_public_ssv_server(listener, None).await?; let url = Url::parse(&format!("http://localhost:{port}/big_data")).unwrap(); let response = request_ssv_pubkeys_from_public_api(url.clone(), Duration::from_secs(120)).await; @@ -113,12 +111,11 @@ async fn test_ssv_network_fetch_timeout() -> Result<()> { // Start the mock server let listener = get_free_listener().await; let port = listener.local_addr().unwrap().port(); - drop(listener); let state = PublicSsvMockState { validators: Arc::new(RwLock::new(vec![])), force_timeout: Arc::new(RwLock::new(true)), }; - let server_handle = create_mock_public_ssv_server(port, Some(state)).await?; + let server_handle = create_mock_public_ssv_server(listener, Some(state)).await?; let url = Url::parse(&format!("http://localhost:{port}/api/v4/test_chain/validators/in_operator/1")) .unwrap(); @@ -144,9 +141,8 @@ async fn test_ssv_network_fetch_big_data_without_content_length() -> Result<()> // Start the mock server let listener = get_free_listener().await; let port = listener.local_addr().unwrap().port(); - drop(listener); set_ignore_content_length(true); - let server_handle = create_mock_public_ssv_server(port, None).await?; + let server_handle = create_mock_public_ssv_server(listener, None).await?; let url = Url::parse(&format!("http://localhost:{port}/big_data")).unwrap(); let response = request_ssv_pubkeys_from_public_api(url.clone(), Duration::from_secs(120)).await; @@ -178,8 +174,7 @@ async fn test_ssv_node_network_fetch() -> Result<()> { // Start the mock server let listener = get_free_listener().await; let port = listener.local_addr().unwrap().port(); - drop(listener); - let _server_handle = create_mock_ssv_node_server(port, None).await?; + let _server_handle = create_mock_ssv_node_server(listener, None).await?; let url = Url::parse(&format!("http://localhost:{port}/v1/validators")).unwrap(); let response = request_ssv_pubkeys_from_ssv_node( url, @@ -248,8 +243,7 @@ async fn test_mux() -> Result<()> { // Run PBS service let state = PbsState::new(config, PathBuf::new()); - drop(pbs_listener); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::spawn(PbsService::run_with_listener::<(), DefaultBuilderApi>(state, pbs_listener)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; @@ -337,10 +331,6 @@ async fn test_ssv_multi_with_node() -> Result<()> { let ssv_node_port = ssv_node_listener.local_addr().unwrap().port(); let ssv_public_port = ssv_public_listener.local_addr().unwrap().port(); let relay_port = relay_listener.local_addr().unwrap().port(); - // Drop SSV node + public listeners because their mock server helpers bind the - // port themselves. - drop(ssv_node_listener); - drop(ssv_public_listener); // Start the mock SSV node let ssv_node_url = Url::parse(&format!("http://localhost:{ssv_node_port}/v1/"))?; @@ -352,7 +342,7 @@ async fn test_ssv_multi_with_node() -> Result<()> { force_timeout: Arc::new(RwLock::new(false)), }; let ssv_node_handle = - create_mock_ssv_node_server(ssv_node_port, Some(mock_ssv_node_state.clone())).await?; + create_mock_ssv_node_server(ssv_node_listener, Some(mock_ssv_node_state.clone())).await?; // Start the mock SSV public API let ssv_public_url = Url::parse(&format!("http://localhost:{ssv_public_port}/api/v4/"))?; @@ -361,7 +351,8 @@ async fn test_ssv_multi_with_node() -> Result<()> { force_timeout: Arc::new(RwLock::new(false)), }; let ssv_public_handle = - create_mock_public_ssv_server(ssv_public_port, Some(mock_ssv_public_state.clone())).await?; + create_mock_public_ssv_server(ssv_public_listener, Some(mock_ssv_public_state.clone())) + .await?; // Start a mock relay to be used by the mux let relay = generate_mock_relay(relay_port, pubkey.clone())?; @@ -386,6 +377,10 @@ async fn test_ssv_multi_with_node() -> Result<()> { relays: vec![(*relay.config).clone()], timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], + builder_boost_factor: None, + min_bid_wei: None, + builder_boost_factor_p2p: None, + min_bid_p2p_wei: None, }], }; @@ -403,8 +398,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 +444,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 +462,8 @@ async fn test_ssv_multi_with_public() -> Result<()> { force_timeout: Arc::new(RwLock::new(false)), }; let ssv_public_handle = - create_mock_public_ssv_server(ssv_public_port, Some(mock_ssv_public_state.clone())).await?; + create_mock_public_ssv_server(ssv_public_listener, Some(mock_ssv_public_state.clone())) + .await?; // Start a mock relay to be used by the mux let relay = generate_mock_relay(relay_port, pubkey.clone())?; @@ -494,6 +488,10 @@ async fn test_ssv_multi_with_public() -> Result<()> { relays: vec![(*relay.config).clone()], timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], + builder_boost_factor: None, + min_bid_wei: None, + builder_boost_factor_p2p: None, + min_bid_p2p_wei: None, }], }; @@ -511,8 +509,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..f087bb1d 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -47,8 +47,6 @@ async fn test_auto_refresh() -> Result<()> { let ssv_api_port = ssv_api_listener.local_addr().unwrap().port(); let default_relay_port = default_relay_listener.local_addr().unwrap().port(); let mux_relay_port = mux_relay_listener.local_addr().unwrap().port(); - // create_mock_public_ssv_server binds the port itself. - drop(ssv_api_listener); // Start the mock SSV API server // Intentionally missing a trailing slash to ensure this is handled properly @@ -60,7 +58,7 @@ async fn test_auto_refresh() -> Result<()> { force_timeout: Arc::new(RwLock::new(false)), }; let ssv_server_handle = - create_mock_public_ssv_server(ssv_api_port, Some(mock_ssv_state.clone())).await?; + create_mock_public_ssv_server(ssv_api_listener, Some(mock_ssv_state.clone())).await?; // Start a default relay for non-mux keys let default_relay = generate_mock_relay(default_relay_port, default_pubkey.clone())?; @@ -95,6 +93,10 @@ async fn test_auto_refresh() -> Result<()> { relays: vec![(*mux_relay.config).clone()], timeout_get_header_ms: Some(u64::MAX - 1), validator_pubkeys: vec![], + builder_boost_factor: None, + min_bid_wei: None, + builder_boost_factor_p2p: None, + min_bid_p2p_wei: None, }], }; @@ -111,8 +113,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..4e2f285e --- /dev/null +++ b/tests/tests/pbs_submit_builder_preferences.rs @@ -0,0 +1,737 @@ +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, generate_mock_relay_url_only, + generate_mock_relay_with_auth_data, opaque_auth, setup_pbs, setup_relay, setup_relays, + setup_relays_with_auth_data, signed_auth, spawn_mock_relay, + }, +}; +use eyre::Result; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::Encode; +const TEST_MAX_EXECUTION_PAYMENT: u64 = 1_000_000_000; + +/// A slot comfortably ahead of now. Preferences name the proposal slot they +/// apply to, and one that has already ended is rejected, so tests cannot use a +/// fixed constant the way the bid tests do. +fn future_slot(chain: Chain) -> u64 { + let now_sec = utcnow_ms() / 1_000; + (now_sec.saturating_sub(chain.genesis_time_sec())) / chain.slot_time_sec() + 10 +} + +/// A slot that has already ended. Saturating: a chain whose genesis is under 10 +/// slots old would otherwise underflow rather than yield slot 0. +fn past_slot(chain: Chain) -> u64 { + let now_sec = utcnow_ms() / 1_000; + ((now_sec.saturating_sub(chain.genesis_time_sec())) / chain.slot_time_sec()).saturating_sub(10) +} + +fn preferences( + auth: 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, future_slot(chain)); + let request = preferences(auth.clone(), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1); + assert_eq!(mock_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + + // The builder verifies what the proposer signed, so the auth must survive + // the hop byte for byte + let forwarded = mock_state.received_preferences_auth().expect("auth forwarded"); + assert_eq!(forwarded.message.data.to_vec(), TEST_AUTH_DATA.to_vec()); + assert_eq!(forwarded.message.slot, auth.message.slot); + assert_eq!(forwarded.signature, auth.signature); + + // A builder files preferences per proposer, so the wrong path segment would + // store them against the wrong validator + assert_eq!( + mock_state.received_preferences_pubkey(), + Some(mock_validator.comm_boost.pubkey().clone()), + "preferences must be filed under the proposer from the request path" + ); + Ok(()) +} + +/// The JSON wire form is the spec's, not merely whatever our own Serialize +/// produces: Gwei and slot are quoted strings and `data` is hex. Built from a +/// literal so that dropping the serde attributes fails here instead of +/// round-tripping through the same impl the assertion uses. +#[tokio::test] +async fn test_submit_builder_preferences_json_wire_form() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + let slot = future_slot(chain); + + let body = serde_json::json!({ + "preferences": { "max_execution_payment": TEST_MAX_EXECUTION_PAYMENT.to_string() }, + "auth": { + "message": { "data": "0xdead", "slot": slot.to_string() }, + "signature": format!("0x{}", "0".repeat(192)), + } + }); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, EncodingType::Json.content_type_header().clone()) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(serde_json::to_vec(&body)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED, "the spec's JSON wire form must be accepted"); + assert_eq!(mock_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + let forwarded = mock_state.received_preferences_auth().expect("auth forwarded"); + assert_eq!(forwarded.message.data.to_vec(), TEST_AUTH_DATA.to_vec()); + assert_eq!(forwarded.message.slot.as_u64(), slot); + Ok(()) +} + +/// Quotes are optional on decode by ecosystem convention, so a client sending +/// an unquoted Gwei number is still accepted even though the spec's wire form +/// is the quoted string. +#[tokio::test] +async fn test_submit_builder_preferences_unquoted_gwei_accepted() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let body = serde_json::json!({ + "preferences": { "max_execution_payment": TEST_MAX_EXECUTION_PAYMENT }, + "auth": { + "message": { "data": "0xdead", "slot": future_slot(chain).to_string() }, + "signature": format!("0x{}", "0".repeat(192)), + } + }); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, EncodingType::Json.content_type_header().clone()) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(serde_json::to_vec(&body)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + Ok(()) +} + +/// Only 202 means the builder committed to storing the preferences. Another 2xx +/// must not be reported to the proposer as acceptance. +#[tokio::test] +async fn test_submit_builder_preferences_non_202_success_is_failure() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + mock_state.set_response_override(StatusCode::OK); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_ne!(res.status(), StatusCode::ACCEPTED, "a 200 from the builder is not an acceptance"); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + Ok(()) +} + +/// A signature valid under one key submitted at another proposer's path must be +/// rejected: the auth is only meaningful bound to the pubkey it is filed under. +#[tokio::test] +async fn test_submit_builder_preferences_signature_bound_to_path_pubkey() -> Result<()> { + let chain = Chain::Hoodi; + let signer = random_secret(); + let other_pubkey = random_secret().public_key(); + let (mock_validator, mock_state) = + setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay) + .await?; + + // Genuinely signed, just not by the proposer named in the path + let auth = signed_auth(&signer, TEST_AUTH_DATA, future_slot(chain), chain); + let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT); + let res = mock_validator + .do_submit_builder_preferences(Some(other_pubkey), &request, EncodingType::Ssz) + .await?; + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(mock_state.received_builder_preferences(), 0); + Ok(()) +} + +/// With no `Content-Type` the body is SSZ, which is this endpoint's documented +/// no-preference default and differs from the shared JSON default. The version +/// header still travels: an SSZ-default body requires it like explicit SSZ. +#[tokio::test] +async fn test_submit_builder_preferences_no_content_type_is_ssz() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header("Eth-Consensus-Version", "gloas") + .body(request.as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + Ok(()) +} + +/// An unsupported media type is a 415, distinct from the 400 a malformed body +/// of a supported type produces. +#[tokio::test] +async fn test_submit_builder_preferences_unsupported_media_type_415() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "text/plain") + .body("nonsense") + .send() + .await?; + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!(mock_state.received_builder_preferences(), 0); + Ok(()) +} + +/// A JSON submission is accepted too and decodes to the same values, including +/// the quoted-string Gwei on the JSON wire. +#[tokio::test] +async fn test_submit_builder_preferences_json() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Json).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + Ok(()) +} + +/// An SSZ submission missing `Eth-Consensus-Version` is a 400: builder-specs +/// fork-versions the request wire type, so the header is required to accept the +/// SSZ form (and the same submission with the header is a 202). +#[tokio::test] +async fn test_submit_builder_preferences_ssz_missing_version_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + // Note: SSZ Content-Type but no Eth-Consensus-Version + let res = mock_validator + .comm_boost + .client + .post(url.clone()) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(request.as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!( + mock_state.received_builder_preferences(), + 0, + "an undecodable request must not forward" + ); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + + // The identical submission carrying the literal spec header is accepted + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .header("Eth-Consensus-Version", "gloas") + .body(request.as_ssz_bytes()) + .send() + .await?; + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1); + Ok(()) +} + +/// builder-specs marks `Eth-Consensus-Version` required for JSON and SSZ alike +/// (builder-specs #165): a JSON submission without it is a 400. +#[tokio::test] +async fn test_submit_builder_preferences_json_no_version_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + // No Eth-Consensus-Version: required regardless of encoding + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, EncodingType::Json.content_type_header().clone()) + .body(serde_json::to_vec(&request)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("missing consensus version"), + "the 400 must name the missing header: {body}" + ); + assert_eq!(mock_state.received_builder_preferences(), 0, "rejected before any builder"); + Ok(()) +} + +/// 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, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, TEST_AUTH_DATA), + ) + .await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, past_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1, "past-slot preference is forwarded"); + Ok(()) +} + +/// The body is required, exactly as on the bid endpoint. +#[tokio::test] +async fn test_submit_builder_preferences_missing_body_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let url = mock_validator + .comm_boost + .submit_builder_preferences_url(&mock_validator.comm_boost.pubkey().clone())?; + let res = mock_validator.comm_boost.client.post(url).send().await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_builder_preferences(), 0); + + // Distinguish the missing-body guard from the decode failure an empty SSZ + // body would otherwise produce, which is also a 400 + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert!( + body["message"].as_str().unwrap_or_default().contains("missing request body"), + "expected the missing-body error, got {}", + body["message"] + ); + Ok(()) +} + +/// PIPE: preferences whose auth data names a builder URL outside CB's config +/// are forwarded to it via the same transient-client pipe as the bid endpoint +/// and accepted with the builder's 202. +#[tokio::test] +async fn test_submit_builder_preferences_pipe_dials_unconfigured_builder() -> Result<()> { + // This test's mock builder binds to a local (unspecified/loopback) address the + // pipe's SSRF guard blocks; skip that check so the forward path is exercised. + cb_pbs::set_skip_pipe_target_check(true); + let chain = Chain::Hoodi; + // A configured relay, addressed by opaque bytes only + let (mock_validator, cfg_state) = setup_relay( + chain, + |cfg| cfg.advertised_urls = vec!["http://cb.self.example:18550".parse().unwrap()], + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, &[0xaa]), + ) + .await?; + // The pipe builder runs but is NOT in CB's config + let (pipe_state, pipe_port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + + let pipe_url = format!("http://0.0.0.0:{pipe_port}/"); + let auth = opaque_auth(pipe_url.as_bytes(), future_slot(chain)); + let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(pipe_state.received_builder_preferences(), 1); + assert_eq!(cfg_state.received_builder_preferences(), 0, "only the piped builder is dialed"); + assert_eq!(pipe_state.received_max_execution_payment(), Some(TEST_MAX_EXECUTION_PAYMENT)); + let forwarded = pipe_state.received_preferences_auth().expect("auth forwarded"); + assert_eq!(forwarded.message.data.to_vec(), pipe_url.as_bytes().to_vec()); + Ok(()) +} + +/// PIPE self-URL guard, preferences side: auth data decoding to one of CB's +/// `advertised_urls` is a clean 400 and nothing is dialed; with +/// `advertised_urls` unset the guard fails closed the same way. +#[tokio::test] +async fn test_submit_builder_preferences_pipe_self_url_not_dialed() -> Result<()> { + let chain = Chain::Hoodi; + + for advertise_self in [true, false] { + // The mock stands in for whatever answers at the named URL: anything + // it receives means a dial went out + let (mock_state, port) = + spawn_mock_relay(MockRelayState::new(chain, random_secret())).await?; + let relay = + generate_mock_relay_with_auth_data(port, mock_state.signer.public_key(), &[0xaa])?; + let self_url = format!("http://0.0.0.0:{port}/"); + let mock_validator = setup_pbs(chain, vec![relay], |pbs_config| { + if advertise_self { + pbs_config.advertised_urls = vec![self_url.parse().unwrap()]; + } + }) + .await?; + + let auth = opaque_auth(self_url.as_bytes(), future_slot(chain)); + let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "advertise_self={advertise_self}"); + assert_eq!( + mock_state.received_builder_preferences(), + 0, + "no dial (advertise_self={advertise_self})" + ); + } + Ok(()) +} + +/// Preferences addressed to a builder this PBS does not serve are rejected by +/// the demux, not blindly fanned out. +#[tokio::test] +async fn test_submit_builder_preferences_auth_data_mismatch_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay( + chain, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, TEST_AUTH_DATA), + ) + .await?; + + let request = + preferences(opaque_auth(&[0xbe, 0xef], future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_builder_preferences(), 0, "the demux must not fan out"); + Ok(()) +} + +/// Opaque data matching no relay is a 400 with the builder's data-mismatch +/// message even when a relay declares no `expected_auth_data`: unmatched means +/// CB has no builder to proxy to, and proposer-private preferences must never +/// broadcast to builders the proposer did not address. +#[tokio::test] +async fn test_submit_builder_preferences_unmatched_opaque_auth_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = + setup_relay(chain, |_| {}, generate_mock_relay_url_only).await?; + + let request = + preferences(opaque_auth(&[0xbe, 0xef], future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_builder_preferences(), 0, "no relay receives anything"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert_eq!( + body["message"], + "Invalid SignedBuilderRequestAuth: auth.message.data does not match the value agreed with this builder" + ); + Ok(()) +} + +/// Preferences addressed by matching auth data reach that builder. +#[tokio::test] +async fn test_submit_builder_preferences_auth_data_match() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay( + chain, + |_| {}, + |port, pubkey| generate_mock_relay_with_auth_data(port, pubkey, TEST_AUTH_DATA), + ) + .await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1); + Ok(()) +} + +/// With verification on, an unsigned submission is a 401 and never reaches a +/// builder. +#[tokio::test] +async fn test_submit_builder_preferences_bad_signature_401() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = + setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay) + .await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["message"], "Invalid SignedBuilderRequestAuth: signature verification failed"); + assert_eq!(mock_state.received_builder_preferences(), 0); + Ok(()) +} + +/// With verification on, a properly signed submission is accepted. +#[tokio::test] +async fn test_submit_builder_preferences_valid_signature() -> Result<()> { + let chain = Chain::Hoodi; + let secret = random_secret(); + let pubkey = secret.public_key(); + let (mock_validator, mock_state) = + setup_relay(chain, |config| config.verify_builder_request_auth = true, generate_mock_relay) + .await?; + + let auth = signed_auth(&secret, TEST_AUTH_DATA, future_slot(chain), chain); + let request = preferences(auth, TEST_MAX_EXECUTION_PAYMENT); + let res = mock_validator + .do_submit_builder_preferences(Some(pubkey), &request, EncodingType::Ssz) + .await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(mock_state.received_builder_preferences(), 1); + Ok(()) +} + +/// A builder that refuses the submission is surfaced as a failure rather than +/// reported as accepted. +#[tokio::test] +async fn test_submit_builder_preferences_lone_builder_400_propagates() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + mock_state.set_response_override(StatusCode::BAD_REQUEST); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(mock_state.received_builder_preferences(), 1, "the builder was still asked"); + + // The builder's own body is untrusted and must not be relayed onward + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert_eq!(body["message"], "The addressed builder rejected the submission with status 400"); + Ok(()) +} + +/// A lone builder's 401 propagates too, so the spec's authentication failure is +/// reachable at all rather than always collapsing into the blanket failure. +#[tokio::test] +async fn test_submit_builder_preferences_lone_builder_401_propagates() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + mock_state.set_response_override(StatusCode::UNAUTHORIZED); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) +} + +/// A status the spec does not sanction for this endpoint is not passed through: +/// only the builder's 400 and 401 are meaningful to the proposer. The override +/// is 503, NOT 500, so passthrough and the NoBuilderResponse fallback produce +/// different codes and the assertion discriminates (a 500 override could not). +#[tokio::test] +async fn test_submit_builder_preferences_builder_503_is_500() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, mock_state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + mock_state.set_response_override(StatusCode::SERVICE_UNAVAILABLE); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + Ok(()) +} + +/// Two addressed builders both reject: with more than one addressed builder no +/// single verdict is unambiguous, so even a 400 (which passes through for a +/// lone builder) must NOT be handed back — the result is a blanket 500 +/// (`NoBuilderResponse`; 502 is not in the spec's declared response set). The +/// inverse of `lone_builder_400_propagates`, guarding the `relays.len() == 1` +/// condition in the route. +#[tokio::test] +async fn test_submit_builder_preferences_two_relays_all_reject_500() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + // Both reject with a 400: a lone builder's 400 propagates, so two of them + // must prove the multi-builder path collapses to the blanket 500 instead. + for state in &states { + state.set_response_override(StatusCode::BAD_REQUEST); + } + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!( + res.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "two rejecting builders collapse to 500, not a 400 passthrough" + ); + assert_eq!(states[0].received_builder_preferences(), 1, "each addressed builder is asked"); + assert_eq!(states[1].received_builder_preferences(), 1, "each addressed builder is asked"); + Ok(()) +} + +/// Two relays with distinct auth data: preferences addressing one must reach +/// only that builder, the other's received counter stays 0. +#[tokio::test] +async fn test_submit_builder_preferences_two_relays_addressed_one_only() -> Result<()> { + let chain = Chain::Hoodi; + let other_auth_data: &[u8] = &[0xbe, 0xef]; + let (mock_validator, states) = setup_relays_with_auth_data(chain, vec![ + (MockRelayState::new(chain, random_secret()), TEST_AUTH_DATA), + (MockRelayState::new(chain, random_secret()), other_auth_data), + ]) + .await?; + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(states[0].received_builder_preferences(), 1, "the addressed builder is asked"); + assert_eq!(states[1].received_builder_preferences(), 0, "the unaddressed builder is not"); + Ok(()) +} + +/// Two builders behind the same auth data, one 202 and one 400: the documented +/// any-success policy makes the submission a 202. +#[tokio::test] +async fn test_submit_builder_preferences_two_relays_one_202_one_400_is_202() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays_with_auth_data(chain, vec![ + (MockRelayState::new(chain, random_secret()), TEST_AUTH_DATA), + (MockRelayState::new(chain, random_secret()), TEST_AUTH_DATA), + ]) + .await?; + + // The first rejects with a 400; the second accepts by default + states[0].set_response_override(StatusCode::BAD_REQUEST); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED, "any-success: one acceptance is a 202"); + assert_eq!(states[0].received_builder_preferences(), 1, "each addressed builder is asked"); + assert_eq!(states[1].received_builder_preferences(), 1, "each addressed builder is asked"); + Ok(()) +} + +/// Two addressed builders, one accepts and one rejects: they are separate +/// destinations, not replicas, so a single acceptance is a successful 202. +#[tokio::test] +async fn test_submit_builder_preferences_two_relays_one_accepts_202() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + // The first rejects; the second accepts by default + states[0].set_response_override(StatusCode::INTERNAL_SERVER_ERROR); + + let request = + preferences(opaque_auth(TEST_AUTH_DATA, future_slot(chain)), TEST_MAX_EXECUTION_PAYMENT); + let res = + mock_validator.do_submit_builder_preferences(None, &request, EncodingType::Ssz).await?; + + assert_eq!( + res.status(), + StatusCode::ACCEPTED, + "one accepting builder makes the submission a success" + ); + assert_eq!(states[0].received_builder_preferences(), 1, "each addressed builder is asked"); + assert_eq!(states[1].received_builder_preferences(), 1, "each addressed builder is asked"); + Ok(()) +} diff --git a/tests/tests/pbs_submit_signed_beacon_block.rs b/tests/tests/pbs_submit_signed_beacon_block.rs new file mode 100644 index 00000000..1278382d --- /dev/null +++ b/tests/tests/pbs_submit_signed_beacon_block.rs @@ -0,0 +1,441 @@ +use alloy::primitives::B256; +use cb_common::{ + pbs::SignedBeaconBlock, + signer::random_secret, + types::Chain, + utils::TestRandomSeed, + wire::{CONSENSUS_VERSION_HEADER, EncodingType}, +}; +use cb_tests::{ + mock_relay::MockRelayState, + utils::{generate_mock_relay, setup_relay, setup_relays}, +}; +use eyre::Result; +use lh_types::{MainnetEthSpec, SignedBeaconBlockElectra, SignedBeaconBlockGloas, Slot}; +use reqwest::{ + StatusCode, + header::{CONTENT_TYPE, HeaderValue}, +}; +use ssz::Encode; + +const TEST_SLOT: u64 = 100; + +/// A fixed committed bid `block_hash` for building test blocks. +fn mock_bid_block_hash() -> B256 { + let mut hash = B256::ZERO; + hash.0[0] = 1; + hash +} + +/// A Gloas `SignedBeaconBlock` at `slot` whose committed bid names +/// `committed_block_hash`. Built from a random block with just those two fields +/// pinned. +fn gloas_block(slot: u64, committed_block_hash: B256) -> SignedBeaconBlock { + let mut block = SignedBeaconBlockGloas::::test_random(); + block.message.slot = Slot::new(slot); + block.message.body.signed_execution_payload_bid.message.block_hash = + committed_block_hash.into(); + SignedBeaconBlock::Gloas(block) +} + +/// The endpoint is stateless: the block is always broadcast to every configured +/// builder, and each relay's received counter increments. +#[tokio::test] +async fn test_submit_signed_beacon_block_broadcasts_to_all_relays() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let res = mock_validator.do_submit_signed_beacon_block(&block, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(states[0].received_signed_beacon_block(), 1, "every builder receives the block"); + assert_eq!(states[1].received_signed_beacon_block(), 1, "every builder receives the block"); + assert_eq!(states[0].received_block_slot(), Some(TEST_SLOT)); + assert_eq!(states[1].received_block_slot(), Some(TEST_SLOT)); + Ok(()) +} + +/// A valid SSZ submission is a 202 and reaches the builder decoded: the mock +/// recovers the same slot and committed bid hash from the forwarded SSZ. +#[tokio::test] +async fn test_submit_signed_beacon_block_ssz_202() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let committed = B256::repeat_byte(0x07); + let block = gloas_block(TEST_SLOT, committed); + let res = mock_validator.do_submit_signed_beacon_block(&block, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(state.received_signed_beacon_block(), 1); + assert_eq!(state.received_block_slot(), Some(TEST_SLOT)); + assert_eq!(state.received_block_committed_hash(), Some(committed)); + Ok(()) +} + +/// A JSON submission is accepted and decodes to the same block: CB re-encodes +/// it to SSZ for the builder, which recovers the committed hash unchanged. +#[tokio::test] +async fn test_submit_signed_beacon_block_json_202() -> Result<()> { + let chain = Chain::Hoodi; + // Decoding (JSON->SSZ normalization) is opt-in via strict_block_decode. + let (mock_validator, state) = + setup_relay(chain, |config| config.strict_block_decode = true, generate_mock_relay).await?; + + let committed = B256::repeat_byte(0x5a); + let block = gloas_block(TEST_SLOT, committed); + let res = mock_validator.do_submit_signed_beacon_block(&block, EncodingType::Json).await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED, "the spec's JSON wire form must be accepted"); + assert_eq!(state.received_signed_beacon_block(), 1); + assert_eq!(state.received_block_committed_hash(), Some(committed)); + Ok(()) +} + +/// Pin the literal spec route: `POST /eth/v1/builder/beacon_blocks`. +#[tokio::test] +async fn test_submit_signed_beacon_block_spec_url() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, B256::repeat_byte(0x09)); + let url = format!("{}eth/v1/builder/beacon_blocks", mock_validator.comm_boost.config.entry.url); + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "application/octet-stream") + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(block.as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(state.received_signed_beacon_block(), 1); + Ok(()) +} + +/// A non-Gloas block, submitted as JSON under the (mandatory) gloas label, +/// is a 400 +/// the endpoint is Gloas-only per spec. +#[tokio::test] +async fn test_submit_signed_beacon_block_non_gloas_400() -> Result<()> { + let chain = Chain::Hoodi; + // The gloas-only 400 is part of strict decode; the default pipe forwards. + let (mock_validator, state) = + setup_relay(chain, |config| config.strict_block_decode = true, generate_mock_relay).await?; + + // gloas is the only header the wire layer admits, so a non-Gloas BLOCK can + // only reach the route's Gloas-only check as JSON. The untagged decode + // picks the FIRST field-matching variant - Electra (a Fulu body would also + // land on Electra; the two are JSON-identical) - and the route rejects it. + let block = + SignedBeaconBlock::Electra(SignedBeaconBlockElectra::::test_random()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(serde_json::to_vec(&block)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(state.received_signed_beacon_block(), 0, "a non-Gloas block must not be forwarded"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + assert!( + body["message"].as_str().unwrap_or_default().contains("Gloas"), + "the rejection must name the Gloas-only constraint, got: {}", + body["message"] + ); + Ok(()) +} + +/// builder-specs requires `Eth-Consensus-Version` on every body-carrying +/// request (spec PR #165, superseding the earlier best-effort policy): a JSON +/// body with no version header is a 400 and nothing is broadcast. +#[tokio::test] +async fn test_submit_signed_beacon_block_json_no_version_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + // No CONSENSUS_VERSION_HEADER: required regardless of encoding + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(serde_json::to_vec(&block)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("missing consensus version"), + "the 400 must name the missing header: {body}" + ); + assert_eq!(state.received_signed_beacon_block(), 0, "rejected before any broadcast"); + + // Present but unrecognized is also a 400, and the error names the VALUE + // rather than claiming the header is missing + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header(CONSENSUS_VERSION_HEADER, HeaderValue::from_static("futurefork")) + .body(serde_json::to_vec(&block)?) + .send() + .await?; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("futurefork"), + "the 400 must echo the unrecognized value: {body}" + ); + assert_eq!(state.received_signed_beacon_block(), 0); + Ok(()) +} + +/// The block is broadcast; if every builder rejects it, PBS maps the all-reject +/// outcome to a 500 (no builder accepted). +#[tokio::test] +async fn test_submit_signed_beacon_block_broadcast_all_reject_500() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + // Make every builder reject the broadcast + for state in &states { + state.set_response_override(StatusCode::INTERNAL_SERVER_ERROR); + } + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let res = mock_validator.do_submit_signed_beacon_block(&block, EncodingType::Ssz).await?; + + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(states[0].received_signed_beacon_block(), 1, "every builder is asked"); + assert_eq!(states[1].received_signed_beacon_block(), 1, "every builder is asked"); + Ok(()) +} + +/// A broadcast where one builder accepts and the other rejects is still a 202: +/// one acceptance across the broadcast is success. This is the core of the +/// stateless broadcast model, distinct from the all-accept and all-reject +/// extremes the other tests cover. +#[tokio::test] +async fn test_submit_signed_beacon_block_broadcast_one_accepts_202() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, states) = setup_relays(chain, vec![ + MockRelayState::new(chain, random_secret()), + MockRelayState::new(chain, random_secret()), + ]) + .await?; + + // Only the second builder accepts; the first rejects. PBS must still 202. + states[0].set_response_override(StatusCode::INTERNAL_SERVER_ERROR); + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let res = mock_validator.do_submit_signed_beacon_block(&block, EncodingType::Ssz).await?; + + assert_eq!( + res.status(), + StatusCode::ACCEPTED, + "one accepting builder makes the broadcast a success" + ); + assert_eq!(states[0].received_signed_beacon_block(), 1, "every builder is asked"); + assert_eq!(states[1].received_signed_beacon_block(), 1, "every builder is asked"); + Ok(()) +} + +/// gloas is the only value the wire layer admits on these post-fork endpoints: +/// a Gloas JSON block labelled `fulu` is rejected with a 400 naming the value, +/// like every other deprecated fork. +#[tokio::test] +async fn test_submit_signed_beacon_block_fulu_label_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header(CONSENSUS_VERSION_HEADER, "fulu") + .body(serde_json::to_vec(&block)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = res.json().await?; + assert!( + body["message"].as_str().unwrap_or_default().contains("fulu"), + "the 400 must echo the unsupported value: {body}" + ); + assert_eq!(state.received_signed_beacon_block(), 0, "nothing is broadcast"); + Ok(()) +} + +/// An unsupported request `Content-Type` is a 415, distinct from the 400 a +/// malformed body of a supported type produces. Mirrors the preferences and bid +/// endpoints. +#[tokio::test] +async fn test_submit_signed_beacon_block_unsupported_content_type_415() -> Result<()> { + let chain = Chain::Hoodi; + // Content-Type validation is part of strict decode; the default pipe + // forwards without inspecting it. + let (mock_validator, state) = + setup_relay(chain, |config| config.strict_block_decode = true, generate_mock_relay).await?; + + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, "text/plain") + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body("nonsense") + .send() + .await?; + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!( + state.received_signed_beacon_block(), + 0, + "an unsupported media type must not be forwarded" + ); + Ok(()) +} + +/// In the default blind pipe the reveal must be SSZ: a JSON `Content-Type` is +/// rejected up front with a 415 (and nothing is forwarded) rather than being +/// relabeled octet-stream and failing opaquely at the builder. +#[tokio::test] +async fn test_submit_signed_beacon_block_blind_json_415() -> Result<()> { + let chain = Chain::Hoodi; + // Default config: blind pipe, no strict decode. + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(serde_json::to_vec(&block)?) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!( + state.received_signed_beacon_block(), + 0, + "a JSON reveal must not be forwarded in blind mode" + ); + Ok(()) +} + +/// Blind mode requires an explicit SSZ Content-Type. An unlabeled reveal +/// defaults to JSON per builder-specs, so it is rejected with 415 rather than +/// assumed to be SSZ and forwarded mislabeled (which would 500 opaquely at the +/// builder). +#[tokio::test] +async fn test_submit_signed_beacon_block_blind_absent_content_type_415() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + // SSZ bytes, but no Content-Type header at all + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .body(block.as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!( + state.received_signed_beacon_block(), + 0, + "an unlabeled reveal must not be forwarded in blind mode" + ); + Ok(()) +} + +/// An SSZ submission missing `Eth-Consensus-Version` is a 400: the SSZ block is +/// not self-describing, so the fork header is required to select the variant +/// (and the spec mandates the header regardless of encoding). +#[tokio::test] +async fn test_submit_signed_beacon_block_ssz_missing_version_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let block = gloas_block(TEST_SLOT, mock_bid_block_hash()); + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + // Note: SSZ Content-Type but no CONSENSUS_VERSION_HEADER + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONTENT_TYPE, EncodingType::Ssz.content_type_header().clone()) + .body(block.as_ssz_bytes()) + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(state.received_signed_beacon_block(), 0, "an undecodable request must not forward"); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body["code"], 400); + Ok(()) +} + +/// A submission with no body is a 400 (`MissingBody`), distinguished from the +/// decode failure an empty SSZ body would otherwise produce. +#[tokio::test] +async fn test_submit_signed_beacon_block_missing_body_400() -> Result<()> { + let chain = Chain::Hoodi; + let (mock_validator, state) = setup_relay(chain, |_| {}, generate_mock_relay).await?; + + let url = mock_validator.comm_boost.submit_signed_beacon_block_url()?; + // A version header, but no body at all + let res = mock_validator + .comm_boost + .client + .post(url) + .header(CONSENSUS_VERSION_HEADER, "gloas") + .send() + .await?; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(state.received_signed_beacon_block(), 0); + let body: serde_json::Value = serde_json::from_slice(&res.bytes().await?)?; + assert!( + body["message"].as_str().unwrap_or_default().contains("missing request body"), + "expected the missing-body error, got {}", + body["message"] + ); + Ok(()) +} diff --git a/tests/tests/signer_jwt_auth.rs b/tests/tests/signer_jwt_auth.rs index 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