diff --git a/.cargo/audit.toml b/.cargo/audit.toml deleted file mode 100644 index 38e462f5..00000000 --- a/.cargo/audit.toml +++ /dev/null @@ -1,27 +0,0 @@ -# RUSTSEC-2026-0049: CRL revocation checking bug in rustls-webpki 0.101.7. -# -# Background: CRL (Certificate Revocation List) checking is an optional TLS -# feature where a client fetches a list of revoked certificates from URLs -# embedded in the cert itself, to confirm it hasn't been invalidated since -# issuance. This is distinct from normal certificate validation. -# -# The bug: when a cert lists multiple CRL distribution point URLs, only the -# first URL is checked; the rest are silently ignored. This matters only when -# CRL checking is enabled AND the UnknownStatusPolicy is set to Allow (meaning -# "if I can't determine revocation status, accept the cert anyway"). With that -# combination, a revoked certificate from a compromised CA could be accepted. -# -# Why this does not affect Commit-Boost: the vulnerable code path is never -# reached because no code in this codebase enables CRL checking at all. -# TLS is used in four places: (1) relay communication via reqwest with -# rustls-tls uses default CA validation with no CRL configured; (2) the signer -# server presents a TLS certificate but does not check client revocation; -# (3) the signer client pins a single self-signed certificate via -# add_root_certificate — CRL is irrelevant for self-signed certs; (4) the Dirk -# remote signer uses mTLS with a custom CA but again no CRL. In all cases the -# buggy CRL code in rustls-webpki is never invoked. -# -# Blocked on sigp/lighthouse upgrading past v8.0.1 without a compilation -# regression (SseEventSource missing cfg guard in eth2 error.rs). -[advisories] -ignore = ["RUSTSEC-2026-0049"] \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index b8b001f2..18e32040 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,19 +17,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aes" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" -dependencies = [ - "cfg-if", - "cipher 0.3.0", - "cpufeatures 0.2.17", - "ctr 0.8.0", - "opaque-debug", -] - [[package]] name = "aes" version = "0.8.4" @@ -37,22 +24,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher 0.4.4", + "cipher", "cpufeatures 0.2.17", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -267,11 +242,11 @@ dependencies = [ "c-kzg", "derive_more", "either", - "ethereum_ssz", - "ethereum_ssz_derive", + "ethereum_ssz 0.9.1", + "ethereum_ssz_derive 0.9.1", "serde", "serde_with", - "sha2 0.10.9", + "sha2", "thiserror 2.0.18", ] @@ -578,13 +553,13 @@ dependencies = [ "alloy-primitives 1.5.7", "alloy-rpc-types-engine", "derive_more", - "ethereum_ssz", - "ethereum_ssz_derive", + "ethereum_ssz 0.9.1", + "ethereum_ssz_derive 0.9.1", "serde", "serde_json", "serde_with", "thiserror 2.0.18", - "tree_hash", + "tree_hash 0.10.0", "tree_hash_derive 0.10.0", ] @@ -612,8 +587,8 @@ dependencies = [ "alloy-rlp", "alloy-serde", "derive_more", - "ethereum_ssz", - "ethereum_ssz_derive", + "ethereum_ssz 0.9.1", + "ethereum_ssz_derive 0.9.1", "rand 0.8.5", "serde", "strum", @@ -1436,11 +1411,11 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "pin-project-lite", - "rustls 0.23.37", - "rustls-pemfile 2.2.0", + "rustls", + "rustls-pemfile", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", ] @@ -1459,28 +1434,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base-x" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" - [[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base256emoji" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" -dependencies = [ - "const-str", - "match-lookup", -] - [[package]] name = "base64" version = "0.21.7" @@ -1563,15 +1522,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "block-buffer" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" -dependencies = [ - "generic-array 0.14.7", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -1584,20 +1534,20 @@ dependencies = [ [[package]] name = "bls" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "alloy-primitives 1.5.7", "arbitrary", "blst", - "ethereum_hashing", + "ethereum_hashing 0.8.0", "ethereum_serde_utils 0.8.0", - "ethereum_ssz", + "ethereum_ssz 0.10.3", "fixed_bytes", "hex", "rand 0.9.2", "safe_arith", "serde", - "tree_hash", + "tree_hash 0.12.1", "zeroize", ] @@ -1619,7 +1569,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde_bare", - "sha2 0.10.9", + "sha2", "sha3", "subtle", "thiserror 1.0.69", @@ -1697,15 +1647,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bstr" version = "1.12.1" @@ -1813,38 +1754,41 @@ dependencies = [ name = "cb-common" version = "0.9.3" dependencies = [ - "aes 0.8.4", + "aes", "alloy", "async-trait", "axum 0.8.8", "base64 0.22.1", "bimap", + "bls", "bytes", - "cipher 0.4.4", + "cipher", "const_format", - "ctr 0.9.2", + "ctr", "derive_more", "docker-image", "eth2", "eth2_keystore", "ethereum_serde_utils 0.7.0", - "ethereum_ssz", - "ethereum_ssz_derive", + "ethereum_ssz 0.10.3", + "ethereum_ssz_derive 0.10.3", "eyre", "futures", + "headers-accept", "jsonwebtoken", "lazy_static", + "mediatype 0.20.0", "notify", - "pbkdf2 0.12.2", + "pbkdf2", "rand 0.9.2", "rayon", "reqwest 0.12.28", - "reqwest-eventsource", + "reqwest-eventsource 0.5.0", "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", - "ssz_types", + "sha2", + "ssz_types 0.11.0", "tempfile", "thiserror 2.0.18", "tokio", @@ -1853,8 +1797,8 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "tree_hash", - "tree_hash_derive 0.9.1", + "tree_hash 0.12.1", + "tree_hash_derive 0.12.1", "types", "unicode-normalization", "url", @@ -1883,8 +1827,11 @@ dependencies = [ "axum-extra", "cb-common", "cb-metrics", + "ethereum_serde_utils 0.7.0", + "ethereum_ssz 0.10.3", "eyre", "futures", + "headers", "lazy_static", "notify", "parking_lot", @@ -1895,7 +1842,8 @@ dependencies = [ "tokio", "tower-http", "tracing", - "tree_hash", + "tree_hash 0.12.1", + "types", "url", "uuid 1.22.0", ] @@ -1921,13 +1869,13 @@ dependencies = [ "prometheus", "prost", "rand 0.9.2", - "rustls 0.23.37", + "rustls", "thiserror 2.0.18", "tokio", "tonic", "tonic-build", "tracing", - "tree_hash", + "tree_hash 0.12.1", "uuid 1.22.0", ] @@ -1940,6 +1888,8 @@ dependencies = [ "cb-common", "cb-pbs", "cb-signer", + "eth2", + "ethereum_ssz 0.10.3", "eyre", "jsonwebtoken", "rcgen", @@ -1952,7 +1902,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-test", - "tree_hash", + "tree_hash 0.12.1", "types", "url", ] @@ -2031,15 +1981,6 @@ dependencies = [ "half", ] -[[package]] -name = "cipher" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" -dependencies = [ - "generic-array 0.14.7", -] - [[package]] name = "cipher" version = "0.4.4" @@ -2161,25 +2102,28 @@ dependencies = [ "tempfile", "tokio", "tracing", - "tree_hash", - "tree_hash_derive 0.9.1", + "tree_hash 0.12.1", + "tree_hash_derive 0.12.1", ] [[package]] name = "compare_fields" -version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f45d0b4d61b582303179fb7a1a142bc9d647b7583db3b0d5f25a21d286fab9" dependencies = [ - "itertools 0.10.5", + "compare_fields_derive", + "itertools 0.14.0", ] [[package]] name = "compare_fields_derive" -version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ff1dbbda10d495b2c92749c002b2025e0be98f42d1741ecc9ff820d2f04dce" dependencies = [ "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2200,12 +2144,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-str" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" - [[package]] name = "const_format" version = "0.2.35" @@ -2228,22 +2166,22 @@ dependencies = [ [[package]] name = "context_deserialize" -version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c523eea4af094b5970c321f4604abc42c5549d3cbae332e98325403fbbdbf70" dependencies = [ "context_deserialize_derive", - "milhouse", "serde", - "ssz_types", ] [[package]] name = "context_deserialize_derive" -version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7bf98c48ffa511b14bb3c76202c24a8742cea1efa9570391c5d41373419a09" dependencies = [ "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2444,59 +2382,13 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-mac" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" -dependencies = [ - "generic-array 0.14.7", - "subtle", -] - -[[package]] -name = "ctr" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" -dependencies = [ - "cipher 0.3.0", -] - [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher 0.4.4", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version 0.4.1", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "cipher", ] [[package]] @@ -2640,26 +2532,6 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" -[[package]] -name = "data-encoding-macro" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" -dependencies = [ - "data-encoding", - "data-encoding-macro-internal", -] - -[[package]] -name = "data-encoding-macro-internal" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" -dependencies = [ - "data-encoding", - "syn 2.0.117", -] - [[package]] name = "der" version = "0.7.10" @@ -2777,7 +2649,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", + "block-buffer", "const-oid", "crypto-common", "subtle", @@ -2858,31 +2730,6 @@ dependencies = [ "spki", ] -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core 0.6.4", - "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - [[package]] name = "educe" version = "0.6.0" @@ -2911,17 +2758,7 @@ dependencies = [ "itertools 0.14.0", "serde", "serde_json", - "sha2 0.10.9", -] - -[[package]] -name = "eip_3076" -version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" -dependencies = [ - "ethereum_serde_utils 0.8.0", - "serde", - "types", + "sha2", ] [[package]] @@ -2972,7 +2809,7 @@ dependencies = [ "ekzg-bls12-381", "ekzg-maybe-rayon", "ekzg-polynomial", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -3049,25 +2886,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enr" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851bd664a3d3a3c175cff92b2f0df02df3c541b4895d0ae307611827aae46152" -dependencies = [ - "alloy-rlp", - "base64 0.22.1", - "bytes", - "ed25519-dalek", - "hex", - "k256", - "log", - "rand 0.8.5", - "serde", - "sha3", - "zeroize", -] - [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -3107,42 +2925,35 @@ dependencies = [ [[package]] name = "eth2" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ - "derivative", - "eip_3076", - "either", - "enr", - "eth2_keystore", + "bls", + "context_deserialize", + "educe", "ethereum_serde_utils 0.8.0", - "ethereum_ssz", - "ethereum_ssz_derive", + "ethereum_ssz 0.10.3", + "ethereum_ssz_derive 0.10.3", "futures", "futures-util", - "libp2p-identity", - "mediatype", - "multiaddr", + "mediatype 0.19.20", "pretty_reqwest_error", - "proto_array", - "rand 0.9.2", - "reqwest 0.11.27", - "reqwest-eventsource", + "reqwest 0.12.28", + "reqwest-eventsource 0.6.0", "sensitive_url", "serde", "serde_json", - "ssz_types", - "test_random_derive", + "ssz_types 0.14.1", + "superstruct", "types", - "zeroize", ] [[package]] name = "eth2_interop_keypairs" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "bls", - "ethereum_hashing", + "ethereum_hashing 0.8.0", "hex", "num-bigint", "serde", @@ -3152,32 +2963,34 @@ dependencies = [ [[package]] name = "eth2_key_derivation" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "bls", "num-bigint-dig", "ring", - "sha2 0.9.9", + "sha2", "zeroize", ] [[package]] name = "eth2_keystore" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ - "aes 0.7.5", + "aes", "bls", + "cipher", + "ctr", "eth2_key_derivation", "hex", - "hmac 0.11.0", - "pbkdf2 0.8.0", + "hmac", + "pbkdf2", "rand 0.9.2", "scrypt", "serde", "serde_json", "serde_repr", - "sha2 0.9.9", + "sha2", "unicode-normalization", "uuid 0.8.2", "zeroize", @@ -3191,7 +3004,18 @@ checksum = "c853bd72c9e5787f8aafc3df2907c2ed03cff3150c3acd94e2e53a98ab70a8ab" dependencies = [ "cpufeatures 0.2.17", "ring", - "sha2 0.10.9", + "sha2", +] + +[[package]] +name = "ethereum_hashing" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa93f58bb1eb3d1e556e4f408ef1dac130bad01ac37db4e7ade45de40d1c86a" +dependencies = [ + "cpufeatures 0.2.17", + "ring", + "sha2", ] [[package]] @@ -3235,6 +3059,22 @@ dependencies = [ "typenum", ] +[[package]] +name = "ethereum_ssz" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368a4a4e4273b0135111fe9464e35465067766a8f664615b5a86338b73864407" +dependencies = [ + "alloy-primitives 1.5.7", + "context_deserialize", + "ethereum_serde_utils 0.8.0", + "itertools 0.14.0", + "serde", + "serde_derive", + "smallvec", + "typenum", +] + [[package]] name = "ethereum_ssz_derive" version = "0.9.1" @@ -3247,6 +3087,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ethereum_ssz_derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cd82c68120c89361e1a457245cf212f7d9f541bffaffed530c8f2d54a160b2" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -3268,18 +3120,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fastrand" version = "2.3.0" @@ -3319,12 +3159,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -3346,7 +3180,7 @@ dependencies = [ [[package]] name = "fixed_bytes" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "alloy-primitives 1.5.7", "safe_arith", @@ -3687,10 +3521,6 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -3715,15 +3545,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "hashlink" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" -dependencies = [ - "hashbrown 0.14.5", -] - [[package]] name = "headers" version = "0.4.1" @@ -3739,6 +3560,17 @@ dependencies = [ "sha1", ] +[[package]] +name = "headers-accept" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479bcb872e714e11f72fcc6a71afadbc86d0dbe887bc44252b04cfbc63272897" +dependencies = [ + "headers-core", + "http 1.4.0", + "mediatype 0.20.0", +] + [[package]] name = "headers-core" version = "0.3.0" @@ -3790,17 +3622,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", -] - -[[package]] -name = "hmac" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" -dependencies = [ - "crypto-mac", - "digest 0.9.0", + "hmac", ] [[package]] @@ -3926,20 +3748,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.7" @@ -3949,10 +3757,10 @@ dependencies = [ "http 1.4.0", "hyper 1.8.1", "hyper-util", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", "webpki-roots 1.0.6", ] @@ -3970,19 +3778,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", -] - [[package]] name = "hyper-tls" version = "0.6.0" @@ -4243,7 +4038,7 @@ dependencies = [ [[package]] name = "int_to_bytes" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "bytes", ] @@ -4373,8 +4168,7 @@ dependencies = [ "elliptic-curve", "once_cell", "serdect", - "sha2 0.10.9", - "signature", + "sha2", ] [[package]] @@ -4419,22 +4213,22 @@ dependencies = [ [[package]] name = "kzg" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "arbitrary", "c-kzg", - "derivative", - "ethereum_hashing", + "educe", + "ethereum_hashing 0.8.0", "ethereum_serde_utils 0.8.0", - "ethereum_ssz", - "ethereum_ssz_derive", + "ethereum_ssz 0.10.3", + "ethereum_ssz_derive 0.10.3", "hex", "rayon", "rust_eth_kzg", "serde", "serde_json", "tracing", - "tree_hash", + "tree_hash 0.12.1", ] [[package]] @@ -4464,31 +4258,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libp2p-identity" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c7892c221730ba55f7196e98b0b8ba5e04b4155651736036628e9f73ed6fc3" -dependencies = [ - "bs58", - "hkdf", - "multihash", - "sha2 0.10.9", - "thiserror 2.0.18", - "tracing", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29f835d03d717946d28b1d1ed632eb6f0e24a299388ee623d0c23118d3e8a7fa" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4554,17 +4323,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" -[[package]] -name = "match-lookup" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "matchers" version = "0.2.0" @@ -4592,6 +4350,12 @@ version = "0.19.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33746aadcb41349ec291e7f2f0a3aa6834d1d7c58066fb4b01f68efc4c4b7631" +[[package]] +name = "mediatype" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f490ea2ae935dd8ac89c472d4df28c7f6b87cc20767e1b21fd5ed6a16e7f61e4" + [[package]] name = "memchr" version = "2.8.0" @@ -4601,10 +4365,10 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "merkle_proof" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "alloy-primitives 1.5.7", - "ethereum_hashing", + "ethereum_hashing 0.8.0", "fixed_bytes", "safe_arith", ] @@ -4646,21 +4410,22 @@ dependencies = [ [[package]] name = "milhouse" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bdb104e38d3a8c5ffb7e9d2c43c522e6bcc34070edbadba565e722f0dee56c7" +checksum = "259dd9da2ae5e0278b95da0b7ecef9c18c309d0a2d9e6db57ed33b9e8910c5e7" dependencies = [ "alloy-primitives 1.5.7", + "context_deserialize", "educe", - "ethereum_hashing", - "ethereum_ssz", - "ethereum_ssz_derive", + "ethereum_hashing 0.8.0", + "ethereum_ssz 0.10.3", + "ethereum_ssz_derive 0.10.3", "itertools 0.13.0", "parking_lot", "rayon", "serde", "smallvec", - "tree_hash", + "tree_hash 0.12.1", "triomphe", "typenum", "vec_map", @@ -4699,47 +4464,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "multiaddr" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6351f60b488e04c1d21bc69e56b89cb3f5e8f5d22557d6e8031bdfd79b6961" -dependencies = [ - "arrayref", - "byteorder", - "data-encoding", - "libp2p-identity", - "multibase", - "multihash", - "percent-encoding", - "serde", - "static_assertions", - "unsigned-varint", - "url", -] - -[[package]] -name = "multibase" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" -dependencies = [ - "base-x", - "base256emoji", - "data-encoding", - "data-encoding-macro", -] - -[[package]] -name = "multihash" -version = "0.19.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" -dependencies = [ - "core2", - "unsigned-varint", -] - [[package]] name = "multimap" version = "0.10.1" @@ -4950,12 +4674,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl" version = "0.10.76" @@ -5082,15 +4800,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pbkdf2" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95f5254224e617595d2cc3cc73ff0a5eaf2637519e25f03388154e9378b6ffa" -dependencies = [ - "crypto-mac", -] - [[package]] name = "pbkdf2" version = "0.12.2" @@ -5098,7 +4807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", ] [[package]] @@ -5280,9 +4989,9 @@ dependencies = [ [[package]] name = "pretty_reqwest_error" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ - "reqwest 0.11.27", + "reqwest 0.12.28", "sensitive_url", ] @@ -5433,20 +5142,6 @@ dependencies = [ "prost", ] -[[package]] -name = "proto_array" -version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" -dependencies = [ - "ethereum_ssz", - "ethereum_ssz_derive", - "safe_arith", - "serde", - "serde_yaml", - "superstruct", - "types", -] - [[package]] name = "protobuf" version = "3.7.2" @@ -5485,7 +5180,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.37", + "rustls", "socket2 0.6.3", "thiserror 2.0.18", "tokio", @@ -5505,7 +5200,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -5773,26 +5468,19 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", - "hyper-rustls 0.24.2", - "hyper-tls 0.5.0", "ipnet", "js-sys", "log", "mime", - "native-tls", "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.21.12", - "rustls-pemfile 1.0.4", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", "system-configuration 0.5.1", "tokio", - "tokio-native-tls", - "tokio-rustls 0.24.1", "tokio-util", "tower-service", "url", @@ -5800,7 +5488,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 0.25.4", "winreg", ] @@ -5813,6 +5500,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.13", @@ -5820,8 +5508,8 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.8.1", - "hyper-rustls 0.27.7", - "hyper-tls 0.6.0", + "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", @@ -5830,7 +5518,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -5838,7 +5526,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower 0.5.3", "tower-http", @@ -5867,13 +5555,29 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "reqwest-eventsource" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde" +dependencies = [ + "eventsource-stream", + "futures-core", + "futures-timer", + "mime", + "nom", + "pin-project-lite", + "reqwest 0.12.28", + "thiserror 1.0.69", +] + [[package]] name = "rfc6979" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -5944,20 +5648,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" -[[package]] -name = "rusqlite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01e213bc3ecb39ac32e81e51ebe31fd888a940515173e3a18a35f8c6e896422a" -dependencies = [ - "bitflags 1.3.2", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - [[package]] name = "rust_eth_kzg" version = "0.9.1" @@ -6024,18 +5714,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.37" @@ -6047,20 +5725,11 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.10", + "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -6082,19 +5751,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.101.7" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", @@ -6134,11 +5793,11 @@ checksum = "b147bb6111014916d3ef9d4c85173124a8e12193a67f6176d67244afd558d6c1" [[package]] name = "salsa20" -version = "0.8.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecbd2eb639fd7cab5804a0837fe373cc2172d15437e804c054a9fb885cb923b0" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher 0.3.0", + "cipher", ] [[package]] @@ -6191,24 +5850,13 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scrypt" -version = "0.7.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879588d8f90906e73302547e20fffefdd240eb3e0e744e142321f5d49dea0518" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "hmac 0.11.0", - "pbkdf2 0.8.0", + "pbkdf2", "salsa20", - "sha2 0.9.9", -] - -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", + "sha2", ] [[package]] @@ -6303,7 +5951,8 @@ checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "sensitive_url" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b0221fa9905eec4163dbf7660b1876cc95663af1deddc3e19ebe49167c58c" dependencies = [ "serde", "url", @@ -6469,19 +6118,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.9.0", - "opaque-debug", -] - [[package]] name = "sha2" version = "0.10.9" @@ -6607,12 +6243,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b55bedc9a18ed2860a46d6beb4f4082416ee1d60be0cc364cebdcdddc7afd4" dependencies = [ "ethereum_serde_utils 0.8.0", - "ethereum_ssz", + "ethereum_ssz 0.9.1", "itertools 0.13.0", "serde", "serde_derive", "smallvec", - "tree_hash", + "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" +dependencies = [ + "context_deserialize", + "educe", + "ethereum_serde_utils 0.8.0", + "ethereum_ssz 0.10.3", + "itertools 0.14.0", + "serde", + "serde_derive", + "smallvec", + "tree_hash 0.12.1", "typenum", ] @@ -6695,10 +6349,10 @@ dependencies = [ [[package]] name = "swap_or_not_shuffle" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "alloy-primitives 1.5.7", - "ethereum_hashing", + "ethereum_hashing 0.8.0", "fixed_bytes", ] @@ -6832,10 +6486,10 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "test_random_derive" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -7029,23 +6683,13 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.37", + "rustls", "tokio", ] @@ -7069,10 +6713,10 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tungstenite", "webpki-roots 0.26.11", ] @@ -7182,10 +6826,10 @@ dependencies = [ "percent-encoding", "pin-project", "prost", - "rustls-pemfile 2.2.0", + "rustls-pemfile", "socket2 0.5.10", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tower 0.4.13", "tower-layer", @@ -7399,17 +7043,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee44f4cef85f88b4dea21c0b1f58320bdf35715cf56d840969487cff00613321" dependencies = [ "alloy-primitives 1.5.7", - "ethereum_hashing", - "ethereum_ssz", + "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" +dependencies = [ + "alloy-primitives 1.5.7", + "ethereum_hashing 0.8.0", + "ethereum_ssz 0.10.3", "smallvec", "typenum", ] [[package]] name = "tree_hash_derive" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "699e7fb6b3fdfe0c809916f251cf5132d64966858601695c3736630a87e7166a" +checksum = "0bee2ea1551f90040ab0e34b6fb7f2fa3bad8acc925837ac654f2c78a13e3089" dependencies = [ "darling 0.20.11", "proc-macro2", @@ -7419,11 +7076,11 @@ dependencies = [ [[package]] name = "tree_hash_derive" -version = "0.10.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bee2ea1551f90040ab0e34b6fb7f2fa3bad8acc925837ac654f2c78a13e3089" +checksum = "8840ad4d852e325d3afa7fde8a50b2412f89dce47d7eb291c0cc7f87cd040f38" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7457,7 +7114,7 @@ dependencies = [ "httparse", "log", "rand 0.9.2", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "sha1", "thiserror 2.0.18", @@ -7473,20 +7130,19 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "types" version = "0.2.1" -source = "git+https://github.com/sigp/lighthouse?tag=v8.0.0#e3ee7febce64c1b5a85c3ab0be0619571ee92d58" +source = "git+https://github.com/sigp/lighthouse?tag=v8.1.3#176cce585c1ba979a6210ed79b6b6528596cdb8c" dependencies = [ "alloy-primitives 1.5.7", "alloy-rlp", "bls", "compare_fields", - "compare_fields_derive", "context_deserialize", - "derivative", + "educe", "eth2_interop_keypairs", - "ethereum_hashing", + "ethereum_hashing 0.8.0", "ethereum_serde_utils 0.8.0", - "ethereum_ssz", - "ethereum_ssz_derive", + "ethereum_ssz 0.10.3", + "ethereum_ssz_derive 0.10.3", "fixed_bytes", "hex", "int_to_bytes", @@ -7502,20 +7158,20 @@ dependencies = [ "rayon", "regex", "rpds", - "rusqlite", "safe_arith", "serde", "serde_json", "serde_yaml", "smallvec", - "ssz_types", + "ssz_types 0.14.1", "superstruct", "swap_or_not_shuffle", "tempfile", "test_random_derive", "tracing", - "tree_hash", - "tree_hash_derive 0.10.0", + "tree_hash 0.12.1", + "tree_hash_derive 0.12.1", + "typenum", ] [[package]] @@ -7590,12 +7246,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "unsigned-varint" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" - [[package]] name = "untrusted" version = "0.9.0" @@ -7888,12 +7538,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index 99621100..42230035 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,17 +44,20 @@ derive_more = { version = "2.0.1", features = ["deref", "display", "from", "into docker-compose-types = "0.16.0" docker-image = "0.2.1" ethereum_serde_utils = "0.7.0" -ethereum_ssz = "0.9" -ethereum_ssz_derive = "0.9" +ethereum_ssz = "0.10" +ethereum_ssz_derive = "0.10" eyre = "0.6.12" futures = "0.3.30" headers = "0.4.0" +headers-accept = "0.2.1" indexmap = "2.2.6" jsonwebtoken = { version = "9.3.1", default-features = false } lazy_static = "1.5.0" -lh_eth2 = { package = "eth2", git = "https://github.com/sigp/lighthouse", tag = "v8.0.0" } -lh_eth2_keystore = { package = "eth2_keystore", git = "https://github.com/sigp/lighthouse", tag = "v8.0.0" } -lh_types = { package = "types", git = "https://github.com/sigp/lighthouse", tag = "v8.0.0" } +mediatype = "0.20.0" +lh_eth2 = { package = "eth2", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3", features = ["events"] } +lh_eth2_keystore = { package = "eth2_keystore", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } +lh_bls = { package = "bls", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } +lh_types = { package = "types", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } notify = "8.2.0" parking_lot = "0.12.3" pbkdf2 = "0.12.2" @@ -84,12 +87,12 @@ tracing = "0.1.40" tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] } tracing-test = { version = "0.2.5", features = ["no-env-filter"] } -tree_hash = "^0.10" -tree_hash_derive = "0.9" +tree_hash = "0.12" +tree_hash_derive = "0.12" typenum = "1.17.0" unicode-normalization = "0.1.24" url = { version = "2.5.0", features = ["serde"] } uuid = { version = "1.8.0", features = ["fast-rng", "serde", "v4"] } [patch.crates-io] -blstrs_plus = { git = "https://github.com/Commit-Boost/blstrs" } \ No newline at end of file +blstrs_plus = { git = "https://github.com/Commit-Boost/blstrs" } diff --git a/benches/microbench/src/get_header.rs b/benches/microbench/src/get_header.rs index 44eff329..40094ba6 100644 --- a/benches/microbench/src/get_header.rs +++ b/benches/microbench/src/get_header.rs @@ -40,16 +40,16 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; use alloy::primitives::B256; use axum::http::HeaderMap; -use cb_common::{pbs::GetHeaderParams, signer::random_secret, types::Chain}; +use cb_common::{pbs::GetHeaderParams, signer::random_secret, types::Chain, utils::EncodingType}; use cb_pbs::{PbsState, get_header}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, - utils::{generate_mock_relay, get_pbs_static_config, to_pbs_config}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, + utils::{generate_mock_relay, get_free_listener, get_pbs_config, to_pbs_config}, }; use criterion::{Criterion, black_box, criterion_group, criterion_main}; -// Ports 19201–19205 are reserved for the microbenchmark mock relays. -const BASE_PORT: u16 = 19200; +// Mock relay ports are allocated dynamically via get_free_listener() so that +// parallel test/bench runs don't collide on hardcoded ports. const CHAIN: Chain = Chain::Hoodi; const MAX_RELAYS: usize = 5; const RELAY_COUNTS: [usize; 3] = [1, 3, MAX_RELAYS]; @@ -83,10 +83,23 @@ fn bench_get_header(c: &mut Criterion) { let pubkey = signer.public_key(); let mock_state = Arc::new(MockRelayState::new(CHAIN, signer)); - let relay_clients: Vec<_> = (0..MAX_RELAYS) - .map(|i| { - let port = BASE_PORT + 1 + i as u16; - tokio::spawn(start_mock_relay_service(mock_state.clone(), port)); + // Allocate all listeners upfront so each port is reserved until the + // server takes ownership — avoids TOCTOU bind races. + let listeners: Vec<_> = { + let mut v = Vec::with_capacity(MAX_RELAYS); + for _ in 0..MAX_RELAYS { + v.push(get_free_listener().await); + } + v + }; + let ports: Vec = listeners.iter().map(|l| l.local_addr().unwrap().port()).collect(); + + let relay_clients: Vec<_> = listeners + .into_iter() + .enumerate() + .map(|(i, listener)| { + let port = ports[i]; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), listener)); generate_mock_relay(port, pubkey.clone()).expect("relay client") }) .collect(); @@ -103,8 +116,7 @@ fn bench_get_header(c: &mut Criterion) { let states: Vec = RELAY_COUNTS .iter() .map(|&n| { - let config = - to_pbs_config(CHAIN, get_pbs_static_config(0), relay_clients[..n].to_vec()); + let config = to_pbs_config(CHAIN, get_pbs_config(0), relay_clients[..n].to_vec()); PbsState::new(config, PathBuf::new()) }) .collect(); @@ -138,6 +150,7 @@ fn bench_get_header(c: &mut Criterion) { black_box(params.clone()), black_box(headers.clone()), black_box(state.clone()), + black_box(vec![EncodingType::Json, EncodingType::Ssz]), )) .expect("get_header failed") }) diff --git a/config.example.toml b/config.example.toml index 41707354..f7745df4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -49,9 +49,16 @@ min_bid_eth = 0.0 # to force local building and miniminzing the risk of missed slots. See also the timing games section below # OPTIONAL, DEFAULT: 2000 late_in_slot_time_ms = 2000 -# Whether to enable extra validation of get_header responses, if this is enabled `rpc_url` must also be set -# OPTIONAL, DEFAULT: false -extra_validation_enabled = false +# The level of validation to perform on get_header responses. Less is faster but not as safe. Supported values: +# - "none": no validation, just accept the bid provided by the relay as-is and pass it back without decoding or checking it +# - "standard": perform standard validation of the header provided by the relay, which checks the bid's signature and several hashes to make sure it's legal (default) +# - "extra": perform extra validation on top of standard validation, which includes checking the bid against the execution layer via the `rpc_url` (requires `rpc_url` to be set) +# OPTIONAL, DEFAULT: standard +header_validation_mode = "standard" +# The level of validation to perform on submit_block responses. Less is faster but not as safe. Supported values: +# - "none": no validation, just accept the full unblinded block provided by the relay as-is and pass it back without decoding or checking it +# - "standard": perform standard validation of the unblinded block provided by the relay, which verifies things like the included KZG commitments and the block hash (default) +block_validation_mode = "standard" # Execution Layer RPC url to use for extra validation # OPTIONAL # rpc_url = "https://ethereum-holesky-rpc.publicnode.com" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index cbb62fb4..eb87dd94 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -26,11 +26,14 @@ ethereum_ssz.workspace = true ethereum_ssz_derive.workspace = true eyre.workspace = true futures.workspace = true +headers-accept.workspace = true jsonwebtoken.workspace = true lazy_static.workspace = true +lh_bls.workspace = true lh_eth2.workspace = true lh_eth2_keystore.workspace = true lh_types.workspace = true +mediatype.workspace = true notify.workspace = true pbkdf2.workspace = true rand.workspace = true diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 907fbecf..ff1ec68e 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -38,6 +38,34 @@ use crate::{ }, }; +/// Header validation modes for get_header responses +#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HeaderValidationMode { + // Bypass all validation and minimize decoding, which is faster but requires complete trust in + // the relays + None, + + // Validate the header itself, ensuring that it's for a correct block on the correct chain and + // fork. This is the default mode. + Standard, + + // Standard header validation, plus validation that the parent block is correct as well + Extra, +} + +/// Block validation modes for submit_block responses +#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BlockValidationMode { + // Bypass all validation, which is faster but requires complete trust in the relays + None, + + // Validate the block matches the header previously received from get_header and that it's for + // the correct chain and fork. This is the default mode. + Standard, +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct RelayConfig { @@ -122,8 +150,11 @@ pub struct PbsConfig { #[serde(default = "default_u64::")] pub late_in_slot_time_ms: u64, /// Enable extra validation of get_header responses - #[serde(default = "default_bool::")] - pub extra_validation_enabled: bool, + #[serde(default = "default_header_validation_mode")] + pub header_validation_mode: HeaderValidationMode, + /// Enable extra validation of submit_block requests + #[serde(default = "default_block_validation_mode")] + pub block_validation_mode: BlockValidationMode, /// Execution Layer RPC url to use for extra validation pub rpc_url: Option, /// URL for the user's own SSV node API endpoint @@ -175,10 +206,10 @@ impl PbsConfig { format!("min bid is too high: {} ETH", format_ether(self.min_bid_wei)) ); - if self.extra_validation_enabled { + if self.header_validation_mode == HeaderValidationMode::Extra { ensure!( self.rpc_url.is_some(), - "rpc_url is required if extra_validation_enabled is true" + "rpc_url is required if header_validation_mode is set to extra" ); } @@ -291,7 +322,7 @@ pub async fn load_pbs_config(config_path: Option) -> Result<(PbsModuleC // Validate the muxes and build the lookup tables let (mux_lookup, registry_muxes) = match config.muxes { Some(muxes) => { - let (mux_lookup, registry_muxes) = + let (mux_lookup, registry_muxes): (_, _) = muxes.validate_and_fill(config.chain, &config.pbs.pbs_config).await?; (Some(mux_lookup), Some(registry_muxes)) } @@ -373,7 +404,7 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC // Validate the muxes and build the lookup tables let (mux_lookup, registry_muxes) = match cb_config.muxes { Some(muxes) => { - let (mux_lookup, registry_muxes) = muxes + let (mux_lookup, registry_muxes): (_, _) = muxes .validate_and_fill(cb_config.chain, &cb_config.pbs.static_config.pbs_config) .await?; (Some(mux_lookup), Some(registry_muxes)) @@ -442,6 +473,16 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC )) } +/// Default value for header validation mode +fn default_header_validation_mode() -> HeaderValidationMode { + HeaderValidationMode::Standard +} + +/// Default value for block validation mode +fn default_block_validation_mode() -> BlockValidationMode { + BlockValidationMode::Standard +} + /// Default URL for the user's SSV node API endpoint (/v1/validators). fn default_ssv_node_api_url() -> Url { Url::parse("http://localhost:16000/v1/").expect("default URL is valid") diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 95110958..06d43a01 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -424,7 +424,10 @@ mod tests { use alloy::primitives::{Uint, b256}; use super::*; - use crate::config::{LogsSettings, ModuleKind, PbsConfig, StaticModuleConfig, StaticPbsConfig}; + use crate::config::{ + BlockValidationMode, HeaderValidationMode, LogsSettings, ModuleKind, PbsConfig, + StaticModuleConfig, StaticPbsConfig, + }; // Wrapper needed because TOML requires a top-level struct (can't serialize // a bare enum). @@ -472,7 +475,8 @@ mod tests { skip_sigverify: false, min_bid_wei: Uint::<256, 4>::from(0), late_in_slot_time_ms: 0, - extra_validation_enabled: false, + header_validation_mode: HeaderValidationMode::Standard, + block_validation_mode: BlockValidationMode::Standard, rpc_url: None, http_timeout_seconds: 30, register_validator_retry_limit: 3, diff --git a/crates/common/src/pbs/error.rs b/crates/common/src/pbs/error.rs index 77d942cd..16ebdc35 100644 --- a/crates/common/src/pbs/error.rs +++ b/crates/common/src/pbs/error.rs @@ -14,6 +14,9 @@ pub enum PbsError { #[error("json decode error: {err:?}, raw: {raw}")] JsonDecode { err: serde_json::Error, raw: String }, + #[error("error with request: {0}")] + GeneralRequest(String), + #[error("{0}")] ReadResponse(#[from] ResponseReadError), @@ -107,3 +110,25 @@ pub enum ValidationError { #[error("unsupported fork")] UnsupportedFork, } + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SszValueError { + #[error("invalid payload length: required {required} but payload was {actual}")] + InvalidPayloadLength { required: usize, actual: usize }, + + #[error("unsupported fork")] + UnsupportedFork { name: String }, +} + +impl From for PbsError { + fn from(err: SszValueError) -> Self { + match err { + SszValueError::InvalidPayloadLength { required, actual } => PbsError::GeneralRequest( + format!("invalid payload length: required {required} but payload was {actual}"), + ), + SszValueError::UnsupportedFork { name } => { + PbsError::GeneralRequest(format!("unsupported fork: {name}")) + } + } + } +} diff --git a/crates/common/src/pbs/mod.rs b/crates/common/src/pbs/mod.rs index af2c07b4..a1152b58 100644 --- a/crates/common/src/pbs/mod.rs +++ b/crates/common/src/pbs/mod.rs @@ -6,5 +6,6 @@ mod types; pub use builder::*; pub use constants::*; +pub use lh_types::ForkVersionDecode; pub use relay::*; pub use types::*; diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index 8ad87c08..d9691e8a 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -1,6 +1,7 @@ use alloy::primitives::{B256, U256, b256}; +pub use lh_eth2::ForkVersionedResponse; +pub use lh_types::ForkName; use lh_types::{BlindedPayload, ExecPayload, MainnetEthSpec}; -pub use lh_types::{ForkName, ForkVersionedResponse}; use serde::{Deserialize, Serialize}; use crate::types::BlsPublicKey; @@ -8,39 +9,48 @@ use crate::types::BlsPublicKey; pub const EMPTY_TX_ROOT_HASH: B256 = b256!("7ffe241ea60187fdb0187bfa22de35d1f9bed7ab061d9401fd47e34a54fbede1"); -pub type ExecutionRequests = lh_types::execution_requests::ExecutionRequests; +pub type ExecutionRequests = lh_types::ExecutionRequests; /// Request object of POST `/eth/v1/builder/blinded_blocks` -pub type SignedBlindedBeaconBlock = - lh_types::signed_beacon_block::SignedBlindedBeaconBlock; +pub type SignedBlindedBeaconBlock = lh_types::SignedBlindedBeaconBlock; pub type BlindedBeaconBlock<'a> = - lh_types::beacon_block::BeaconBlockRef<'a, MainnetEthSpec, BlindedPayload>; + lh_types::BeaconBlockRef<'a, MainnetEthSpec, BlindedPayload>; pub type BlindedBeaconBlockElectra = - lh_types::beacon_block::BeaconBlockElectra>; + lh_types::BeaconBlockElectra>; pub type BlindedBeaconBlockFulu = - lh_types::beacon_block::BeaconBlockFulu>; + lh_types::BeaconBlockFulu>; pub type BlobsBundle = lh_eth2::types::BlobsBundle; pub type PayloadAndBlobs = lh_eth2::types::ExecutionPayloadAndBlobs; /// Response object of POST `/eth/v1/builder/blinded_blocks` -pub type SubmitBlindedBlockResponse = lh_types::ForkVersionedResponse; +pub type SubmitBlindedBlockResponse = ForkVersionedResponse; pub type ExecutionPayloadHeader = lh_types::ExecutionPayloadHeader; +pub type ExecutionPayloadHeaderBellatrix = + lh_types::ExecutionPayloadHeaderBellatrix; +pub type ExecutionPayloadHeaderCapella = lh_types::ExecutionPayloadHeaderCapella; +pub type ExecutionPayloadHeaderDeneb = lh_types::ExecutionPayloadHeaderDeneb; pub type ExecutionPayloadHeaderElectra = lh_types::ExecutionPayloadHeaderElectra; pub type ExecutionPayloadHeaderFulu = lh_types::ExecutionPayloadHeaderFulu; pub type ExecutionPayloadHeaderRef<'a> = lh_types::ExecutionPayloadHeaderRef<'a, MainnetEthSpec>; pub type ExecutionPayload = lh_types::ExecutionPayload; pub type ExecutionPayloadElectra = lh_types::ExecutionPayloadElectra; pub type ExecutionPayloadFulu = lh_types::ExecutionPayloadFulu; -pub type SignedBuilderBid = lh_types::builder_bid::SignedBuilderBid; -pub type BuilderBid = lh_types::builder_bid::BuilderBid; -pub type BuilderBidElectra = lh_types::builder_bid::BuilderBidElectra; +pub type SignedBuilderBid = lh_types::SignedBuilderBid; +pub type BuilderBid = lh_types::BuilderBid; +pub type BuilderBidBellatrix = lh_types::BuilderBidBellatrix; +pub type BuilderBidCapella = lh_types::BuilderBidCapella; +pub type BuilderBidDeneb = lh_types::BuilderBidDeneb; +pub type BuilderBidElectra = lh_types::BuilderBidElectra; +pub type BuilderBidFulu = lh_types::BuilderBidFulu; /// Response object of GET /// `/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}` -pub type GetHeaderResponse = lh_types::ForkVersionedResponse; +pub type GetHeaderResponse = ForkVersionedResponse; -pub type KzgCommitments = lh_types::beacon_block_body::KzgCommitments; +pub type KzgCommitments = lh_types::KzgCommitments; + +pub type Uint256 = lh_types::Uint256; /// Response params of GET /// `/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}` @@ -54,6 +64,17 @@ pub struct GetHeaderParams { pub pubkey: BlsPublicKey, } +/// Which encoding types the original requester accepts in the response. +/// As the builder spec adds more encoding types, this struct can be expanded. +#[derive(Clone)] +pub struct AcceptTypes { + /// Whether SSZ encoding is accepted + pub ssz: bool, + + /// Whether JSON encoding is accepted + pub json: bool, +} + pub trait GetHeaderInfo { fn block_hash(&self) -> B256; fn value(&self) -> &U256; diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index b347c187..38354ffc 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -9,10 +9,10 @@ use tree_hash_derive::TreeHash; use crate::{constants::APPLICATION_BUILDER_DOMAIN, signature::compute_domain}; -pub type BlsPublicKeyBytes = lh_types::PublicKeyBytes; -pub type BlsPublicKey = lh_types::PublicKey; -pub type BlsSignature = lh_types::Signature; -pub type BlsSecretKey = lh_types::SecretKey; +pub type BlsPublicKeyBytes = lh_bls::PublicKeyBytes; +pub type BlsPublicKey = lh_bls::PublicKey; +pub type BlsSignature = lh_bls::Signature; +pub type BlsSecretKey = lh_bls::SecretKey; #[derive(Clone, Debug, Display, PartialEq, Eq, Hash, Deref, From, Into, Serialize, Deserialize)] #[into(owned, ref, ref_mut)] diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index e504e477..d24acc57 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -1,7 +1,10 @@ #[cfg(feature = "testing-flags")] use std::cell::Cell; use std::{ + collections::HashMap, + fmt::Display, net::Ipv4Addr, + str::FromStr, time::{SystemTime, UNIX_EPOCH}, }; @@ -9,14 +12,30 @@ use alloy::{ hex, primitives::{U256, keccak256}, }; -use axum::http::HeaderValue; +use axum::{ + extract::{FromRequest, Request}, + http::HeaderValue, + response::{IntoResponse, Response as AxumResponse}, +}; +use bytes::Bytes; use futures::StreamExt; -use lh_types::test_utils::{SeedableRng, TestRandom, XorShiftRng}; +use headers_accept::Accept; +use lazy_static::lazy_static; +use lh_bls::Signature; +pub use lh_types::ForkName; +use lh_types::{ + BeaconBlock, + test_utils::{SeedableRng, TestRandom, XorShiftRng}, +}; +use mediatype::{MediaType, ReadParams}; use rand::{Rng, distr::Alphanumeric}; -use reqwest::{Response, header::HeaderMap}; +use reqwest::{ + Response, + header::{ACCEPT, CONTENT_TYPE, HeaderMap}, +}; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; -use ssz::{Decode, Encode}; +use ssz::{BYTES_PER_LENGTH_OFFSET, Decode, Encode}; use thiserror::Error; use tracing::Level; use tracing_appender::{non_blocking::WorkerGuard, rolling::Rotation}; @@ -29,11 +48,40 @@ use tracing_subscriber::{ use crate::{ config::LogsSettings, constants::SIGNER_JWT_EXPIRATION, - pbs::HEADER_VERSION_VALUE, + pbs::{ + BuilderBidBellatrix, BuilderBidCapella, BuilderBidDeneb, BuilderBidElectra, BuilderBidFulu, + ExecutionPayloadHeaderBellatrix, ExecutionPayloadHeaderCapella, + ExecutionPayloadHeaderDeneb, ExecutionPayloadHeaderElectra, ExecutionPayloadHeaderFulu, + ExecutionRequests, HEADER_VERSION_VALUE, KzgCommitments, SignedBlindedBeaconBlock, + error::SszValueError, + }, types::{BlsPublicKey, Chain, Jwt, JwtAdminClaims, JwtClaims, ModuleId}, }; +pub const APPLICATION_JSON: &str = "application/json"; +pub const APPLICATION_OCTET_STREAM: &str = "application/octet-stream"; +pub const WILDCARD: &str = "*/*"; + const MILLIS_PER_SECOND: u64 = 1_000; +pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; + +lazy_static! { + static ref SSZ_VALUE_OFFSETS_BY_FORK: HashMap = { + let mut map: HashMap = HashMap::new(); + let forks = [ + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ForkName::Electra, + ForkName::Fulu, + ]; + for fork in forks { + let offset = get_ssz_value_offset_for_fork(fork).unwrap(); // If there isn't a supported fork, this needs to be updated prior to release so panicking is fine + map.insert(fork, offset); + } + map + }; +} #[derive(Debug, Error)] pub enum ResponseReadError { @@ -517,6 +565,274 @@ pub fn get_user_agent_with_version(req_headers: &HeaderMap) -> eyre::Result eyre::Result> { + let mut ordered: Vec = Vec::new(); + let mut saw_any = false; + let mut had_supported = false; + for header in req_headers.get_all(ACCEPT).iter() { + let accept = Accept::from_str(header.to_str()?) + .map_err(|e| eyre::eyre!("invalid accept header: {e}"))?; + for mt in accept.media_types() { + saw_any = true; + + // Skip q=0 entries — RFC 7231 §5.3.1: "A request without any Accept + // header field implies that the user agent will accept any media + // type in response. When a header field is present ... a value of + // 0 means 'not acceptable'." + if let Some(q) = mt.get_param(mediatype::names::Q) && + q.as_str().parse::().is_ok_and(|v| v <= 0.0) + { + continue; + } + + let parsed = match mt.essence().to_string().as_str() { + APPLICATION_OCTET_STREAM => Some(EncodingType::Ssz), + APPLICATION_JSON | WILDCARD => Some(EncodingType::Json), + _ => None, + }; + if let Some(enc) = parsed { + had_supported = true; + if !ordered.contains(&enc) { + ordered.push(enc); + } + } + } + } + + if ordered.is_empty() { + if saw_any && !had_supported { + return Err(eyre::eyre!("unsupported accept type")); + } + + // No accept header (or only q=0 rejections): fall back to the request + // Content-Type, which mirrors the historical behavior. + ordered.push(get_content_type(req_headers)); + } + Ok(ordered) +} + +/// Pick the caller's highest-preference encoding from a list of types the +/// server can actually produce. `accepts` is expected to be pre-ordered by +/// descending preference (as returned by [`get_accept_types`]). Returns +/// `None` if no overlap exists. +pub fn preferred_encoding( + accepts: &[EncodingType], + supported: &[EncodingType], +) -> Option { + accepts.iter().copied().find(|a| supported.contains(a)) +} + +/// Compute the q-value for the `index`-th preferred encoding when building an +/// outbound `Accept` header. The first entry gets q=1.0, each subsequent entry +/// decreases by 0.1, and the value is clamped to a minimum of 0.1 so we never +/// emit q=0 (which per RFC 7231 §5.3.1 means "not acceptable"). +fn accept_q_value_for_index(index: usize) -> f32 { + // `as i32` would silently wrap for large indices (e.g. usize::MAX → -1), + // which would invert the clamp. Saturate the cast explicitly. + let idx = i32::try_from(index).unwrap_or(i32::MAX); + let step = 10_i32.saturating_sub(idx).max(1); + step as f32 / 10.0 +} + +/// Format a single `Accept` header entry as `";q="`. +fn format_accept_entry(enc: EncodingType, q: f32) -> String { + format!("{};q={:.1}", enc.content_type(), q) +} + +/// Build an `Accept` header string that mirrors the caller's preference order +/// so the relay sees the same priority the beacon node asked us for. Each +/// subsequent entry receives a q-value 0.1 lower than the previous one, +/// starting at 1.0. Returns an empty string for an empty preference list. +pub fn build_outbound_accept(preferred: &[EncodingType]) -> String { + preferred + .iter() + .enumerate() + .map(|(i, enc)| format_accept_entry(*enc, accept_q_value_for_index(i))) + .collect::>() + .join(",") +} + +/// Parse CONTENT TYPE header to get the encoding type of the body, defaulting +/// to JSON if missing or malformed. +pub fn get_content_type(req_headers: &HeaderMap) -> EncodingType { + EncodingType::from_str( + req_headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(APPLICATION_JSON), + ) + .unwrap_or(EncodingType::Json) +} + +/// Parse CONSENSUS_VERSION header +pub fn get_consensus_version_header(req_headers: &HeaderMap) -> Option { + ForkName::from_str( + req_headers + .get(CONSENSUS_VERSION_HEADER) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""), + ) + .ok() +} + +/// Enum for types that can be used to encode incoming request bodies or +/// outgoing response bodies +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EncodingType { + /// Body is UTF-8 encoded as JSON + Json, + + /// Body is raw bytes representing an SSZ object + Ssz, +} + +impl EncodingType { + /// Get the content type string for the encoding type + pub fn content_type(&self) -> &str { + match self { + EncodingType::Json => APPLICATION_JSON, + EncodingType::Ssz => APPLICATION_OCTET_STREAM, + } + } +} + +impl std::fmt::Display for EncodingType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.content_type()) + } +} + +impl FromStr for EncodingType { + type Err = String; + fn from_str(value: &str) -> Result { + // Preserve prior behavior: empty defaults to JSON (used by + // `get_content_type` when Content-Type header is absent). + if value.is_empty() { + return Ok(EncodingType::Json); + } + // Parse as a media type so we tolerate RFC 7231 §3.1.1.1 parameters + // (e.g. `application/json; charset=utf-8`). Compare essence only. + let parsed = + MediaType::parse(value).map_err(|e| format!("invalid content type {value}: {e}"))?; + match parsed.essence().to_string().to_ascii_lowercase().as_str() { + APPLICATION_JSON => Ok(EncodingType::Json), + APPLICATION_OCTET_STREAM => Ok(EncodingType::Ssz), + _ => Err(format!("unsupported encoding type: {value}")), + } + } +} + +/// Parse the Content-Type and Eth-Consensus-Version headers from a relay +/// response, returning the encoding to use for body decoding and the +/// optional fork name. Tolerates MIME parameters per RFC 7231 §3.1.1.1 and +/// defaults to JSON when no Content-Type header is present (matching legacy +/// relay behavior). `code` is the HTTP status of the response and is echoed +/// back in any `PbsError::RelayResponse` this function produces, so callers +/// can surface the original status on decode failure. +pub fn parse_response_encoding_and_fork( + headers: &HeaderMap, + code: u16, +) -> Result<(EncodingType, Option), crate::pbs::error::PbsError> { + use crate::pbs::error::PbsError; + let content_type = match headers.get(CONTENT_TYPE) { + // Assume missing Content-Type means JSON; shouldn't happen in + // practice but just in case. + None => EncodingType::Json, + Some(hv) => { + let header_str = hv.to_str().map_err(|e| PbsError::RelayResponse { + error_msg: format!("cannot decode content-type header: {e}"), + code, + })?; + EncodingType::from_str(header_str) + .map_err(|msg| PbsError::RelayResponse { error_msg: msg, code })? + } + }; + Ok((content_type, get_consensus_version_header(headers))) +} + +pub enum BodyDeserializeError { + SerdeJsonError(serde_json::Error), + SszDecodeError(ssz::DecodeError), + UnsupportedMediaType, + MissingVersionHeader, +} + +impl Display for BodyDeserializeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BodyDeserializeError::SerdeJsonError(e) => write!(f, "JSON deserialization error: {e}"), + BodyDeserializeError::SszDecodeError(e) => { + write!(f, "SSZ deserialization error: {e:?}") + } + BodyDeserializeError::UnsupportedMediaType => write!(f, "unsupported media type"), + BodyDeserializeError::MissingVersionHeader => { + write!(f, "missing consensus version header") + } + } + } +} + +pub async fn deserialize_body( + headers: &HeaderMap, + body: Bytes, +) -> Result { + if headers.contains_key(CONTENT_TYPE) { + return match get_content_type(headers) { + EncodingType::Json => serde_json::from_slice::(&body) + .map_err(BodyDeserializeError::SerdeJsonError), + EncodingType::Ssz => { + // Get the version header + match get_consensus_version_header(headers) { + Some(version) => { + SignedBlindedBeaconBlock::from_ssz_bytes_with(&body, |bytes| { + BeaconBlock::from_ssz_bytes_for_fork(bytes, version) + }) + .map_err(BodyDeserializeError::SszDecodeError) + } + None => Err(BodyDeserializeError::MissingVersionHeader), + } + } + }; + } + + Err(BodyDeserializeError::UnsupportedMediaType) +} + +#[must_use] +#[derive(Debug, Clone, Default)] +pub struct RawRequest { + pub body_bytes: Bytes, +} + +impl FromRequest for RawRequest +where + S: Send + Sync, +{ + type Rejection = AxumResponse; + + async fn from_request(req: Request, _state: &S) -> Result { + let bytes = Bytes::from_request(req, _state).await.map_err(IntoResponse::into_response)?; + Ok(Self { body_bytes: bytes }) + } +} + #[cfg(unix)] pub async fn wait_for_signal() -> eyre::Result<()> { use tokio::signal::unix::{SignalKind, signal}; @@ -566,19 +882,131 @@ pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { bls_pubkey_from_hex(hex).unwrap() } +// Get the offset of the message in a SignedBuilderBid SSZ structure +fn get_ssz_value_offset_for_fork(fork: ForkName) -> Option { + match fork { + ForkName::Bellatrix => { + // Message goes header -> value -> pubkey + Some( + get_message_offset::() + + ::ssz_fixed_len(), + ) + } + + ForkName::Capella => { + // Message goes header -> value -> pubkey + Some( + get_message_offset::() + + ::ssz_fixed_len(), + ) + } + + ForkName::Deneb => { + // Message goes header -> blob_kzg_commitments -> value -> pubkey + Some( + get_message_offset::() + + ::ssz_fixed_len() + + ::ssz_fixed_len(), + ) + } + + ForkName::Electra => { + // Message goes header -> blob_kzg_commitments -> execution_requests -> value -> + // pubkey + Some( + get_message_offset::() + + ::ssz_fixed_len() + + ::ssz_fixed_len() + + ::ssz_fixed_len(), + ) + } + + ForkName::Fulu => { + // Message goes header -> blob_kzg_commitments -> execution_requests -> value -> + // pubkey + Some( + get_message_offset::() + + ::ssz_fixed_len() + + ::ssz_fixed_len() + + ::ssz_fixed_len(), + ) + } + + _ => None, + } +} + +/// Extracts the bid value from SSZ-encoded SignedBuilderBid response bytes. +pub fn get_bid_value_from_signed_builder_bid_ssz( + response_bytes: &[u8], + fork: ForkName, +) -> Result { + let value_offset = SSZ_VALUE_OFFSETS_BY_FORK + .get(&fork) + .ok_or(SszValueError::UnsupportedFork { name: fork.to_string() })?; + + // Sanity check the response length so we don't panic trying to slice it + let end_offset = value_offset + 32; // U256 is 32 bytes + if response_bytes.len() < end_offset { + return Err(SszValueError::InvalidPayloadLength { + required: end_offset, + actual: response_bytes.len(), + }); + } + + // Extract the value bytes and convert to U256 + let value_bytes = &response_bytes[*value_offset..end_offset]; + let value = U256::from_le_slice(value_bytes); + Ok(value) +} + +// Get the offset where the `message` field starts in some SignedBuilderBid SSZ +// data. Requires that SignedBuilderBid always has the following structure: +// message -> signature +// where `message` is a BuilderBid type determined by the fork choice, and +// `signature` is a fixed-length Signature type. +fn get_message_offset() -> usize +where + BuilderBidType: ssz::Encode, +{ + // Since `message` is the first field, its offset is always 0 + let mut offset = 0; + + // If it's variable length, then it will be represented by a pointer to + // the actual data, so we need to get the location of where that data starts + if !BuilderBidType::is_ssz_fixed_len() { + offset += BYTES_PER_LENGTH_OFFSET + ::ssz_fixed_len(); + } + + offset +} + #[cfg(test)] mod test { use alloy::primitives::keccak256; + use axum::http::{HeaderMap, HeaderName, HeaderValue}; + use bytes::Bytes; + use reqwest::header::{ACCEPT, CONTENT_TYPE}; use super::{ - create_admin_jwt, create_jwt, decode_admin_jwt, decode_jwt, random_jwt_secret, + BodyDeserializeError, CONSENSUS_VERSION_HEADER, OUTBOUND_ACCEPT, accept_q_value_for_index, + build_outbound_accept, create_admin_jwt, create_jwt, decode_admin_jwt, decode_jwt, + deserialize_body, format_accept_entry, get_consensus_version_header, get_content_type, + parse_response_encoding_and_fork, preferred_encoding, random_jwt_secret, validate_admin_jwt, validate_jwt, }; use crate::{ constants::SIGNER_JWT_EXPIRATION, + pbs::error::SszValueError, types::{Jwt, JwtAdminClaims, ModuleId}, + utils::{ + APPLICATION_JSON, APPLICATION_OCTET_STREAM, EncodingType, ForkName, WILDCARD, + get_accept_types, get_bid_value_from_signed_builder_bid_ssz, + }, }; + const APPLICATION_TEXT: &str = "application/text"; + #[test] fn test_jwt_validation_no_payload_hash() { // Check valid JWT @@ -605,6 +1033,212 @@ mod test { assert_eq!(response.unwrap_err().to_string(), "InvalidSignature"); } + /// Make sure a missing Accept header is interpreted as JSON + #[test] + fn test_missing_accept_header() { + let headers = HeaderMap::new(); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, vec![EncodingType::Json]); + } + + /// Test accepting JSON + #[test] + fn test_accept_header_json() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_JSON).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, vec![EncodingType::Json]); + } + + /// Test accepting SSZ + #[test] + fn test_accept_header_ssz() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, vec![EncodingType::Ssz]); + } + + /// Test accepting wildcards + #[test] + fn test_accept_header_wildcard() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(WILDCARD).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, vec![EncodingType::Json]); + } + + /// Test accepting one header with multiple values (order preserved, + /// first listed wins at equal q) + #[test] + fn test_accept_header_multiple_values() { + let header_string = format!("{APPLICATION_JSON}, {APPLICATION_OCTET_STREAM}"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, vec![EncodingType::Json, EncodingType::Ssz]); + } + + /// Test accepting multiple headers + #[test] + fn test_multiple_accept_headers() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_JSON).unwrap()); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert!(result.contains(&EncodingType::Json)); + assert!(result.contains(&EncodingType::Ssz)); + assert_eq!(result.len(), 2); + } + + /// Test accepting one header with multiple values, including a type that + /// can't be used + #[test] + fn test_accept_header_multiple_values_including_unknown() { + let header_string = + format!("{APPLICATION_JSON}, {APPLICATION_OCTET_STREAM}, {APPLICATION_TEXT}"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, vec![EncodingType::Json, EncodingType::Ssz]); + } + + /// Test rejecting an unknown accept type + #[test] + fn test_invalid_accept_header_type() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_TEXT).unwrap()); + let result = get_accept_types(&headers); + assert!(result.is_err()); + } + + /// Test accepting one header with multiple values + #[test] + fn test_accept_header_invalid_parse() { + let header_string = format!("{APPLICATION_JSON}, a?;ef)"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + let result = get_accept_types(&headers); + assert!(result.is_err()); + } + + /// q-values are honored: JSON@1.0 should outrank SSZ@0.1 regardless of + /// byte order in the header. + #[test] + fn test_accept_header_q_value_ordering() { + let mut headers = HeaderMap::new(); + headers.append( + ACCEPT, + HeaderValue::from_str("application/json;q=1.0, application/octet-stream;q=0.1") + .unwrap(), + ); + assert_eq!(get_accept_types(&headers).unwrap(), vec![ + EncodingType::Json, + EncodingType::Ssz + ]); + + let mut headers = HeaderMap::new(); + headers.append( + ACCEPT, + HeaderValue::from_str("application/octet-stream;q=0.1, application/json;q=1.0") + .unwrap(), + ); + assert_eq!(get_accept_types(&headers).unwrap(), vec![ + EncodingType::Json, + EncodingType::Ssz + ]); + } + + /// q=0 is an explicit rejection per RFC 7231 §5.3.1 and must be dropped. + #[test] + fn test_accept_header_q_zero_rejected() { + let mut headers = HeaderMap::new(); + headers.append( + ACCEPT, + HeaderValue::from_str("application/json, application/octet-stream;q=0").unwrap(), + ); + assert_eq!(get_accept_types(&headers).unwrap(), vec![EncodingType::Json]); + } + + /// An Accept header containing only q=0 for every supported type is a + /// deliberate "I accept nothing" and must error (so the route can return + /// 406 Not Acceptable per RFC 7231 §5.3.1 and §6.5.6). + #[test] + fn test_accept_header_only_q_zero_errors() { + let mut headers = HeaderMap::new(); + headers.append( + ACCEPT, + HeaderValue::from_str("application/json;q=0, application/octet-stream;q=0").unwrap(), + ); + assert!(get_accept_types(&headers).is_err()); + } + + /// `preferred_encoding` picks the caller's first choice that the server + /// can actually produce. + #[test] + fn test_preferred_encoding_picks_highest_q_match() { + let accepts = [EncodingType::Json, EncodingType::Ssz]; + let supported = [EncodingType::Ssz, EncodingType::Json]; + assert_eq!(preferred_encoding(&accepts, &supported), Some(EncodingType::Json)); + + let accepts = [EncodingType::Ssz]; + let supported = [EncodingType::Json]; + assert_eq!(preferred_encoding(&accepts, &supported), None); + } + + /// Outbound Accept should be deterministic and q-ordered to match caller + /// preference. + #[test] + fn test_build_outbound_accept_deterministic() { + let s = build_outbound_accept(&[EncodingType::Ssz, EncodingType::Json]); + assert_eq!(s, "application/octet-stream;q=1.0,application/json;q=0.9"); + + let s = build_outbound_accept(&[EncodingType::Json, EncodingType::Ssz]); + assert_eq!(s, "application/json;q=1.0,application/octet-stream;q=0.9"); + + // Stable across repeats + for _ in 0..100 { + assert_eq!( + build_outbound_accept(&[EncodingType::Ssz, EncodingType::Json]), + "application/octet-stream;q=1.0,application/json;q=0.9" + ); + } + } + /// Snapshot test: constant emits exactly what we document in + /// OUTBOUND_ACCEPT. + #[test] + fn test_outbound_accept_constant_snapshot() { + assert_eq!(OUTBOUND_ACCEPT, "application/octet-stream;q=1.0,application/json;q=0.9"); + } + + /// q-value ladder: first entry is 1.0, each subsequent entry drops by 0.1. + #[test] + fn test_accept_q_value_for_index_ladder() { + assert!((accept_q_value_for_index(0) - 1.0).abs() < f32::EPSILON); + assert!((accept_q_value_for_index(1) - 0.9).abs() < f32::EPSILON); + assert!((accept_q_value_for_index(5) - 0.5).abs() < f32::EPSILON); + assert!((accept_q_value_for_index(9) - 0.1).abs() < f32::EPSILON); + } + + /// Clamp at 0.1: we never emit q=0 (which per RFC 7231 §5.3.1 would mean + /// "not acceptable"). + #[test] + fn test_accept_q_value_for_index_clamps_to_minimum() { + assert!((accept_q_value_for_index(10) - 0.1).abs() < f32::EPSILON); + assert!((accept_q_value_for_index(100) - 0.1).abs() < f32::EPSILON); + // Even an adversarial usize::MAX must not underflow or drop to zero. + assert!((accept_q_value_for_index(usize::MAX) - 0.1).abs() < f32::EPSILON); + } + + /// Entry formatter emits the spec-shaped string. + #[test] + fn test_format_accept_entry_shape() { + assert_eq!(format_accept_entry(EncodingType::Ssz, 1.0), "application/octet-stream;q=1.0"); + assert_eq!(format_accept_entry(EncodingType::Json, 0.9), "application/json;q=0.9"); + // One decimal place, even when the value has more precision. + assert_eq!(format_accept_entry(EncodingType::Json, 0.12345), "application/json;q=0.1"); + } + #[test] fn test_jwt_validation_with_payload() { // Pretend payload @@ -783,4 +1417,266 @@ mod test { // Two calls should produce distinct values with overwhelming probability. assert_ne!(secret, random_jwt_secret()); } + + // ── get_content_type ───────────────────────────────────────────────────── + + #[test] + fn test_content_type_missing_defaults_to_json() { + let headers = HeaderMap::new(); + assert_eq!(get_content_type(&headers), EncodingType::Json); + } + + #[test] + fn test_content_type_json() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_str(APPLICATION_JSON).unwrap()); + assert_eq!(get_content_type(&headers), EncodingType::Json); + } + + #[test] + fn test_content_type_ssz() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap()); + assert_eq!(get_content_type(&headers), EncodingType::Ssz); + } + + #[test] + fn test_content_type_unknown_defaults_to_json() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_str("application/xml").unwrap()); + assert_eq!(get_content_type(&headers), EncodingType::Json); + } + + // ── get_consensus_version_header ───────────────────────────────────────── + + #[test] + fn test_consensus_version_header_electra() { + let mut headers = HeaderMap::new(); + let name = HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(); + headers.insert(name, HeaderValue::from_str("electra").unwrap()); + assert_eq!(get_consensus_version_header(&headers), Some(ForkName::Electra)); + } + + #[test] + fn test_consensus_version_header_missing() { + let headers = HeaderMap::new(); + assert_eq!(get_consensus_version_header(&headers), None); + } + + #[test] + fn test_consensus_version_header_invalid() { + let mut headers = HeaderMap::new(); + let name = HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(); + headers.insert(name, HeaderValue::from_str("not_a_fork").unwrap()); + assert_eq!(get_consensus_version_header(&headers), None); + } + + // ── EncodingType ───────────────────────────────────────────────────────── + + #[test] + fn test_encoding_type_from_str_variants() { + use std::str::FromStr; + assert_eq!(EncodingType::from_str(APPLICATION_JSON).unwrap(), EncodingType::Json); + assert_eq!(EncodingType::from_str(APPLICATION_OCTET_STREAM).unwrap(), EncodingType::Ssz); + // empty string defaults to JSON per the impl + assert_eq!(EncodingType::from_str("").unwrap(), EncodingType::Json); + assert!(EncodingType::from_str("application/xml").is_err()); + } + + #[test] + fn test_encoding_type_from_str_with_mime_params() { + // RFC 7231 §3.1.1.1: media-type parameters must be tolerated. + // Relays behind proxies routinely add charset= and similar. + use std::str::FromStr; + assert_eq!( + EncodingType::from_str("application/json; charset=utf-8").unwrap(), + EncodingType::Json + ); + assert_eq!( + EncodingType::from_str("application/octet-stream; boundary=x").unwrap(), + EncodingType::Ssz + ); + // Case-insensitivity per RFC 7231: type/subtype are lowercased before + // comparison. + assert_eq!(EncodingType::from_str("APPLICATION/OCTET-STREAM").unwrap(), EncodingType::Ssz); + // Extra whitespace around parameters is tolerated by the MIME parser. + assert_eq!( + EncodingType::from_str("application/json;charset=utf-8").unwrap(), + EncodingType::Json + ); + // Garbage that can't parse as a media type is an error. + assert!(EncodingType::from_str("garbage").is_err()); + // A parseable media type that isn't one we support is an error. + assert!(EncodingType::from_str("text/plain").is_err()); + } + + #[test] + fn test_parse_response_encoding_and_fork_tolerates_mime_params() { + // Full integration of the helper: missing header defaults to JSON, + // present header with params still decodes correctly. + let mut headers = HeaderMap::new(); + let (enc, fork) = parse_response_encoding_and_fork(&headers, 200).unwrap(); + assert_eq!(enc, EncodingType::Json); + assert!(fork.is_none()); + + headers.insert( + CONTENT_TYPE, + HeaderValue::from_str("application/octet-stream; charset=binary").unwrap(), + ); + let (enc, _) = parse_response_encoding_and_fork(&headers, 200).unwrap(); + assert_eq!(enc, EncodingType::Ssz); + + headers.insert(CONTENT_TYPE, HeaderValue::from_str("application/xml").unwrap()); + let err = parse_response_encoding_and_fork(&headers, 415).unwrap_err(); + match err { + crate::pbs::error::PbsError::RelayResponse { code, .. } => assert_eq!(code, 415), + other => panic!("expected RelayResponse, got {other:?}"), + } + } + + #[test] + fn test_encoding_type_display() { + assert_eq!(EncodingType::Json.to_string(), APPLICATION_JSON); + assert_eq!(EncodingType::Ssz.to_string(), APPLICATION_OCTET_STREAM); + } + + // ── get_bid_value_from_signed_builder_bid_ssz ──────────────────────────── + + #[test] + fn test_ssz_value_extraction_unsupported_fork() { + let dummy_bytes = vec![0u8; 1000]; + let err = + get_bid_value_from_signed_builder_bid_ssz(&dummy_bytes, ForkName::Altair).unwrap_err(); + assert!(matches!(err, SszValueError::UnsupportedFork { .. })); + } + + #[test] + fn test_ssz_value_extraction_truncated_payload() { + // A payload that is far too short for any supported fork's value offset + let tiny_bytes = vec![0u8; 4]; + let err = + get_bid_value_from_signed_builder_bid_ssz(&tiny_bytes, ForkName::Electra).unwrap_err(); + assert!(matches!(err, SszValueError::InvalidPayloadLength { .. })); + } + + /// Per-fork positive tests: construct a `SignedBuilderBid` with a known + /// value for each supported fork, SSZ-encode it, and verify + /// `get_bid_value_from_signed_builder_bid_ssz` round-trips correctly. + #[test] + fn test_ssz_value_extraction_with_known_bid() { + use alloy::primitives::U256; + use ssz::Encode; + + use crate::{ + pbs::{ + BuilderBid, BuilderBidBellatrix, BuilderBidCapella, BuilderBidDeneb, + BuilderBidElectra, BuilderBidFulu, ExecutionPayloadHeaderBellatrix, + ExecutionPayloadHeaderCapella, ExecutionPayloadHeaderDeneb, + ExecutionPayloadHeaderElectra, ExecutionPayloadHeaderFulu, ExecutionRequests, + SignedBuilderBid, + }, + types::{BlsPublicKeyBytes, BlsSignature}, + utils::TestRandomSeed, + }; + + // Distinctive value — large enough that endianness bugs produce a + // different number and zero-matches are impossible. + let known_value = U256::from(0x0102_0304_0506_0708_u64); + let pubkey = BlsPublicKeyBytes::test_random(); + let sig = BlsSignature::test_random(); + + // ── Bellatrix ──────────────────────────────────────────────────────── + { + let message = BuilderBid::Bellatrix(BuilderBidBellatrix { + header: ExecutionPayloadHeaderBellatrix::test_random(), + value: known_value, + pubkey, + }); + let bid = SignedBuilderBid { message, signature: sig.clone() }; + let got = + get_bid_value_from_signed_builder_bid_ssz(&bid.as_ssz_bytes(), ForkName::Bellatrix) + .expect("Bellatrix extraction failed"); + assert_eq!(got, known_value, "Bellatrix: value mismatch"); + } + + // ── Capella ────────────────────────────────────────────────────────── + { + let message = BuilderBid::Capella(BuilderBidCapella { + header: ExecutionPayloadHeaderCapella::test_random(), + value: known_value, + pubkey, + }); + let bid = SignedBuilderBid { message, signature: sig.clone() }; + let got = + get_bid_value_from_signed_builder_bid_ssz(&bid.as_ssz_bytes(), ForkName::Capella) + .expect("Capella extraction failed"); + assert_eq!(got, known_value, "Capella: value mismatch"); + } + + // ── Deneb ──────────────────────────────────────────────────────────── + { + let message = BuilderBid::Deneb(BuilderBidDeneb { + header: ExecutionPayloadHeaderDeneb::test_random(), + blob_kzg_commitments: Default::default(), + value: known_value, + pubkey, + }); + let bid = SignedBuilderBid { message, signature: sig.clone() }; + let got = + get_bid_value_from_signed_builder_bid_ssz(&bid.as_ssz_bytes(), ForkName::Deneb) + .expect("Deneb extraction failed"); + assert_eq!(got, known_value, "Deneb: value mismatch"); + } + + // ── Electra ────────────────────────────────────────────────────────── + { + let message = BuilderBid::Electra(BuilderBidElectra { + header: ExecutionPayloadHeaderElectra::test_random(), + blob_kzg_commitments: Default::default(), + execution_requests: ExecutionRequests::default(), + value: known_value, + pubkey, + }); + let bid = SignedBuilderBid { message, signature: sig.clone() }; + let got = + get_bid_value_from_signed_builder_bid_ssz(&bid.as_ssz_bytes(), ForkName::Electra) + .expect("Electra extraction failed"); + assert_eq!(got, known_value, "Electra: value mismatch"); + } + + // ── Fulu ───────────────────────────────────────────────────────────── + { + let message = BuilderBid::Fulu(BuilderBidFulu { + header: ExecutionPayloadHeaderFulu::test_random(), + blob_kzg_commitments: Default::default(), + execution_requests: ExecutionRequests::default(), + value: known_value, + pubkey, + }); + let bid = SignedBuilderBid { message, signature: sig }; + let got = + get_bid_value_from_signed_builder_bid_ssz(&bid.as_ssz_bytes(), ForkName::Fulu) + .expect("Fulu extraction failed"); + assert_eq!(got, known_value, "Fulu: value mismatch"); + } + } + + // ── deserialize_body error paths ───────────────────────────────────────── + + #[tokio::test] + async fn test_deserialize_body_missing_content_type() { + let headers = HeaderMap::new(); + let body = Bytes::from_static(b"{}"); + let err = deserialize_body(&headers, body).await.unwrap_err(); + assert!(matches!(err, BodyDeserializeError::UnsupportedMediaType)); + } + + #[tokio::test] + async fn test_deserialize_body_ssz_missing_version_header() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap()); + let body = Bytes::from_static(b"\x00\x01\x02\x03"); + let err = deserialize_body(&headers, body).await.unwrap_err(); + assert!(matches!(err, BodyDeserializeError::MissingVersionHeader)); + } } diff --git a/crates/pbs/Cargo.toml b/crates/pbs/Cargo.toml index a9124c06..9d9df214 100644 --- a/crates/pbs/Cargo.toml +++ b/crates/pbs/Cargo.toml @@ -12,9 +12,13 @@ axum.workspace = true axum-extra.workspace = true cb-common.workspace = true cb-metrics.workspace = true +ethereum_serde_utils.workspace = true +ethereum_ssz.workspace = true eyre.workspace = true futures.workspace = true +headers.workspace = true lazy_static.workspace = true +lh_types.workspace = true notify.workspace = true parking_lot.workspace = true prometheus.workspace = true diff --git a/crates/pbs/src/api.rs b/crates/pbs/src/api.rs index 594b7d36..03b1a8f1 100644 --- a/crates/pbs/src/api.rs +++ b/crates/pbs/src/api.rs @@ -2,13 +2,13 @@ use std::sync::Arc; use async_trait::async_trait; use axum::{Router, http::HeaderMap}; -use cb_common::pbs::{ - BuilderApiVersion, GetHeaderParams, GetHeaderResponse, SignedBlindedBeaconBlock, - SubmitBlindedBlockResponse, +use cb_common::{ + pbs::{BuilderApiVersion, GetHeaderParams, SignedBlindedBeaconBlock}, + utils::EncodingType, }; use crate::{ - mev_boost, + CompoundGetHeaderResponse, CompoundSubmitBlockResponse, mev_boost, state::{BuilderApiState, PbsState, PbsStateGuard}, }; @@ -24,8 +24,9 @@ pub trait BuilderApi: 'static { params: GetHeaderParams, req_headers: HeaderMap, state: PbsState, - ) -> eyre::Result> { - mev_boost::get_header(params, req_headers, state).await + accepted_types: Vec, + ) -> eyre::Result> { + mev_boost::get_header(params, req_headers, state, accepted_types).await } /// https://ethereum.github.io/builder-specs/#/Builder/status @@ -40,8 +41,16 @@ pub trait BuilderApi: 'static { req_headers: HeaderMap, state: PbsState, api_version: BuilderApiVersion, - ) -> eyre::Result> { - mev_boost::submit_block(signed_blinded_block, req_headers, state, api_version).await + accepted_types: Vec, + ) -> eyre::Result { + mev_boost::submit_block( + signed_blinded_block, + req_headers, + state, + api_version, + accepted_types, + ) + .await } /// https://ethereum.github.io/builder-specs/#/Builder/registerValidator diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index 590c03d4..1214fd6a 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -1,4 +1,5 @@ use axum::{http::StatusCode, response::IntoResponse}; +use cb_common::utils::BodyDeserializeError; #[derive(Debug)] /// Errors that the PbsService returns to client @@ -6,6 +7,8 @@ pub enum PbsClientError { NoResponse, NoPayload, Internal, + DecodeError(String), + RelayError(String), } impl PbsClientError { @@ -14,16 +17,26 @@ impl PbsClientError { PbsClientError::NoResponse => StatusCode::BAD_GATEWAY, PbsClientError::NoPayload => StatusCode::BAD_GATEWAY, PbsClientError::Internal => StatusCode::INTERNAL_SERVER_ERROR, + PbsClientError::DecodeError(_) => StatusCode::BAD_REQUEST, + PbsClientError::RelayError(_) => StatusCode::FAILED_DEPENDENCY, } } } +impl From for PbsClientError { + fn from(e: BodyDeserializeError) -> Self { + PbsClientError::DecodeError(format!("failed to deserialize body: {e}")) + } +} + impl IntoResponse for PbsClientError { fn into_response(self) -> axum::response::Response { let msg = match &self { PbsClientError::NoResponse => "no response from relays".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::RelayError(e) => format!("error processing relay response: {e}"), }; (self.status_code(), msg).into_response() diff --git a/crates/pbs/src/metrics.rs b/crates/pbs/src/metrics.rs index 1f91e47f..2bf9b912 100644 --- a/crates/pbs/src/metrics.rs +++ b/crates/pbs/src/metrics.rs @@ -60,4 +60,14 @@ lazy_static! { &["http_status_code", "endpoint"], PBS_METRICS_REGISTRY ).unwrap(); + + /// Count of v2 submit_block requests that fell back to the v1 endpoint + /// because the relay returned 404 on v2. A high value indicates the relay + /// fleet has not been upgraded to support submitBlindedBlockV2. + pub static ref V2_FALLBACK_TO_V1: IntCounterVec = register_int_counter_vec_with_registry!( + "pbs_submit_block_v2_fallback_to_v1_total", + "Count of v2 submit_block requests that fell back to v1 because the relay did not support v2", + &["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 d25df53c..e47e0e63 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -10,22 +10,30 @@ use alloy::{ }; use axum::http::{HeaderMap, HeaderValue}; use cb_common::{ + config::HeaderValidationMode, constants::APPLICATION_BUILDER_DOMAIN, pbs::{ - EMPTY_TX_ROOT_HASH, ExecutionPayloadHeaderRef, GetHeaderInfo, GetHeaderParams, - GetHeaderResponse, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, RelayClient, + EMPTY_TX_ROOT_HASH, ExecutionPayloadHeaderRef, ForkName, ForkVersionDecode, GetHeaderInfo, + GetHeaderParams, GetHeaderResponse, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, + RelayClient, SignedBuilderBid, error::{PbsError, ValidationError}, }, signature::verify_signed_message, types::{BlsPublicKey, BlsPublicKeyBytes, BlsSignature, Chain}, utils::{ - get_user_agent_with_version, ms_into_slot, read_chunked_body_with_max, - timestamp_of_slot_start_sec, utcnow_ms, + EncodingType, OUTBOUND_ACCEPT, build_outbound_accept, + get_bid_value_from_signed_builder_bid_ssz, get_user_agent_with_version, ms_into_slot, + parse_response_encoding_and_fork, read_chunked_body_with_max, timestamp_of_slot_start_sec, + utcnow_ms, }, }; use futures::future::join_all; use parking_lot::RwLock; -use reqwest::{StatusCode, header::USER_AGENT}; +use reqwest::{ + StatusCode, + header::{ACCEPT, USER_AGENT}, +}; +use serde::Deserialize; use tokio::time::sleep; use tracing::{Instrument, debug, error, info, warn}; use tree_hash::TreeHash; @@ -37,21 +45,84 @@ use crate::{ TIMEOUT_ERROR_CODE_STR, }, metrics::{RELAY_HEADER_VALUE, RELAY_LAST_SLOT, RELAY_LATENCY, RELAY_STATUS_CODE}, + mev_boost::{CompoundGetHeaderResponse, LightGetHeaderResponse}, state::{BuilderApiState, PbsState}, utils::check_gas_limit, }; +/// Info about an incoming get_header request. +/// Sent from get_header to each send_timed_get_header call. +#[derive(Clone)] +struct RequestInfo { + /// The blockchain parameters of the get_header request (what slot it's for, + /// which pubkey is requesting it, etc) + params: GetHeaderParams, + + /// Common baseline of headers to send with each request + headers: Arc, + + /// The chain the request is for + chain: Chain, + + /// Context for validating the header returned by the relay + validation: ValidationContext, + + /// The accepted encoding types from the original request, ordered by + /// descending caller preference (q-value). + accepted_types: Vec, +} + +/// Used interally to provide info and context about a get_header request and +/// its response +struct GetHeaderResponseInfo { + /// ID of the relay the response came from + relay_id: Arc, + + /// The raw body of the response + response_bytes: Vec, + + /// The content type the response is encoded with + content_type: EncodingType, + + /// Which fork the response bid is for (if provided as a header, rather than + /// part of the body) + fork: Option, + + /// The status code of the response, for logging + code: StatusCode, + + /// The round-trip latency of the request + request_latency: Duration, +} + +/// Context for validating the header +#[derive(Clone)] +struct ValidationContext { + /// Whether to skip signature verification + skip_sigverify: bool, + + /// Minimum acceptable bid, in wei + min_bid_wei: U256, + + /// The mode used for response validation + mode: HeaderValidationMode, + + /// The parent block, if fetched + parent_block: Arc>>, +} + /// Implements https://ethereum.github.io/builder-specs/#/Builder/getHeader /// Returns 200 if at least one relay returns 200, else 204 pub async fn get_header( params: GetHeaderParams, req_headers: HeaderMap, state: PbsState, -) -> eyre::Result> { + accepted_types: Vec, +) -> eyre::Result> { let parent_block = Arc::new(RwLock::new(None)); - if state.extra_validation_enabled() && - let Some(rpc_url) = state.pbs_config().rpc_url.clone() - { + let extra_validation_enabled = + state.config.pbs_config.header_validation_mode == HeaderValidationMode::Extra; + if extra_validation_enabled && let Some(rpc_url) = state.pbs_config().rpc_url.clone() { tokio::spawn( fetch_parent_block(rpc_url, params.parent_hash, parent_block.clone()).in_current_span(), ); @@ -97,22 +168,48 @@ pub async fn get_header( let mut send_headers = HeaderMap::new(); send_headers.insert(USER_AGENT, get_user_agent_with_version(&req_headers)?); + // Create the Accept headers for requests + let mode = state.pbs_config().header_validation_mode; + let accept_types = match mode { + HeaderValidationMode::None => { + // No validation mode, so forward the caller's preference verbatim + // (still q-ordered) — the relay's response is passed through. + build_outbound_accept(&accepted_types) + } + _ => { + // We're unpacking the body, so use the documented, deterministic + // preference: SSZ first (wire-efficient), JSON fallback. + OUTBOUND_ACCEPT.to_string() + } + }; + send_headers.insert( + ACCEPT, + HeaderValue::from_str(&accept_types) + .map_err(|e| PbsError::GeneralRequest(format!("invalid accept header value: {e}")))?, + ); + + // Send requests to all relays concurrently + let slot = params.slot as i64; + let request_info = Arc::new(RequestInfo { + params, + headers: Arc::new(send_headers), + chain: state.config.chain, + validation: ValidationContext { + skip_sigverify: state.pbs_config().skip_sigverify, + min_bid_wei: state.pbs_config().min_bid_wei, + mode, + parent_block, + }, + accepted_types, + }); let mut handles = Vec::with_capacity(relays.len()); for relay in relays.iter() { handles.push( send_timed_get_header( - params.clone(), + request_info.clone(), relay.clone(), - state.config.chain, - send_headers.clone(), ms_into_slot, max_timeout_ms, - ValidationContext { - skip_sigverify: state.pbs_config().skip_sigverify, - min_bid_wei: state.pbs_config().min_bid_wei, - extra_validation_enabled: state.extra_validation_enabled(), - parent_block: parent_block.clone(), - }, ) .in_current_span(), ); @@ -125,10 +222,12 @@ pub async fn get_header( match res { Ok(Some(res)) => { - RELAY_LAST_SLOT.with_label_values(&[relay_id]).set(params.slot as i64); - let value_gwei = (res.data.message.value() / U256::from(1_000_000_000)) - .try_into() - .unwrap_or_default(); + let value = match &res { + CompoundGetHeaderResponse::Full(full) => *full.value(), + CompoundGetHeaderResponse::Light(light) => light.value, + }; + RELAY_LAST_SLOT.with_label_values(&[relay_id]).set(slot); + let value_gwei = (value / U256::from(1_000_000_000)).try_into().unwrap_or_default(); RELAY_HEADER_VALUE.with_label_values(&[relay_id]).set(value_gwei); relay_bids.push((relay_id, res)) @@ -139,15 +238,29 @@ pub async fn get_header( } } - let max_bid = relay_bids.into_iter().max_by_key(|(_, bid)| *bid.value()); + let max_bid = relay_bids.into_iter().max_by_key(|(_, bid)| match bid { + CompoundGetHeaderResponse::Full(full) => *full.value(), + CompoundGetHeaderResponse::Light(light) => light.value, + }); if let Some((winning_relay_id, ref bid)) = max_bid { - info!( - relay_id = winning_relay_id, - value_eth = format_ether(*bid.value()), - block_hash = %bid.block_hash(), - "auction winner" - ); + match bid { + CompoundGetHeaderResponse::Full(full) => { + info!( + relay_id = winning_relay_id, + value_eth = format_ether(*full.value()), + block_hash = %full.block_hash(), + "auction winner" + ); + } + CompoundGetHeaderResponse::Light(light) => { + info!( + relay_id = winning_relay_id, + value_eth = format_ether(light.value), + "auction winner (light mode, no block_hash available)" + ); + } + } } Ok(max_bid.map(|(_, bid)| bid)) @@ -179,15 +292,13 @@ async fn fetch_parent_block( } async fn send_timed_get_header( - params: GetHeaderParams, + request_info: Arc, relay: RelayClient, - chain: Chain, - headers: HeaderMap, ms_into_slot: u64, mut timeout_left_ms: u64, - validation: ValidationContext, -) -> Result, PbsError> { - let url = relay.get_header_url(params.slot, ¶ms.parent_hash, ¶ms.pubkey)?; +) -> Result, PbsError> { + let params = &request_info.params; + let url = Arc::new(relay.get_header_url(params.slot, ¶ms.parent_hash, ¶ms.pubkey)?); if relay.config.enable_timing_games { if let Some(target_ms) = relay.config.target_first_request_ms { @@ -218,18 +329,12 @@ async fn send_timed_get_header( ); loop { - let params = params.clone(); handles.push(tokio::spawn( send_one_get_header( - params, + request_info.clone(), relay.clone(), - chain, - RequestContext { - timeout_ms: timeout_left_ms, - url: url.clone(), - headers: headers.clone(), - }, - validation.clone(), + url.clone(), + timeout_left_ms, ) .in_current_span(), )); @@ -285,54 +390,270 @@ async fn send_timed_get_header( } // if no timing games or no repeated send, just send one request - send_one_get_header( - params, - relay, - chain, - RequestContext { timeout_ms: timeout_left_ms, url, headers }, - validation, - ) - .await - .map(|(_, maybe_header)| maybe_header) + send_one_get_header(request_info, relay, url, timeout_left_ms) + .await + .map(|(_, maybe_header)| maybe_header) +} + +/// Handles requesting a header from a relay, decoding, and validation. +/// Used by send_timed_get_header to handle each individual request. +async fn send_one_get_header( + request_info: Arc, + relay: RelayClient, + url: Arc, + timeout_left_ms: u64, +) -> Result<(u64, Option), PbsError> { + match request_info.validation.mode { + HeaderValidationMode::None => { + // Minimal processing: extract fork and value, forward response bytes directly. + // Expensive crypto/structural validation is skipped (sigverify, parent hash, + // timestamp), but the min_bid check is applied. + let (start_request_time, get_header_response) = send_get_header_light( + &relay, + url, + timeout_left_ms, + (*request_info.headers).clone(), /* Create a copy of the HeaderMap because the + * impl + * will + * modify it */ + ) + .await?; + match get_header_response { + None => Ok((start_request_time, None)), + Some(res) => { + let min_bid = request_info.validation.min_bid_wei; + if res.value < min_bid { + return Err(PbsError::Validation(ValidationError::BidTooLow { + min: min_bid, + got: res.value, + })); + } + + // Make sure the response is encoded in one of the accepted + // types since we're passing the raw response directly to the client + if !request_info.accepted_types.contains(&res.encoding_type) { + return Err(PbsError::RelayResponse { + error_msg: format!( + "relay returned unsupported encoding type for get_header in no-validation mode: {:?}", + res.encoding_type + ), + code: 406, // Not Acceptable + }); + } + Ok((start_request_time, Some(CompoundGetHeaderResponse::Light(res)))) + } + } + } + _ => { + // Full processing: decode full response and validate + let (start_request_time, get_header_response) = send_get_header_full( + &relay, + url, + timeout_left_ms, + (*request_info.headers).clone(), /* Create a copy of the HeaderMap because the + * impl + * will + * modify it */ + ) + .await?; + let get_header_response = match get_header_response { + None => { + // Break if there's no header + return Ok((start_request_time, None)); + } + Some(res) => res, + }; + + // Extract the basic header data needed for validation + let header_data = match &get_header_response.data.message.header() { + ExecutionPayloadHeaderRef::Bellatrix(_) | + ExecutionPayloadHeaderRef::Capella(_) | + ExecutionPayloadHeaderRef::Deneb(_) => { + Err(PbsError::Validation(ValidationError::UnsupportedFork)) + } + ExecutionPayloadHeaderRef::Electra(res) => Ok(HeaderData { + block_hash: res.block_hash.0, + parent_hash: res.parent_hash.0, + tx_root: res.transactions_root, + value: *get_header_response.value(), + timestamp: res.timestamp, + }), + ExecutionPayloadHeaderRef::Fulu(res) => Ok(HeaderData { + block_hash: res.block_hash.0, + parent_hash: res.parent_hash.0, + tx_root: res.transactions_root, + value: *get_header_response.value(), + timestamp: res.timestamp, + }), + }?; + + // Validate the header + let chain = request_info.chain; + let params = &request_info.params; + let validation = &request_info.validation; + validate_header_data( + &header_data, + chain, + params.parent_hash, + validation.min_bid_wei, + params.slot, + )?; + + // Validate the relay signature + if !validation.skip_sigverify { + validate_signature( + chain, + relay.pubkey(), + get_header_response.data.message.pubkey(), + &get_header_response.data.message, + &get_header_response.data.signature, + )?; + } + + // Validate the parent block if enabled + if validation.mode == HeaderValidationMode::Extra { + let parent_block = validation.parent_block.read(); + if let Some(parent_block) = parent_block.as_ref() { + extra_validation(parent_block, &get_header_response)?; + } else { + warn!( + relay_id = relay.id.as_ref(), + "parent block not found, skipping extra validation" + ); + } + } + + Ok(( + start_request_time, + Some(CompoundGetHeaderResponse::Full(Box::new(get_header_response))), + )) + } + } } -struct RequestContext { - url: Url, - timeout_ms: u64, +/// Send and decode a full get_header response, will all of the fields. +async fn send_get_header_full( + relay: &RelayClient, + url: Arc, + timeout_left_ms: u64, headers: HeaderMap, +) -> Result<(u64, Option), PbsError> { + // Send the request + let (start_request_time, info) = + send_get_header_impl(relay, url, timeout_left_ms, headers).await?; + let info = match info { + Some(info) => info, + None => { + return Ok((start_request_time, None)); + } + }; + + // Decode the response + let get_header_response = match info.content_type { + EncodingType::Json => decode_json_payload(&info.response_bytes)?, + EncodingType::Ssz => { + let fork = info.fork.ok_or(PbsError::RelayResponse { + error_msg: "relay did not provide consensus version header for ssz payload" + .to_string(), + code: info.code.as_u16(), + })?; + decode_ssz_payload(&info.response_bytes, fork)? + } + }; + + // Log and return + info!( + relay_id = info.relay_id.as_ref(), + header_size_bytes = info.response_bytes.len(), + latency = ?info.request_latency, + version =? get_header_response.version, + value_eth = format_ether(*get_header_response.value()), + block_hash = %get_header_response.block_hash(), + content_type = ?info.content_type, + "received new header" + ); + Ok((start_request_time, Some(get_header_response))) } -#[derive(Clone)] -struct ValidationContext { - skip_sigverify: bool, - min_bid_wei: U256, - extra_validation_enabled: bool, - parent_block: Arc>>, +/// Send a get_header request and decode only the fork and bid value from the +/// response, leaving the raw bytes intact for direct forwarding to the caller. +/// Used in `HeaderValidationMode::None` where expensive crypto/structural +/// checks are skipped. +async fn send_get_header_light( + relay: &RelayClient, + url: Arc, + timeout_left_ms: u64, + headers: HeaderMap, +) -> Result<(u64, Option), PbsError> { + // Send the request + let (start_request_time, info) = + send_get_header_impl(relay, url, timeout_left_ms, headers).await?; + let info = match info { + Some(info) => info, + None => { + return Ok((start_request_time, None)); + } + }; + + // Decode the value / fork from the response + let (fork, value) = match info.content_type { + EncodingType::Json => get_light_info_from_json(&info.response_bytes)?, + EncodingType::Ssz => { + let fork = info.fork.ok_or(PbsError::RelayResponse { + error_msg: "relay did not provide consensus version header for ssz payload" + .to_string(), + code: info.code.as_u16(), + })?; + (fork, get_bid_value_from_signed_builder_bid_ssz(&info.response_bytes, fork)?) + } + }; + + // Log and return + debug!( + relay_id = info.relay_id.as_ref(), + header_size_bytes = info.response_bytes.len(), + latency = ?info.request_latency, + version =? fork, + value_eth = format_ether(value), + content_type = ?info.content_type, + "received new header (light processing)" + ); + Ok(( + start_request_time, + Some(LightGetHeaderResponse { + version: fork, + value, + raw_bytes: info.response_bytes, + encoding_type: info.content_type, + }), + )) } -async fn send_one_get_header( - params: GetHeaderParams, - relay: RelayClient, - chain: Chain, - mut req_config: RequestContext, - validation: ValidationContext, -) -> Result<(u64, Option), PbsError> { +/// Sends a get_header request to a relay, returning the response, the time the +/// request was started, and the encoding type of the response (if any). +/// Used by send_one_get_header to perform the actual request submission. +async fn send_get_header_impl( + relay: &RelayClient, + url: Arc, + timeout_left_ms: u64, + mut headers: HeaderMap, +) -> Result<(u64, Option), PbsError> { // the timestamp in the header is the consensus block time which is fixed, // use the beginning of the request as proxy to make sure we use only the // last one received + let start_request = Instant::now(); let start_request_time = utcnow_ms(); - req_config.headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); + headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); // The timeout header indicating how long a relay has to respond, so they can // minimize timing games without losing the bid - req_config.headers.insert(HEADER_TIMEOUT_MS, HeaderValue::from(req_config.timeout_ms)); + headers.insert(HEADER_TIMEOUT_MS, HeaderValue::from(timeout_left_ms)); - let start_request = Instant::now(); let res = match relay .client - .get(req_config.url) - .timeout(Duration::from_millis(req_config.timeout_ms)) - .headers(req_config.headers) + .get(url.as_ref().clone()) + .timeout(Duration::from_millis(timeout_left_ms)) + .headers(headers) .send() .await { @@ -345,129 +666,108 @@ async fn send_one_get_header( } }; + // Log the response code and latency + let code = res.status(); let request_latency = start_request.elapsed(); RELAY_LATENCY .with_label_values(&[GET_HEADER_ENDPOINT_TAG, &relay.id]) .observe(request_latency.as_secs_f64()); - - let code = res.status(); RELAY_STATUS_CODE.with_label_values(&[code.as_str(), GET_HEADER_ENDPOINT_TAG, &relay.id]).inc(); - let response_bytes = read_chunked_body_with_max(res, MAX_SIZE_GET_HEADER_RESPONSE).await?; - let header_size_bytes = response_bytes.len(); - if !code.is_success() { - return Err(PbsError::RelayResponse { - error_msg: String::from_utf8_lossy(&response_bytes).into_owned(), - code: code.as_u16(), - }); - }; - if code == StatusCode::NO_CONTENT { - debug!( - relay_id = relay.id.as_ref(), - ?code, - latency = ?request_latency, - response = ?response_bytes, - "no header from relay" - ); - return Ok((start_request_time, None)); - } - - let get_header_response = match serde_json::from_slice::(&response_bytes) { - Ok(parsed) => parsed, - Err(err) => { - return Err(PbsError::JsonDecode { - err, - raw: String::from_utf8_lossy(&response_bytes).into_owned(), + // According to the spec, OK is the only allowed success code so this can break + // early + if code != StatusCode::OK { + if code == StatusCode::NO_CONTENT { + let response_bytes = + read_chunked_body_with_max(res, MAX_SIZE_GET_HEADER_RESPONSE).await?; + debug!( + relay_id = relay.id.as_ref(), + ?code, + latency = ?request_latency, + response = ?response_bytes, + "no header from relay" + ); + return Ok((start_request_time, None)); + } else { + return Err(PbsError::RelayResponse { + error_msg: format!("unexpected status code from relay: {code}"), + code: code.as_u16(), }); } - }; + } - info!( - relay_id = relay.id.as_ref(), - header_size_bytes, - latency = ?request_latency, - version =? get_header_response.version, - value_eth = format_ether(*get_header_response.value()), - block_hash = %get_header_response.block_hash(), - "received new header" - ); + // Parse Content-Type (tolerating MIME parameters per RFC 7231 §3.1.1.1) + // and Eth-Consensus-Version headers in one shot. + let (content_type, fork) = parse_response_encoding_and_fork(res.headers(), code.as_u16())?; - match &get_header_response.data.message.header() { - ExecutionPayloadHeaderRef::Bellatrix(_) | - ExecutionPayloadHeaderRef::Capella(_) | - ExecutionPayloadHeaderRef::Deneb(_) | - ExecutionPayloadHeaderRef::Gloas(_) => { - return Err(PbsError::Validation(ValidationError::UnsupportedFork)) - } - ExecutionPayloadHeaderRef::Electra(res) => { - let header_data = HeaderData { - block_hash: res.block_hash.0, - parent_hash: res.parent_hash.0, - tx_root: res.transactions_root, - value: *get_header_response.value(), - timestamp: res.timestamp, - }; + // Decode the body + let response_bytes = read_chunked_body_with_max(res, MAX_SIZE_GET_HEADER_RESPONSE).await?; - validate_header_data( - &header_data, - chain, - params.parent_hash, - validation.min_bid_wei, - params.slot, - )?; + Ok(( + start_request_time, + Some(GetHeaderResponseInfo { + relay_id: relay.id.clone(), + response_bytes, + content_type, + fork, + code, + request_latency, + }), + )) +} - if !validation.skip_sigverify { - validate_signature( - chain, - relay.pubkey(), - get_header_response.data.message.pubkey(), - &get_header_response.data.message, - &get_header_response.data.signature, - )?; - } - } - ExecutionPayloadHeaderRef::Fulu(res) => { - let header_data = HeaderData { - block_hash: res.block_hash.0, - parent_hash: res.parent_hash.0, - tx_root: res.transactions_root, - value: *get_header_response.value(), - timestamp: res.timestamp, - }; +/// Decode a JSON-encoded get_header response +fn decode_json_payload(response_bytes: &[u8]) -> Result { + match serde_json::from_slice::(response_bytes) { + Ok(parsed) => Ok(parsed), + Err(err) => Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(response_bytes).into_owned(), + }), + } +} - validate_header_data( - &header_data, - chain, - params.parent_hash, - validation.min_bid_wei, - params.slot, - )?; +/// Get the value of a builder bid and the fork name from a get_header JSON +/// response (used for light-level processing) +fn get_light_info_from_json(response_bytes: &[u8]) -> Result<(ForkName, U256), PbsError> { + #[derive(Deserialize)] + struct LightBuilderBid { + #[serde(with = "serde_utils::quoted_u256")] + pub value: U256, + } - if !validation.skip_sigverify { - validate_signature( - chain, - relay.pubkey(), - get_header_response.data.message.pubkey(), - &get_header_response.data.message, - &get_header_response.data.signature, - )?; - } - } + #[derive(Deserialize)] + struct LightSignedBuilderBid { + pub message: LightBuilderBid, } - 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, &get_header_response)?; - } else { - warn!( - relay_id = relay.id.as_ref(), - "parent block not found, skipping extra validation" - ); - } + #[derive(Deserialize)] + struct LightHeaderResponse { + version: ForkName, + data: LightSignedBuilderBid, } - Ok((start_request_time, Some(get_header_response))) + match serde_json::from_slice::(response_bytes) { + Ok(parsed) => Ok((parsed.version, parsed.data.message.value)), + Err(err) => Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(response_bytes).into_owned(), + }), + } +} + +/// Decode an SSZ-encoded get_header response +fn decode_ssz_payload( + response_bytes: &[u8], + fork: ForkName, +) -> Result { + let data = SignedBuilderBid::from_ssz_bytes_by_fork(response_bytes, fork).map_err(|e| { + PbsError::RelayResponse { + error_msg: (format!("error decoding relay payload: {e:?}")).to_string(), + code: 200, + } + })?; + Ok(GetHeaderResponse { version: fork, data, metadata: Default::default() }) } struct HeaderData { @@ -566,13 +866,16 @@ fn extra_validation( #[cfg(test)] mod tests { + use std::{fs, path::Path}; + use alloy::primitives::{B256, U256}; use cb_common::{ - pbs::{EMPTY_TX_ROOT_HASH, error::ValidationError}, + pbs::*, signature::sign_builder_message, - types::{BlsSecretKey, Chain}, + types::{BlsPublicKeyBytes, BlsSecretKey, BlsSignature, Chain}, utils::{TestRandomSeed, timestamp_of_slot_start_sec}, }; + use ssz::Encode; use super::{validate_header_data, *}; @@ -674,4 +977,42 @@ mod tests { .is_ok() ); } + + #[test] + fn test_ssz_value_extraction() { + for fork_name in ForkName::list_all() { + match fork_name { + // Handle forks that didn't have builder bids yet + ForkName::Altair | ForkName::Base => continue, + + // Handle supported forks + ForkName::Bellatrix | + ForkName::Capella | + ForkName::Deneb | + ForkName::Electra | + ForkName::Fulu => {} + + // Skip unsupported forks + ForkName::Gloas => continue, + } + + // Load get_header JSON from test data + let fork_name_str = fork_name.to_string().to_lowercase(); + let path_str = format!("../../tests/data/get_header/{fork_name_str}.json"); + let path = Path::new(path_str.as_str()); + let json_bytes = fs::read(path).expect("file not found"); + let decoded = decode_json_payload(&json_bytes).expect("failed to decode JSON"); + + // Extract the bid value from the SSZ + let encoded = decoded.data.as_ssz_bytes(); + let bid_value = get_bid_value_from_signed_builder_bid_ssz(&encoded, fork_name) + .expect("failed to extract bid value from SSZ"); + + // Compare to the original value + println!("Testing fork: {}", fork_name); + println!("Original value: {}", decoded.value()); + println!("Extracted value: {}", bid_value); + assert_eq!(*decoded.value(), bid_value); + } + } } diff --git a/crates/pbs/src/mev_boost/mod.rs b/crates/pbs/src/mev_boost/mod.rs index a41b79db..81dc4bf6 100644 --- a/crates/pbs/src/mev_boost/mod.rs +++ b/crates/pbs/src/mev_boost/mod.rs @@ -4,8 +4,73 @@ mod reload; mod status; mod submit_block; +use alloy::primitives::U256; +use cb_common::{ + pbs::{GetHeaderResponse, SubmitBlindedBlockResponse}, + utils::EncodingType, +}; pub use get_header::get_header; +use lh_types::ForkName; pub use register_validator::register_validator; pub use reload::reload; pub use status::get_status; pub use submit_block::submit_block; + +/// Enum that handles different GetHeader response types based on the level of +/// validation required +pub enum CompoundGetHeaderResponse { + /// Standard response type, fully parsing the response from a relay into a + /// complete response struct + Full(Box), + + /// Light response type, only extracting the fork and value from the builder + /// bid with the entire (undecoded) payload for forwarding + Light(LightGetHeaderResponse), +} + +/// Core details of a GetHeaderResponse, used for light processing when +/// validation mode is set to none. +#[derive(Clone)] +pub struct LightGetHeaderResponse { + /// The fork name for the bid + pub version: ForkName, + + /// The bid value in wei + pub value: U256, + + /// The raw bytes of the response, for forwarding to the caller + pub raw_bytes: Vec, + + /// The format the response bytes are encoded with + pub encoding_type: EncodingType, +} + +/// Enum that handles different SubmitBlock response types based on the level of +/// validation required +pub enum CompoundSubmitBlockResponse { + /// Standard response type, fully parsing the response from a relay into a + /// complete response struct + Full(Box), + + /// Light response type, only extracting the fork from the response with the + /// entire (undecoded) payload for forwarding + Light(LightSubmitBlockResponse), + + /// Response with no body, used for v2 requests when the relay does not + /// return any content intentionally + EmptyBody, +} + +/// Core details of a SubmitBlockResponse, used for light processing when +/// validation mode is set to none. +#[derive(Clone, Debug)] +pub struct LightSubmitBlockResponse { + /// The fork name for the bid + pub version: ForkName, + + /// The raw bytes of the response, for forwarding to the caller + pub raw_bytes: Vec, + + /// The format the response bytes are encoded with + pub encoding_type: EncodingType, +} diff --git a/crates/pbs/src/mev_boost/submit_block.rs b/crates/pbs/src/mev_boost/submit_block.rs index b416dba2..a133bff6 100644 --- a/crates/pbs/src/mev_boost/submit_block.rs +++ b/crates/pbs/src/mev_boost/submit_block.rs @@ -1,5 +1,4 @@ use std::{ - str::FromStr, sync::Arc, time::{Duration, Instant}, }; @@ -7,27 +6,77 @@ use std::{ use alloy::{eips::eip7594::CELLS_PER_EXT_BLOB, primitives::B256}; use axum::http::{HeaderMap, HeaderValue}; use cb_common::{ + config::BlockValidationMode, pbs::{ - BlindedBeaconBlock, BlobsBundle, BuilderApiVersion, ForkName, HEADER_CONSENSUS_VERSION, - HEADER_START_TIME_UNIX_MS, KzgCommitments, RelayClient, SignedBlindedBeaconBlock, - SubmitBlindedBlockResponse, + BlindedBeaconBlock, BlobsBundle, BuilderApiVersion, ForkName, ForkVersionDecode, + HEADER_START_TIME_UNIX_MS, KzgCommitments, PayloadAndBlobs, RelayClient, + SignedBlindedBeaconBlock, SubmitBlindedBlockResponse, error::{PbsError, ValidationError}, }, - utils::{get_user_agent_with_version, read_chunked_body_with_max, utcnow_ms}, + utils::{ + CONSENSUS_VERSION_HEADER, EncodingType, OUTBOUND_ACCEPT, build_outbound_accept, + get_user_agent_with_version, parse_response_encoding_and_fork, read_chunked_body_with_max, + utcnow_ms, + }, }; use futures::{FutureExt, future::select_ok}; -use reqwest::header::USER_AGENT; +use reqwest::{ + StatusCode, + header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, +}; +use serde::Deserialize; +use ssz::Encode; use tracing::{debug, warn}; use url::Url; use crate::{ - constants::{ - MAX_SIZE_SUBMIT_BLOCK_RESPONSE, SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, TIMEOUT_ERROR_CODE_STR, - }, - metrics::{RELAY_LATENCY, RELAY_STATUS_CODE}, + CompoundSubmitBlockResponse, LightSubmitBlockResponse, TIMEOUT_ERROR_CODE_STR, + constants::{MAX_SIZE_SUBMIT_BLOCK_RESPONSE, SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG}, + metrics::{RELAY_LATENCY, RELAY_STATUS_CODE, V2_FALLBACK_TO_V1}, state::{BuilderApiState, PbsState}, }; +/// Info about a proposal submission request. +/// Sent from submit_block to the submit_block_with_timeout function. +#[derive(Clone)] +struct ProposalInfo { + /// The signed blinded block to submit + signed_blinded_block: Arc, + + /// Common baseline of headers to send with each request + headers: Arc, + + /// The version of the submit_block route being used + api_version: BuilderApiVersion, + + /// How to validate the block returned by the relay + validation_mode: BlockValidationMode, + + /// The accepted encoding types from the original request, ordered by + /// descending caller preference (q-value). + accepted_types: Vec, +} + +/// Used interally to provide info and context about a submit_block request and +/// its response +struct SubmitBlockResponseInfo { + /// The raw body of the response + response_bytes: Vec, + + /// The content type the response is encoded with + content_type: EncodingType, + + /// Which fork the response bid is for (if provided as a header, rather than + /// part of the body) + fork: Option, + + /// The status code of the response, for logging + code: StatusCode, + + /// The round-trip latency of the request + request_latency: Duration, +} + /// Implements https://ethereum.github.io/builder-specs/#/Builder/submitBlindedBlock and /// https://ethereum.github.io/builder-specs/#/Builder/submitBlindedBlockV2. Use `api_version` to /// distinguish between the two. @@ -36,39 +85,46 @@ pub async fn submit_block( req_headers: HeaderMap, state: PbsState, api_version: BuilderApiVersion, -) -> eyre::Result> { + accepted_types: Vec, +) -> eyre::Result { debug!(?req_headers, "received headers"); - let fork_name = req_headers - .get(HEADER_CONSENSUS_VERSION) - .and_then(|h| { - let str = h.to_str().ok()?; - ForkName::from_str(str).ok() - }) - .unwrap_or_else(|| { - let slot = signed_blinded_block.slot().as_u64(); - state.config.chain.fork_by_slot(slot) - }); - - // safe because ForkName is visible ASCII chars - let consensus_version = HeaderValue::from_str(&fork_name.to_string()).unwrap(); - // prepare headers let mut send_headers = HeaderMap::new(); send_headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(utcnow_ms())); send_headers.insert(USER_AGENT, get_user_agent_with_version(&req_headers)?); - send_headers.insert(HEADER_CONSENSUS_VERSION, consensus_version); + // Create the Accept headers for requests + let mode = state.pbs_config().block_validation_mode; + let accept_types = match mode { + BlockValidationMode::None => { + // No validation mode, so forward the caller's preference verbatim + // (still q-ordered) — the relay's response is passed through. + build_outbound_accept(&accepted_types) + } + _ => { + // We're unpacking the body, so use the documented, deterministic + // preference: SSZ first (wire-efficient), JSON fallback. + OUTBOUND_ACCEPT.to_string() + } + }; + send_headers.insert(ACCEPT, HeaderValue::from_str(&accept_types).unwrap()); + + // Send requests to all relays concurrently + let proposal_info = Arc::new(ProposalInfo { + signed_blinded_block, + headers: Arc::new(send_headers), + api_version, + validation_mode: mode, + accepted_types, + }); let mut handles = Vec::with_capacity(state.all_relays().len()); - for relay in state.all_relays().iter().cloned() { + for relay in state.all_relays().iter() { handles.push( tokio::spawn(submit_block_with_timeout( - signed_blinded_block.clone(), - relay, - send_headers.clone(), + proposal_info.clone(), + relay.clone(), state.pbs_config().timeout_get_payload_ms, - api_version, - fork_name, )) .map(|join_result| match join_result { Ok(res) => res, @@ -87,40 +143,46 @@ pub async fn submit_block( /// Submit blinded block to relay, retry connection errors until the /// given timeout has passed async fn submit_block_with_timeout( - signed_blinded_block: Arc, + proposal_info: Arc, relay: RelayClient, - headers: HeaderMap, timeout_ms: u64, - api_version: BuilderApiVersion, - fork_name: ForkName, -) -> Result, PbsError> { - let mut url = relay.submit_block_url(api_version)?; +) -> Result { + let mut url = Arc::new(relay.submit_block_url(proposal_info.api_version)?); let mut remaining_timeout_ms = timeout_ms; let mut retry = 0; let mut backoff = Duration::from_millis(250); - let mut request_api_version = api_version; + let mut request_api_version = proposal_info.api_version; loop { let start_request = Instant::now(); match send_submit_block( + proposal_info.clone(), url.clone(), - &signed_blinded_block, &relay, - headers.clone(), remaining_timeout_ms, retry, - &request_api_version, - fork_name, + request_api_version, ) .await { Ok(response) => { - // If the original request was for v2 but we had to fall back to v1, return a v2 - // response + // If the original request was for v2 but we had to fall back to v1, the + // V1 response body (execution payload + blobs bundle) MUST be forwarded + // back to the beacon node so the proposer can broadcast. Returning an + // empty 202 here would cause silent block loss because the BN never + // receives the unblinded payload. + // + // The caller (routes/submit_block.rs) serialises Full/Light responses + // with the caller's negotiated encoding, independent of which endpoint + // the relay actually served. if request_api_version == BuilderApiVersion::V1 && - api_version != request_api_version + proposal_info.api_version != request_api_version { - return Ok(None); + warn!( + relay_id = relay.id.as_ref(), + "v2 submit_block fell back to v1; forwarding v1 payload to beacon node" + ); + V2_FALLBACK_TO_V1.with_label_values(&[relay.id.as_ref()]).inc(); } return Ok(response); } @@ -144,7 +206,7 @@ async fn submit_block_with_timeout( relay_id = relay.id.as_ref(), "relay does not support v2 endpoint, retrying with v1" ); - url = relay.submit_block_url(BuilderApiVersion::V1)?; + url = Arc::new(relay.submit_block_url(BuilderApiVersion::V1)?); request_api_version = BuilderApiVersion::V1; } @@ -159,22 +221,243 @@ async fn submit_block_with_timeout( // back #[allow(clippy::too_many_arguments)] async fn send_submit_block( - url: Url, - signed_blinded_block: &SignedBlindedBeaconBlock, + proposal_info: Arc, + url: Arc, relay: &RelayClient, - headers: HeaderMap, timeout_ms: u64, retry: u32, - api_version: &BuilderApiVersion, - fork_name: ForkName, + api_version: BuilderApiVersion, +) -> Result { + match proposal_info.validation_mode { + BlockValidationMode::None => { + // No validation so do some light processing and forward the response directly + let response = send_submit_block_light( + proposal_info.clone(), + url, + relay, + timeout_ms, + retry, + api_version, + ) + .await?; + match response { + None => Ok(CompoundSubmitBlockResponse::EmptyBody), + Some(res) => { + // Make sure the response is encoded in one of the accepted + // types since we're passing the raw response directly to the client + if !proposal_info.accepted_types.contains(&res.encoding_type) { + return Err(PbsError::RelayResponse { + error_msg: format!( + "relay returned unsupported encoding type for submit_block in no-validation mode: {:?}", + res.encoding_type + ), + code: 406, // Not Acceptable + }); + } + Ok(CompoundSubmitBlockResponse::Light(res)) + } + } + } + _ => { + // Full processing: decode full response and validate + let response = send_submit_block_full( + proposal_info.clone(), + url, + relay, + timeout_ms, + retry, + api_version, + ) + .await?; + let response = match response { + None => { + // v2 request with no body + return Ok(CompoundSubmitBlockResponse::EmptyBody); + } + Some(res) => res, + }; + // Extract the info needed for validation + let got_block_hash = response.data.execution_payload.block_hash().0; + + // request has different type so cant be deserialized in the wrong version, + // response has a "version" field + match &proposal_info.signed_blinded_block.message() { + BlindedBeaconBlock::Electra(blinded_block) => { + let expected_block_hash = + blinded_block.body.execution_payload.execution_payload_header.block_hash.0; + let expected_commitments = &blinded_block.body.blob_kzg_commitments; + + validate_unblinded_block( + expected_block_hash, + got_block_hash, + expected_commitments, + &response.data.blobs_bundle, + response.version, + ) + } + + BlindedBeaconBlock::Fulu(blinded_block) => { + let expected_block_hash = + blinded_block.body.execution_payload.execution_payload_header.block_hash.0; + let expected_commitments = &blinded_block.body.blob_kzg_commitments; + + validate_unblinded_block( + expected_block_hash, + got_block_hash, + expected_commitments, + &response.data.blobs_bundle, + response.version, + ) + } + + _ => return Err(PbsError::Validation(ValidationError::UnsupportedFork)), + }?; + Ok(CompoundSubmitBlockResponse::Full(Box::new(response))) + } + } +} + +/// Send and fully process a submit_block request, returning a complete decoded +/// response +async fn send_submit_block_full( + proposal_info: Arc, + url: Arc, + relay: &RelayClient, + timeout_ms: u64, + retry: u32, + api_version: BuilderApiVersion, ) -> Result, PbsError> { + // Send the request + let block_response = send_submit_block_impl( + relay, + url, + timeout_ms, + (*proposal_info.headers).clone(), + &proposal_info.signed_blinded_block, + retry, + api_version, + ) + .await?; + + // If this is not v1, there's no body to decode + if api_version != BuilderApiVersion::V1 { + return Ok(None); + } + + // Decode the payload based on content type + let decoded_response = match block_response.content_type { + EncodingType::Json => decode_json_payload(&block_response.response_bytes)?, + EncodingType::Ssz => { + let fork = match block_response.fork { + Some(fork) => fork, + None => { + return Err(PbsError::RelayResponse { + error_msg: "missing fork version header in SSZ submit_block response" + .to_string(), + code: block_response.code.as_u16(), + }); + } + }; + decode_ssz_payload(&block_response.response_bytes, fork)? + } + }; + + // Log and return + debug!( + relay_id = relay.id.as_ref(), + retry, + latency = ?block_response.request_latency, + version =% decoded_response.version, + "received unblinded block" + ); + + Ok(Some(decoded_response)) +} + +/// Send and lightly process a submit_block request, minimizing the amount of +/// decoding and validation done +async fn send_submit_block_light( + proposal_info: Arc, + url: Arc, + relay: &RelayClient, + timeout_ms: u64, + retry: u32, + api_version: BuilderApiVersion, +) -> Result, PbsError> { + // Send the request + let block_response = send_submit_block_impl( + relay, + url, + timeout_ms, + (*proposal_info.headers).clone(), + &proposal_info.signed_blinded_block, + retry, + api_version, + ) + .await?; + + // v2 responses have no body to decode. Use the endpoint version we actually + // dispatched to (api_version), not the original proposal_info.api_version, + // because the caller may have fallen back from v2 to v1 — in which case we + // DO have a body that must be forwarded to the beacon node. + if api_version != BuilderApiVersion::V1 { + return Ok(None); + } + + // Decode the payload based on content type + let fork = match block_response.content_type { + EncodingType::Json => get_light_info_from_json(&block_response.response_bytes)?, + EncodingType::Ssz => match block_response.fork { + Some(fork) => fork, + None => { + return Err(PbsError::RelayResponse { + error_msg: "missing fork version header in SSZ submit_block response" + .to_string(), + code: block_response.code.as_u16(), + }); + } + }, + }; + + // Log and return + debug!( + relay_id = relay.id.as_ref(), + retry, + latency = ?block_response.request_latency, + version =% fork, + "received unblinded block (light processing)" + ); + + Ok(Some(LightSubmitBlockResponse { + version: fork, + encoding_type: block_response.content_type, + raw_bytes: block_response.response_bytes, + })) +} + +/// Sends the actual HTTP request to the relay's submit_block endpoint, +/// returning the response (if applicable), the round-trip time, and the +/// encoding type used for the body (if any). Used by send_submit_block. +async fn send_submit_block_impl( + relay: &RelayClient, + url: Arc, + timeout_ms: u64, + headers: HeaderMap, + signed_blinded_block: &SignedBlindedBeaconBlock, + retry: u32, + api_version: BuilderApiVersion, +) -> Result { let start_request = Instant::now(); - let res = match relay + + // Try SSZ first + let mut res = match relay .client - .post(url) + .post(url.as_ref().clone()) .timeout(Duration::from_millis(timeout_ms)) - .headers(headers) - .json(&signed_blinded_block) + .headers(headers.clone()) + .body(signed_blinded_block.as_ssz_bytes()) + .header(CONTENT_TYPE, EncodingType::Ssz.to_string()) + .header(CONSENSUS_VERSION_HEADER, signed_blinded_block.fork_name_unchecked().to_string()) .send() .await { @@ -190,96 +473,161 @@ async fn send_submit_block( return Err(err.into()); } }; + + // Retry as JSON only on the two status codes the builder-spec defines as + // "media type is the problem": 406 Not Acceptable and 415 Unsupported + // Media Type (RFC 7231 §6.5.13). Any other 4xx (400 malformed, 401/403 + // auth, 409 conflict, 429 rate limit, etc.) is orthogonal to encoding + // and MUST surface unchanged — retrying pollutes observability, doubles + // load on the relay, and can mask real errors behind a JSON-path reply. + if matches!(res.status(), StatusCode::NOT_ACCEPTABLE | StatusCode::UNSUPPORTED_MEDIA_TYPE,) { + warn!( + relay_id = relay.id.as_ref(), + status = %res.status(), + "relay rejected SSZ content-type, resubmitting block with JSON content-type" + ); + res = match relay + .client + .post(url.as_ref().clone()) + .timeout(Duration::from_millis(timeout_ms)) + .headers(headers) + .body(serde_json::to_vec(&signed_blinded_block).unwrap()) + .header(CONTENT_TYPE, EncodingType::Json.to_string()) + .send() + .await + { + Ok(res) => res, + Err(err) => { + RELAY_STATUS_CODE + .with_label_values(&[ + TIMEOUT_ERROR_CODE_STR, + SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, + &relay.id, + ]) + .inc(); + return Err(err.into()); + } + }; + } + + // Log the response code and latency + let code = res.status(); let request_latency = start_request.elapsed(); RELAY_LATENCY .with_label_values(&[SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, &relay.id]) .observe(request_latency.as_secs_f64()); - - let code = res.status(); RELAY_STATUS_CODE .with_label_values(&[code.as_str(), SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, &relay.id]) .inc(); - let response_bytes = read_chunked_body_with_max(res, MAX_SIZE_SUBMIT_BLOCK_RESPONSE).await?; - if !code.is_success() { + // If this was API v2 and succeeded then we can just return here + if api_version != BuilderApiVersion::V1 { + debug!( + relay_id = relay.id.as_ref(), + retry, + latency = ?request_latency, + "received 202 Accepted for v2 submit_block" + ); + + match code { + StatusCode::ACCEPTED => { + return Ok(SubmitBlockResponseInfo { + response_bytes: Vec::new(), + content_type: EncodingType::Json, // dummy value + fork: None, + code, + request_latency, + }); + } + StatusCode::OK => { + warn!( + relay_id = relay.id.as_ref(), + "relay sent OK response for v2 submit_block, expected 202 Accepted" + ); + return Ok(SubmitBlockResponseInfo { + response_bytes: Vec::new(), + content_type: EncodingType::Json, // dummy value + fork: None, + code, + request_latency, + }); + } + _ => { + return Err(PbsError::RelayResponse { + error_msg: format!( + "relay sent unexpected code for builder route v2 {}: {code}", + relay.id.as_ref() + ), + code: code.as_u16(), + }); + } + } + } + + // If the code is not OK, return early + if code != StatusCode::OK { + let response_bytes = + read_chunked_body_with_max(res, MAX_SIZE_SUBMIT_BLOCK_RESPONSE).await?; let err = PbsError::RelayResponse { error_msg: String::from_utf8_lossy(&response_bytes).into_owned(), code: code.as_u16(), }; // we requested the payload from all relays, but some may have not received it - warn!(relay_id = relay.id.as_ref(), retry, %err, "failed to get payload (this might be ok if other relays have it)"); + warn!(relay_id = relay.id.as_ref(), %err, "failed to get payload (this might be ok if other relays have it)"); return Err(err); - }; - - if api_version != &BuilderApiVersion::V1 { - // v2 response is going to be empty, so just break here - debug!( - relay_id = relay.id.as_ref(), - retry, - latency = ?request_latency, - "successful request" - ); - - return Ok(None); } - let block_response = match serde_json::from_slice::(&response_bytes) - { - Ok(parsed) => parsed, - Err(err) => { - return Err(PbsError::JsonDecode { - err, - raw: String::from_utf8_lossy(&response_bytes).into_owned(), - }); - } - }; + // We're on v1 so decode the payload normally. Parse Content-Type + // (tolerating MIME parameters per RFC 7231 §3.1.1.1) and + // Eth-Consensus-Version headers + let (content_type, fork) = parse_response_encoding_and_fork(res.headers(), code.as_u16())?; - debug!( - relay_id = relay.id.as_ref(), - retry, - latency = ?request_latency, - version =% block_response.version, - "received unblinded block" - ); + // Decode the body + let response_bytes = read_chunked_body_with_max(res, MAX_SIZE_SUBMIT_BLOCK_RESPONSE).await?; + Ok(SubmitBlockResponseInfo { response_bytes, content_type, fork, code, request_latency }) +} - let got_block_hash = block_response.data.execution_payload.block_hash().0; - - // request has different type so cant be deserialized in the wrong version, - // response has a "version" field - match &signed_blinded_block.message() { - BlindedBeaconBlock::Electra(blinded_block) => { - let expected_block_hash = - blinded_block.body.execution_payload.execution_payload_header.block_hash.0; - let expected_commitments = &blinded_block.body.blob_kzg_commitments; - - validate_unblinded_block( - expected_block_hash, - got_block_hash, - expected_commitments, - &block_response.data.blobs_bundle, - fork_name, - ) - } +/// Decode a JSON-encoded submit_block response +fn decode_json_payload(response_bytes: &[u8]) -> Result { + match serde_json::from_slice::(response_bytes) { + Ok(parsed) => Ok(parsed), + Err(err) => Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(response_bytes).into_owned(), + }), + } +} - BlindedBeaconBlock::Fulu(blinded_block) => { - let expected_block_hash = - blinded_block.body.execution_payload.execution_payload_header.block_hash.0; - let expected_commitments = &blinded_block.body.blob_kzg_commitments; - - validate_unblinded_block( - expected_block_hash, - got_block_hash, - expected_commitments, - &block_response.data.blobs_bundle, - fork_name, - ) - } +/// Get the fork name from a submit_block JSON response (used for light +/// processing) +fn get_light_info_from_json(response_bytes: &[u8]) -> Result { + #[derive(Deserialize)] + struct LightSubmitBlockResponse { + version: ForkName, + } - _ => return Err(PbsError::Validation(ValidationError::UnsupportedFork)), - }?; + match serde_json::from_slice::(response_bytes) { + Ok(parsed) => Ok(parsed.version), + Err(err) => Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(response_bytes).into_owned(), + }), + } +} - Ok(Some(block_response)) +/// Decode an SSZ-encoded submit_block response +fn decode_ssz_payload( + response_bytes: &[u8], + fork: ForkName, +) -> Result { + let data = PayloadAndBlobs::from_ssz_bytes_by_fork(response_bytes, fork).map_err(|e| { + PbsError::RelayResponse { + error_msg: (format!("error decoding relay payload: {e:?}")).to_string(), + code: 200, + } + })?; + Ok(SubmitBlindedBlockResponse { version: fork, data, metadata: Default::default() }) } fn validate_unblinded_block( diff --git a/crates/pbs/src/routes/get_header.rs b/crates/pbs/src/routes/get_header.rs index 9ed312af..0dc0943f 100644 --- a/crates/pbs/src/routes/get_header.rs +++ b/crates/pbs/src/routes/get_header.rs @@ -1,17 +1,22 @@ use alloy::primitives::utils::format_ether; use axum::{ extract::{Path, State}, - http::HeaderMap, + http::{HeaderMap, HeaderValue}, response::IntoResponse, }; use cb_common::{ pbs::{GetHeaderInfo, GetHeaderParams}, - utils::{get_user_agent, ms_into_slot}, + utils::{ + CONSENSUS_VERSION_HEADER, EncodingType, get_accept_types, get_user_agent, ms_into_slot, + preferred_encoding, + }, }; -use reqwest::StatusCode; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::Encode; use tracing::{error, info}; use crate::{ + CompoundGetHeaderResponse, api::BuilderApi, constants::GET_HEADER_ENDPOINT_TAG, error::PbsClientError, @@ -32,16 +37,103 @@ pub async fn handle_get_header>( let ua = get_user_agent(&req_headers); let ms_into_slot = ms_into_slot(params.slot, state.config.chain); + let accept_types = get_accept_types(&req_headers).map_err(|e| { + error!(%e, "error parsing accept header"); + PbsClientError::DecodeError(format!("error parsing accept header: {e}")) + })?; + // Honor caller q-value preference: pick the highest-priority encoding that + // we can actually produce. Server preference for tiebreaks is SSZ first. + let response_encoding = + preferred_encoding(&accept_types, &[EncodingType::Ssz, EncodingType::Json]); + let accepts_ssz = response_encoding == Some(EncodingType::Ssz); + let accepts_json = response_encoding == Some(EncodingType::Json); info!(ua, ms_into_slot, "new request"); - match A::get_header(params, req_headers, state).await { + match A::get_header(params, req_headers, state, accept_types).await { Ok(res) => { if let Some(max_bid) = res { - info!(value_eth = format_ether(*max_bid.data.message.value()), block_hash =% max_bid.block_hash(), "received header"); - BEACON_NODE_STATUS.with_label_values(&["200", GET_HEADER_ENDPOINT_TAG]).inc(); - Ok((StatusCode::OK, axum::Json(max_bid)).into_response()) + match max_bid { + CompoundGetHeaderResponse::Light(light_bid) => { + // Light validation mode, so just forward the raw response + info!( + value_eth = format_ether(light_bid.value), + "received header (unvalidated)" + ); + + // Create the headers + let consensus_version_header = + match HeaderValue::from_str(&light_bid.version.to_string()) { + Ok(consensus_version_header) => { + Ok::(consensus_version_header) + } + Err(e) => { + return Err(PbsClientError::RelayError(format!( + "error decoding consensus version from relay payload: {e}" + ))); + } + }?; + let content_type = light_bid.encoding_type.content_type(); + let content_type_header = HeaderValue::from_str(content_type).unwrap(); + + // Build response + let mut res = light_bid.raw_bytes.into_response(); + res.headers_mut() + .insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + res.headers_mut().insert(CONTENT_TYPE, content_type_header); + info!("sending response as {} (light)", content_type); + Ok(res) + } + CompoundGetHeaderResponse::Full(max_bid) => { + // Full validation mode, so respond based on requester accept types + info!(value_eth = format_ether(*max_bid.data.message.value()), block_hash =% max_bid.block_hash(), "received header"); + + // Handle SSZ + if accepts_ssz { + let mut res = max_bid.data.as_ssz_bytes().into_response(); + let consensus_version_header = match HeaderValue::from_str( + &max_bid.version.to_string(), + ) { + Ok(consensus_version_header) => { + Ok::(consensus_version_header) + } + Err(e) => { + if accepts_json { + info!("sending response as JSON"); + return Ok( + (StatusCode::OK, axum::Json(max_bid)).into_response() + ); + } else { + return Err(PbsClientError::RelayError(format!( + "error decoding consensus version from relay payload: {e}" + ))); + } + } + }?; + + // This won't actually fail since the string is a const + let content_type_header = + HeaderValue::from_str(EncodingType::Ssz.content_type()).unwrap(); + + res.headers_mut() + .insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + res.headers_mut().insert(CONTENT_TYPE, content_type_header); + info!("sending response as SSZ"); + return Ok(res); + } + + // Handle JSON + if accepts_json { + Ok((StatusCode::OK, axum::Json(max_bid)).into_response()) + } else { + // This shouldn't ever happen but the compiler needs it + Err(PbsClientError::DecodeError( + "no viable accept types in request".to_string(), + )) + } + } + } } else { // spec: return 204 if request is valid but no bid available info!("no header available for slot"); diff --git a/crates/pbs/src/routes/submit_block.rs b/crates/pbs/src/routes/submit_block.rs index 004b601e..99eb14d8 100644 --- a/crates/pbs/src/routes/submit_block.rs +++ b/crates/pbs/src/routes/submit_block.rs @@ -1,14 +1,23 @@ use std::sync::Arc; -use axum::{Json, extract::State, http::HeaderMap, response::IntoResponse}; +use axum::{ + extract::State, + http::{HeaderMap, HeaderValue}, + response::IntoResponse, +}; use cb_common::{ - pbs::{BuilderApiVersion, GetPayloadInfo, SignedBlindedBeaconBlock}, - utils::{get_user_agent, timestamp_of_slot_start_millis, utcnow_ms}, + pbs::{BuilderApiVersion, GetPayloadInfo}, + utils::{ + CONSENSUS_VERSION_HEADER, EncodingType, RawRequest, deserialize_body, get_accept_types, + get_user_agent, preferred_encoding, timestamp_of_slot_start_millis, utcnow_ms, + }, }; -use reqwest::StatusCode; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::Encode; use tracing::{error, info, trace}; use crate::{ + CompoundSubmitBlockResponse, api::BuilderApi, constants::SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, error::PbsClientError, @@ -19,37 +28,27 @@ use crate::{ pub async fn handle_submit_block_v1>( state: State>, req_headers: HeaderMap, - Json(signed_blinded_block): Json>, + raw_request: RawRequest, ) -> Result { - handle_submit_block_impl::( - state, - req_headers, - signed_blinded_block, - BuilderApiVersion::V1, - ) - .await + handle_submit_block_impl::(state, req_headers, raw_request, BuilderApiVersion::V1).await } pub async fn handle_submit_block_v2>( state: State>, req_headers: HeaderMap, - Json(signed_blinded_block): Json>, + raw_request: RawRequest, ) -> Result { - handle_submit_block_impl::( - state, - req_headers, - signed_blinded_block, - BuilderApiVersion::V2, - ) - .await + handle_submit_block_impl::(state, req_headers, raw_request, BuilderApiVersion::V2).await } async fn handle_submit_block_impl>( State(state): State>, req_headers: HeaderMap, - signed_blinded_block: Arc, + raw_request: RawRequest, api_version: BuilderApiVersion, ) -> Result { + let signed_blinded_block = + Arc::new(deserialize_body(&req_headers, raw_request.body_bytes).await?); tracing::Span::current().record("slot", signed_blinded_block.slot().as_u64() as i64); tracing::Span::current() .record("block_hash", tracing::field::debug(signed_blinded_block.block_hash())); @@ -64,27 +63,95 @@ async fn handle_submit_block_impl>( let block_hash = signed_blinded_block.block_hash(); let slot_start_ms = timestamp_of_slot_start_millis(slot.into(), state.config.chain); let ua = get_user_agent(&req_headers); + let accept_types = get_accept_types(&req_headers).map_err(|e| { + error!(%e, "error parsing accept header"); + PbsClientError::DecodeError(format!("error parsing accept header: {e}")) + })?; + // Honor caller q-value preference: pick the highest-priority encoding that + // we can actually produce. Server preference for tiebreaks is SSZ first. + let response_encoding = + preferred_encoding(&accept_types, &[EncodingType::Ssz, EncodingType::Json]); + let accepts_ssz = response_encoding == Some(EncodingType::Ssz); + let accepts_json = response_encoding == Some(EncodingType::Json); info!(ua, ms_into_slot = now.saturating_sub(slot_start_ms), "new request"); - match A::submit_block(signed_blinded_block, req_headers, state, api_version).await { + match A::submit_block(signed_blinded_block, req_headers, state, api_version, accept_types).await + { Ok(res) => match res { - Some(block_response) => { - trace!(?block_response); - info!("received unblinded block (v1)"); + crate::CompoundSubmitBlockResponse::EmptyBody => { + info!("received unblinded block (v2)"); + + // Note: this doesn't provide consensus_version_header because it doesn't pass + // the body through, and there's no content-type header since the body is empty. + BEACON_NODE_STATUS + .with_label_values(&["202", SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG]) + .inc(); + Ok((StatusCode::ACCEPTED, "").into_response()) + } + CompoundSubmitBlockResponse::Light(payload_and_blobs) => { + trace!(?payload_and_blobs); + info!("received unblinded block (v1, unvalidated)"); BEACON_NODE_STATUS .with_label_values(&["200", SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG]) .inc(); - Ok((StatusCode::OK, Json(block_response).into_response())) + + // Create the headers + let consensus_version_header = + match HeaderValue::from_str(&payload_and_blobs.version.to_string()) { + Ok(consensus_version_header) => { + Ok::(consensus_version_header) + } + Err(e) => { + return Err(PbsClientError::RelayError(format!( + "error decoding consensus version from relay payload: {e}" + ))); + } + }?; + let content_type = payload_and_blobs.encoding_type.content_type(); + let content_type_header = HeaderValue::from_str(content_type).unwrap(); + + // Build response + let mut res = payload_and_blobs.raw_bytes.into_response(); + res.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + res.headers_mut().insert(CONTENT_TYPE, content_type_header); + info!("sending response as {} (light)", content_type); + Ok(res) } - None => { - info!("received unblinded block (v2)"); + CompoundSubmitBlockResponse::Full(payload_and_blobs) => { + trace!(?payload_and_blobs); + info!("received unblinded block (v1)"); BEACON_NODE_STATUS - .with_label_values(&["202", SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG]) + .with_label_values(&["200", SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG]) .inc(); - Ok((StatusCode::ACCEPTED, "".into_response())) + + // Try SSZ + if accepts_ssz { + let mut response = payload_and_blobs.data.as_ssz_bytes().into_response(); + + // This won't actually fail since the string is a const + let content_type_header = + HeaderValue::from_str(EncodingType::Ssz.content_type()).unwrap(); + response.headers_mut().insert(CONTENT_TYPE, content_type_header); + response.headers_mut().insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&payload_and_blobs.version.to_string()).unwrap(), + ); + info!("sending response as SSZ"); + return Ok(response); + } + + // Handle JSON + if accepts_json { + Ok((StatusCode::OK, axum::Json(payload_and_blobs)).into_response()) + } else { + // This shouldn't ever happen but the compiler needs it + Err(PbsClientError::DecodeError( + "no viable accept types in request".to_string(), + )) + } } }, diff --git a/crates/pbs/src/state.rs b/crates/pbs/src/state.rs index bd683e5f..cbe86af9 100644 --- a/crates/pbs/src/state.rs +++ b/crates/pbs/src/state.rs @@ -64,8 +64,4 @@ where None => (self.pbs_config(), &self.config.relays, None), } } - - pub fn extra_validation_enabled(&self) -> bool { - self.config.pbs_config.extra_validation_enabled - } } diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index b3a3045b..c8d84ec8 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -630,6 +630,7 @@ async fn handle_reload( ) -> Result { debug!(event = "reload", "New request"); + // Regenerate the config let config = match StartSignerConfig::load_from_env() { Ok(config) => config, Err(err) => { diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 7eefb277..db1072a7 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -9,10 +9,31 @@ Commit-Boost needs a configuration file detailing all the services that you want - For a full explanation of all the fields, check out [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml). - For some additional examples on config presets, check out [here](https://github.com/Commit-Boost/commit-boost-client/tree/main/configs). -## Minimal PBS setup on Holesky +## Validation + +The PBS service can be configured to perform various levels of validation against both builder bid requests and unblinded blocks returned by relays. This allows the user to trade-off between speed and safety. + +For requesting builder bids, you can specify the `header_validation_mode` setting within the `[pbs]` configuration section. It has three modes: + +- `header_validation_mode = "none"`: The bids returned by the relay will not undergo any validation, and they will only be partially decoded to check the fork version and the value. The bid with the highest value will still be returned, but the PBS service won't check to confirm whether or not the bid is actually legal. We recommend that this only gets used when you absolutely trust each relay you've configured. + +- `header_validation_mode = "standard"`: The bids returned by the relay will be fully decoded and validated against the expected request (such as a matching parent hash, correct relay signature, and so on). This takes a small amount of extra computing power but ensures any invalid bids will be ignored. + +- `header_validation_mode = "extra"`: Performs all of the `standard` validation, plus ensures the block number is correct and the block's gas limit is legal. Requires the `rpc_url` parameter to be set, so the PBS service can query an Execution Client to confirm those details. + +For submitting signed blinded blocks and retrieving unblinded blocks, you can specify the `block_validation_mode` setting: + +- `block_validation_mode = "none"`: The unblinded blocks returned by the relay will not undergo any validation, and they will only be partially decoded to check that the fork version is correct. The unblinded block won't be checked to verify that it matches the original blinded block you submitted. We recommend that this only gets used when you absolutely trust each relay you've configured. + + Blocks will be returned directly from the relay to the Beacon Node, and may not necessarily be in a format the Beacon Node requested. For example, if the Beacon Node sends the signed blinded block as SSZ, but the relay only accepts JSON, it will return the unblinded block to the Beacon Node as JSON rather than having the PBS service re-encode it into SSZ. Whether or not this is supported is an implementation detail of the particular Beacon Node you're using. + +- `block_validation_mode = "standard"`: The unblinded blocks returned by the relay will be fully decoded and validated to ensure they match the original request, and are valid according to the rules of the Beacon Chain. This takes a small amount of extra computing power but ensures the block was properly unblinded. + + +## Minimal PBS Setup on Hoodi ```toml -chain = "Holesky" +chain = "Hoodi" [pbs] port = 18550 @@ -24,20 +45,20 @@ url = "" enabled = true ``` -You can find a list of MEV-Boost Holesky relays [here](https://www.coincashew.com/coins/overview-eth/mev-boost/mev-relay-list#holesky-testnet-relays). +You can find a list of MEV-Boost Hoodi relays [here](https://www.coincashew.com/coins/overview-eth/mev-boost/mev-relay-list#hoodi-testnet-relays). After the sidecar is started, it will expose a port (`18550` in this example), that you need to point your CL to. This may be different depending on which CL you're running, check out [here](https://docs.flashbots.net/flashbots-mev-boost/getting-started/system-requirements#consensus-client-configuration-guides) for a list of configuration guides. :::note -In this setup, the signer module will not be started. +In this setup, the Signer service will not be started. ::: -## Signer module +## Signer Service -Commit-Boost supports both local and remote signers. The signer module is responsible for signing the transactions that other modules generates. Please note that only one signer at a time is allowed. +Commit-Boost supports both local and remote signers. The Signer service is responsible for signing the transactions that other modules generates. Please note that only one Signer at a time is allowed. -### Local signer +### Local Signer -To start a local signer module, you need to include its parameters in the config file +To start a local Signer Service, you need to include its parameters in the config file ```toml [pbs] @@ -219,9 +240,9 @@ All keys have the same password stored in `secrets/password.txt` ``` -### Proxy keys store +### Proxy Keys -Proxy keys can be used to sign transactions with a different key than the one used to sign the block. Proxy keys are generated by the Signer module and authorized by the validator key. Each module have their own proxy keys, that can be BLS or ECDSA. +Proxy keys can be used to sign transactions with a different key than the one used to sign the block. Proxy keys are generated by the Signer service and authorized by the validator key. Each service can have their own proxy keys, both BLS and ECDSA. To persist proxy keys across restarts, you must enable the proxy store in the config file. There are 2 options for this: @@ -230,7 +251,7 @@ To persist proxy keys across restarts, you must enable the proxy store in the co The keys are stored in plain text in a file. This method is unsafe and should only be used for testing. -#### File structure +#### File Structure ``` @@ -269,7 +290,7 @@ Where each `` file contains the following: The keys are stored in a ERC-2335 style keystore, along with a password. This way, you can safely share the keys directory as without the password they are useless. -#### File structure +#### File Structure ``` ├── @@ -305,13 +326,13 @@ Where the `.json` files contain ERC-2335 keystore, the ` -### Remote signer +### Remote Signer You might choose to use an external service to sign the transactions. For now, two types of remote signers are supported: Web3Signer and Dirk. #### Web3Signer -Web3Signer implements the same API as Commit-Boost, so there's no need to set up a Signer module. The parameters needed for the remote signer are: +Web3Signer implements the same API as Commit-Boost, so there's no need to set up a Signer service. The parameters needed for the remote signer are: ```toml [signer.remote] @@ -320,7 +341,7 @@ url = "https://remote.signer.url" #### Dirk -Dirk is a distributed key management system that can be used to sign transactions. In this case the Signer module is needed as an intermediary between the modules and Dirk. The following parameters are needed: +Dirk is a distributed key management system that can be used to sign transactions. In this case the Signer service is needed as an intermediary between the modules and Dirk. The following parameters are needed: ```toml [signer.dirk] @@ -344,7 +365,7 @@ wallets = ["AnotherWallet", "DistributedWallet"] ``` - `cert_path` and `key_path` are the paths to the client certificate and key used to authenticate with Dirk. -- `wallets` is a list of wallets from which the Signer module will load all accounts as consensus keys. Generated proxy keys will have format `///`, so accounts found with that pattern will be ignored. +- `wallets` is a list of wallets from which the Signer service will load all accounts as consensus keys. Generated proxy keys will have format `///`, so accounts found with that pattern will be ignored. - `secrets_path` is the path to the folder containing the passwords of the generated proxy accounts, which will be stored in `////.pass`. Additionally, you can set a proxy store so that the delegation signatures for generated proxy keys are stored locally. As these signatures are not sensitive, the only supported store type is `File`: @@ -424,7 +445,7 @@ Note: `trusted_count` is the number of trusted proxies in front of the Signer se ## Custom module -We currently provide a test module that needs to be built locally. To build the module run: +We currently provide a test module that needs to be built locally. To build the module, run: ```bash just docker-build-test-modules @@ -474,7 +495,7 @@ To learn more about developing modules, check out [here](/category/developing). ## Vouch -[Vouch](https://github.com/attestantio/vouch) is a multi-node validator client built by [Attestant](https://www.attestant.io/). Vouch is particular in that it also integrates an MEV-Boost client to interact with relays. The Commit-Boost PBS module is compatible with the Vouch `blockrelay` since it implements the same Builder-API as relays. For example, depending on your setup and preference, you may want to fetch headers from a given relay using Commit-Boost vs using the built-in Vouch `blockrelay`. +[Vouch](https://github.com/attestantio/vouch) is a multi-node validator client built by [Attestant](https://www.attestant.io/). Vouch is particular in that it also integrates an MEV-Boost client to interact with relays. The Commit-Boost PBS service is compatible with the Vouch `blockrelay` since it implements the same Builder-API as relays. For example, depending on your setup and preference, you may want to fetch headers from a given relay using Commit-Boost vs using the built-in Vouch `blockrelay`. ### Configuration @@ -497,7 +518,7 @@ Modify the `blockrelay.config` file to add Commit-Boost: #### Beacon Node to Commit-Boost -In this setup, the BN Builder-API endpoint will be pointing to the PBS module (e.g. for Lighthouse you will need the flag `--builder=http://127.0.0.0:18550`). +In this setup, the BN Builder-API endpoint will be pointing to the PBS service (e.g. for Lighthouse you will need the flag `--builder=http://127.0.0.0:18550`). This will bypass the `blockrelay` entirely so make sure all relays are properly configured in the `[[relays]]` section. @@ -512,7 +533,7 @@ This approach could also work if you have a multi-beacon-node setup, where some ## Hot Reload -Commit-Boost supports hot-reloading the configuration file. This means that you can modify the `cb-config.toml` file and apply the changes without needing to restart the modules. To do this, you need to send a `POST` request to the `/reload` endpoint on each module you want to reload the configuration. In the case the module is running in a Docker container without the port exposed (like the signer), you can use the following command: +Commit-Boost supports hot-reloading the configuration file. This means that you can modify the `cb-config.toml` file and apply the changes without needing to restart the services. To do this, you need to send a `POST` request to the `/reload` endpoint on each service you want to reload the configuration. In the case the service is running in a Docker container without the port exposed (like the signer), you can use the following command: ```bash docker compose -f cb.docker-compose.yml exec cb_signer curl -X POST http://localhost:20000/reload @@ -556,7 +577,7 @@ Send `POST /revoke_jwt` with the module ID. This removes the module from the sig ### Notes -- The hot reload feature is available for PBS modules (both default and custom) and signer module. +- The hot reload feature is available for both the PBS service (both default and custom) and Signer service. - Changes related to listening hosts and ports will not been applied, as it requires the server to be restarted. - If running in Docker containers, changes in `volumes` will not be applied, as it requires the container to be recreated. Be careful if changing a path to a local file as it may not be accessible from the container. - Custom PBS modules may override the default behaviour of the hot reload feature to parse extra configuration fields. Check the [examples](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/status_api/src/main.rs) for more details. diff --git a/tests/Cargo.toml b/tests/Cargo.toml index c1c51f58..3f755301 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -10,8 +10,10 @@ axum.workspace = true cb-common.workspace = true cb-pbs.workspace = true cb-signer.workspace = true +ethereum_ssz.workspace = true eyre.workspace = true jsonwebtoken.workspace = true +lh_eth2.workspace = true lh_types.workspace = true rcgen.workspace = true reqwest.workspace = true diff --git a/tests/data/configs/pbs.happy.toml b/tests/data/configs/pbs.happy.toml index d77af2b6..67b39911 100644 --- a/tests/data/configs/pbs.happy.toml +++ b/tests/data/configs/pbs.happy.toml @@ -2,7 +2,8 @@ chain = "Holesky" [pbs] docker_image = "ghcr.io/commit-boost/pbs:latest" -extra_validation_enabled = false +header_validation_mode = "standard" +block_validation_mode = "standard" host = "127.0.0.1" late_in_slot_time_ms = 2000 min_bid_eth = 0.5 diff --git a/tests/data/get_header/bellatrix.json b/tests/data/get_header/bellatrix.json new file mode 100644 index 00000000..16dfb330 --- /dev/null +++ b/tests/data/get_header/bellatrix.json @@ -0,0 +1,26 @@ +{ + "version": "bellatrix", + "data": { + "message": { + "header": { + "parent_hash": "0x114d1897fefa402a01a653c21a7f1f1db049d1373a5e73a2d25d7a8045dc02a1", + "fee_recipient": "0x477cc10a5b54aed5c88544c2e71ea0581cf64593", + "state_root": "0x6724be16ef8e65681cb66f9c144da67347b8983aa5e3f4662c9b5dba90ab5bc6", + "receipts_root": "0xf2f6d2fe6960e4dedad18cca0c7881e6509d551d3e04c1879a627fb8aba30272", + "logs_bloom": "0x00000400000000000000848008100000000000000000000004000000010080000000000100000400000000000000000000000000020100000000000000000000080004000000000800008008000000000000000020004000000400000000000000000000000400000000000000000000000000000010000002000010000000000000000000800000200100000000000000004000000000200002000004000000000800000000000000000000000000008000000000000000800000008000000400012002000000000000000000000000000200000000000000000000000000040000000000000000000000000000000000408000000000040000000000000000", + "prev_randao": "0x0fde820be6404bcb71d7bbeee140c16cd28b1940a40fa8a4e2c493114a08b38a", + "block_number": "1598034", + "gas_limit": "30000000", + "gas_used": "1939652", + "timestamp": "1716481836", + "extra_data": "0xd983010d0c846765746889676f312e32312e3130856c696e7578", + "base_fee_per_gas": "1266581747", + "block_hash": "0x0d9eccac62175d903e4242783d7252f4ab6cdd35995810646bda627b4c35adac", + "transactions_root": "0x9dca93e8c6c9a1b5fcc850990ed95cd44af96ff0a6094c87b119a34259eb64b0" + }, + "value": "1234567890", + "pubkey": "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + }, + "signature": "0xa9f158bca1d9d6b93a9104f48bd2d1e7689bef3fc974651fc755cc6f50d3649c5153a342a12f95cd8f9cac4f90144985189f498a7e0e1cb202ed5e7c98f3f504f371a53b9293bdd973fbb019c91242f808072d0ffcd9d17e2404baea3190fd18" + } +} \ No newline at end of file diff --git a/tests/data/get_header/capella.json b/tests/data/get_header/capella.json new file mode 100644 index 00000000..6cdbeb98 --- /dev/null +++ b/tests/data/get_header/capella.json @@ -0,0 +1,27 @@ +{ + "version": "capella", + "data": { + "message": { + "header": { + "parent_hash": "0x114d1897fefa402a01a653c21a7f1f1db049d1373a5e73a2d25d7a8045dc02a1", + "fee_recipient": "0x477cc10a5b54aed5c88544c2e71ea0581cf64593", + "state_root": "0x6724be16ef8e65681cb66f9c144da67347b8983aa5e3f4662c9b5dba90ab5bc6", + "receipts_root": "0xf2f6d2fe6960e4dedad18cca0c7881e6509d551d3e04c1879a627fb8aba30272", + "logs_bloom": "0x00000400000000000000848008100000000000000000000004000000010080000000000100000400000000000000000000000000020100000000000000000000080004000000000800008008000000000000000020004000000400000000000000000000000400000000000000000000000000000010000002000010000000000000000000800000200100000000000000004000000000200002000004000000000800000000000000000000000000008000000000000000800000008000000400012002000000000000000000000000000200000000000000000000000000040000000000000000000000000000000000408000000000040000000000000000", + "prev_randao": "0x0fde820be6404bcb71d7bbeee140c16cd28b1940a40fa8a4e2c493114a08b38a", + "block_number": "1598034", + "gas_limit": "30000000", + "gas_used": "1939652", + "timestamp": "1716481836", + "extra_data": "0xd983010d0c846765746889676f312e32312e3130856c696e7578", + "base_fee_per_gas": "1266581747", + "block_hash": "0x0d9eccac62175d903e4242783d7252f4ab6cdd35995810646bda627b4c35adac", + "transactions_root": "0x9dca93e8c6c9a1b5fcc850990ed95cd44af96ff0a6094c87b119a34259eb64b0", + "withdrawals_root": "0x2daccf0e476ca3e2644afbd13b2621d55b4d515b813a3b867cdacea24bb352d1" + }, + "value": "1234567890", + "pubkey": "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + }, + "signature": "0xa9f158bca1d9d6b93a9104f48bd2d1e7689bef3fc974651fc755cc6f50d3649c5153a342a12f95cd8f9cac4f90144985189f498a7e0e1cb202ed5e7c98f3f504f371a53b9293bdd973fbb019c91242f808072d0ffcd9d17e2404baea3190fd18" + } +} \ No newline at end of file diff --git a/tests/data/get_header/deneb.json b/tests/data/get_header/deneb.json new file mode 100644 index 00000000..28d3426a --- /dev/null +++ b/tests/data/get_header/deneb.json @@ -0,0 +1,37 @@ +{ + "version": "deneb", + "data": { + "message": { + "header": { + "parent_hash": "0x114d1897fefa402a01a653c21a7f1f1db049d1373a5e73a2d25d7a8045dc02a1", + "fee_recipient": "0x477cc10a5b54aed5c88544c2e71ea0581cf64593", + "state_root": "0x6724be16ef8e65681cb66f9c144da67347b8983aa5e3f4662c9b5dba90ab5bc6", + "receipts_root": "0xf2f6d2fe6960e4dedad18cca0c7881e6509d551d3e04c1879a627fb8aba30272", + "logs_bloom": "0x00000400000000000000848008100000000000000000000004000000010080000000000100000400000000000000000000000000020100000000000000000000080004000000000800008008000000000000000020004000000400000000000000000000000400000000000000000000000000000010000002000010000000000000000000800000200100000000000000004000000000200002000004000000000800000000000000000000000000008000000000000000800000008000000400012002000000000000000000000000000200000000000000000000000000040000000000000000000000000000000000408000000000040000000000000000", + "prev_randao": "0x0fde820be6404bcb71d7bbeee140c16cd28b1940a40fa8a4e2c493114a08b38a", + "block_number": "1598034", + "gas_limit": "30000000", + "gas_used": "1939652", + "timestamp": "1716481836", + "extra_data": "0xd983010d0c846765746889676f312e32312e3130856c696e7578", + "base_fee_per_gas": "1266581747", + "blob_gas_used": "786432", + "excess_blob_gas": "95158272", + "block_hash": "0x0d9eccac62175d903e4242783d7252f4ab6cdd35995810646bda627b4c35adac", + "transactions_root": "0x9dca93e8c6c9a1b5fcc850990ed95cd44af96ff0a6094c87b119a34259eb64b0", + "withdrawals_root": "0x2daccf0e476ca3e2644afbd13b2621d55b4d515b813a3b867cdacea24bb352d1" + }, + "blob_kzg_commitments": [ + "0x9559cce9cd71a3416793c8e28d3aaaae9f53732180f57e046bf725c74ab348a7b16693fd03194cac9dd2199a526461b7", + "0xabc493f754d156c7156eb8365d28eee13e5b3413767356ce4cb30cb0306fbe0ed45eaba92936a94e81ed976aa0d787c2", + "0xa5d87332b5dd391ed3153fe36dbd67775dcbc1818cbf6a68d2089a5c6015de1de02e5138f039f2375e6b3511cc94764b", + "0xa49c576627561ec9ae1ef7494e7cee7ede7fa7695d4462436c3e549cc3ce78674b407e8b5f8903b80f77a68814642d6c", + "0x83155fbeb04758d267193800fb89fa30eb13ac0e217005ae7e271733205ca8a6cd80fba08bf5c9a4a5cc0c9d463ac633", + "0xa20c71d1985996098aa63e8b5dc7b7fedb70de31478fe309dad3ac0e9b6d28d82be8e5e543021a0203dc785742e94b2f" + ], + "value": "1234567890", + "pubkey": "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + }, + "signature": "0xa9f158bca1d9d6b93a9104f48bd2d1e7689bef3fc974651fc755cc6f50d3649c5153a342a12f95cd8f9cac4f90144985189f498a7e0e1cb202ed5e7c98f3f504f371a53b9293bdd973fbb019c91242f808072d0ffcd9d17e2404baea3190fd18" + } +} \ No newline at end of file diff --git a/tests/data/get_header/electra.json b/tests/data/get_header/electra.json new file mode 100644 index 00000000..458018d6 --- /dev/null +++ b/tests/data/get_header/electra.json @@ -0,0 +1,62 @@ +{ + "version": "electra", + "data": { + "message": { + "header": { + "parent_hash": "0x114d1897fefa402a01a653c21a7f1f1db049d1373a5e73a2d25d7a8045dc02a1", + "fee_recipient": "0x477cc10a5b54aed5c88544c2e71ea0581cf64593", + "state_root": "0x6724be16ef8e65681cb66f9c144da67347b8983aa5e3f4662c9b5dba90ab5bc6", + "receipts_root": "0xf2f6d2fe6960e4dedad18cca0c7881e6509d551d3e04c1879a627fb8aba30272", + "logs_bloom": "0x00000400000000000000848008100000000000000000000004000000010080000000000100000400000000000000000000000000020100000000000000000000080004000000000800008008000000000000000020004000000400000000000000000000000400000000000000000000000000000010000002000010000000000000000000800000200100000000000000004000000000200002000004000000000800000000000000000000000000008000000000000000800000008000000400012002000000000000000000000000000200000000000000000000000000040000000000000000000000000000000000408000000000040000000000000000", + "prev_randao": "0x0fde820be6404bcb71d7bbeee140c16cd28b1940a40fa8a4e2c493114a08b38a", + "block_number": "1598034", + "gas_limit": "30000000", + "gas_used": "1939652", + "timestamp": "1716481836", + "extra_data": "0xd983010d0c846765746889676f312e32312e3130856c696e7578", + "base_fee_per_gas": "1266581747", + "blob_gas_used": "786432", + "excess_blob_gas": "95158272", + "block_hash": "0x0d9eccac62175d903e4242783d7252f4ab6cdd35995810646bda627b4c35adac", + "transactions_root": "0x9dca93e8c6c9a1b5fcc850990ed95cd44af96ff0a6094c87b119a34259eb64b0", + "withdrawals_root": "0x2daccf0e476ca3e2644afbd13b2621d55b4d515b813a3b867cdacea24bb352d1" + }, + "blob_kzg_commitments": [ + "0x9559cce9cd71a3416793c8e28d3aaaae9f53732180f57e046bf725c74ab348a7b16693fd03194cac9dd2199a526461b7", + "0xabc493f754d156c7156eb8365d28eee13e5b3413767356ce4cb30cb0306fbe0ed45eaba92936a94e81ed976aa0d787c2", + "0xa5d87332b5dd391ed3153fe36dbd67775dcbc1818cbf6a68d2089a5c6015de1de02e5138f039f2375e6b3511cc94764b", + "0xa49c576627561ec9ae1ef7494e7cee7ede7fa7695d4462436c3e549cc3ce78674b407e8b5f8903b80f77a68814642d6c", + "0x83155fbeb04758d267193800fb89fa30eb13ac0e217005ae7e271733205ca8a6cd80fba08bf5c9a4a5cc0c9d463ac633", + "0xa20c71d1985996098aa63e8b5dc7b7fedb70de31478fe309dad3ac0e9b6d28d82be8e5e543021a0203dc785742e94b2f" + ], + "execution_requests": { + "deposits": [ + { + "pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "withdrawal_credentials": "0x0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f", + "amount": "100", + "signature": "0x8aeb4642fb2982039a43fd6a6d9cc0ebf7598dbf02343c4617d9a68d799393c162492add63f31099a25eacc2782ba27a190e977a8c58760b6636dccb503d528b3be9e885c93d5b79699e68fcca870b0c790cdb00d67604d8b4a3025ae75efa2f", + "index": "1" + } + ], + "withdrawals": [ + { + "source_address": "0x1100000000000000000000000000000000000000", + "validator_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "amount": "1" + } + ], + "consolidations": [ + { + "source_address": "0x1200000000000000000000000000000000000000", + "source_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "target_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5" + } + ] + }, + "value": "1234567890", + "pubkey": "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + }, + "signature": "0xa9f158bca1d9d6b93a9104f48bd2d1e7689bef3fc974651fc755cc6f50d3649c5153a342a12f95cd8f9cac4f90144985189f498a7e0e1cb202ed5e7c98f3f504f371a53b9293bdd973fbb019c91242f808072d0ffcd9d17e2404baea3190fd18" + } +} \ No newline at end of file diff --git a/tests/data/get_header/fulu.json b/tests/data/get_header/fulu.json new file mode 100644 index 00000000..b4cef51a --- /dev/null +++ b/tests/data/get_header/fulu.json @@ -0,0 +1,62 @@ +{ + "version": "fulu", + "data": { + "message": { + "header": { + "parent_hash": "0x114d1897fefa402a01a653c21a7f1f1db049d1373a5e73a2d25d7a8045dc02a1", + "fee_recipient": "0x477cc10a5b54aed5c88544c2e71ea0581cf64593", + "state_root": "0x6724be16ef8e65681cb66f9c144da67347b8983aa5e3f4662c9b5dba90ab5bc6", + "receipts_root": "0xf2f6d2fe6960e4dedad18cca0c7881e6509d551d3e04c1879a627fb8aba30272", + "logs_bloom": "0x00000400000000000000848008100000000000000000000004000000010080000000000100000400000000000000000000000000020100000000000000000000080004000000000800008008000000000000000020004000000400000000000000000000000400000000000000000000000000000010000002000010000000000000000000800000200100000000000000004000000000200002000004000000000800000000000000000000000000008000000000000000800000008000000400012002000000000000000000000000000200000000000000000000000000040000000000000000000000000000000000408000000000040000000000000000", + "prev_randao": "0x0fde820be6404bcb71d7bbeee140c16cd28b1940a40fa8a4e2c493114a08b38a", + "block_number": "1598034", + "gas_limit": "30000000", + "gas_used": "1939652", + "timestamp": "1716481836", + "extra_data": "0xd983010d0c846765746889676f312e32312e3130856c696e7578", + "base_fee_per_gas": "1266581747", + "blob_gas_used": "786432", + "excess_blob_gas": "95158272", + "block_hash": "0x0d9eccac62175d903e4242783d7252f4ab6cdd35995810646bda627b4c35adac", + "transactions_root": "0x9dca93e8c6c9a1b5fcc850990ed95cd44af96ff0a6094c87b119a34259eb64b0", + "withdrawals_root": "0x2daccf0e476ca3e2644afbd13b2621d55b4d515b813a3b867cdacea24bb352d1" + }, + "blob_kzg_commitments": [ + "0x9559cce9cd71a3416793c8e28d3aaaae9f53732180f57e046bf725c74ab348a7b16693fd03194cac9dd2199a526461b7", + "0xabc493f754d156c7156eb8365d28eee13e5b3413767356ce4cb30cb0306fbe0ed45eaba92936a94e81ed976aa0d787c2", + "0xa5d87332b5dd391ed3153fe36dbd67775dcbc1818cbf6a68d2089a5c6015de1de02e5138f039f2375e6b3511cc94764b", + "0xa49c576627561ec9ae1ef7494e7cee7ede7fa7695d4462436c3e549cc3ce78674b407e8b5f8903b80f77a68814642d6c", + "0x83155fbeb04758d267193800fb89fa30eb13ac0e217005ae7e271733205ca8a6cd80fba08bf5c9a4a5cc0c9d463ac633", + "0xa20c71d1985996098aa63e8b5dc7b7fedb70de31478fe309dad3ac0e9b6d28d82be8e5e543021a0203dc785742e94b2f" + ], + "execution_requests": { + "deposits": [ + { + "pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "withdrawal_credentials": "0x0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f", + "amount": "100", + "signature": "0x8aeb4642fb2982039a43fd6a6d9cc0ebf7598dbf02343c4617d9a68d799393c162492add63f31099a25eacc2782ba27a190e977a8c58760b6636dccb503d528b3be9e885c93d5b79699e68fcca870b0c790cdb00d67604d8b4a3025ae75efa2f", + "index": "1" + } + ], + "withdrawals": [ + { + "source_address": "0x1100000000000000000000000000000000000000", + "validator_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "amount": "1" + } + ], + "consolidations": [ + { + "source_address": "0x1200000000000000000000000000000000000000", + "source_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "target_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5" + } + ] + }, + "value": "1234567890", + "pubkey": "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + }, + "signature": "0xa9f158bca1d9d6b93a9104f48bd2d1e7689bef3fc974651fc755cc6f50d3649c5153a342a12f95cd8f9cac4f90144985189f498a7e0e1cb202ed5e7c98f3f504f371a53b9293bdd973fbb019c91242f808072d0ffcd9d17e2404baea3190fd18" + } +} \ No newline at end of file diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index 4d7f0fc1..be055684 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -1,43 +1,57 @@ use std::{ + collections::HashSet, net::SocketAddr, sync::{ Arc, RwLock, atomic::{AtomicU64, Ordering}, }, + time::Duration, }; use alloy::{primitives::U256, rpc::types::beacon::relay::ValidatorRegistration}; use axum::{ Json, Router, extract::{Path, State}, - http::StatusCode, + http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, }; use cb_common::{ pbs::{ BUILDER_V1_API_PATH, BUILDER_V2_API_PATH, BlobsBundle, BuilderBid, BuilderBidElectra, - ExecutionPayloadElectra, ExecutionPayloadHeaderElectra, ExecutionRequests, ForkName, - GET_HEADER_PATH, GET_STATUS_PATH, GetHeaderParams, GetHeaderResponse, GetPayloadInfo, - PayloadAndBlobs, REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SignedBlindedBeaconBlock, - SignedBuilderBid, SubmitBlindedBlockResponse, + BuilderBidFulu, ExecutionPayloadElectra, ExecutionPayloadHeaderElectra, + ExecutionPayloadHeaderFulu, ExecutionRequests, ForkName, GET_HEADER_PATH, GET_STATUS_PATH, + GetHeaderParams, GetHeaderResponse, GetPayloadInfo, PayloadAndBlobs, + REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SignedBuilderBid, SubmitBlindedBlockResponse, }, signature::sign_builder_root, types::{BlsSecretKey, Chain}, - utils::{TestRandomSeed, timestamp_of_slot_start_sec}, + utils::{ + CONSENSUS_VERSION_HEADER, EncodingType, RawRequest, TestRandomSeed, deserialize_body, + get_accept_types, get_consensus_version_header, get_content_type, + timestamp_of_slot_start_sec, + }, }; use cb_pbs::MAX_SIZE_SUBMIT_BLOCK_RESPONSE; use lh_types::KzgProof; +use reqwest::header::CONTENT_TYPE; +use ssz::Encode; use tokio::net::TcpListener; -use tracing::debug; +use tracing::{debug, error}; use tree_hash::TreeHash; pub async fn start_mock_relay_service(state: Arc, port: u16) -> eyre::Result<()> { - let app = mock_relay_app_router(state); - let socket = SocketAddr::new("0.0.0.0".parse()?, port); let listener = TcpListener::bind(socket).await?; + start_mock_relay_service_with_listener(state, listener).await +} +/// Like [`start_mock_relay_service`], but accepts a pre-bound [`TcpListener`]. +pub async fn start_mock_relay_service_with_listener( + state: Arc, + listener: TcpListener, +) -> eyre::Result<()> { + let app = mock_relay_app_router(state); axum::serve(listener, app).await?; Ok(()) } @@ -45,14 +59,29 @@ pub async fn start_mock_relay_service(state: Arc, port: u16) -> pub struct MockRelayState { pub chain: Chain, pub signer: BlsSecretKey, + pub supported_content_types: Arc>, large_body: bool, supports_submit_block_v2: bool, use_not_found_for_submit_block: bool, + /// If set, `handle_submit_block_v1`/`v2` short-circuits with this status + /// when the inbound request carries `Content-Type: + /// application/octet-stream`. The counter is still incremented before + /// the short-circuit so tests can observe the attempt. Used to drive C3 + /// (retry-as-JSON) tests. + submit_block_ssz_status_override: Option, + /// If set, this literal string is sent as the outgoing `Content-Type` + /// header on `handle_get_header` and `handle_submit_block_v1` responses + /// instead of the canonical `application/json` / `application/octet-stream` + /// value. The body is still serialized according to the encoding that was + /// negotiated via `Accept`. Used to exercise PBS tolerance of + /// MIME-parameter suffixes like `application/octet-stream; charset=binary`. + response_content_type_override: Option, received_get_header: Arc, received_get_status: Arc, received_register_validator: Arc, received_submit_block: Arc, response_override: RwLock>, + bid_value: RwLock, } impl MockRelayState { @@ -77,6 +106,12 @@ impl MockRelayState { pub fn use_not_found_for_submit_block(&self) -> bool { self.use_not_found_for_submit_block } + pub fn submit_block_ssz_status_override(&self) -> Option { + self.submit_block_ssz_status_override + } + pub fn response_content_type_override(&self) -> Option<&str> { + self.response_content_type_override.as_deref() + } pub fn set_response_override(&self, status: StatusCode) { *self.response_override.write().unwrap() = Some(status); } @@ -90,14 +125,27 @@ impl MockRelayState { large_body: false, supports_submit_block_v2: true, use_not_found_for_submit_block: false, + submit_block_ssz_status_override: None, + response_content_type_override: None, received_get_header: Default::default(), received_get_status: Default::default(), received_register_validator: Default::default(), received_submit_block: Default::default(), response_override: RwLock::new(None), + bid_value: RwLock::new(U256::from(10)), + supported_content_types: Arc::new( + [EncodingType::Json, EncodingType::Ssz].iter().cloned().collect(), + ), } } + /// Override the bid value returned by this relay. Defaults to + /// `U256::from(10)`. + pub fn with_bid_value(self, value: U256) -> Self { + *self.bid_value.write().unwrap() = value; + self + } + pub fn with_large_body(self) -> Self { Self { large_body: true, ..self } } @@ -109,6 +157,23 @@ impl MockRelayState { pub fn with_not_found_for_submit_block(self) -> Self { Self { use_not_found_for_submit_block: true, ..self } } + + /// Make `handle_submit_block_v1`/`v2` respond with `status` whenever the + /// request comes in as SSZ (`Content-Type: application/octet-stream`). + /// JSON requests still go through the normal happy path, which lets a + /// single test cover the SSZ→JSON retry behavior. + pub fn with_submit_block_ssz_status(self, status: StatusCode) -> Self { + Self { submit_block_ssz_status_override: Some(status), ..self } + } + + /// Make the relay advertise `raw_content_type` as the `Content-Type` + /// header on `get_header` and `submit_block_v1` responses. The body is + /// still encoded via the negotiated [`EncodingType`] — only the header + /// string changes. Use this to drive PBS tolerance of MIME-parameter + /// suffixes (e.g. `application/octet-stream; charset=binary`). + pub fn with_response_content_type(self, raw_content_type: impl Into) -> Self { + Self { response_content_type_override: Some(raw_content_type.into()), ..self } + } } pub fn mock_relay_app_router(state: Arc) -> Router { @@ -132,40 +197,126 @@ pub fn mock_relay_app_router(state: Arc) -> Router { async fn handle_get_header( State(state): State>, Path(GetHeaderParams { parent_hash, .. }): Path, + headers: HeaderMap, ) -> Response { state.received_get_header.fetch_add(1, Ordering::Relaxed); + let accept_types = get_accept_types(&headers) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("error parsing accept header: {e}"))); + if let Err(e) = accept_types { + return e.into_response(); + } + let accept_types = accept_types.unwrap(); + let consensus_version_header = + get_consensus_version_header(&headers).unwrap_or(ForkName::Electra); - let mut header = ExecutionPayloadHeaderElectra { - parent_hash: parent_hash.into(), - block_hash: Default::default(), - timestamp: timestamp_of_slot_start_sec(0, state.chain), - ..ExecutionPayloadHeaderElectra::test_random() + 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(); }; - header.block_hash.0[0] = 1; + let bid_value = *state.bid_value.read().unwrap(); - let message = BuilderBid::Electra(BuilderBidElectra { - header, - blob_kzg_commitments: Default::default(), - execution_requests: ExecutionRequests::default(), - value: U256::from(10), - pubkey: state.signer.public_key().into(), - }); + let data = match consensus_version_header { + ForkName::Electra => { + let mut header = ExecutionPayloadHeaderElectra { + parent_hash: parent_hash.into(), + block_hash: Default::default(), + timestamp: timestamp_of_slot_start_sec(0, state.chain), + ..ExecutionPayloadHeaderElectra::test_random() + }; + header.block_hash.0[0] = 1; - let object_root = message.tree_hash_root(); - let signature = sign_builder_root(state.chain, &state.signer, &object_root); - let response = SignedBuilderBid { message, signature }; + let message = BuilderBid::Electra(BuilderBidElectra { + header, + blob_kzg_commitments: Default::default(), + execution_requests: ExecutionRequests::default(), + value: bid_value, + pubkey: state.signer.public_key().into(), + }); + let object_root = message.tree_hash_root(); + let signature = sign_builder_root(state.chain, &state.signer, &object_root); + let response = SignedBuilderBid { message, signature }; + if content_type == EncodingType::Ssz { + response.as_ssz_bytes() + } else { + let versioned_response = GetHeaderResponse { + version: ForkName::Electra, + data: response, + metadata: Default::default(), + }; + serde_json::to_vec(&versioned_response).unwrap() + } + } + ForkName::Fulu => { + let mut header = ExecutionPayloadHeaderFulu { + parent_hash: parent_hash.into(), + block_hash: Default::default(), + timestamp: timestamp_of_slot_start_sec(0, state.chain), + ..ExecutionPayloadHeaderFulu::test_random() + }; + header.block_hash.0[0] = 1; - let response = GetHeaderResponse { - version: ForkName::Electra, - data: response, - metadata: Default::default(), + let message = BuilderBid::Fulu(BuilderBidFulu { + header, + blob_kzg_commitments: Default::default(), + execution_requests: ExecutionRequests::default(), + value: bid_value, + pubkey: state.signer.public_key().into(), + }); + let object_root = message.tree_hash_root(); + let signature = sign_builder_root(state.chain, &state.signer, &object_root); + let response = SignedBuilderBid { message, signature }; + if content_type == EncodingType::Ssz { + response.as_ssz_bytes() + } else { + let versioned_response = GetHeaderResponse { + version: ForkName::Fulu, + data: response, + metadata: Default::default(), + }; + serde_json::to_vec(&versioned_response).unwrap() + } + } + _ => { + return ( + StatusCode::BAD_REQUEST, + format!("Unsupported fork {consensus_version_header}"), + ) + .into_response(); + } }; - (StatusCode::OK, Json(response)).into_response() + + let mut response = (StatusCode::OK, data).into_response(); + let consensus_version_header = + HeaderValue::from_str(&consensus_version_header.to_string()).unwrap(); + let content_type_str = state + .response_content_type_override() + .map(|s| s.to_string()) + .unwrap_or_else(|| content_type.to_string()); + let content_type_header = HeaderValue::from_str(&content_type_str).unwrap(); + response.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + response.headers_mut().insert(CONTENT_TYPE, content_type_header); + response } async fn handle_get_status(State(state): State>) -> impl IntoResponse { state.received_get_status.fetch_add(1, Ordering::Relaxed); + // Production `get_status` dispatches relays concurrently via `select_ok`, + // which cancels losing futures as soon as any relay returns OK. On a + // loaded runner this can abort a sibling relay's reqwest send before + // its handler is entered, so the test-side counter only reaches 1. A + // tiny response delay (counter already bumped above) guarantees every + // concurrent request lands in a handler before any response is written, + // eliminating the flake without altering production behavior. + tokio::time::sleep(Duration::from_millis(20)).await; StatusCode::OK } @@ -184,17 +335,61 @@ async fn handle_register_validator( } async fn handle_submit_block_v1( + headers: HeaderMap, State(state): State>, - Json(submit_block): Json, + raw_request: RawRequest, ) -> Response { if state.use_not_found_for_submit_block() { return StatusCode::NOT_FOUND.into_response(); } state.received_submit_block.fetch_add(1, Ordering::Relaxed); - if state.large_body() { - (StatusCode::OK, Json(vec![1u8; 1 + MAX_SIZE_SUBMIT_BLOCK_RESPONSE])).into_response() + // Short-circuit SSZ requests with an overridden status so tests can + // drive the PBS SSZ→JSON retry logic. JSON requests still take the + // normal path so a single mock run can exercise both attempts. + if let Some(status) = state.submit_block_ssz_status_override() && + get_content_type(&headers) == EncodingType::Ssz + { + return (status, "forced ssz override").into_response(); + } + let accept_types = get_accept_types(&headers) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("error parsing accept header: {e}"))); + if let Err(e) = accept_types { + return e.into_response(); + } + let accept_types = accept_types.unwrap(); + let consensus_version_header = get_consensus_version_header(&headers); + let response_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(); + }; + + // Error out if the request content type is not supported + let content_type = get_content_type(&headers); + if !state.supported_content_types.contains(&content_type) { + return (StatusCode::UNSUPPORTED_MEDIA_TYPE, "Unsupported content type".to_string()) + .into_response(); + }; + + let data = if state.large_body() { + vec![1u8; 1 + MAX_SIZE_SUBMIT_BLOCK_RESPONSE] } else { let mut execution_payload = ExecutionPayloadElectra::test_random(); + let submit_block = deserialize_body(&headers, raw_request.body_bytes).await.map_err(|e| { + error!(%e, "failed to deserialize signed blinded block"); + (StatusCode::BAD_REQUEST, format!("failed to deserialize body: {e}")) + }); + if let Err(e) = submit_block { + return e.into_response(); + } + let submit_block = submit_block.unwrap(); execution_payload.block_hash = submit_block.block_hash().into(); let mut blobs_bundle = BlobsBundle::default(); @@ -207,19 +402,60 @@ async fn handle_submit_block_v1( let response = PayloadAndBlobs { execution_payload: execution_payload.into(), blobs_bundle }; - let response = SubmitBlindedBlockResponse { - version: ForkName::Electra, - metadata: Default::default(), - data: response, - }; + if response_content_type == EncodingType::Ssz { + response.as_ssz_bytes() + } else { + // Return JSON for everything else; this is fine for the mock + let response = SubmitBlindedBlockResponse { + version: ForkName::Electra, + metadata: Default::default(), + data: response, + }; + serde_json::to_vec(&response).unwrap() + } + }; - (StatusCode::OK, Json(response)).into_response() + let mut response = (StatusCode::OK, data).into_response(); + if response_content_type == EncodingType::Ssz { + let consensus_version_header = match consensus_version_header { + Some(header) => header, + None => { + return (StatusCode::BAD_REQUEST, "Missing consensus version header".to_string()) + .into_response() + } + }; + let consensus_version_header = + HeaderValue::from_str(&consensus_version_header.to_string()).unwrap(); + response.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); } + let content_type_str = state + .response_content_type_override() + .map(|s| s.to_string()) + .unwrap_or_else(|| response_content_type.to_string()); + let content_type_header = HeaderValue::from_str(&content_type_str).unwrap(); + response.headers_mut().insert(CONTENT_TYPE, content_type_header); + response } -async fn handle_submit_block_v2(State(state): State>) -> Response { + +async fn handle_submit_block_v2( + headers: HeaderMap, + State(state): State>, +) -> Response { if state.use_not_found_for_submit_block() { return StatusCode::NOT_FOUND.into_response(); } state.received_submit_block.fetch_add(1, Ordering::Relaxed); + // See comment in `handle_submit_block_v1`. Override SSZ with the + // injected status so C3 tests can assert retry / no-retry behavior. + if let Some(status) = state.submit_block_ssz_status_override() && + get_content_type(&headers) == EncodingType::Ssz + { + return (status, "forced ssz override").into_response(); + } + let content_type = get_content_type(&headers); + if !state.supported_content_types.contains(&content_type) { + return (StatusCode::NOT_ACCEPTABLE, "No acceptable content type found".to_string()) + .into_response(); + }; (StatusCode::ACCEPTED, "").into_response() } diff --git a/tests/src/mock_ssv_public.rs b/tests/src/mock_ssv_public.rs index a014db42..dcd62df5 100644 --- a/tests/src/mock_ssv_public.rs +++ b/tests/src/mock_ssv_public.rs @@ -30,6 +30,18 @@ pub async fn create_mock_public_ssv_server( port: u16, state: Option, ) -> Result, axum::Error> { + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let listener = TcpListener::bind(address).await.map_err(axum::Error::new)?; + create_mock_public_ssv_server_with_listener(listener, state).await +} + +/// Like [`create_mock_public_ssv_server`], but accepts a pre-bound +/// [`TcpListener`]. +pub async fn create_mock_public_ssv_server_with_listener( + listener: TcpListener, + state: Option, +) -> Result, axum::Error> { + let port = listener.local_addr().map(|a| a.port()).unwrap_or(0); let data = include_str!("../../tests/data/ssv_valid_public.json"); let response = serde_json::from_str::(data).expect("failed to parse test data"); @@ -46,8 +58,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"); }); diff --git a/tests/src/mock_validator.rs b/tests/src/mock_validator.rs index ab593277..35adae87 100644 --- a/tests/src/mock_validator.rs +++ b/tests/src/mock_validator.rs @@ -2,9 +2,13 @@ use alloy::{primitives::B256, rpc::types::beacon::relay::ValidatorRegistration}; use cb_common::{ pbs::{BuilderApiVersion, RelayClient, SignedBlindedBeaconBlock}, types::BlsPublicKey, - utils::bls_pubkey_from_hex, + utils::{CONSENSUS_VERSION_HEADER, EncodingType, ForkName, bls_pubkey_from_hex}, }; -use reqwest::Response; +use reqwest::{ + Response, + header::{ACCEPT, CONTENT_TYPE}, +}; +use ssz::Encode; use crate::utils::generate_mock_relay; @@ -20,13 +24,36 @@ impl MockValidator { Ok(Self { comm_boost: generate_mock_relay(port, pubkey)? }) } - pub async fn do_get_header(&self, pubkey: Option) -> eyre::Result { + pub async fn do_get_header( + &self, + pubkey: Option, + accept: Vec, + fork_name: ForkName, + ) -> eyre::Result { let default_pubkey = bls_pubkey_from_hex( "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", )?; let url = self.comm_boost.get_header_url(0, &B256::ZERO, &pubkey.unwrap_or(default_pubkey))?; - Ok(self.comm_boost.client.get(url).send().await?) + let accept = match accept.len() { + 0 => None, + 1 => Some(accept.into_iter().next().unwrap().to_string()), + _ => { + let accept_strings: Vec = + accept.into_iter().map(|e| e.to_string()).collect(); + Some(accept_strings.join(", ")) + } + }; + let mut res = self + .comm_boost + .client + .get(url) + .header(CONSENSUS_VERSION_HEADER, &fork_name.to_string()); + if let Some(accept_header) = accept { + res = res.header(ACCEPT, accept_header); + } + let res = res.send().await?; + Ok(res) } pub async fn do_get_status(&self) -> eyre::Result { @@ -49,29 +76,78 @@ impl MockValidator { pub async fn do_submit_block_v1( &self, - signed_blinded_block: Option, + signed_blinded_block_opt: Option, + accept: Vec, + content_type: EncodingType, + fork_name: ForkName, ) -> eyre::Result { - self.do_submit_block_impl(signed_blinded_block, BuilderApiVersion::V1).await + self.do_submit_block_impl( + signed_blinded_block_opt, + accept, + content_type, + fork_name, + BuilderApiVersion::V1, + ) + .await } pub async fn do_submit_block_v2( &self, - signed_blinded_block: Option, + signed_blinded_block_opt: Option, + accept: Vec, + content_type: EncodingType, + fork_name: ForkName, ) -> eyre::Result { - self.do_submit_block_impl(signed_blinded_block, BuilderApiVersion::V2).await + self.do_submit_block_impl( + signed_blinded_block_opt, + accept, + content_type, + fork_name, + BuilderApiVersion::V2, + ) + .await } async fn do_submit_block_impl( &self, - signed_blinded_block: Option, + signed_blinded_block_opt: Option, + accept: Vec, + content_type: EncodingType, + fork_name: ForkName, api_version: BuilderApiVersion, ) -> eyre::Result { let url = self.comm_boost.submit_block_url(api_version).unwrap(); let signed_blinded_block = - signed_blinded_block.unwrap_or_else(load_test_signed_blinded_block); + signed_blinded_block_opt.unwrap_or_else(load_test_signed_blinded_block); + let body = match content_type { + EncodingType::Json => serde_json::to_vec(&signed_blinded_block).unwrap(), + EncodingType::Ssz => signed_blinded_block.as_ssz_bytes(), + }; - Ok(self.comm_boost.client.post(url).json(&signed_blinded_block).send().await?) + let accept = match accept.len() { + 0 => None, + 1 => Some(accept.into_iter().next().unwrap().to_string()), + _ => { + // Ordered: first-listed is highest preference. Server honors + // RFC 9110 §12.5.1 (first-listed wins at equal q). + let accept_strings: Vec = + accept.into_iter().map(|e| e.to_string()).collect(); + Some(accept_strings.join(", ")) + } + }; + let mut res = self + .comm_boost + .client + .post(url) + .body(body) + .header(CONSENSUS_VERSION_HEADER, &fork_name.to_string()) + .header(CONTENT_TYPE, &content_type.to_string()); + if let Some(accept_header) = accept { + res = res.header(ACCEPT, accept_header); + } + let res = res.send().await?; + Ok(res) } } diff --git a/tests/src/utils.rs b/tests/src/utils.rs index e079ef11..ebd96ae8 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -8,11 +8,11 @@ use std::{ use alloy::primitives::{B256, U256}; use cb_common::{ config::{ - CommitBoostConfig, LogsSettings, ModuleKind, ModuleSigningConfig, PbsConfig, - PbsModuleConfig, RelayConfig, ReverseProxyHeaderSetup, SIGNER_IMAGE_DEFAULT, - SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, - SIGNER_PORT_DEFAULT, SignerConfig, SignerType, StartSignerConfig, StaticModuleConfig, - StaticPbsConfig, TlsMode, + BlockValidationMode, CommitBoostConfig, HeaderValidationMode, LogsSettings, ModuleKind, + ModuleSigningConfig, PbsConfig, PbsModuleConfig, RelayConfig, ReverseProxyHeaderSetup, + SIGNER_IMAGE_DEFAULT, SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, + SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, SIGNER_PORT_DEFAULT, SignerConfig, + SignerType, StartSignerConfig, StaticModuleConfig, StaticPbsConfig, TlsMode, }, pbs::{RelayClient, RelayEntry}, signer::SignerLoader, @@ -27,6 +27,18 @@ 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. +/// +/// The returned listener keeps the port reserved. Pass it to +/// [`start_mock_relay_service_with_listener`] so the socket is never released +/// between allocation and use (zero TOCTOU race). For servers that bind +/// internally (e.g. `PbsService::run`), read the port with +/// `listener.local_addr().unwrap().port()`, then `drop` the listener +/// immediately before starting the server. +pub async fn get_free_listener() -> tokio::net::TcpListener { + tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap() +} + static SYNC_SETUP: Once = Once::new(); pub fn setup_test_env() { SYNC_SETUP.call_once(|| { @@ -82,7 +94,8 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { skip_sigverify: false, min_bid_wei: U256::ZERO, late_in_slot_time_ms: u64::MAX, - extra_validation_enabled: false, + header_validation_mode: HeaderValidationMode::Standard, + block_validation_mode: BlockValidationMode::Standard, ssv_node_api_url: Url::parse("http://localhost:0").unwrap(), ssv_public_api_url: Url::parse("http://localhost:0").unwrap(), rpc_url: None, diff --git a/tests/tests/config.rs b/tests/tests/config.rs index bffefcbc..27b02318 100644 --- a/tests/tests/config.rs +++ b/tests/tests/config.rs @@ -1,7 +1,11 @@ use std::{net::Ipv4Addr, path::PathBuf}; use alloy::primitives::U256; -use cb_common::{config::CommitBoostConfig, types::Chain, utils::WEI_PER_ETH}; +use cb_common::{ + config::{BlockValidationMode, CommitBoostConfig, HeaderValidationMode}, + types::Chain, + utils::WEI_PER_ETH, +}; use eyre::Result; use url::Url; @@ -54,7 +58,8 @@ async fn test_load_pbs_happy() -> Result<()> { dbg!(&U256::from(0.5)); assert_eq!(config.pbs.pbs_config.min_bid_wei, U256::from((0.5 * WEI_PER_ETH as f64) as u64)); assert_eq!(config.pbs.pbs_config.late_in_slot_time_ms, 2000); - assert!(!config.pbs.pbs_config.extra_validation_enabled); + assert_eq!(config.pbs.pbs_config.header_validation_mode, HeaderValidationMode::Standard); + assert_eq!(config.pbs.pbs_config.block_validation_mode, BlockValidationMode::Standard); // Relay specific settings let relay = &config.relays[0]; @@ -156,7 +161,7 @@ async fn test_validate_bad_min_bid() -> Result<()> { #[tokio::test] async fn test_validate_missing_rpc_url() -> Result<()> { let mut config = load_happy_config().await?; - config.pbs.pbs_config.extra_validation_enabled = true; + config.pbs.pbs_config.header_validation_mode = HeaderValidationMode::Extra; config.pbs.pbs_config.rpc_url = None; let result = config.validate().await; @@ -165,7 +170,7 @@ async fn test_validate_missing_rpc_url() -> Result<()> { result .unwrap_err() .to_string() - .contains("rpc_url is required if extra_validation_enabled is true") + .contains("rpc_url is required if header_validation_mode is set to extra") ); Ok(()) } diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index 770421a3..ae97590d 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -2,18 +2,24 @@ use std::{net::Ipv4Addr, sync::Arc, time::Duration}; use alloy::primitives::U256; use cb_common::{ - config::{CommitBoostConfig, LogsSettings, PbsConfig, RelayConfig, StaticPbsConfig}, + config::{ + BlockValidationMode, CommitBoostConfig, HeaderValidationMode, LogsSettings, PbsConfig, + RelayConfig, StaticPbsConfig, + }, pbs::RelayEntry, 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 lh_types::ForkName; use reqwest::StatusCode; use tracing::info; use url::Url; @@ -28,20 +34,23 @@ async fn test_cfg_file_update() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Hoodi; - let pbs_port = 3730; + let pbs_listener = get_free_listener().await; + let relay1_listener = get_free_listener().await; + let relay2_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay1_port = relay1_listener.local_addr().unwrap().port(); + let relay2_port = relay2_listener.local_addr().unwrap().port(); // Start relay 1 - let relay1_port = pbs_port + 1; let relay1 = generate_mock_relay(relay1_port, pubkey.clone())?; let relay1_state = Arc::new(MockRelayState::new(chain, signer.clone())); - tokio::spawn(start_mock_relay_service(relay1_state.clone(), relay1_port)); + tokio::spawn(start_mock_relay_service_with_listener(relay1_state.clone(), relay1_listener)); // Start relay 2 - let relay2_port = relay1_port + 1; let relay2 = generate_mock_relay(relay2_port, pubkey.clone())?; let relay2_id = relay2.id.clone().to_string(); let relay2_state = Arc::new(MockRelayState::new(chain, signer)); - tokio::spawn(start_mock_relay_service(relay2_state.clone(), relay2_port)); + tokio::spawn(start_mock_relay_service_with_listener(relay2_state.clone(), relay2_listener)); // Make a config with relay 1 only let pbs_config = PbsConfig { @@ -57,7 +66,8 @@ async fn test_cfg_file_update() -> Result<()> { min_bid_wei: U256::ZERO, late_in_slot_time_ms: u64::MAX / 2, /* serde gets very upset about serializing u64::MAX * or anything close to it */ - extra_validation_enabled: false, + block_validation_mode: BlockValidationMode::Standard, + header_validation_mode: HeaderValidationMode::Standard, rpc_url: None, ssv_node_api_url: Url::parse("http://example.com").unwrap(), ssv_public_api_url: Url::parse("http://example.com").unwrap(), @@ -104,6 +114,7 @@ async fn test_cfg_file_update() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![relay1.clone()]); let state = PbsState::new(config, config_path.clone()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers - extra time for the file watcher @@ -112,7 +123,7 @@ async fn test_cfg_file_update() -> Result<()> { // Send a get header request - should go to relay 1 only let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(None).await?; + let res = mock_validator.do_get_header(None, Vec::new(), ForkName::Fulu).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(relay1_state.received_get_header(), 1); assert_eq!(relay2_state.received_get_header(), 0); @@ -154,7 +165,7 @@ async fn test_cfg_file_update() -> Result<()> { // Send another get header request - should go to relay 2 only info!("Sending get header after config update"); - let res = mock_validator.do_get_header(None).await?; + let res = mock_validator.do_get_header(None, Vec::new(), ForkName::Fulu).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(relay1_state.received_get_header(), 1); // no change assert_eq!(relay2_state.received_get_header(), 1); // incremented diff --git a/tests/tests/pbs_get_header.rs b/tests/tests/pbs_get_header.rs index 1cfdc3bb..2920e1ce 100644 --- a/tests/tests/pbs_get_header.rs +++ b/tests/tests/pbs_get_header.rs @@ -1,60 +1,295 @@ -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration}; use alloy::primitives::{B256, U256}; use cb_common::{ - pbs::GetHeaderResponse, + config::HeaderValidationMode, + pbs::{GetHeaderResponse, SignedBuilderBid}, signature::sign_builder_root, signer::random_secret, types::{BlsPublicKeyBytes, Chain}, - utils::timestamp_of_slot_start_sec, + utils::{ + EncodingType, ForkName, get_bid_value_from_signed_builder_bid_ssz, + get_consensus_version_header, timestamp_of_slot_start_sec, + }, }; 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 lh_types::ForkName; +use lh_eth2::EmptyMetadata; +use lh_types::ForkVersionDecode; use reqwest::StatusCode; use tracing::info; use tree_hash::TreeHash; +use url::Url; +/// Test requesting JSON when the relay supports JSON #[tokio::test] async fn test_get_header() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + 1, + HeaderValidationMode::Standard, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting SSZ when the relay supports SSZ +#[tokio::test] +async fn test_get_header_ssz() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + 1, + HeaderValidationMode::Standard, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting SSZ when the relay only supports JSON, which should be +/// handled because PBS supports both types internally and re-maps them on the +/// fly +#[tokio::test] +async fn test_get_header_ssz_into_json() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + 1, + HeaderValidationMode::Standard, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting multiple types when the relay supports SSZ, which should +/// return SSZ +#[tokio::test] +async fn test_get_header_multitype_ssz() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Ssz]), + 1, + HeaderValidationMode::Standard, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting multiple types when the relay supports JSON, which should +/// still work +#[tokio::test] +async fn test_get_header_multitype_json() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Json]), + 1, + HeaderValidationMode::Standard, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +// === Light Mode Tests === + +/// Test requesting JSON without validation when the relay supports JSON +#[tokio::test] +async fn test_get_header_light() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + 1, + HeaderValidationMode::None, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting SSZ without validation when the relay supports SSZ +#[tokio::test] +async fn test_get_header_ssz_light() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + 1, + HeaderValidationMode::None, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting SSZ without validation when the relay only supports JSON. +/// This should actually fail because in no-validation mode we just forward the +/// response without re-encoding it. +#[tokio::test] +async fn test_get_header_ssz_into_json_light() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + 1, + HeaderValidationMode::None, + StatusCode::NO_CONTENT, // Should fail because the only relay can't be used + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting multiple types without validation when the relay supports +/// SSZ, which should return SSZ +#[tokio::test] +async fn test_get_header_multitype_ssz_light() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Ssz]), + 1, + HeaderValidationMode::None, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting multiple types without validation when the relay supports +/// JSON, which should still work +#[tokio::test] +async fn test_get_header_multitype_json_light() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Json]), + 1, + HeaderValidationMode::None, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Core implementation for get_header tests. +/// Pass `rpc_url: Some(url)` when testing `HeaderValidationMode::Extra` — PBS +/// requires a non-None rpc_url to start in that mode. A non-existent address is +/// fine; if the parent block fetch fails the relay response is still returned +/// (extra validation is skipped with a warning). +async fn test_get_header_impl( + accept_types: Vec, + relay_types: HashSet, + expected_try_count: u64, + mode: HeaderValidationMode, + expected_code: StatusCode, + bid_value: U256, + min_bid_wei: U256, + rpc_url: Option, + fork_name: ForkName, +) -> Result<()> { + // Setup test environment setup_test_env(); let signer = random_secret(); let pubkey = signer.public_key(); - let chain = Chain::Holesky; - let pbs_port = 3200; - 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().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); - // Run a mock relay - let mock_state = Arc::new(MockRelayState::new(chain, signer)); + let mut mock_state = MockRelayState::new(chain, signer).with_bid_value(bid_value); + mock_state.supported_content_types = Arc::new(relay_types); + let mock_state = Arc::new(mock_state); let mock_relay = generate_mock_relay(relay_port, pubkey)?; - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); + 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), vec![mock_relay.clone()]); + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.header_validation_mode = mode; + pbs_config.min_bid_wei = min_bid_wei; + 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)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; + // Send the get_header request let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(None).await?; - assert_eq!(res.status(), StatusCode::OK); + let res = mock_validator.do_get_header(None, accept_types.clone(), fork_name).await?; + assert_eq!(res.status(), expected_code); + assert_eq!(mock_state.received_get_header(), expected_try_count); + match expected_code { + StatusCode::OK => {} + _ => return Ok(()), + } - let res = serde_json::from_slice::(&res.bytes().await?)?; + // Get the content type + let content_type = match res + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + .unwrap() + { + ct if ct == EncodingType::Ssz.to_string() => EncodingType::Ssz, + ct if ct == EncodingType::Json.to_string() => EncodingType::Json, + _ => panic!("unexpected content type"), + }; + assert!(accept_types.contains(&content_type)); - assert_eq!(mock_state.received_get_header(), 1); - assert_eq!(res.version, ForkName::Electra); + // Get the data + let res = match content_type { + EncodingType::Json => serde_json::from_slice::(&res.bytes().await?)?, + EncodingType::Ssz => { + let fork = + get_consensus_version_header(res.headers()).expect("missing fork version header"); + let data = SignedBuilderBid::from_ssz_bytes_by_fork(&res.bytes().await?, fork).unwrap(); + GetHeaderResponse { version: fork, data, metadata: EmptyMetadata::default() } + } + }; assert_eq!(res.data.message.header().block_hash().0[0], 1); assert_eq!(res.data.message.header().parent_hash().0, B256::ZERO); - assert_eq!(*res.data.message.value(), U256::from(10)); + assert_eq!(*res.data.message.value(), bid_value); assert_eq!(*res.data.message.pubkey(), BlsPublicKeyBytes::from(mock_state.signer.public_key())); assert_eq!(res.data.message.header().timestamp(), timestamp_of_slot_start_sec(0, chain)); assert_eq!( @@ -71,19 +306,24 @@ async fn test_get_header_returns_204_if_relay_down() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3300; - 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().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); // Create a mock relay client let mock_state = Arc::new(MockRelayState::new(chain, signer)); let mock_relay = generate_mock_relay(relay_port, pubkey)?; // Don't start the relay - // tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); + // tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), + // relay_listener)); + drop(relay_listener); // 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)); // leave some time to start servers @@ -91,7 +331,7 @@ async fn test_get_header_returns_204_if_relay_down() -> Result<()> { let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(None).await?; + let res = mock_validator.do_get_header(None, Vec::new(), ForkName::Electra).await?; assert_eq!(res.status(), StatusCode::NO_CONTENT); // 204 error assert_eq!(mock_state.received_get_header(), 0); // no header received @@ -105,17 +345,20 @@ async fn test_get_header_returns_400_if_request_is_invalid() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3400; - 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().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); // Run a mock relay let mock_state = Arc::new(MockRelayState::new(chain, signer)); let mock_relay = generate_mock_relay(relay_port, pubkey.clone())?; - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); + 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), vec![mock_relay.clone()]); let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -141,3 +384,389 @@ async fn test_get_header_returns_400_if_request_is_invalid() -> Result<()> { assert_eq!(mock_state.received_get_header(), 0); // no header received Ok(()) } + +/// All validation modes (None, Standard, Extra) enforce the min-bid threshold. +/// None skips expensive crypto checks; Standard adds sigverify + structural +/// checks; Extra adds the parent-block check via EL RPC (which is skipped with +/// a warning if the fetch fails, so a non-existent RPC URL still passes here). +#[tokio::test] +async fn test_get_header_all_modes_enforce_min_bid() -> Result<()> { + let relay_bid = U256::from(7u64); + let min_bid_above_relay = relay_bid + U256::from(1); + // A syntactically valid URL that will never connect — Extra mode config + // validation only requires rpc_url to be Some; the actual fetch failing is + // handled gracefully (extra validation is skipped with a warning). + let fake_rpc: Url = "http://127.0.0.1:1".parse()?; + + for (mode, rpc_url) in [ + (HeaderValidationMode::Standard, None), + (HeaderValidationMode::None, None), + (HeaderValidationMode::Extra, Some(fake_rpc.clone())), + ] { + // Bid below min → all modes reject (204). + test_get_header_impl( + vec![EncodingType::Json], + HashSet::from([EncodingType::Json]), + 1, + mode, + StatusCode::NO_CONTENT, + relay_bid, + min_bid_above_relay, + rpc_url.clone(), + ForkName::Electra, + ) + .await?; + + // Bid above min → all modes accept (200). + test_get_header_impl( + vec![EncodingType::Json], + HashSet::from([EncodingType::Json]), + 1, + mode, + StatusCode::OK, + min_bid_above_relay, + U256::ZERO, + rpc_url, + ForkName::Electra, + ) + .await?; + } + Ok(()) +} + +/// SSZ round-trip: configure the relay with a specific bid value, request via +/// PBS in None mode with SSZ encoding, and verify the raw response bytes decode +/// to the exact value that was configured. This exercises the byte-offset +/// extraction logic (`get_bid_value_from_signed_builder_bid_ssz`) end-to-end +/// through a live HTTP relay for both currently-supported forks. +#[tokio::test] +async fn test_get_header_ssz_bid_value_round_trip() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + + // Use a distinctive value so accidental zero-matches are impossible. + let relay_bid = U256::from(999_888_777u64); + + for fork_name in [ForkName::Electra, ForkName::Fulu] { + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + let mock_state = + Arc::new(MockRelayState::new(chain, signer.clone()).with_bid_value(relay_bid)); + let mock_relay = generate_mock_relay(relay_port, pubkey.clone())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let mut pbs_config = get_pbs_config(pbs_port); + // None mode: PBS forwards the raw SSZ bytes without re-encoding. + pbs_config.header_validation_mode = HeaderValidationMode::None; + pbs_config.min_bid_wei = U256::ZERO; + 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::time::sleep(Duration::from_millis(100)).await; + + let mock_validator = MockValidator::new(pbs_port)?; + let res = mock_validator.do_get_header(None, vec![EncodingType::Ssz], fork_name).await?; + assert_eq!(res.status(), StatusCode::OK, "fork {fork_name}: expected 200"); + + let bytes = res.bytes().await?; + let extracted = get_bid_value_from_signed_builder_bid_ssz(&bytes, fork_name) + .map_err(|e| eyre::eyre!("fork {fork_name}: SSZ extraction failed: {e}"))?; + assert_eq!( + extracted, relay_bid, + "fork {fork_name}: SSZ-extracted bid value does not match configured relay bid" + ); + } + Ok(()) +} + +/// Verify the mock relay returns 400 when the validator requests an unsupported +/// fork. Tested by pointing MockValidator directly at the relay (no PBS) so the +/// assertion is on the relay's raw response, not PBS's 204 fallback. +#[tokio::test] +async fn test_get_header_unsupported_fork_returns_400() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let chain = Chain::Holesky; + + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr().unwrap().port(); + let mock_state = Arc::new(MockRelayState::new(chain, signer.clone())); + tokio::spawn(start_mock_relay_service_with_listener(mock_state, relay_listener)); + + tokio::time::sleep(Duration::from_millis(100)).await; + + // Point MockValidator directly at the relay (no PBS in the path). + let direct = MockValidator::new(relay_port)?; + for unsupported_fork in [ForkName::Base, ForkName::Altair] { + let res = direct.do_get_header(None, vec![EncodingType::Json], unsupported_fork).await?; + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "expected 400 for unsupported fork {unsupported_fork}" + ); + } + Ok(()) +} + +/// Exhaustive bid-acceptance matrix across every (fork, encoding, mode, bid) +/// combination. +#[tokio::test] +async fn test_get_header_bid_validation_matrix() -> Result<()> { + let bid_low = U256::from(5u64); + let bid_high = U256::from(100u64); + let min_bid = U256::from(50u64); + + // (fork, encoding, mode, relay_bid, expected_status) + let cases: &[(ForkName, EncodingType, HeaderValidationMode, U256, StatusCode)] = &[ + ( + ForkName::Electra, + EncodingType::Json, + HeaderValidationMode::None, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Electra, + EncodingType::Json, + HeaderValidationMode::None, + bid_high, + StatusCode::OK, + ), + ( + ForkName::Electra, + EncodingType::Ssz, + HeaderValidationMode::None, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Electra, + EncodingType::Ssz, + HeaderValidationMode::None, + bid_high, + StatusCode::OK, + ), + ( + ForkName::Fulu, + EncodingType::Json, + HeaderValidationMode::None, + bid_low, + StatusCode::NO_CONTENT, + ), + (ForkName::Fulu, EncodingType::Json, HeaderValidationMode::None, bid_high, StatusCode::OK), + ( + ForkName::Fulu, + EncodingType::Ssz, + HeaderValidationMode::None, + bid_low, + StatusCode::NO_CONTENT, + ), + (ForkName::Fulu, EncodingType::Ssz, HeaderValidationMode::None, bid_high, StatusCode::OK), + ( + ForkName::Electra, + EncodingType::Json, + HeaderValidationMode::Standard, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Electra, + EncodingType::Json, + HeaderValidationMode::Standard, + bid_high, + StatusCode::OK, + ), + ( + ForkName::Electra, + EncodingType::Ssz, + HeaderValidationMode::Standard, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Electra, + EncodingType::Ssz, + HeaderValidationMode::Standard, + bid_high, + StatusCode::OK, + ), + ( + ForkName::Fulu, + EncodingType::Json, + HeaderValidationMode::Standard, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Fulu, + EncodingType::Json, + HeaderValidationMode::Standard, + bid_high, + StatusCode::OK, + ), + ( + ForkName::Fulu, + EncodingType::Ssz, + HeaderValidationMode::Standard, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Fulu, + EncodingType::Ssz, + HeaderValidationMode::Standard, + bid_high, + StatusCode::OK, + ), + ]; + + for (i, &(fork, encoding, mode, relay_bid, expected_status)) in cases.iter().enumerate() { + test_get_header_impl( + vec![encoding], + HashSet::from([encoding]), + 1, + mode, + expected_status, + relay_bid, + min_bid, + None, + fork, + ) + .await + .map_err(|e| eyre::eyre!("case {i} (fork={fork} enc={encoding} mode={mode:?} bid={relay_bid} min={min_bid}): {e}"))?; + } + Ok(()) +} + +/// PBS must accept relay `Content-Type` values that include MIME parameters +/// (e.g. `application/octet-stream; charset=binary`). The audit fix for C2 +/// switched `EncodingType::from_str` to parse via the `mediatype` crate; +/// this test exercises the full relay→PBS→BN path to guard against +/// regressions at the wire boundary. +#[tokio::test] +async fn test_get_header_tolerates_mime_params_in_content_type() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + + let mut mock_state = MockRelayState::new(chain, signer) + .with_response_content_type("application/octet-stream; charset=binary"); + mock_state.supported_content_types = Arc::new(HashSet::from([EncodingType::Ssz])); + let mock_state = Arc::new(mock_state); + let mock_relay = generate_mock_relay(relay_port, pubkey)?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + 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::time::sleep(Duration::from_millis(100)).await; + + let mock_validator = MockValidator::new(pbs_port)?; + let res = + mock_validator.do_get_header(None, vec![EncodingType::Ssz], ForkName::Electra).await?; + assert_eq!(res.status(), StatusCode::OK, "PBS should tolerate `; charset=binary` MIME param"); + assert_eq!(mock_state.received_get_header(), 1); + + let fork = get_consensus_version_header(res.headers()).expect("missing fork version header"); + let bytes = res.bytes().await?; + let data = SignedBuilderBid::from_ssz_bytes_by_fork(&bytes, fork).unwrap(); + assert_eq!(data.message.header().block_hash().0[0], 1); + Ok(()) +} + +/// Same guarantee on the JSON path: `application/json; charset=utf-8` (the +/// value some production relays actually emit) must be accepted as JSON. +#[tokio::test] +async fn test_get_header_tolerates_json_charset_param() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + + let mut mock_state = MockRelayState::new(chain, signer) + .with_response_content_type("application/json; charset=utf-8"); + mock_state.supported_content_types = Arc::new(HashSet::from([EncodingType::Json])); + let mock_state = Arc::new(mock_state); + let mock_relay = generate_mock_relay(relay_port, pubkey)?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + 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::time::sleep(Duration::from_millis(100)).await; + + let mock_validator = MockValidator::new(pbs_port)?; + let res = + mock_validator.do_get_header(None, vec![EncodingType::Json], ForkName::Electra).await?; + assert_eq!(res.status(), StatusCode::OK, "PBS should tolerate `; charset=utf-8` MIME param"); + assert_eq!(mock_state.received_get_header(), 1); + + let body: GetHeaderResponse = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body.data.message.header().block_hash().0[0], 1); + Ok(()) +} + +/// Standard mode rejects a bid whose embedded pubkey does not match the relay's +/// configured pubkey; None mode forwards it unchecked, proving the bypass works +/// for the signature/pubkey validation check. +#[tokio::test] +async fn test_get_header_none_mode_bypasses_pubkey_validation() -> Result<()> { + setup_test_env(); + let chain = Chain::Holesky; + + // The mock relay signs with `signer` and embeds `signer.public_key()` in + // its message, but we register the relay in PBS with a *different* pubkey. + // Standard mode catches this mismatch; None mode does not check. + let signer = random_secret(); + let wrong_pubkey = random_secret().public_key(); + + for (mode, expected_status) in [ + (HeaderValidationMode::Standard, StatusCode::NO_CONTENT), + (HeaderValidationMode::None, StatusCode::OK), + ] { + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + let mock_state = Arc::new(MockRelayState::new(chain, signer.clone())); + // Register with `wrong_pubkey` — PBS will expect this key but the relay + // embeds `signer.public_key()`, causing a mismatch in Standard mode. + let mock_relay = generate_mock_relay(relay_port, wrong_pubkey.clone())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.header_validation_mode = mode; + 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::time::sleep(Duration::from_millis(100)).await; + + let mock_validator = MockValidator::new(pbs_port)?; + let res = mock_validator.do_get_header(None, Vec::new(), ForkName::Electra).await?; + assert_eq!(res.status(), expected_status, "unexpected status for mode {mode:?}"); + } + Ok(()) +} diff --git a/tests/tests/pbs_get_status.rs b/tests/tests/pbs_get_status.rs index cd2ab51d..9f49fb78 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,20 +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().unwrap().port(); + let relay_0_port = relay_0_listener.local_addr().unwrap().port(); + let relay_1_port = relay_1_listener.local_addr().unwrap().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()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -54,17 +60,22 @@ 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().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); 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)); + // tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), + // relay_listener)); + drop(relay_listener); let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index 4f842d56..6d301e00 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -12,17 +12,17 @@ use cb_common::{ }, signer::random_secret, types::Chain, - utils::{ResponseReadError, set_ignore_content_length}, + utils::{EncodingType, ForkName, ResponseReadError, set_ignore_content_length}, }; 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_ssv_node::{SsvNodeMockState, create_mock_ssv_node_server}, mock_ssv_public::{PublicSsvMockState, TEST_HTTP_TIMEOUT, create_mock_public_ssv_server}, mock_validator::MockValidator, utils::{ - bls_pubkey_from_hex_unchecked, generate_mock_relay, get_pbs_config, setup_test_env, - to_pbs_config, + bls_pubkey_from_hex_unchecked, generate_mock_relay, get_free_listener, get_pbs_config, + setup_test_env, to_pbs_config, }, }; use eyre::Result; @@ -36,7 +36,9 @@ use url::Url; /// from the public API async fn test_ssv_public_network_fetch() -> Result<()> { // Start the mock server - let port = 30100; + 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 url = Url::parse(&format!("http://localhost:{port}/api/v4/test_chain/validators/in_operator/1")) @@ -74,7 +76,9 @@ async fn test_ssv_public_network_fetch() -> Result<()> { /// body is too large async fn test_ssv_network_fetch_big_data() -> Result<()> { // Start the mock server - let port = 30101; + 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?; let url = Url::parse(&format!("http://localhost:{port}/big_data")).unwrap(); @@ -106,7 +110,9 @@ async fn test_ssv_network_fetch_big_data() -> Result<()> { /// times out async fn test_ssv_network_fetch_timeout() -> Result<()> { // Start the mock server - let port = 30102; + 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)), @@ -135,7 +141,9 @@ async fn test_ssv_network_fetch_timeout() -> Result<()> { /// content-length header is missing async fn test_ssv_network_fetch_big_data_without_content_length() -> Result<()> { // Start the mock server - let port = 30103; + 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 url = Url::parse(&format!("http://localhost:{port}/big_data")).unwrap(); @@ -167,7 +175,9 @@ async fn test_ssv_network_fetch_big_data_without_content_length() -> Result<()> /// from the node API async fn test_ssv_node_network_fetch() -> Result<()> { // Start the mock server - let port = 30104; + 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 url = Url::parse(&format!("http://localhost:{port}/v1/validators")).unwrap(); let response = request_ssv_pubkeys_from_ssv_node( @@ -200,17 +210,24 @@ async fn test_mux() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3700; - - let mux_relay_1 = generate_mock_relay(pbs_port + 1, pubkey.clone())?; - let mux_relay_2 = generate_mock_relay(pbs_port + 2, pubkey.clone())?; - let default_relay = generate_mock_relay(pbs_port + 3, pubkey.clone())?; + let pbs_listener = get_free_listener().await; + let relay_1_listener = get_free_listener().await; + let relay_2_listener = get_free_listener().await; + let relay_3_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_1_port = relay_1_listener.local_addr().unwrap().port(); + let relay_2_port = relay_2_listener.local_addr().unwrap().port(); + let relay_3_port = relay_3_listener.local_addr().unwrap().port(); + + let mux_relay_1 = generate_mock_relay(relay_1_port, pubkey.clone())?; + let mux_relay_2 = generate_mock_relay(relay_2_port, pubkey.clone())?; + let default_relay = generate_mock_relay(relay_3_port, pubkey.clone())?; // Run 3 mock relays 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(mock_state.clone(), pbs_port + 2)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 3)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_1_listener)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_2_listener)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_3_listener)); // Register all relays in PBS config let relays = vec![default_relay.clone()]; @@ -230,6 +247,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)); // leave some time to start servers @@ -238,13 +256,19 @@ async fn test_mux() -> Result<()> { // Send default request without specifying a validator key let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header with default"); - assert_eq!(mock_validator.do_get_header(None).await?.status(), StatusCode::OK); + assert_eq!( + mock_validator.do_get_header(None, Vec::new(), ForkName::Electra).await?.status(), + StatusCode::OK + ); assert_eq!(mock_state.received_get_header(), 1); // only default relay was used // Send request specifying a validator key to use mux info!("Sending get header with mux"); assert_eq!( - mock_validator.do_get_header(Some(validator_pubkey)).await?.status(), + mock_validator + .do_get_header(Some(validator_pubkey), Vec::new(), ForkName::Electra) + .await? + .status(), StatusCode::OK ); assert_eq!(mock_state.received_get_header(), 3); // two mux relays were used @@ -261,12 +285,34 @@ async fn test_mux() -> Result<()> { // v1 Submit block requests should go to all relays info!("Sending submit block v1"); - assert_eq!(mock_validator.do_submit_block_v1(None).await?.status(), StatusCode::OK); + assert_eq!( + mock_validator + .do_submit_block_v1( + None, + vec![EncodingType::Json], + EncodingType::Json, + ForkName::Electra + ) + .await? + .status(), + StatusCode::OK + ); assert_eq!(mock_state.received_submit_block(), 3); // default + 2 mux relays were used // v2 Submit block requests should go to all relays info!("Sending submit block v2"); - assert_eq!(mock_validator.do_submit_block_v2(None).await?.status(), StatusCode::ACCEPTED); + assert_eq!( + mock_validator + .do_submit_block_v2( + None, + vec![EncodingType::Json], + EncodingType::Json, + ForkName::Electra + ) + .await? + .status(), + StatusCode::ACCEPTED + ); assert_eq!(mock_state.received_submit_block(), 6); // default + 2 mux relays were used Ok(()) @@ -282,10 +328,20 @@ async fn test_ssv_multi_with_node() -> Result<()> { let pubkey2 = signer2.public_key(); let chain = Chain::Hoodi; - let pbs_port = 3711; + let pbs_listener = get_free_listener().await; + let ssv_node_listener = get_free_listener().await; + let ssv_public_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + 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_port = pbs_port + 1; let ssv_node_url = Url::parse(&format!("http://localhost:{ssv_node_port}/v1/"))?; let mock_ssv_node_state = SsvNodeMockState { validators: Arc::new(RwLock::new(vec![ @@ -298,7 +354,6 @@ async fn test_ssv_multi_with_node() -> Result<()> { create_mock_ssv_node_server(ssv_node_port, Some(mock_ssv_node_state.clone())).await?; // Start the mock SSV public API - let ssv_public_port = ssv_node_port + 1; let ssv_public_url = Url::parse(&format!("http://localhost:{ssv_public_port}/api/v4/"))?; let mock_ssv_public_state = PublicSsvMockState { validators: Arc::new(RwLock::new(vec![SSVPublicValidator { pubkey: pubkey.clone() }])), @@ -308,11 +363,11 @@ async fn test_ssv_multi_with_node() -> Result<()> { create_mock_public_ssv_server(ssv_public_port, Some(mock_ssv_public_state.clone())).await?; // Start a mock relay to be used by the mux - let relay_port = ssv_public_port + 1; let relay = generate_mock_relay(relay_port, pubkey.clone())?; let relay_id = relay.id.clone().to_string(); let relay_state = Arc::new(MockRelayState::new(chain, signer)); - let relay_task = tokio::spawn(start_mock_relay_service(relay_state.clone(), relay_port)); + let relay_task = + tokio::spawn(start_mock_relay_service_with_listener(relay_state.clone(), relay_listener)); // Create the registry mux let loader = MuxKeysLoader::Registry { @@ -346,6 +401,7 @@ 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)); info!("Started PBS server with pubkey {pubkey}"); @@ -356,7 +412,8 @@ async fn test_ssv_multi_with_node() -> Result<()> { // relay only since it hasn't been seen in the mux yet let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(Some(pubkey2.clone())).await?; + let res = + mock_validator.do_get_header(Some(pubkey2.clone()), Vec::new(), ForkName::Electra).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(relay_state.received_get_header(), 1); // pubkey2 was loaded from the SSV node @@ -380,10 +437,20 @@ async fn test_ssv_multi_with_public() -> Result<()> { let pubkey2 = signer2.public_key(); let chain = Chain::Hoodi; - let pbs_port = 3720; + let pbs_listener = get_free_listener().await; + let ssv_node_listener = get_free_listener().await; + let ssv_public_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + 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(); + // 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_port = pbs_port + 1; let ssv_node_url = Url::parse(&format!("http://localhost:{ssv_node_port}/v1/"))?; // Don't start the SSV node server to simulate it being down @@ -391,7 +458,6 @@ async fn test_ssv_multi_with_public() -> Result<()> { // Some(mock_ssv_node_state.clone())).await?; // Start the mock SSV public API - let ssv_public_port = ssv_node_port + 1; let ssv_public_url = Url::parse(&format!("http://localhost:{ssv_public_port}/api/v4/"))?; let mock_ssv_public_state = PublicSsvMockState { validators: Arc::new(RwLock::new(vec![ @@ -404,11 +470,11 @@ async fn test_ssv_multi_with_public() -> Result<()> { create_mock_public_ssv_server(ssv_public_port, Some(mock_ssv_public_state.clone())).await?; // Start a mock relay to be used by the mux - let relay_port = ssv_public_port + 1; let relay = generate_mock_relay(relay_port, pubkey.clone())?; let relay_id = relay.id.clone().to_string(); let relay_state = Arc::new(MockRelayState::new(chain, signer)); - let relay_task = tokio::spawn(start_mock_relay_service(relay_state.clone(), relay_port)); + let relay_task = + tokio::spawn(start_mock_relay_service_with_listener(relay_state.clone(), relay_listener)); // Create the registry mux let loader = MuxKeysLoader::Registry { @@ -442,6 +508,7 @@ 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)); info!("Started PBS server with pubkey {pubkey}"); @@ -452,7 +519,8 @@ async fn test_ssv_multi_with_public() -> Result<()> { // relay only since it hasn't been seen in the mux yet let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(Some(pubkey2.clone())).await?; + let res = + mock_validator.do_get_header(Some(pubkey2.clone()), Vec::new(), ForkName::Electra).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(relay_state.received_get_header(), 1); // pubkey2 was loaded from the SSV public API diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index 1d590a49..5935af98 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -8,12 +8,13 @@ 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_ssv_public::{PublicSsvMockState, create_mock_public_ssv_server}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_config, to_pbs_config}, + utils::{generate_mock_relay, get_free_listener, get_pbs_config, to_pbs_config}, }; use eyre::Result; +use lh_types::ForkName; use reqwest::StatusCode; use tokio::sync::RwLock; use tracing::info; @@ -38,10 +39,18 @@ async fn test_auto_refresh() -> Result<()> { let new_mux_pubkey = new_mux_signer.public_key(); let chain = Chain::Hoodi; - let pbs_port = 3710; + let pbs_listener = get_free_listener().await; + let ssv_api_listener = get_free_listener().await; + let default_relay_listener = get_free_listener().await; + let mux_relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + 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 - let ssv_api_port = pbs_port + 1; // Intentionally missing a trailing slash to ensure this is handled properly let ssv_api_url = Url::parse(&format!("http://localhost:{ssv_api_port}/api/v4"))?; let mock_ssv_state = PublicSsvMockState { @@ -54,19 +63,21 @@ async fn test_auto_refresh() -> Result<()> { create_mock_public_ssv_server(ssv_api_port, Some(mock_ssv_state.clone())).await?; // Start a default relay for non-mux keys - let default_relay_port = ssv_api_port + 1; let default_relay = generate_mock_relay(default_relay_port, default_pubkey.clone())?; let default_relay_state = Arc::new(MockRelayState::new(chain, default_signer.clone())); - let default_relay_task = - tokio::spawn(start_mock_relay_service(default_relay_state.clone(), default_relay_port)); + let default_relay_task = tokio::spawn(start_mock_relay_service_with_listener( + default_relay_state.clone(), + default_relay_listener, + )); // Start a mock relay to be used by the mux - let mux_relay_port = default_relay_port + 1; let mux_relay = generate_mock_relay(mux_relay_port, default_pubkey.clone())?; let mux_relay_id = mux_relay.id.clone().to_string(); let mux_relay_state = Arc::new(MockRelayState::new(chain, default_signer)); - let mux_relay_task = - tokio::spawn(start_mock_relay_service(mux_relay_state.clone(), mux_relay_port)); + let mux_relay_task = tokio::spawn(start_mock_relay_service_with_listener( + mux_relay_state.clone(), + mux_relay_listener, + )); // Create the registry mux let loader = MuxKeysLoader::Registry { @@ -99,6 +110,7 @@ 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)); info!("Started PBS server with pubkey {default_pubkey}"); @@ -109,7 +121,9 @@ async fn test_auto_refresh() -> Result<()> { // relay only since it hasn't been seen in the mux yet let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(Some(new_mux_pubkey.clone())).await?; + let res = mock_validator + .do_get_header(Some(new_mux_pubkey.clone()), Vec::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 1); // default relay was used assert_eq!(mux_relay_state.received_get_header(), 0); // mux relay was not used @@ -137,14 +151,18 @@ async fn test_auto_refresh() -> Result<()> { assert!(logs_contain(&format!("fetched 2 pubkeys for registry mux {mux_relay_id}"))); // Try to run a get_header on the new pubkey - now it should use the mux relay - let res = mock_validator.do_get_header(Some(new_mux_pubkey.clone())).await?; + let res = mock_validator + .do_get_header(Some(new_mux_pubkey.clone()), Vec::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 1); // default relay was not used here assert_eq!(mux_relay_state.received_get_header(), 1); // mux relay was used // Now try to do a get_header with the old pubkey - it should only use the // default relay - let res = mock_validator.do_get_header(Some(default_pubkey.clone())).await?; + let res = mock_validator + .do_get_header(Some(default_pubkey.clone()), Vec::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 2); // default relay was used assert_eq!(mux_relay_state.received_get_header(), 1); // mux relay was not used @@ -161,7 +179,9 @@ async fn test_auto_refresh() -> Result<()> { // Try to do a get_header with the removed pubkey - it should only use the // default relay - let res = mock_validator.do_get_header(Some(existing_mux_pubkey.clone())).await?; + let res = mock_validator + .do_get_header(Some(existing_mux_pubkey.clone()), Vec::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 3); // default relay was used assert_eq!(mux_relay_state.received_get_header(), 1); // mux relay was not used diff --git a/tests/tests/pbs_post_blinded_blocks.rs b/tests/tests/pbs_post_blinded_blocks.rs index bf4703c2..aa793aa4 100644 --- a/tests/tests/pbs_post_blinded_blocks.rs +++ b/tests/tests/pbs_post_blinded_blocks.rs @@ -1,25 +1,39 @@ -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration}; use cb_common::{ - pbs::{BuilderApiVersion, GetPayloadInfo, SubmitBlindedBlockResponse}, + config::BlockValidationMode, + pbs::{BuilderApiVersion, GetPayloadInfo, PayloadAndBlobs, SubmitBlindedBlockResponse}, signer::random_secret, types::Chain, + utils::{EncodingType, ForkName}, }; 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, load_test_signed_blinded_block}, - 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 lh_types::ForkVersionDecode; use reqwest::{Response, StatusCode}; use tracing::info; #[tokio::test] async fn test_submit_block_v1() -> Result<()> { - let res = submit_block_impl(3800, &BuilderApiVersion::V1, false, false).await?; - assert_eq!(res.status(), StatusCode::OK); - + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + BlockValidationMode::Standard, + StatusCode::OK, + false, + false, + ) + .await?; let signed_blinded_block = load_test_signed_blinded_block(); let response_body = serde_json::from_slice::(&res.bytes().await?)?; @@ -32,19 +46,73 @@ async fn test_submit_block_v1() -> Result<()> { #[tokio::test] async fn test_submit_block_v2() -> Result<()> { - let res = submit_block_impl(3802, &BuilderApiVersion::V2, false, false).await?; - assert_eq!(res.status(), StatusCode::ACCEPTED); + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + BlockValidationMode::Standard, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; assert_eq!(res.bytes().await?.len(), 0); Ok(()) } // Test that when submitting a block using v2 to a relay that does not support -// v2, PBS falls back to v1 and successfully submits the block. +// v2, PBS falls back to v1 and forwards the v1 response body to the beacon +// node (a 200 with the execution payload), rather than swallowing the payload +// and replying 202 with an empty body — which would cause silent block loss. #[tokio::test] async fn test_submit_block_v2_without_relay_support() -> Result<()> { - let res = submit_block_impl(3804, &BuilderApiVersion::V2, true, false).await?; - assert_eq!(res.status(), StatusCode::ACCEPTED); - assert_eq!(res.bytes().await?.len(), 0); + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + BlockValidationMode::Standard, + StatusCode::OK, + true, + false, + ) + .await?; + // Payload must be forwarded so the BN can broadcast. + let signed_blinded_block = load_test_signed_blinded_block(); + let response_body = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!( + response_body.data.execution_payload.block_hash(), + signed_blinded_block.block_hash().into(), + "v2->v1 fallback must forward the execution payload to the BN" + ); + Ok(()) +} + +// Same guarantee as above, but exercising the unvalidated (light) path. +// In BlockValidationMode::None the v1 body is passed through as raw bytes; +// the v2->v1 fallback must still deliver those bytes to the beacon node. +#[tokio::test] +async fn test_submit_block_v2_without_relay_support_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + BlockValidationMode::None, + StatusCode::OK, + true, + false, + ) + .await?; + let body = res.bytes().await?; + assert!(!body.is_empty(), "v2->v1 fallback (light) must forward a non-empty body"); + // Body is a raw forwarded v1 response — should decode as + // SubmitBlindedBlockResponse. + let _: SubmitBlindedBlockResponse = serde_json::from_slice(&body)?; Ok(()) } @@ -52,8 +120,339 @@ async fn test_submit_block_v2_without_relay_support() -> Result<()> { // for both v1 and v2, PBS doesn't loop forever. #[tokio::test] async fn test_submit_block_on_broken_relay() -> Result<()> { - let res = submit_block_impl(3806, &BuilderApiVersion::V2, true, true).await?; - assert_eq!(res.status(), StatusCode::BAD_GATEWAY); + let _res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + BlockValidationMode::Standard, + StatusCode::BAD_GATEWAY, + true, + true, + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn test_submit_block_v1_ssz() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Ssz, + 1, + BlockValidationMode::Standard, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +#[tokio::test] +async fn test_submit_block_v2_ssz() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Ssz, + 1, + BlockValidationMode::Standard, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +/// Test that a v1 submit block request in SSZ is converted to JSON if the relay +/// only supports JSON +#[tokio::test] +async fn test_submit_block_v1_ssz_into_json() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + BlockValidationMode::Standard, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +/// Test that a v2 submit block request in SSZ is converted to JSON if the relay +/// only supports JSON +#[tokio::test] +async fn test_submit_block_v2_ssz_into_json() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + BlockValidationMode::Standard, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +/// Test v1 requesting multiple types when the relay supports SSZ, which should +/// return SSZ +#[tokio::test] +async fn test_submit_block_v1_multitype_ssz() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Ssz]), + EncodingType::Ssz, + 1, + BlockValidationMode::Standard, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +/// Test v1 requesting multiple types when the relay supports JSON, which should +/// still return SSZ +#[tokio::test] +async fn test_submit_block_v1_multitype_json() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + BlockValidationMode::Standard, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +#[tokio::test] +async fn test_submit_block_v1_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + BlockValidationMode::None, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!( + response_body.data.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +#[tokio::test] +async fn test_submit_block_v2_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + BlockValidationMode::None, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +#[tokio::test] +async fn test_submit_block_v1_ssz_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Ssz, + 1, + BlockValidationMode::None, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +#[tokio::test] +async fn test_submit_block_v2_ssz_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Ssz, + 1, + BlockValidationMode::None, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +/// Test that a v1 submit block request in light mode, with SSZ, is converted to +/// JSON if the relay only supports JSON +#[tokio::test] +async fn test_submit_block_v1_ssz_into_json_light() -> Result<()> { + submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + BlockValidationMode::None, + StatusCode::BAD_GATEWAY, + false, + false, + ) + .await?; + Ok(()) +} + +/// Test that a v2 submit block request in light mode, with SSZ, is converted to +/// JSON if the relay only supports JSON +#[tokio::test] +async fn test_submit_block_v2_ssz_into_json_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + BlockValidationMode::Standard, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +/// Test v1 requesting multiple types in light mode when the relay supports SSZ, +/// which should return SSZ +#[tokio::test] +async fn test_submit_block_v1_multitype_ssz_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Ssz]), + EncodingType::Ssz, + 1, + BlockValidationMode::None, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +/// Test v1 requesting multiple types in light mode when the relay supports +/// JSON, which should be able to handle an SSZ request by converting to JSON +#[tokio::test] +async fn test_submit_block_v1_multitype_json_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + BlockValidationMode::None, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!( + response_body.data.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); Ok(()) } @@ -64,14 +463,18 @@ async fn test_submit_block_too_large() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3900; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); - 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).with_large_body()); - 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)); 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)); // leave some time to start servers @@ -79,7 +482,9 @@ async fn test_submit_block_too_large() -> Result<()> { let mock_validator = MockValidator::new(pbs_port)?; info!("Sending submit block"); - let res = mock_validator.do_submit_block_v1(None).await; + let res = mock_validator + .do_submit_block_v1(None, vec![EncodingType::Json], EncodingType::Json, ForkName::Electra) + .await; // response size exceeds max size: max: 20971520 assert_eq!(res.unwrap().status(), StatusCode::BAD_GATEWAY); @@ -87,21 +492,31 @@ async fn test_submit_block_too_large() -> Result<()> { Ok(()) } +#[allow(clippy::too_many_arguments)] async fn submit_block_impl( - pbs_port: u16, - api_version: &BuilderApiVersion, + api_version: BuilderApiVersion, + accept_types: Vec, + relay_types: HashSet, + serialization_mode: EncodingType, + expected_try_count: u64, + mode: BlockValidationMode, + expected_code: StatusCode, remove_v2_support: bool, force_404s: bool, ) -> Result { setup_test_env(); let signer = random_secret(); let pubkey = signer.public_key(); - let chain = Chain::Holesky; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); // Run a mock relay - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; + let mock_relay = generate_mock_relay(relay_port, pubkey)?; let mut mock_relay_state = MockRelayState::new(chain, signer); + mock_relay_state.supported_content_types = Arc::new(relay_types); if remove_v2_support { mock_relay_state = mock_relay_state.with_no_submit_block_v2(); } @@ -109,28 +524,249 @@ async fn submit_block_impl( mock_relay_state = mock_relay_state.with_not_found_for_submit_block(); } let mock_state = Arc::new(mock_relay_state); - 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 mut pbs_config = get_pbs_config(pbs_port); + pbs_config.block_validation_mode = mode; + 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)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; + // Send the submit block request let signed_blinded_block = load_test_signed_blinded_block(); let mock_validator = MockValidator::new(pbs_port)?; info!("Sending submit block"); let res = match api_version { BuilderApiVersion::V1 => { - mock_validator.do_submit_block_v1(Some(signed_blinded_block)).await? + mock_validator + .do_submit_block_v1( + Some(signed_blinded_block), + accept_types, + serialization_mode, + ForkName::Electra, + ) + .await? } BuilderApiVersion::V2 => { - mock_validator.do_submit_block_v2(Some(signed_blinded_block)).await? + mock_validator + .do_submit_block_v2( + Some(signed_blinded_block), + accept_types, + serialization_mode, + ForkName::Electra, + ) + .await? } }; - let expected_count = if force_404s { 0 } else { 1 }; + let expected_count = if force_404s { 0 } else { expected_try_count }; assert_eq!(mock_state.received_submit_block(), expected_count); + assert_eq!(res.status(), expected_code); Ok(res) } + +// Retry-as-JSON trigger must be restricted +// to 406 Not Acceptable and 415 Unsupported Media Type. Any other 4xx is +// orthogonal to encoding and MUST surface unchanged. + +/// Shared fixture: relay returns `ssz_status` when the PBS sends SSZ, +/// everything else takes the happy path. Returns `(Response, attempt_count)`. +/// `api_version` picks v1 or v2 endpoint; `relay_types` controls what the +/// relay advertises as supported so the happy JSON path works when retried. +async fn submit_block_ssz_override( + api_version: BuilderApiVersion, + ssz_status: StatusCode, +) -> Result<(Response, u64)> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + + let mock_relay = generate_mock_relay(relay_port, pubkey)?; + let mut mock_relay_state = MockRelayState::new(chain, signer); + // Relay only advertises JSON so the retry (which goes out as JSON) lands + // on a clean success path. The SSZ-status override below intercepts + // before the supported-types check, so the first SSZ attempt still hits + // our injected status regardless of what's advertised here. + mock_relay_state.supported_content_types = Arc::new(HashSet::from([EncodingType::Json])); + mock_relay_state = mock_relay_state.with_submit_block_ssz_status(ssz_status); + let mock_state = Arc::new(mock_relay_state); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + 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::time::sleep(Duration::from_millis(100)).await; + + let signed_blinded_block = load_test_signed_blinded_block(); + let mock_validator = MockValidator::new(pbs_port)?; + // The BN sends SSZ; PBS forwards SSZ first, that's what our override hits. + let accept_types = vec![EncodingType::Ssz, EncodingType::Json]; + let res = match api_version { + BuilderApiVersion::V1 => { + mock_validator + .do_submit_block_v1( + Some(signed_blinded_block), + accept_types, + EncodingType::Ssz, + ForkName::Electra, + ) + .await? + } + BuilderApiVersion::V2 => { + mock_validator + .do_submit_block_v2( + Some(signed_blinded_block), + accept_types, + EncodingType::Ssz, + ForkName::Electra, + ) + .await? + } + }; + Ok((res, mock_state.received_submit_block())) +} + +/// 406 is the spec-defined "retry with a different media type" signal, so we +/// MUST retry as JSON and succeed. +#[tokio::test] +async fn test_submit_block_ssz_retries_as_json_on_406() -> Result<()> { + let (res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::NOT_ACCEPTABLE).await?; + assert_eq!(res.status(), StatusCode::OK, "retry-as-JSON must succeed on 406"); + assert_eq!(attempts, 2, "expected SSZ attempt + JSON retry"); + Ok(()) +} + +/// 415 is the other spec-defined media-type rejection status; same retry. +#[tokio::test] +async fn test_submit_block_ssz_retries_as_json_on_415() -> Result<()> { + let (res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::UNSUPPORTED_MEDIA_TYPE) + .await?; + assert_eq!(res.status(), StatusCode::OK, "retry-as-JSON must succeed on 415"); + assert_eq!(attempts, 2); + Ok(()) +} + +/// 400 Bad Request is a validation failure — encoding is not the problem. +/// Retrying doubles relay load and hides the real error. MUST NOT retry. +#[tokio::test] +async fn test_submit_block_ssz_does_not_retry_on_400() -> Result<()> { + let (_res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::BAD_REQUEST).await?; + assert_eq!(attempts, 1, "400 is not a media-type error; must not retry"); + Ok(()) +} + +/// 401 Unauthorized — auth problem, not encoding. No retry. +#[tokio::test] +async fn test_submit_block_ssz_does_not_retry_on_401() -> Result<()> { + let (_res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::UNAUTHORIZED).await?; + assert_eq!(attempts, 1); + Ok(()) +} + +/// 409 Conflict — state mismatch. No retry. +#[tokio::test] +async fn test_submit_block_ssz_does_not_retry_on_409() -> Result<()> { + let (_res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::CONFLICT).await?; + assert_eq!(attempts, 1); + Ok(()) +} + +/// 429 Too Many Requests — `PbsError::should_retry` already excludes this; +/// retrying as JSON would add insult to injury. No retry. +#[tokio::test] +async fn test_submit_block_ssz_does_not_retry_on_429() -> Result<()> { + let (_res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::TOO_MANY_REQUESTS).await?; + assert_eq!(attempts, 1); + Ok(()) +} + +/// Same policy applies to the v2 endpoint. +#[tokio::test] +async fn test_submit_block_v2_ssz_retries_as_json_on_415() -> Result<()> { + let (res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V2, StatusCode::UNSUPPORTED_MEDIA_TYPE) + .await?; + assert_eq!(res.status(), StatusCode::ACCEPTED, "v2 success is 202 Accepted"); + assert_eq!(attempts, 2); + Ok(()) +} + +/// v2 + 400: same no-retry rule as v1. +#[tokio::test] +async fn test_submit_block_v2_ssz_does_not_retry_on_400() -> Result<()> { + let (_res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V2, StatusCode::BAD_REQUEST).await?; + assert_eq!(attempts, 1); + Ok(()) +} + +/// PBS must accept relay `Content-Type: application/octet-stream; +/// charset=binary` on `submit_block` responses. The audit fix for C2 switched +/// `EncodingType::from_str` to parse via the `mediatype` crate; this test +/// exercises the full relay→PBS→BN path to guard against regressions on the +/// v1 submit path. +#[tokio::test] +async fn test_submit_block_tolerates_mime_params_in_content_type() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + + let mock_relay = generate_mock_relay(relay_port, pubkey)?; + let mut mock_relay_state = MockRelayState::new(chain, signer) + .with_response_content_type("application/octet-stream; charset=binary"); + mock_relay_state.supported_content_types = Arc::new(HashSet::from([EncodingType::Ssz])); + let mock_state = Arc::new(mock_relay_state); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + 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::time::sleep(Duration::from_millis(100)).await; + + let signed_blinded_block = load_test_signed_blinded_block(); + let mock_validator = MockValidator::new(pbs_port)?; + let res = mock_validator + .do_submit_block_v1( + Some(signed_blinded_block.clone()), + vec![EncodingType::Ssz], + EncodingType::Ssz, + ForkName::Electra, + ) + .await?; + assert_eq!(res.status(), StatusCode::OK, "PBS should tolerate `; charset=binary` MIME param"); + assert_eq!(mock_state.received_submit_block(), 1); + + let bytes = res.bytes().await?; + let response_body = PayloadAndBlobs::from_ssz_bytes_by_fork(&bytes, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} diff --git a/tests/tests/pbs_post_validators.rs b/tests/tests/pbs_post_validators.rs index 12601cda..9210502a 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,16 +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().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().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()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -68,19 +74,23 @@ 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().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().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()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); // Leave some time to start servers @@ -121,14 +131,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().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().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,6 +149,7 @@ 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()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); tokio::time::sleep(Duration::from_millis(100)).await;