From e4f1fea9efb7fec00649059d9609b8e95b396223 Mon Sep 17 00:00:00 2001 From: shane-moore Date: Wed, 26 Aug 2026 19:59:33 -0700 Subject: [PATCH 1/2] feat(validator_store): sign builder request auth Bump the Lighthouse pin to the Gloas builder-API stack head (sigp/lighthouse#9807) and implement the one new required trait method, sign_request_auth_v1, as a distributed threshold signing round: kind 9 (RequestAuth) riding Role::ProposerPreferences under the fixed builder-specs #165 application domain, with a slot-aware collection bound (future slots 2 slots, current slot 1s fail-fast, elapsed slots declined without broadcast). Wire the BuilderStore and RequestAuthCache the new BlockServiceBuilder requires at startup, decline elapsed-slot proposer-preferences signing after restarts, and migrate the one test broken by the pin (private attestation-due fields). --- Cargo.lock | 245 ++++++-- Cargo.toml | 37 +- anchor/client/Cargo.toml | 1 + anchor/client/src/lib.rs | 16 +- anchor/common/global_config/src/data_dir.rs | 7 + anchor/validator_store/Cargo.toml | 1 + anchor/validator_store/src/lib.rs | 216 ++++++- anchor/validator_store/src/metrics.rs | 25 + anchor/validator_store/src/testing/mod.rs | 1 + .../src/testing/proposer_preferences.rs | 92 ++- .../src/testing/request_auth.rs | 568 ++++++++++++++++++ 11 files changed, 1145 insertions(+), 64 deletions(-) create mode 100644 anchor/validator_store/src/testing/request_auth.rs diff --git a/Cargo.lock b/Cargo.lock index 78a4b44bd..fd4a53966 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,26 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "account_utils" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" +dependencies = [ + "bls", + "eth2_keystore", + "eth2_wallet", + "filesystem", + "rand 0.9.2", + "regex", + "rpassword 5.0.1", + "serde", + "tracing", + "types", + "validator_dir", + "yaml_serde", + "zeroize", +] + [[package]] name = "adler2" version = "2.0.1" @@ -363,7 +383,7 @@ dependencies = [ "proptest", "rand 0.8.5", "ruint", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "sha3", "tiny-keccak", @@ -392,7 +412,7 @@ dependencies = [ "rand 0.9.2", "rapidhash", "ruint", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "sha3", ] @@ -784,6 +804,7 @@ dependencies = [ "async-channel 1.9.0", "beacon_node_fallback", "bls", + "builder_types", "database", "eth2", "ethereum_ssz", @@ -1464,7 +1485,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "beacon_node_fallback" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bls", "clap", @@ -1564,7 +1585,7 @@ dependencies = [ [[package]] name = "bls" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "alloy-primitives 1.5.7", "blst", @@ -1661,6 +1682,44 @@ dependencies = [ "serde", ] +[[package]] +name = "builder_store" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" +dependencies = [ + "account_utils", + "bls", + "builder_types", + "filesystem", + "futures", + "hex", + "parking_lot", + "serde", + "ssz_types", + "tracing", + "types", + "yaml_serde", +] + +[[package]] +name = "builder_types" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" +dependencies = [ + "bls", + "context_deserialize", + "ethereum_serde_utils 0.8.0", + "ethereum_ssz", + "ethereum_ssz_derive", + "sensitive_url", + "serde", + "ssz_types", + "tree_hash", + "tree_hash_derive", + "typenum", + "types", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1879,6 +1938,7 @@ version = "1.3.1" dependencies = [ "anchor_validator_store", "beacon_node_fallback", + "builder_store", "clap", "cli", "database", @@ -2375,6 +2435,24 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "deposit_contract" +version = "0.2.0" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives 1.5.7", + "bls", + "ethereum_ssz", + "hex", + "reqwest", + "serde_json", + "sha2", + "tree_hash", + "types", +] + [[package]] name = "der" version = "0.7.10" @@ -2680,7 +2758,7 @@ dependencies = [ [[package]] name = "eip_3076" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bls", "ethereum_serde_utils 0.8.0", @@ -2904,9 +2982,10 @@ dependencies = [ [[package]] name = "eth2" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bls", + "builder_types", "context_deserialize", "educe", "eip_3076", @@ -2934,7 +3013,7 @@ dependencies = [ [[package]] name = "eth2_config" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "paste", "types", @@ -2943,7 +3022,7 @@ dependencies = [ [[package]] name = "eth2_interop_keypairs" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bls", "ethereum_hashing 0.8.0", @@ -2956,7 +3035,7 @@ dependencies = [ [[package]] name = "eth2_key_derivation" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bls", "num-bigint-dig", @@ -2968,7 +3047,7 @@ dependencies = [ [[package]] name = "eth2_keystore" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "aes", "bls", @@ -2992,7 +3071,7 @@ dependencies = [ [[package]] name = "eth2_network_config" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bytes", "discv5", @@ -3010,6 +3089,21 @@ dependencies = [ "zip", ] +[[package]] +name = "eth2_wallet" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" +dependencies = [ + "eth2_key_derivation", + "eth2_keystore", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tiny-bip39", + "uuid", +] + [[package]] name = "ethereum_hashing" version = "0.7.0" @@ -3215,7 +3309,7 @@ dependencies = [ [[package]] name = "filesystem" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "winapi", "windows-acl", @@ -3242,7 +3336,7 @@ dependencies = [ [[package]] name = "fixed_bytes" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "alloy-primitives 1.5.7", "safe_arith", @@ -3318,7 +3412,7 @@ dependencies = [ [[package]] name = "fork_choice" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "ethereum_ssz", "ethereum_ssz_derive", @@ -3341,6 +3435,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "funty" version = "2.0.0" @@ -3597,7 +3701,7 @@ dependencies = [ [[package]] name = "graffiti_file" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bls", "serde", @@ -3684,7 +3788,7 @@ dependencies = [ [[package]] name = "health_metrics" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "eth2", "metrics", @@ -4230,7 +4334,7 @@ dependencies = [ [[package]] name = "int_to_bytes" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bytes", ] @@ -4415,7 +4519,7 @@ dependencies = [ "global_config", "openssl", "operator_key", - "rpassword", + "rpassword 7.4.0", "serde_json", "thiserror 2.0.18", "tracing", @@ -4440,7 +4544,7 @@ dependencies = [ "openssl", "operator_key", "rayon", - "rpassword", + "rpassword 7.4.0", "serde", "serde_json", "tempfile", @@ -4453,7 +4557,7 @@ dependencies = [ [[package]] name = "kzg" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "educe", "ethereum_hashing 0.8.0", @@ -4953,6 +5057,14 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lockfile" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" +dependencies = [ + "fs2", +] + [[package]] name = "log" version = "0.4.29" @@ -4979,7 +5091,7 @@ dependencies = [ [[package]] name = "logging" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "chrono", "logroller", @@ -5025,7 +5137,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lru_cache" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "fnv", ] @@ -5106,7 +5218,7 @@ dependencies = [ [[package]] name = "merkle_proof" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "alloy-primitives 1.5.7", "ethereum_hashing 0.8.0", @@ -5204,7 +5316,7 @@ dependencies = [ [[package]] name = "metrics" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "prometheus", ] @@ -5451,7 +5563,7 @@ dependencies = [ [[package]] name = "network_utils" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "discv5", "libp2p-identity", @@ -5731,7 +5843,7 @@ dependencies = [ "eth2_keystore", "openssl", "rand 0.9.2", - "rpassword", + "rpassword 7.4.0", "serde", "serde_json", "tempfile", @@ -6029,7 +6141,7 @@ dependencies = [ [[package]] name = "pretty_reqwest_error" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "reqwest", "sensitive_url", @@ -6250,7 +6362,7 @@ dependencies = [ [[package]] name = "proto_array" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "ethereum_ssz", "ethereum_ssz_derive", @@ -6343,7 +6455,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "socket2 0.6.4", "thiserror 2.0.18", @@ -6365,7 +6477,7 @@ dependencies = [ "rand 0.10.2", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", @@ -6802,6 +6914,16 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rpassword" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "rpassword" version = "7.4.0" @@ -6927,6 +7049,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -7531,7 +7659,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slashing_protection" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bls", "eip_3076", @@ -7551,7 +7679,7 @@ dependencies = [ [[package]] name = "slot_clock" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "metrics", "parking_lot", @@ -7722,7 +7850,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "state_processing" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bls", "educe", @@ -7827,7 +7955,7 @@ dependencies = [ [[package]] name = "swap_or_not_shuffle" version = "0.2.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "alloy-primitives 1.5.7", "ethereum_hashing 0.8.0", @@ -7930,7 +8058,7 @@ checksum = "c63f48baada5c52e65a29eef93ab4f8982681b67f9e8d29c7b05abcfec2b9ffe" [[package]] name = "task_executor" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "async-channel 1.9.0", "futures", @@ -8059,6 +8187,23 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-bip39" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30fd743a02bf35236f6faf99adb03089bb77e91c998dac2c2ad76bb424f668c" +dependencies = [ + "once_cell", + "pbkdf2", + "rand 0.8.5", + "rustc-hash 1.1.0", + "sha2", + "thiserror 1.0.69", + "unicode-normalization", + "wasm-bindgen", + "zeroize", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -8462,7 +8607,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "types" version = "0.2.1" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "alloy-primitives 1.5.7", "alloy-rlp", @@ -8648,10 +8793,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator_dir" +version = "0.1.0" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" +dependencies = [ + "bls", + "deposit_contract", + "educe", + "eth2_keystore", + "filesystem", + "hex", + "lockfile", + "rand 0.9.2", + "tree_hash", + "types", +] + [[package]] name = "validator_metrics" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "metrics", ] @@ -8659,10 +8821,12 @@ dependencies = [ [[package]] name = "validator_services" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "beacon_node_fallback", "bls", + "builder_store", + "builder_types", "either", "eth2", "futures", @@ -8684,9 +8848,10 @@ dependencies = [ [[package]] name = "validator_store" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "bls", + "builder_types", "eth2", "futures", "slashing_protection", @@ -9453,7 +9618,7 @@ dependencies = [ [[package]] name = "workspace_members" version = "0.1.0" -source = "git+https://github.com/sigp/lighthouse?rev=e58ec88fe#e58ec88fe9a8f755a0d92a80b25fd76100d08d49" +source = "git+https://github.com/sigp/lighthouse?rev=44f442479d628ee4dcdcbd53db04791ec368057c#44f442479d628ee4dcdcbd53db04791ec368057c" dependencies = [ "cargo_metadata", "quote", diff --git a/Cargo.toml b/Cargo.toml index 91f6b7618..8a910766c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,23 +73,26 @@ ssv_types = { path = "anchor/common/ssv_types" } subnet_service = { path = "anchor/subnet_service" } version = { path = "anchor/common/version" } -# Lighthouse latest from unstable (e58ec88fe) -beacon_node_fallback = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -bls = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -eth2 = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -eth2_keystore = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -eth2_network_config = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -health_metrics = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -metrics = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -network_utils = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -slashing_protection = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -slot_clock = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -task_executor = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -types = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -validator_metrics = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -validator_services = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -validator_store = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } -workspace_members = { git = "https://github.com/sigp/lighthouse", rev = "e58ec88fe" } +# Lighthouse Gloas builder-API stack head, sigp/lighthouse#9807 (44f442479); re-pin to +# unstable once the #9805-#9807 stack merges +beacon_node_fallback = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +bls = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +builder_store = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +builder_types = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +eth2 = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +eth2_keystore = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +eth2_network_config = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +health_metrics = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +metrics = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +network_utils = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +slashing_protection = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +slot_clock = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +task_executor = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +types = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +validator_metrics = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +validator_services = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +validator_store = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } +workspace_members = { git = "https://github.com/sigp/lighthouse", rev = "44f442479d628ee4dcdcbd53db04791ec368057c" } alloy = { version = "1.2.1", features = [ "sol-types", diff --git a/anchor/client/Cargo.toml b/anchor/client/Cargo.toml index fa3aff722..bf6a5749b 100644 --- a/anchor/client/Cargo.toml +++ b/anchor/client/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [dependencies] anchor_validator_store = { workspace = true } beacon_node_fallback = { workspace = true } +builder_store = { workspace = true } clap = { workspace = true } cli = { workspace = true } database = { workspace = true } diff --git a/anchor/client/src/lib.rs b/anchor/client/src/lib.rs index aa4690ef1..939cdd5ce 100644 --- a/anchor/client/src/lib.rs +++ b/anchor/client/src/lib.rs @@ -21,6 +21,7 @@ use beacon_node_fallback::{ BeaconNodeFallback, CandidateBeaconNode, beacon_head_monitor::HeadEvent, start_fallback_updater_service, }; +use builder_store::BuilderStore; use config::Config; use database::{NetworkDatabase, OwnOperatorId}; use duties_tracker::{duties_tracker::DutiesTracker, voluntary_exit_tracker::VoluntaryExitTracker}; @@ -64,6 +65,7 @@ use validator_services::{ payload_attestation_service::PayloadAttestationService, preparation_service::PreparationServiceBuilder, proposer_preferences_service::ProposerPreferencesService, + request_auth_cache::RequestAuthCache, sync_committee_service::SyncCommitteeService, }; @@ -721,12 +723,24 @@ impl Client { .await?; } + // `BlockServiceBuilder::build()` requires both a `BuilderStore` and a `RequestAuthCache` + // at the new Lighthouse pin. The builder definitions file starts empty, so both are inert + // today: with no builders configured, nothing ever inserts into the cache. #1279 adds + // Anchor's config surface on top, and #1280 wires the builder-preferences service, which + // will share these instances (via clones here) and is the cache's only `prune()` caller; + // enabling builders before that prune caller exists would make the cache insert-only. + let configured_builders = + BuilderStore::open_or_create(config.global_config.data_dir.builder_definitions_dir()) + .map_err(|e| format!("Unable to open or create builder definitions: {e:?}"))?; + let mut block_service_builder = BlockServiceBuilder::new() .slot_clock(slot_clock.clone()) .validator_store(validator_store.clone()) .beacon_nodes(beacon_nodes.clone()) .executor(executor.clone()) - .chain_spec(spec.clone()); + .chain_spec(spec.clone()) + .configured_builders(configured_builders) + .request_auth_cache(RequestAuthCache::default()); // If we have proposer nodes, add them to the block service builder. if proposer_nodes.num_total().await > 0 { diff --git a/anchor/common/global_config/src/data_dir.rs b/anchor/common/global_config/src/data_dir.rs index 4c320b0be..ae7d5a50f 100644 --- a/anchor/common/global_config/src/data_dir.rs +++ b/anchor/common/global_config/src/data_dir.rs @@ -89,6 +89,13 @@ impl DataDir { pub fn default_logs_dir(&self) -> PathBuf { self.path.join("logs") } + + /// Directory holding `builder_definitions.yml`, created on first use by Lighthouse's + /// `BuilderStore`. The datadir root, mirroring Lighthouse's placement of the file directly + /// in its validators dir. + pub fn builder_definitions_dir(&self) -> PathBuf { + self.path.clone() + } } impl Display for DataDir { diff --git a/anchor/validator_store/Cargo.toml b/anchor/validator_store/Cargo.toml index bdc24c4eb..8377a7aa1 100644 --- a/anchor/validator_store/Cargo.toml +++ b/anchor/validator_store/Cargo.toml @@ -7,6 +7,7 @@ authors = ["Sigma Prime "] [dependencies] beacon_node_fallback = { workspace = true } bls = { workspace = true } +builder_types = { workspace = true } database = { workspace = true } eth2 = { workspace = true } ethereum_ssz = { workspace = true } diff --git a/anchor/validator_store/src/lib.rs b/anchor/validator_store/src/lib.rs index 0a46c14ae..bdafc65a3 100644 --- a/anchor/validator_store/src/lib.rs +++ b/anchor/validator_store/src/lib.rs @@ -14,6 +14,7 @@ use std::{ }; use bls::{AggregateSignature, PublicKeyBytes, SecretKey, Signature}; +use builder_types::{RequestAuth, SignedRequestAuth}; use database::{NetworkDatabase, NonUniqueIndex, UniqueIndex}; use eth2::types::{BlockContents, BlockContentsTuple, FullBlockContents, PublishBlockRequest}; use fork::{Fork, ForkSchedule}; @@ -123,6 +124,29 @@ const SYNC_COMMITTEE_CONTRIBUTION_LOG_NAME: &str = "sync committee contribution" /// to be reconstructed. const PROPOSER_PREFERENCES_COLLECTION_TIMEOUT_SLOTS: u32 = 2; +/// Upper bound, in slots, on how long `sign_request_auth_v1` waits when the proposal slot is in +/// the future. Matches [`PROPOSER_PREFERENCES_COLLECTION_TIMEOUT_SLOTS`] and its rationale: the +/// Lighthouse builder-preferences service awaits each proposer sequentially in a per-slot loop, +/// so an unbounded no-quorum wait would head-of-line-block every later proposer. +const REQUEST_AUTH_COLLECTION_TIMEOUT_SLOTS: u32 = 2; + +/// Upper bound on how long `sign_request_auth_v1` waits when the proposal slot is the current +/// slot. This is the block-production path: Lighthouse resolves the whole builder config before +/// requesting a block, so waiting longer costs the proposal itself, while failing fast merely +/// omits the unsignable builder and lets the proposal proceed with a local payload. Quorum here +/// is also unlikely on a cold cache: peers broadcast their partial signatures once, at +/// duty-discovery time, and their request-auth cache suppresses re-signing. +const REQUEST_AUTH_PROPOSAL_SLOT_TIMEOUT: Duration = Duration::from_secs(1); + +/// Whether and how to bound a request-auth signature collection; see +/// `request_auth_collection_bound` for the slot-aware policy. +enum RequestAuthCollectionBound { + /// Run the collection, capped at the given duration. + Bounded(Duration), + /// The proposal slot has already passed: do not start a collection at all. + DeclinePastSlot, +} + /// A request to collect a committee signature for a single validator. /// /// The shared fields (`validator`, `signing_root`) drive `collect_prepared_signatures`, @@ -1360,6 +1384,87 @@ impl + 'static> AnchorValidator } } + /// Slot-aware bound for a request-auth signature collection. + /// + /// - Future proposal slot (cache-warming via the builder-preferences service): allow + /// [`REQUEST_AUTH_COLLECTION_TIMEOUT_SLOTS`] for peers to sign as their own per-slot loops + /// reach the same proposer. + /// - Current slot (block-production path): fail fast within + /// [`REQUEST_AUTH_PROPOSAL_SLOT_TIMEOUT`] so the proposal proceeds with a local payload. + /// - Past slot: decline, so the collection future is never constructed and no partial + /// signature is broadcast. This is a steady-state path, not just a restart edge: the + /// Lighthouse builder-preferences service revisits every current-epoch proposer on every + /// slot tick (its published-entry dedup runs after signing) while its request-auth cache + /// prunes elapsed slots each tick, so every elapsed proposal slot re-misses the cache each + /// slot for the rest of its epoch. Declines are expected behavior and are kept out of the + /// failure reporter so they cannot pollute the divergence metric. + fn request_auth_collection_bound( + &self, + proposal_slot: Slot, + ) -> Result { + let current_slot = self.slot_clock.now().ok_or(SpecificError::SlotClock)?; + Ok(if proposal_slot > current_slot { + RequestAuthCollectionBound::Bounded( + self.spec.get_slot_duration() * REQUEST_AUTH_COLLECTION_TIMEOUT_SLOTS, + ) + } else if proposal_slot == current_slot { + RequestAuthCollectionBound::Bounded(REQUEST_AUTH_PROPOSAL_SLOT_TIMEOUT) + } else { + RequestAuthCollectionBound::DeclinePastSlot + }) + } + + /// Classify and report a RequestAuth signature-collection failure. + /// + /// `request_auth.data` is opaque builder authentication material agreed out of band; it is + /// logged only by length, never raw. + fn report_request_auth_collection_failure( + &self, + error: &Error, + validator_pubkey: &PublicKeyBytes, + request_auth: &RequestAuth, + signing_root: Hash256, + ) { + match instrumentation::classify_collection_failure(error) { + CollectionFailureClass::NoSignature => { + warn!( + ?validator_pubkey, + proposal_slot = %request_auth.slot, + auth_data_len = request_auth.data.len(), + ?signing_root, + ?error, + "Insufficient partial signatures to reconstruct SignedRequestAuth; possible \ + causes: too few operators reached the threshold, partial-signature delivery \ + loss, or operators diverged on the builder auth data" + ); + metrics::inc_counter_vec( + &metrics::REQUEST_AUTH_RECONSTRUCTION_FAILURES, + &[metrics::REQUEST_AUTH_FAILURE_INSUFFICIENT_PARTIAL_SIGNATURES], + ); + } + CollectionFailureClass::Infra => { + error!( + ?validator_pubkey, + proposal_slot = %request_auth.slot, + ?error, + "RequestAuth signature collection infrastructure failure" + ); + metrics::inc_counter_vec( + &metrics::REQUEST_AUTH_RECONSTRUCTION_FAILURES, + &[metrics::REQUEST_AUTH_FAILURE_INFRA], + ); + } + CollectionFailureClass::NonCollection => { + error!( + ?validator_pubkey, + proposal_slot = %request_auth.slot, + ?error, + "Failed to sign RequestAuth" + ); + } + } + } + fn create_proposer_consensus_data_validator( &self, validator_pubkey: PublicKeyBytes, @@ -4080,6 +4185,26 @@ impl + 'static> ValidatorStore ) -> Result { let (validator, cluster) = self.get_validator_and_cluster(validator_pubkey)?; + // Decline elapsed proposal slots, mirroring the request-auth decline below: after a + // restart the Lighthouse proposer-preferences service re-signs every unpublished + // current-epoch duty, including slots that have already passed (its + // `preferences_to_publish` filters on the published set only, which a restart empties). + // Quorum for those is unreachable (peers broadcast once and will not re-sign), so + // waiting the bounded window per elapsed duty head-of-line-blocks the sequential loop + // and each failure would pollute the divergence metric. Same outcome as a timeout for + // the caller, minus the wait, the broadcast, and the reporter. + let current_slot = self.slot_clock.now().ok_or(SpecificError::SlotClock)?; + if preferences.proposal_slot < current_slot { + debug!( + validator_index = preferences.validator_index, + proposal_slot = %preferences.proposal_slot, + "Declining proposer preferences signing for an elapsed proposal slot" + ); + return Err(Error::SpecificError( + SpecificError::SignatureCollectionFailed(CollectionError::CollectionTimeout), + )); + } + let epoch = preferences.proposal_slot.epoch(E::slots_per_epoch()); let domain = self.get_domain(epoch, Domain::ProposerPreferences); let signing_root = preferences.signing_root(domain); @@ -4131,6 +4256,78 @@ impl + 'static> ValidatorStore signature, }) } + + async fn sign_request_auth_v1( + &self, + validator_pubkey: PublicKeyBytes, + request_auth_v1: RequestAuth, + ) -> Result { + let (validator, cluster) = self.get_validator_and_cluster(validator_pubkey)?; + + // Fixed application domain (builder-specs #165): genesis fork version, zeroed + // genesis_validators_root. Unlike `Domain::ProposerPreferences` it never varies across + // fork boundaries, so there is no epoch-keyed `get_domain` call here. + let domain_hash = self.spec.get_request_auth_domain(); + let signing_root = request_auth_v1.signing_root(domain_hash); + + // Envelope slot = the proposal slot the auth is for, as for ProposerPreferences: it + // becomes `PartialSignatureMessages.slot` on the wire, peers validate proposer assignment + // against it, and it keys the collector's lifetime. The slot is also tree-hashed into + // `signing_root` (as it is for ProposerPreferences), so a given root always carries the + // same slot. + let proposal_slot = request_auth_v1.slot; + + let bound = match self.request_auth_collection_bound(proposal_slot)? { + RequestAuthCollectionBound::Bounded(bound) => bound, + RequestAuthCollectionBound::DeclinePastSlot => { + debug!( + ?validator_pubkey, + proposal_slot = %request_auth_v1.slot, + "Declining request auth signing for an elapsed proposal slot" + ); + return Err(Error::SpecificError( + SpecificError::SignatureCollectionFailed(CollectionError::CollectionTimeout), + )); + } + }; + + let collected = Self::collect_within( + bound, + self.collect_signature( + PartialSignatureKind::RequestAuth, + Role::ProposerPreferences, + CollectionMode::SingleValidator, + &validator, + &cluster, + signing_root, + proposal_slot, + ), + ) + .await; + + let signature = match collected { + Ok(signature) => signature, + Err(err) => { + self.report_request_auth_collection_failure( + &err, + &validator_pubkey, + &request_auth_v1, + signing_root, + ); + return Err(err); + } + }; + + validator_metrics::inc_counter_vec( + &metrics::SIGNED_REQUEST_AUTH_TOTAL, + &[validator_metrics::SUCCESS], + ); + + Ok(SignedRequestAuth { + message: request_auth_v1, + signature, + }) + } } struct PublishDecision { @@ -4713,8 +4910,8 @@ mod tests { } } - /// Anchors that `get_attestation_due` flips from `unaggregated_attestation_due` - /// to `unaggregated_attestation_due_gloas` at the Gloas activation boundary. + /// Anchors that `get_attestation_due` flips from the pre-Gloas attestation deadline to the + /// shorter Gloas one at the Gloas activation boundary. /// A LH bump that changes the fork-gating logic will surface here. #[test] fn attestation_due_switches_at_gloas_boundary() { @@ -4730,8 +4927,19 @@ mod tests { let post = spec.get_attestation_due::(first_gloas_slot); assert_ne!(pre, post); - assert_eq!(pre, spec.unaggregated_attestation_due); - assert_eq!(post, spec.unaggregated_attestation_due_gloas); + // Recompute each era's expected deadline from the public basis-point inputs (the derived + // duration fields are private): each value is its bps share of the slot duration, which + // pins the polarity of the flip, not just that a flip happened. + assert_eq!( + pre, + spec.compute_slot_component_duration(spec.attestation_due_bps) + .unwrap() + ); + assert_eq!( + post, + spec.compute_slot_component_duration(spec.attestation_due_bps_gloas) + .unwrap() + ); } // ==================== SlotVote accessor tests ==================== diff --git a/anchor/validator_store/src/metrics.rs b/anchor/validator_store/src/metrics.rs index 3162747c6..17b56436a 100644 --- a/anchor/validator_store/src/metrics.rs +++ b/anchor/validator_store/src/metrics.rs @@ -38,6 +38,14 @@ pub static SIGNED_PROPOSER_PREFERENCES_TOTAL: LazyLock> = ) }); +pub static SIGNED_REQUEST_AUTH_TOTAL: LazyLock> = LazyLock::new(|| { + try_create_int_counter_vec( + "anchor_signed_request_auth_total", + "Total count of RequestAuth signings", + &["status"], + ) +}); + /// The duty's `attester_index` differs from Anchor's stored validator index; indices are /// permanent once assigned, so this is never reorg drift. pub const IDENTITY_MISMATCH_ATTESTER_INDEX: &str = "attester_index"; @@ -257,6 +265,23 @@ pub static PROPOSER_PREFERENCES_RECONSTRUCTION_FAILURES: LazyLock> = + LazyLock::new(|| { + try_create_int_counter_vec( + "anchor_request_auth_reconstruction_failures_total", + "RequestAuth signature collection failures by reason", + &["reason"], + ) + }); + /// Consensus, signing, and content match all succeeded; the envelope is returned for /// publication. pub const ENVELOPE_OUTCOME_PUBLISHED: &str = "published"; diff --git a/anchor/validator_store/src/testing/mod.rs b/anchor/validator_store/src/testing/mod.rs index 6a1ff7fb8..ef635c8fe 100644 --- a/anchor/validator_store/src/testing/mod.rs +++ b/anchor/validator_store/src/testing/mod.rs @@ -12,5 +12,6 @@ mod envelope_signing; mod payload_attestation; mod proposer_delay; mod proposer_preferences; +mod request_auth; mod sync_contribution; mod sync_selection_proof; diff --git a/anchor/validator_store/src/testing/proposer_preferences.rs b/anchor/validator_store/src/testing/proposer_preferences.rs index acfd970b2..e8f1fde58 100644 --- a/anchor/validator_store/src/testing/proposer_preferences.rs +++ b/anchor/validator_store/src/testing/proposer_preferences.rs @@ -16,7 +16,7 @@ //! (see `signature_collector::SignatureCollectorManager::create_message`), and that verbatim copy //! is covered by signature_collector's own tests; asserting `metadata.slot` therefore pins the //! input to that copy. -use std::sync::LazyLock; +use std::{sync::LazyLock, task::Poll}; use signature_collector::{CollectionError, SignatureRequester}; use ssv_types::{OperatorId, msgid::Role, partial_sig::PartialSignatureKind}; @@ -38,7 +38,7 @@ const TEST_GAS_LIMIT: u64 = 30_000_000; /// the envelope-slot test can rule out both alternatives. const LOOKAHEAD_EPOCHS: u64 = 2; -/// Serializes the two metric tests against each other. Both read the same labels of the global +/// Serializes the metric tests against each other. They read the same labels of the global /// prometheus `PROPOSER_PREFERENCES_RECONSTRUCTION_FAILURES` counter, so concurrent execution /// would make their cross-label delta assertions racy. A tokio mutex rather than std because the /// guard is held across awaits on a multi-thread runtime. @@ -529,6 +529,94 @@ async fn proposer_preferences_no_quorum_hits_bounded_timeout() { ); } +/// An elapsed proposal slot is declined before any collection work: the call resolves on its very +/// first poll (there is no await ahead of the decline) with the same `CollectionTimeout` error +/// shape a timeout produces, the collector is never invoked (zero captured calls, so no partial +/// signature is broadcast), and neither reconstruction-failure label moves (declines bypass +/// `report_proposer_preferences_collection_failure`). +/// +/// Why declining matters: after a restart, the Lighthouse proposer-preferences service re-signs +/// every unpublished current-epoch duty including elapsed slots (its `preferences_to_publish` +/// filters on the published set only, which a restart empties). Quorum for an elapsed slot is +/// unreachable, so without the decline each such duty would burn the full +/// `PROPOSER_PREFERENCES_COLLECTION_TIMEOUT_SLOTS` bound sequentially in LH's per-validator loop +/// and pollute the divergence metric with structural noise. Only strictly earlier slots decline; +/// an equal-to-current slot still collects, which this module's TEST_SLOT-based tests exercise. +/// +/// Joins `METRIC_TEST_LOCK` because the zero-delta assertions read the same global labels the +/// other metric tests assert deltas on. +#[tokio::test(flavor = "multi_thread")] +async fn proposer_preferences_elapsed_slot_declines_without_collection() { + let _guard = METRIC_TEST_LOCK.lock().await; + + // Arrange. The hanging collector is a tripwire, not a timing device: if the decline + // regressed, the first poll would start a collection and come back Pending, tripping the + // first-poll panic below instead of quietly succeeding against the mock. + let our_operator_id = OperatorId(1); + let committee = create_committee_setup(&test_operator_ids(), 1, STARTING_VALIDATOR_INDEX); + let pubkey = committee.validators[0].public_key; + let harness = ValidatorStoreTestHarness::new_with_options( + vec![committee], + our_operator_id, + HarnessOptions { + collector_hangs: true, + disable_slashing_protection: true, + ..Default::default() + }, + ); + // Strictly before the clock's current slot (the harness clock sits inside TEST_SLOT = 1, so + // this does not underflow). Equal slots are NOT declined; only strictly earlier ones. + let elapsed_slot = Slot::new(TEST_SLOT - 1); + let preferences = create_proposer_preferences(STARTING_VALIDATOR_INDEX as u64, elapsed_slot); + + let metric = crate::metrics::PROPOSER_PREFERENCES_RECONSTRUCTION_FAILURES + .as_ref() + .expect("metric should be created"); + let insufficient_counter = metric.with_label_values(&[ + crate::metrics::PROPOSER_PREFERENCES_FAILURE_INSUFFICIENT_PARTIAL_SIGNATURES, + ]); + let infra_counter = + metric.with_label_values(&[crate::metrics::PROPOSER_PREFERENCES_FAILURE_INFRA]); + let insufficient_before = insufficient_counter.get(); + let infra_before = infra_counter.get(); + + // Act: poll by hand to pin that the decline resolves without any await. + let fut = harness + .validator_store + .sign_proposer_preferences(pubkey, preferences); + tokio::pin!(fut); + let Poll::Ready(result) = futures::poll!(fut.as_mut()) else { + panic!("an elapsed-slot decline must fail on the first poll"); + }; + + // Assert + assert!( + matches!( + result, + Err(Error::SpecificError( + SpecificError::SignatureCollectionFailed(CollectionError::CollectionTimeout) + )) + ), + "an elapsed proposal slot must decline with the CollectionTimeout error shape, got: \ + {result:?}" + ); + assert!( + harness.captured_calls.lock().is_empty(), + "an elapsed-slot decline must never reach the collector (no partial signature broadcast)" + ); + assert_eq!( + insufficient_counter.get() - insufficient_before, + 0, + "a decline must not increment the insufficient_partial_signatures reconstruction-failure \ + label" + ); + assert_eq!( + infra_counter.get() - infra_before, + 0, + "a decline must not increment the infra reconstruction-failure label" + ); +} + // ==================== Slashing-protection tests ==================== /// `sign_proposer_preferences` succeeds with slashing protection enabled, proving the path never diff --git a/anchor/validator_store/src/testing/request_auth.rs b/anchor/validator_store/src/testing/request_auth.rs new file mode 100644 index 000000000..d50a55beb --- /dev/null +++ b/anchor/validator_store/src/testing/request_auth.rs @@ -0,0 +1,568 @@ +//! Integration tests for the RequestAuth path in `sign_request_auth_v1()`. +//! +//! These tests pin the decisions unique to this duty relative to its kind-8 sibling +//! (`sign_proposer_preferences`): +//! - the signing domain is the *fixed* application domain from builder-specs #165 +//! (`DOMAIN_REQUEST_AUTH` = 0x0B000001 combined with the genesis fork version and a zeroed +//! genesis_validators_root). It is fork-epoch invariant, unlike the epoch-keyed +//! `Domain::ProposerPreferences`, +//! - the collection bound is slot-aware; see `request_auth_timeout_is_slot_aware` for the +//! three-case rule, +//! - the proposal slot is tree-hashed into the signing root itself (`slot` is a field of +//! `RequestAuth`), so a given root always carries the same slot. +//! +//! Collector independence between kind 8 and kind 9 is deliberately not tested here: the +//! signature collector keys collections by `(signing_root, validator_index)` in +//! `signature_collector`'s `get_or_spawn`, and the two duties' domain-separated roots can never +//! collide, so independence follows structurally. Admission-budget independence on the receive +//! side is covered by message_validator's #1281 tests. +//! +//! Scope of the slot assertions: as in the ProposerPreferences module, these tests assert +//! `call.metadata.slot`, the value captured by the mock at the `sign_and_collect` trait boundary. +//! `create_message` copies that value verbatim into the on-wire `PartialSignatureMessages.slot` +//! (see `signature_collector::SignatureCollectorManager::create_message`), and that verbatim copy +//! is covered by signature_collector's own tests. +use std::{sync::LazyLock, task::Poll, time::Duration}; + +use bls::Signature; +use builder_types::{RequestAuth, RequestAuthData}; +use signature_collector::{CollectionError, SignatureRequester}; +use ssv_types::{OperatorId, msgid::Role, partial_sig::PartialSignatureKind}; +use tree_hash::TreeHash; +use types::{ChainSpec, Domain, Epoch, EthSpec, Hash256, MainnetEthSpec, SignedRoot, Slot}; +use validator_store::ValidatorStore; + +use super::common::*; +use crate::{ + Error, REQUEST_AUTH_COLLECTION_TIMEOUT_SLOTS, REQUEST_AUTH_PROPOSAL_SLOT_TIMEOUT, SpecificError, +}; + +/// Non-zero so a correctly targeted validator is distinguishable from an accidental default 0. +const STARTING_VALIDATOR_INDEX: usize = 5; +/// Number of epochs a lookahead proposal slot sits ahead of the send slot. Two epochs places the +/// Gloas fork boundary strictly between the send epoch and the proposal epoch in the success +/// test, which is what makes its epoch-keyed-domain guard falsifiable. +const LOOKAHEAD_EPOCHS: u64 = 2; +/// Builder auth data used by every fixture. The bytes follow the `RequestAuth::data` convention +/// (the builder's advertised URL) but the store treats them as opaque, so any non-empty value +/// exercises the same path. The known-answer vector below uses the same bytes, so its constants +/// double as an independent check of this fixture's merkleization. +const TEST_AUTH_DATA: &[u8] = b"https://builder.example/"; +/// One paused-clock tick used to bracket a timeout deadline: a poll at `bound - epsilon` must be +/// pending and a poll at `bound + epsilon` must be resolved. +const TIMER_EPSILON: Duration = Duration::from_millis(1); + +/// Serializes the metric-reading tests against each other. Both the classification test and the +/// slot-aware timeout test increment labels of the global prometheus +/// `REQUEST_AUTH_RECONSTRUCTION_FAILURES` counter (every timeout failure lands in the +/// `insufficient_partial_signatures` bucket), so concurrent execution would make the +/// classification test's delta assertions racy. A tokio mutex rather than std because the guard +/// is held across awaits. +static METRIC_TEST_LOCK: LazyLock> = + LazyLock::new(|| tokio::sync::Mutex::new(())); + +/// Builds a `RequestAuth` fixture. The store signs whatever it is handed, so fixed auth data is +/// sufficient; `proposal_slot` is a parameter because it is tree-hashed into the signing root and +/// selects the slot-aware collection bound the tests assert on. +fn create_request_auth(proposal_slot: Slot) -> RequestAuth { + RequestAuth { + data: RequestAuthData::new(TEST_AUTH_DATA.to_vec()) + .expect("auth data fits the ByteList limit"), + slot: proposal_slot, + } +} + +// ==================== Success / signing-root tests ==================== + +/// `sign_request_auth_v1` collects a single-validator signature and echoes the input back in the +/// resulting `SignedRequestAuth`, committing to the `RequestAuth` under the *fixed* request-auth +/// application domain (builder-specs #165), with the collection pinned to kind +/// `RequestAuth`, role `ProposerPreferences`, a `SingleValidator` requester, and an envelope slot +/// equal to `request_auth.slot`. +/// +/// The harness runs on a spec with Gloas activated exactly at `LOOKAHEAD_EPOCHS` and the proposal +/// slot sits `LOOKAHEAD_EPOCHS` ahead of the send slot, so a fork boundary lies strictly between +/// the send epoch (genesis fork version) and the proposal epoch (Gloas fork version). The fixed +/// domain must be entirely unaffected by that boundary. Falsifiability guard: the observed root is +/// also asserted NOT to equal a recompute under the epoch-keyed `Domain::ProposerPreferences` at +/// the proposal epoch. A copy-paste regression to the kind-8 domain call would flip that +/// assertion; the fork boundary guarantees the two computations differ in the fork-version bytes +/// as well as the domain type, so even a partially copied regression is observable. +/// +/// Slashing-protection tripwire (folded in from the sibling module's standalone test): the +/// harness runs with slashing protection *enabled* and the validator registered, and the final +/// assertion block exports the interchange to prove the path recorded neither a block proposal +/// nor an attestation. A regression that routed this duty through the slashing DB would either +/// fail the signing call or leave a record behind, flipping one of these assertions. +#[tokio::test(flavor = "multi_thread")] +async fn request_auth_success_pins_root_kind_role_mode_and_envelope_slot() { + // Arrange + let our_operator_id = OperatorId(1); + let committee = + create_committee_setup(&PRIMARY_COMMITTEE_OPERATOR_IDS, 1, STARTING_VALIDATOR_INDEX); + let pubkey = committee.validators[0].public_key; + // Gloas at epoch LOOKAHEAD_EPOCHS puts a fork boundary strictly inside the lookahead window; + // the fixed request-auth domain must not care, while the epoch-keyed kind-8 domain does. + let harness = ValidatorStoreTestHarness::new_with_options( + vec![committee], + our_operator_id, + HarnessOptions { + spec: gloas_at_epoch_spec(Epoch::new(LOOKAHEAD_EPOCHS)), + disable_slashing_protection: false, + ..Default::default() + }, + ); + let future_proposal_slot = + Slot::new(TEST_SLOT + MainnetEthSpec::slots_per_epoch() * LOOKAHEAD_EPOCHS); + let request_auth = create_request_auth(future_proposal_slot); + + // Act + let result = harness + .validator_store + .sign_request_auth_v1(pubkey, request_auth.clone()) + .await; + + // Assert + let signed = result.expect("request auth signing should succeed"); + assert_eq!( + signed.message, request_auth, + "signed message should echo the input request auth unchanged" + ); + assert_eq!( + signed.signature, + Signature::infinity().expect("infinity signature"), + "the signature should be the one the mock collector produced" + ); + + let captured = harness.captured_calls.lock(); + assert_eq!( + captured.len(), + 1, + "expected exactly one sign_and_collect call" + ); + let call = &captured[0]; + match &call.requester { + SignatureRequester::SingleValidator { + pubkey: requester_pubkey, + } => assert_eq!( + *requester_pubkey, pubkey, + "collection should be requested for the signing validator" + ), + other => panic!("expected SignatureRequester::SingleValidator, got: {other:?}"), + } + assert_eq!( + call.metadata.kind, + PartialSignatureKind::RequestAuth, + "partial signature messages should be tagged with the RequestAuth kind" + ); + assert_eq!( + call.metadata.role, + Role::ProposerPreferences, + "the network message should be routed under the ProposerPreferences role" + ); + assert_eq!( + call.metadata.slot, request_auth.slot, + "the collected partial-signature slot should equal the request auth's proposal slot" + ); + + // The signing root must use the fixed application domain: genesis fork version and zeroed + // genesis_validators_root regardless of the proposal epoch's fork. + let expected_root = request_auth.signing_root(harness.spec.get_request_auth_domain()); + assert_eq!( + call.signing_root, expected_root, + "signing root should commit to the request auth under the fixed DOMAIN_REQUEST_AUTH \ + application domain" + ); + // Falsifiability guard: recompute under the kind-8 epoch-keyed domain at the proposal epoch. + // The Gloas boundary inside the lookahead window makes that domain differ from the fixed one + // in both the domain type and the fork-version bytes, so a copy-paste regression to the + // ProposerPreferences domain call would flip this assertion. + let proposal_epoch = request_auth.slot.epoch(MainnetEthSpec::slots_per_epoch()); + let epoch_keyed_domain = harness.spec.get_domain( + proposal_epoch, + Domain::ProposerPreferences, + &harness.spec.fork_at_epoch(proposal_epoch), + harness.genesis_validators_root, + ); + assert_ne!( + call.signing_root, + request_auth.signing_root(epoch_keyed_domain), + "signing root must NOT be the epoch-keyed ProposerPreferences domain at the proposal \ + epoch: the request-auth domain is a fixed application domain, invariant across fork \ + boundaries" + ); + drop(captured); + + // Slashing tripwire: the call succeeded with slashing protection enabled, and the DB (which + // registered exactly this validator during harness setup) recorded nothing. The length check + // keeps the emptiness assertion non-vacuous. + let interchange = harness + .slashing_protection + .export_all_interchange_info(harness.genesis_validators_root) + .expect("interchange export should succeed"); + assert_eq!( + interchange.data.len(), + 1, + "the harness should have registered exactly one validator in the slashing DB" + ); + assert!( + interchange + .data + .iter() + .all(|record| record.signed_blocks.is_empty() && record.signed_attestations.is_empty()), + "signing a request auth must not record anything in the slashing DB" + ); +} + +// ==================== Timeout tests ==================== + +/// The collection bound is slot-aware (`request_auth_collection_bound`), table-driven over the +/// three positions of the proposal slot relative to the clock: +/// - future slot (cache-warming): the bound is `REQUEST_AUTH_COLLECTION_TIMEOUT_SLOTS` slots; a +/// no-quorum collection is still pending just before that bound and resolves to +/// `CollectionTimeout` just after, +/// - current slot (block-production): the bound is the fail-fast +/// `REQUEST_AUTH_PROPOSAL_SLOT_TIMEOUT` (1s), bracketed the same way, +/// - past slot (steady state: LH's preferences loop revisits elapsed proposer slots every tick): +/// the bound resolves to a decline, which fails on the first poll before the collection future +/// is ever constructed. The mock records ZERO collection calls (so no partial signature would +/// have been broadcast) and neither reconstruction-failure label moves: declines bypass the +/// failure reporter so structural noise cannot pollute the divergence metric. +/// +/// All three failures surface as `SignatureCollectionFailed(CollectionTimeout)`. The mock +/// collector hangs forever, so only the production bound can resolve the bounded cases; the +/// bracketing polls under a paused clock make each case's exact bound falsifiable in both +/// directions (a longer bound fails the "resolved just after" poll, a shorter one fails the +/// "still pending just before" poll). +/// +/// Joins `METRIC_TEST_LOCK` because the bounded cases increment the +/// `insufficient_partial_signatures` label the classification test asserts deltas on, and the +/// decline case reads both labels for its own zero-delta assertions. +#[tokio::test(start_paused = true)] +async fn request_auth_timeout_is_slot_aware() { + let _guard = METRIC_TEST_LOCK.lock().await; + + struct TimeoutCase { + name: &'static str, + proposal_slot: Slot, + /// The expected collection bound; `Duration::ZERO` marks the decline case, which fails + /// on the first poll with no collector call and no failure-metric increment. + expected_bound: Duration, + } + + // The harness spec is mainnet, so the future-slot bound is slot duration times the + // production constant; recomputing it from the same inputs keeps the case table free of + // magic seconds. + let mainnet_slot_duration = ChainSpec::mainnet().get_slot_duration(); + let cases = [ + TimeoutCase { + name: "future proposal slot", + proposal_slot: Slot::new(TEST_SLOT + 2), + expected_bound: mainnet_slot_duration * REQUEST_AUTH_COLLECTION_TIMEOUT_SLOTS, + }, + TimeoutCase { + name: "current proposal slot", + proposal_slot: Slot::new(TEST_SLOT), + expected_bound: REQUEST_AUTH_PROPOSAL_SLOT_TIMEOUT, + }, + TimeoutCase { + name: "past proposal slot", + proposal_slot: Slot::new(TEST_SLOT - 1), + expected_bound: Duration::ZERO, + }, + ]; + + let our_operator_id = OperatorId(1); + for case in cases { + // Arrange: a fresh harness per case so captured-call counts do not leak across cases. The + // collector captures each call and then never resolves, so only the production bound can + // unblock the bounded cases. + let committee = + create_committee_setup(&PRIMARY_COMMITTEE_OPERATOR_IDS, 1, STARTING_VALIDATOR_INDEX); + let pubkey = committee.validators[0].public_key; + let harness = ValidatorStoreTestHarness::new_with_options( + vec![committee], + our_operator_id, + HarnessOptions { + collector_hangs: true, + ..Default::default() + }, + ); + let request_auth = create_request_auth(case.proposal_slot); + + // Act + Assert: drive the future by hand under the paused clock so the deadline can be + // bracketed on both sides. + let fut = harness + .validator_store + .sign_request_auth_v1(pubkey, request_auth); + tokio::pin!(fut); + + let result = if case.expected_bound.is_zero() { + // A decline must stay out of the failure reporter: elapsed proposal slots recur + // every tick in steady state, so routing them through the reporter would pollute + // the divergence metric with structural noise. The label deltas are read around + // this case only; the reads are race-free because this test holds + // `METRIC_TEST_LOCK` (the earlier bounded cases DO increment the insufficient + // label, but those increments land before these before-values are captured). + let metric = crate::metrics::REQUEST_AUTH_RECONSTRUCTION_FAILURES + .as_ref() + .expect("metric should be created"); + let insufficient_counter = metric.with_label_values(&[ + crate::metrics::REQUEST_AUTH_FAILURE_INSUFFICIENT_PARTIAL_SIGNATURES, + ]); + let infra_counter = + metric.with_label_values(&[crate::metrics::REQUEST_AUTH_FAILURE_INFRA]); + let insufficient_before = insufficient_counter.get(); + let infra_before = infra_counter.get(); + + // The decline must fail on the very first poll: it happens before the collection + // future is ever constructed. + let Poll::Ready(result) = futures::poll!(fut.as_mut()) else { + panic!( + "{}: a past-slot decline must fail on the first poll", + case.name + ); + }; + assert!( + harness.captured_calls.lock().is_empty(), + "{}: a past-slot request must never reach the collector (no partial signature \ + broadcast)", + case.name + ); + assert_eq!( + insufficient_counter.get() - insufficient_before, + 0, + "{}: a decline must not increment the insufficient_partial_signatures \ + reconstruction-failure label", + case.name + ); + assert_eq!( + infra_counter.get() - infra_before, + 0, + "{}: a decline must not increment the infra reconstruction-failure label", + case.name + ); + result + } else { + // First poll starts the collection (captured by the mock) and arms the bound. + assert!( + futures::poll!(fut.as_mut()).is_pending(), + "{}: the collection should be in flight after the first poll", + case.name + ); + tokio::time::advance(case.expected_bound - TIMER_EPSILON).await; + assert!( + futures::poll!(fut.as_mut()).is_pending(), + "{}: the call must still be pending just before the bound elapses", + case.name + ); + tokio::time::advance(TIMER_EPSILON * 2).await; + let Poll::Ready(result) = futures::poll!(fut.as_mut()) else { + panic!( + "{}: the call must have resolved just after the bound elapsed", + case.name + ); + }; + assert_eq!( + harness.captured_calls.lock().len(), + 1, + "{}: exactly one sign_and_collect call should have been captured before the \ + collector hung", + case.name + ); + result + }; + assert!( + matches!( + result, + Err(Error::SpecificError( + SpecificError::SignatureCollectionFailed(CollectionError::CollectionTimeout) + )) + ), + "{}: expected CollectionTimeout from the slot-aware bound, got: {result:?}", + case.name + ); + } +} + +// ==================== Failure classification / metrics tests ==================== + +/// Collection failures are classified into the two labels of +/// `REQUEST_AUTH_RECONSTRUCTION_FAILURES` by `report_request_auth_collection_failure`: +/// - a no-quorum timeout (the hanging collector plus the current-slot bound, resolved by the +/// production `collect_within` deadline) lands in `insufficient_partial_signatures`, +/// - an `EmptySignature` collection error (the same infra injection the sibling module uses) lands +/// in `infra`. +/// +/// Each phase asserts the cross-label zero delta too, pinning the classification boundary: a +/// timeout drifting into `infra` would hide divergence signals, and an infra failure drifting +/// into `insufficient_partial_signatures` would silently inflate the divergence estimate. The +/// metric lives in the process-global prometheus registry, so deltas are only reliable under +/// `METRIC_TEST_LOCK`. +#[tokio::test(start_paused = true)] +async fn request_auth_failure_classification_increments_metrics() { + let _guard = METRIC_TEST_LOCK.lock().await; + + // Arrange + let metric = crate::metrics::REQUEST_AUTH_RECONSTRUCTION_FAILURES + .as_ref() + .expect("metric should be created"); + let insufficient_counter = metric + .with_label_values(&[crate::metrics::REQUEST_AUTH_FAILURE_INSUFFICIENT_PARTIAL_SIGNATURES]); + let infra_counter = metric.with_label_values(&[crate::metrics::REQUEST_AUTH_FAILURE_INFRA]); + let insufficient_before = insufficient_counter.get(); + let infra_before = infra_counter.get(); + + let our_operator_id = OperatorId(1); + + // Act (phase 1): a no-quorum timeout. The collector hangs and the current-slot proposal + // selects the 1s fail-fast bound, which the paused clock auto-advances past, so the failure + // is the genuine `CollectionTimeout` from `collect_within`. + let committee = + create_committee_setup(&PRIMARY_COMMITTEE_OPERATOR_IDS, 1, STARTING_VALIDATOR_INDEX); + let pubkey = committee.validators[0].public_key; + let harness = ValidatorStoreTestHarness::new_with_options( + vec![committee], + our_operator_id, + HarnessOptions { + collector_hangs: true, + ..Default::default() + }, + ); + let result = harness + .validator_store + .sign_request_auth_v1(pubkey, create_request_auth(Slot::new(TEST_SLOT))) + .await; + + // Assert (phase 1) + assert!( + matches!( + result, + Err(Error::SpecificError( + SpecificError::SignatureCollectionFailed(CollectionError::CollectionTimeout) + )) + ), + "expected the no-quorum timeout surfaced as SignatureCollectionFailed, got: {result:?}" + ); + assert_eq!( + insufficient_counter.get() - insufficient_before, + 1, + "CollectionTimeout should increment the insufficient_partial_signatures \ + reconstruction-failure metric once" + ); + assert_eq!( + infra_counter.get() - infra_before, + 0, + "CollectionTimeout must not leak into the infra reconstruction-failure metric" + ); + + // Act (phase 2): an infra failure. EmptySignature classifies as the infra failure class. + let committee = + create_committee_setup(&PRIMARY_COMMITTEE_OPERATOR_IDS, 1, STARTING_VALIDATOR_INDEX); + let pubkey = committee.validators[0].public_key; + let harness = ValidatorStoreTestHarness::new_with_options( + vec![committee], + our_operator_id, + HarnessOptions { + collector_failure: Some(CollectionError::EmptySignature), + ..Default::default() + }, + ); + let result = harness + .validator_store + .sign_request_auth_v1(pubkey, create_request_auth(Slot::new(TEST_SLOT))) + .await; + + // Assert (phase 2) + assert!( + matches!( + result, + Err(Error::SpecificError( + SpecificError::SignatureCollectionFailed(CollectionError::EmptySignature) + )) + ), + "expected EmptySignature surfaced as SignatureCollectionFailed, got: {result:?}" + ); + assert_eq!( + infra_counter.get() - infra_before, + 1, + "EmptySignature should increment the infra reconstruction-failure metric once" + ); + assert_eq!( + insufficient_counter.get() - insufficient_before, + 1, + "the infra failure must not leak into the insufficient_partial_signatures metric (its \ + delta stays at phase 1's single increment)" + ); +} + +// ==================== Known-answer vector ==================== + +/// Known-answer test for the request-auth signing root, independent of the harness and of +/// Lighthouse's own hashing helpers on the assertion side. +/// +/// The expected values were derived OUTSIDE the Lighthouse code path with a hand-rolled SSZ +/// merkleization script (Python, hashlib only): the `data` ByteList[4096] root is +/// `mix_in_length(merkleize_to_depth7(pad32(data)), 24)`; the `slot` root is the uint64 +/// little-endian bytes zero-padded to 32; the container root is `sha256(data_root || slot_root)`; +/// the domain is `0x0B000001` (DOMAIN_REQUEST_AUTH, builder-specs #165) followed by the first 28 +/// bytes of `fork_data_root(genesis_fork_version=0x00000000, genesis_validators_root=0)`; and the +/// signing root is `sha256(object_root || domain)`. To re-derive, run: +/// +/// ```python +/// import hashlib +/// def H(a,b): return hashlib.sha256(a+b).digest() +/// Z=b'\x00'*32 +/// data=b"https://builder.example/" +/// zh=[Z] +/// for i in range(10): zh.append(H(zh[-1],zh[-1])) +/// node=data.ljust(32,b'\x00') +/// for d in range(7): node=H(node, zh[d]) +/// data_root=H(node, len(data).to_bytes(32,'little')) +/// slot_root=(1234567).to_bytes(8,'little').ljust(32,b'\x00') +/// object_root=H(data_root, slot_root) +/// fork_data_root=H(bytes(4).ljust(32,b'\x00'), Z) +/// domain=bytes([0x0B,0,0,1])+fork_data_root[:28] +/// signing_root=H(object_root, domain) +/// ``` +/// +/// Any change to the domain constant, the `RequestAuth` field set or ordering, or the ByteList +/// limit (and hence merkleization depth) flips at least one of these constants. +#[test] +fn request_auth_signing_root_known_answer_vector() { + // Arrange + let spec = ChainSpec::mainnet(); + let request_auth = create_request_auth(Slot::new(1_234_567)); + let expected_domain: Hash256 = + "0b000001f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9" + .parse() + .expect("valid hash literal"); + let expected_tree_hash_root: Hash256 = + "95be6fadb620639ec806c3e3a0e040a7e5c554316b8a118b532c29c401a084f3" + .parse() + .expect("valid hash literal"); + let expected_signing_root: Hash256 = + "c2affcb4cb84affb3580f31a9edab5ab234073917a8ac4c703dcb3983392aa50" + .parse() + .expect("valid hash literal"); + + // Act + let domain = spec.get_request_auth_domain(); + + // Assert + assert_eq!( + domain, expected_domain, + "DOMAIN_REQUEST_AUTH must be 0x0B000001 over the genesis fork version and a zeroed \ + genesis_validators_root" + ); + assert_eq!( + request_auth.tree_hash_root(), + expected_tree_hash_root, + "RequestAuth tree hash root must match the independently derived SSZ merkleization" + ); + assert_eq!( + request_auth.signing_root(domain), + expected_signing_root, + "RequestAuth signing root must match the independently derived value" + ); +} From d1a56cdd6ffdd46faefa4e9b6f537ba0d418a988 Mon Sep 17 00:00:00 2001 From: shane-moore Date: Wed, 26 Aug 2026 20:45:30 -0700 Subject: [PATCH 2/2] fix: reflow request-auth bound doc, bump blst to 0.3.17 Review follow-ups: apply the nightly rustfmt reflow check-fmt requires on the request_auth_collection_bound doc comment, and carry the blst 0.3.16 -> 0.3.17 lockfile hardening (Pippenger divide-by-zero, blst #283) that Lighthouse #9869 took upstream but Anchor's lock did not inherit. --- Cargo.lock | 4 ++-- anchor/validator_store/src/lib.rs | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd4a53966..60e97524d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1613,9 +1613,9 @@ dependencies = [ [[package]] name = "blst" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" dependencies = [ "cc", "glob", diff --git a/anchor/validator_store/src/lib.rs b/anchor/validator_store/src/lib.rs index bdafc65a3..c22119a8e 100644 --- a/anchor/validator_store/src/lib.rs +++ b/anchor/validator_store/src/lib.rs @@ -1391,13 +1391,13 @@ impl + 'static> AnchorValidator /// reach the same proposer. /// - Current slot (block-production path): fail fast within /// [`REQUEST_AUTH_PROPOSAL_SLOT_TIMEOUT`] so the proposal proceeds with a local payload. - /// - Past slot: decline, so the collection future is never constructed and no partial - /// signature is broadcast. This is a steady-state path, not just a restart edge: the - /// Lighthouse builder-preferences service revisits every current-epoch proposer on every - /// slot tick (its published-entry dedup runs after signing) while its request-auth cache - /// prunes elapsed slots each tick, so every elapsed proposal slot re-misses the cache each - /// slot for the rest of its epoch. Declines are expected behavior and are kept out of the - /// failure reporter so they cannot pollute the divergence metric. + /// - Past slot: decline, so the collection future is never constructed and no partial signature + /// is broadcast. This is a steady-state path, not just a restart edge: the Lighthouse + /// builder-preferences service revisits every current-epoch proposer on every slot tick (its + /// published-entry dedup runs after signing) while its request-auth cache prunes elapsed + /// slots each tick, so every elapsed proposal slot re-misses the cache each slot for the rest + /// of its epoch. Declines are expected behavior and are kept out of the failure reporter so + /// they cannot pollute the divergence metric. fn request_auth_collection_bound( &self, proposal_slot: Slot,