diff --git a/.github/forbidden-files.txt b/.github/forbidden-files.txt index 1c5e9acab9..eca611585b 100644 --- a/.github/forbidden-files.txt +++ b/.github/forbidden-files.txt @@ -9,6 +9,7 @@ beacon_node/beacon_chain/src/block_reward.rs beacon_node/http_api/src/attestation_performance.rs beacon_node/http_api/src/block_packing_efficiency.rs beacon_node/http_api/src/block_rewards.rs +beacon_node/http_api/src/beacon/execution_payload_envelope.rs common/eth2/src/lighthouse/attestation_performance.rs common/eth2/src/lighthouse/block_packing_efficiency.rs common/eth2/src/lighthouse/block_rewards.rs diff --git a/Cargo.lock b/Cargo.lock index 223a6192c8..5d200768cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "account_manager" -version = "8.1.3" +version = "8.2.0" dependencies = [ "account_utils", "bls", @@ -364,7 +364,7 @@ dependencies = [ "either", "futures", "futures-utils-wasm", - "lru 0.13.0", + "lru", "parking_lot", "pin-project", "reqwest", @@ -447,7 +447,7 @@ dependencies = [ "alloy-rlp", "alloy-serde", "alloy-sol-types", - "itertools 0.14.0", + "itertools 0.13.0", "serde", "serde_json", "serde_with", @@ -1229,13 +1229,13 @@ dependencies = [ "fork_choice", "futures", "genesis", + "hashlink", "hex", "int_to_bytes", "itertools 0.14.0", "kzg", "lighthouse_version", "logging", - "lru 0.12.5", "maplit", "merkle_proof", "metrics", @@ -1276,7 +1276,7 @@ dependencies = [ [[package]] name = "beacon_node" -version = "8.1.3" +version = "8.2.0" dependencies = [ "account_utils", "beacon_chain", @@ -1368,7 +1368,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.10.5", "lazy_static", "lazycell", "proc-macro2", @@ -1539,7 +1539,7 @@ dependencies = [ [[package]] name = "boot_node" -version = "8.1.3" +version = "8.2.0" dependencies = [ "beacon_node", "bytes", @@ -1969,7 +1969,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -3114,7 +3114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3394,13 +3394,13 @@ dependencies = [ "fork_choice", "hash-db", "hash256-std-hasher", + "hashlink", "hex", "jsonwebtoken", "keccak-hash", "kzg", "lighthouse_version", "logging", - "lru 0.12.5", "metrics", "parking_lot", "pretty_reqwest_error", @@ -3441,6 +3441,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.2", + "siphasher", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -3588,6 +3600,7 @@ version = "0.1.0" dependencies = [ "beacon_chain", "bls", + "criterion", "ethereum_ssz", "ethereum_ssz_derive", "fixed_bytes", @@ -3654,8 +3667,8 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b604752cefc5aa3ab98992a107a8bd99465d2825c1584e0b60cb6957b21e19d7" dependencies = [ + "futures-timer", "futures-util", - "tokio", ] [[package]] @@ -4240,12 +4253,12 @@ dependencies = [ "fixed_bytes", "futures", "genesis", + "hashlink", "health_metrics", "hex", "lighthouse_network", "lighthouse_version", "logging", - "lru 0.12.5", "metrics", "network", "network_utils", @@ -4409,7 +4422,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -4783,15 +4796,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -4981,7 +4985,7 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "lcli" -version = "8.1.3" +version = "8.2.0" dependencies = [ "account_utils", "beacon_chain", @@ -5086,7 +5090,7 @@ dependencies = [ [[package]] name = "libp2p" version = "0.57.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "bytes", "either", @@ -5117,7 +5121,7 @@ dependencies = [ [[package]] name = "libp2p-allow-block-list" version = "0.7.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5127,7 +5131,7 @@ dependencies = [ [[package]] name = "libp2p-connection-limits" version = "0.7.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5137,7 +5141,7 @@ dependencies = [ [[package]] name = "libp2p-core" version = "0.44.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "either", "fnv", @@ -5161,7 +5165,7 @@ dependencies = [ [[package]] name = "libp2p-dns" version = "0.45.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "hickory-resolver", @@ -5175,7 +5179,7 @@ dependencies = [ [[package]] name = "libp2p-gossipsub" version = "0.50.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "async-channel 2.5.0", "asynchronous-codec", @@ -5205,7 +5209,7 @@ dependencies = [ [[package]] name = "libp2p-identify" version = "0.48.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "asynchronous-codec", "either", @@ -5245,7 +5249,7 @@ dependencies = [ [[package]] name = "libp2p-mdns" version = "0.49.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "hickory-proto", @@ -5263,7 +5267,7 @@ dependencies = [ [[package]] name = "libp2p-metrics" version = "0.18.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "libp2p-core", @@ -5279,7 +5283,7 @@ dependencies = [ [[package]] name = "libp2p-mplex" version = "0.44.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "asynchronous-codec", "bytes", @@ -5297,7 +5301,7 @@ dependencies = [ [[package]] name = "libp2p-noise" version = "0.47.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "asynchronous-codec", "bytes", @@ -5319,7 +5323,7 @@ dependencies = [ [[package]] name = "libp2p-quic" version = "0.14.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "futures-timer", @@ -5328,6 +5332,7 @@ dependencies = [ "libp2p-identity", "libp2p-tls", "quinn", + "quinn-proto", "rand 0.8.5", "ring", "rustls 0.23.40", @@ -5340,7 +5345,7 @@ dependencies = [ [[package]] name = "libp2p-swarm" version = "0.48.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "either", "fnv", @@ -5363,7 +5368,7 @@ dependencies = [ [[package]] name = "libp2p-swarm-derive" version = "0.36.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "heck", "quote", @@ -5373,7 +5378,7 @@ dependencies = [ [[package]] name = "libp2p-tcp" version = "0.45.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "futures-timer", @@ -5388,7 +5393,7 @@ dependencies = [ [[package]] name = "libp2p-tls" version = "0.7.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "futures-rustls", @@ -5406,7 +5411,7 @@ dependencies = [ [[package]] name = "libp2p-upnp" version = "0.7.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "futures-timer", @@ -5420,15 +5425,13 @@ dependencies = [ [[package]] name = "libp2p-yamux" version = "0.48.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ - "either", "futures", "libp2p-core", "thiserror 2.0.17", "tracing", - "yamux 0.12.1", - "yamux 0.13.10", + "yamux", ] [[package]] @@ -5480,7 +5483,7 @@ dependencies = [ [[package]] name = "lighthouse" -version = "8.1.3" +version = "8.2.0" dependencies = [ "account_manager", "account_utils", @@ -5549,6 +5552,7 @@ dependencies = [ "fixed_bytes", "fnv", "futures", + "hashlink", "hex", "if-addrs 0.14.0", "itertools 0.14.0", @@ -5556,7 +5560,6 @@ dependencies = [ "libp2p-mplex", "lighthouse_version", "logging", - "lru 0.12.5", "lru_cache", "metrics", "network_utils", @@ -5612,7 +5615,7 @@ dependencies = [ [[package]] name = "lighthouse_version" -version = "8.1.3" +version = "8.2.0" dependencies = [ "regex", ] @@ -5703,15 +5706,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru" version = "0.13.0" @@ -6099,7 +6093,7 @@ dependencies = [ [[package]] name = "multistream-select" version = "0.14.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "bytes", "futures", @@ -6305,7 +6299,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7127,7 +7121,7 @@ dependencies = [ [[package]] name = "prost-codec" version = "0.4.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "asynchronous-codec", "bytes", @@ -7143,7 +7137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7156,7 +7150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7215,9 +7209,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -7227,7 +7221,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.40", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -7236,11 +7230,12 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", + "fastbloom", "getrandom 0.3.4", "lru-slab", "rand 0.9.2", @@ -7264,9 +7259,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7811,7 +7806,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7917,7 +7912,7 @@ dependencies = [ [[package]] name = "rw-stream-sink" version = "0.5.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "pin-project", @@ -8396,6 +8391,12 @@ dependencies = [ "types", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.11" @@ -8415,10 +8416,10 @@ dependencies = [ "filesystem", "fixed_bytes", "flate2", + "hashlink", "libmdbx", "lmdb-rkv", "lmdb-rkv-sys", - "lru 0.12.5", "maplit", "metrics", "parking_lot", @@ -8652,10 +8653,10 @@ dependencies = [ "ethereum_ssz", "ethereum_ssz_derive", "fixed_bytes", + "hashlink", "itertools 0.14.0", "leveldb", "logging", - "lru 0.12.5", "metrics", "milhouse", "parking_lot", @@ -8879,7 +8880,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9688,7 +9689,7 @@ dependencies = [ [[package]] name = "validator_client" -version = "8.1.3" +version = "8.2.0" dependencies = [ "account_utils", "beacon_node_fallback", @@ -10243,7 +10244,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -10853,31 +10854,15 @@ dependencies = [ [[package]] name = "yamux" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed0164ae619f2dc144909a9f082187ebb5893693d8c0196e8085283ccd4b776" +checksum = "767113d8f66a81e461362462aa71d8d0108cbf3430d4442fb88a04e31be81165" dependencies = [ "futures", "log", "nohash-hasher", "parking_lot", "pin-project", - "rand 0.8.5", - "static_assertions", -] - -[[package]] -name = "yamux" -version = "0.13.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" -dependencies = [ - "futures", - "log", - "nohash-hasher", - "parking_lot", - "pin-project", - "rand 0.9.2", "static_assertions", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index 23bae317b4..f43786efe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ resolver = "2" [workspace.package] edition = "2024" -version = "8.1.3" +version = "8.2.0" [workspace.dependencies] account_utils = { path = "common/account_utils" } @@ -152,7 +152,7 @@ fs2 = "0.4" futures = "0.3" genesis = { path = "beacon_node/genesis" } graffiti_file = { path = "validator_client/graffiti_file" } -hashlink = "0.9.0" +hashlink = "0.11" health_metrics = { path = "common/health_metrics" } hex = "0.4" http_api = { path = "beacon_node/http_api" } @@ -170,7 +170,6 @@ lockfile = { path = "common/lockfile" } log = "0.4" logging = { path = "common/logging" } logroller = "0.1.8" -lru = "0.12" lru_cache = { path = "common/lru_cache" } malloc_utils = { path = "common/malloc_utils" } maplit = "1" @@ -273,3 +272,7 @@ incremental = false inherits = "release" debug = true +[patch."https://github.com/libp2p/rust-libp2p.git"] +libp2p = { git = "https://github.com/sigp/rust-libp2p.git", rev = "3563de5ed20e509885592b391aa816992eef55d4" } +libp2p-mplex = { git = "https://github.com/sigp/rust-libp2p.git", rev = "3563de5ed20e509885592b391aa816992eef55d4" } +libp2p-quic = { git = "https://github.com/sigp/rust-libp2p.git", rev = "3563de5ed20e509885592b391aa816992eef55d4" } diff --git a/beacon_node/beacon_chain/Cargo.toml b/beacon_node/beacon_chain/Cargo.toml index 47ef4d7a03..236ce42abd 100644 --- a/beacon_node/beacon_chain/Cargo.toml +++ b/beacon_node/beacon_chain/Cargo.toml @@ -35,13 +35,13 @@ fixed_bytes = { workspace = true } fork_choice = { workspace = true } futures = { workspace = true } genesis = { workspace = true } +hashlink = { workspace = true } hex = { workspace = true } int_to_bytes = { workspace = true } itertools = { workspace = true } kzg = { workspace = true } lighthouse_version = { workspace = true } logging = { workspace = true } -lru = { workspace = true } merkle_proof = { workspace = true } metrics = { workspace = true } milhouse = { workspace = true } diff --git a/beacon_node/beacon_chain/src/attestation_verification.rs b/beacon_node/beacon_chain/src/attestation_verification.rs index 635ca3a2ae..90ac7d68cf 100644 --- a/beacon_node/beacon_chain/src/attestation_verification.rs +++ b/beacon_node/beacon_chain/src/attestation_verification.rs @@ -174,6 +174,14 @@ pub enum Error { /// The attestation points to a block we have not yet imported. It's unclear if the attestation /// is valid or not. UnknownHeadBlock { beacon_block_root: Hash256 }, + /// An attestation indicating the presence of a payload (`index == 1`) references a block whose + /// execution payload envelope has not been seen yet. + /// + /// ## Peer scoring + /// + /// The attestation may be valid once the payload envelope is retrieved; it's unclear if the + /// attestation is valid or not, so it is ignored (not penalized) pending the envelope. + UnknownPayloadEnvelope { beacon_block_root: Hash256 }, /// The `attestation.data.beacon_block_root` block is from before the finalized checkpoint. /// /// ## Peer scoring @@ -612,6 +620,18 @@ impl<'a, T: BeaconChainTypes> IndexedAggregatedAttestation<'a, T> { )); } + // [New in Gloas]: `index == 1` claims the block's execution payload is present. Ignore the + // attestation until we have seen the block's payload envelope, so it can be re-processed + // (and the envelope retrieved) once the payload is received. + if fork_name.gloas_enabled() + && attestation.data().index == 1 + && !head_block.payload_received + { + return Err(Error::UnknownPayloadEnvelope { + beacon_block_root: attestation.data().beacon_block_root, + }); + } + // Check the attestation target root is consistent with the head root. // // This check is not in the specification, however we guard against it since it opens us up @@ -923,6 +943,16 @@ impl<'a, T: BeaconChainTypes> IndexedUnaggregatedAttestation<'a, T> { )); } + // [New in Gloas]: `index == 1` claims the block's execution payload is present. Ignore the + // attestation until we have seen the block's payload envelope, so it can be re-processed + // (and the envelope retrieved) once the payload is received. + if fork_name.gloas_enabled() && attestation.data.index == 1 && !head_block.payload_received + { + return Err(Error::UnknownPayloadEnvelope { + beacon_block_root: attestation.data.beacon_block_root, + }); + } + // Check the attestation target root is consistent with the head root. verify_attestation_target_root::(&head_block, &attestation.data)?; diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 39261e394f..3678df91a2 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -16,7 +16,7 @@ use crate::block_verification_types::{ }; pub use crate::canonical_head::CanonicalHead; use crate::chain_config::ChainConfig; -use crate::custody_context::CustodyContextSsz; +use crate::custody_context::{CustodyContext, CustodyContextSsz}; use crate::data_availability_checker::{ Availability as BlockAvailability, AvailabilityCheckError, AvailableBlock, AvailableBlockData, DataColumnReconstructionResult as DataColumnReconstructionResultV1, @@ -197,7 +197,7 @@ pub enum WhenSlotSkipped { #[derive(Copy, Clone, Debug, PartialEq)] pub enum AvailabilityProcessingStatus { MissingComponents(Slot, Hash256), - Imported(Hash256), + Imported(Slot, Hash256), } impl TryInto for AvailabilityProcessingStatus { @@ -205,7 +205,7 @@ impl TryInto for AvailabilityProcessingStatus { fn try_into(self) -> Result { match self { - AvailabilityProcessingStatus::Imported(hash) => Ok(hash.into()), + AvailabilityProcessingStatus::Imported(_, hash) => Ok(hash.into()), _ => Err(()), } } @@ -216,7 +216,7 @@ impl TryInto for AvailabilityProcessingStatus { fn try_into(self) -> Result { match self { - AvailabilityProcessingStatus::Imported(hash) => Ok(hash), + AvailabilityProcessingStatus::Imported(_, hash) => Ok(hash), _ => Err(()), } } @@ -501,6 +501,8 @@ pub struct BeaconChain { pub validator_monitor: RwLock>, /// The slot at which blocks are downloaded back to. pub genesis_backfill_slot: Slot, + /// Contains all the information the node requires to calculate which columns to custody + pub custody_context: Arc>, /// Provides KZG verification and temporary storage for pre-Gloas blocks and blobs. pub data_availability_checker: Arc>, /// Provides KZG verification and temporary storage for post-Gloas payload envelopes. @@ -683,11 +685,7 @@ impl BeaconChain { return Ok(()); } - let custody_context: CustodyContextSsz = self - .data_availability_checker - .custody_context() - .as_ref() - .into(); + let custody_context: CustodyContextSsz = self.custody_context.as_ref().into(); // Pattern match to avoid accidentally missing fields and to ignore deprecated fields. let CustodyContextSsz { @@ -3233,9 +3231,9 @@ impl BeaconChain { { Ok(status) => { match status { - AvailabilityProcessingStatus::Imported(block_root) => { + AvailabilityProcessingStatus::Imported(slot, block_root) => { // The block was imported successfully. - imported_blocks.push((block_root, block_slot)); + imported_blocks.push((block_root, slot)); } AvailabilityProcessingStatus::MissingComponents(slot, block_root) => { warn!( @@ -3392,8 +3390,9 @@ impl BeaconChain { ); // Check if we have custody of this column - let sampling_columns = - self.sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch())); + let sampling_columns = self + .custody_context + .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch())); let verified_partial = if sampling_columns.contains(&partial.index) { KzgVerifiedCustodyPartialDataColumn::from_asserted_custody(verified_partial) } else { @@ -3882,10 +3881,10 @@ impl BeaconChain { // Verify and import the block. match import_block.await { // The block was successfully verified and imported. Yay. - Ok(status @ AvailabilityProcessingStatus::Imported(block_root)) => { + Ok(status @ AvailabilityProcessingStatus::Imported(slot, block_root)) => { debug!( ?block_root, - %block_slot, + %slot, source = %block_source, "Beacon block imported" ); @@ -4055,7 +4054,7 @@ impl BeaconChain { )?; let availability = self .data_availability_checker - .put_rpc_blobs(block_root, blobs) + .put_rpc_blobs(block_root, blobs, &self.slot_clock) .map_err(BlockError::from)?; self.process_availability(slot, availability, || Ok(())) @@ -4223,6 +4222,7 @@ impl BeaconChain { payload_verification_outcome, } = *block; + let slot = block.slot(); let BlockImportData { block_root, state, @@ -4257,7 +4257,7 @@ impl BeaconChain { .await?? }; - Ok(AvailabilityProcessingStatus::Imported(block_root)) + Ok(AvailabilityProcessingStatus::Imported(slot, block_root)) } /// Accepts a fully-verified and available block and imports it into the chain without performing any @@ -4322,12 +4322,6 @@ impl BeaconChain { let cached_head = self.canonical_head.cached_head(); let old_head_slot = cached_head.head_slot(); - // Compute the expected proposer for `current_slot` on the canonical chain. This is used by - // `on_block` to gate proposer boost on the block's proposer matching the canonical proposer - // (per spec `update_proposer_boost_root` added in v1.7.0-alpha.5). - let canonical_head_proposer_index = - self.canonical_head_proposer_index(current_slot, &cached_head)?; - // Take an upgradable read lock on fork choice so we can check if this block has already // been imported. We don't want to repeat work importing a block that is already imported. let fork_choice_reader = self.canonical_head.fork_choice_upgradable_read_lock(); @@ -4359,7 +4353,6 @@ impl BeaconChain { block_delay, &state, payload_verification_status, - canonical_head_proposer_index, &self.spec, ) .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))?; @@ -5107,42 +5100,6 @@ impl BeaconChain { })) } - /// Compute the expected beacon proposer for `slot` on the canonical chain extending `cached_head`. - /// - /// Uses the beacon proposer cache to avoid recomputing the shuffling on every block import. - /// - /// This is used by `update_proposer_boost_root` to gate proposer boost on the block's proposer - /// matching the canonical proposer, per consensus-specs v1.7.0-alpha.5. - /// - /// This function should never error unless there is some corruption of the head state. If a - /// state advance is needed, it will be handled by the proposer cache. - pub fn canonical_head_proposer_index( - &self, - slot: Slot, - cached_head: &CachedHead, - ) -> Result { - let proposal_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - let head_block_root = cached_head.head_block_root(); - let head_state = &cached_head.snapshot.beacon_state; - - let shuffling_decision_root = head_state.proposer_shuffling_decision_root_at_epoch( - proposal_epoch, - head_block_root, - &self.spec, - )?; - - self.with_proposer_cache::<_, Error>( - shuffling_decision_root, - proposal_epoch, - |proposers| { - proposers - .get_slot::(slot) - .map(|p| p.index as u64) - }, - || Ok((cached_head.head_state_root(), head_state.clone())), - ) - } - pub fn get_expected_withdrawals( &self, forkchoice_update_params: &ForkchoiceUpdateParameters, @@ -5254,7 +5211,6 @@ impl BeaconChain { }) } - // TODO(gloas): wrong for Gloas, needs an update pub fn overridden_forkchoice_update_params_or_failure_reason( &self, canonical_forkchoice_params: &ForkchoiceUpdateParameters, @@ -5285,6 +5241,11 @@ impl BeaconChain { ) .map_err(|e| e.map_inner_error(Error::ProposerHeadForkChoiceError))?; + // We don't need to override fork choice updates for Gloas. + if info.head_node.is_gloas() { + return Ok(*canonical_forkchoice_params); + } + // The slot of our potential re-org block is always 1 greater than the head block because we // only attempt single-slot re-orgs. let head_slot = info.head_node.slot(); @@ -5418,9 +5379,7 @@ impl BeaconChain { return Err(Box::new(DoNotReOrg::NotProposing.into())); } - // TODO(gloas): V29 nodes don't carry execution_status, so this returns - // None for post-Gloas re-orgs. Need to source the EL block hash from - // the bid's block_hash instead. Re-org is disabled for Gloas for now. + // This only works pre-Gloas, but we don't run this code for Gloas anyway. let parent_head_hash = info .parent_node .execution_status() @@ -6457,8 +6416,15 @@ impl BeaconChain { } let canonical_fcu_params = cached_head.forkchoice_update_parameters(); - let fcu_params = - chain.overridden_forkchoice_update_params(canonical_fcu_params)?; + let fcu_params = if chain + .spec + .fork_name_at_slot::(head_slot) + .gloas_enabled() + { + canonical_fcu_params + } else { + chain.overridden_forkchoice_update_params(canonical_fcu_params)? + }; let pre_payload_attributes = chain.get_pre_payload_attributes( prepare_slot, fcu_params.head_root, @@ -7277,25 +7243,6 @@ impl BeaconChain { }) } - /// The data availability boundary for custodying columns. It will just be the - /// regular data availability boundary unless we are near the Fulu fork epoch. - pub fn column_data_availability_boundary(&self) -> Option { - match self.data_availability_boundary() { - Some(da_boundary_epoch) => { - if let Some(fulu_fork_epoch) = self.spec.fulu_fork_epoch { - if da_boundary_epoch < fulu_fork_epoch { - Some(fulu_fork_epoch) - } else { - Some(da_boundary_epoch) - } - } else { - None // Fulu hasn't been enabled - } - } - None => None, // Deneb hasn't been enabled - } - } - /// Safely update data column custody info by ensuring that: /// - cgc values at the updated epoch and the earliest custodied column epoch are equal /// - we are only decrementing the earliest custodied data column epoch by one epoch @@ -7313,14 +7260,12 @@ impl BeaconChain { } let cgc_at_effective_epoch = self - .data_availability_checker - .custody_context() - .custody_group_count_at_epoch(effective_epoch, &self.spec); + .custody_context + .custody_group_count_at_epoch(effective_epoch); let cgc_at_earliest_data_colum_epoch = self - .data_availability_checker - .custody_context() - .custody_group_count_at_epoch(earliest_data_column_epoch, &self.spec); + .custody_context + .custody_group_count_at_epoch(earliest_data_column_epoch); let can_update_data_column_custody_info = cgc_at_effective_epoch == cgc_at_earliest_data_colum_epoch @@ -7347,16 +7292,16 @@ impl BeaconChain { /// Compare columns custodied for `epoch` versus columns custodied for the head of the chain /// and return any column indices that are missing. pub fn get_missing_columns_for_epoch(&self, epoch: Epoch) -> HashSet { - let custody_context = self.data_availability_checker.custody_context(); - - let columns_required = custody_context - .custody_columns_for_epoch(None, &self.spec) + let columns_required = self + .custody_context + .custody_columns_for_epoch(None) .iter() .cloned() .collect::>(); - let current_columns_at_epoch = custody_context - .custody_columns_for_epoch(Some(epoch), &self.spec) + let current_columns_at_epoch = self + .custody_context + .custody_columns_for_epoch(Some(epoch)) .iter() .cloned() .collect::>(); @@ -7367,24 +7312,6 @@ impl BeaconChain { .collect::>() } - /// The da boundary for custodying columns. It will just be the DA boundary unless we are near the Fulu fork epoch. - pub fn get_column_da_boundary(&self) -> Option { - match self.data_availability_boundary() { - Some(da_boundary_epoch) => { - if let Some(fulu_fork_epoch) = self.spec.fulu_fork_epoch { - if da_boundary_epoch < fulu_fork_epoch { - Some(fulu_fork_epoch) - } else { - Some(da_boundary_epoch) - } - } else { - None - } - } - None => None, // If no DA boundary set, dont try to custody backfill - } - } - /// This method serves to get a sense of the current chain health. It is used in block proposal /// to determine whether we should outsource payload production duties. /// @@ -7587,30 +7514,6 @@ impl BeaconChain { gossip_attested || block_attested || aggregated || produced_block } - /// The epoch at which we require a data availability check in block processing. - /// `None` if the `Deneb` fork is disabled. - pub fn data_availability_boundary(&self) -> Option { - self.data_availability_checker.data_availability_boundary() - } - - /// Returns true if epoch is within the data availability boundary - pub fn da_check_required_for_epoch(&self, epoch: Epoch) -> bool { - self.data_availability_checker - .da_check_required_for_epoch(epoch) - } - - /// Returns true if we should fetch blobs for this block - pub fn should_fetch_blobs(&self, block_epoch: Epoch) -> bool { - self.da_check_required_for_epoch(block_epoch) - && !self.spec.is_peer_das_enabled_for_epoch(block_epoch) - } - - /// Returns true if we should fetch custody columns for this block - pub fn should_fetch_custody_columns(&self, block_epoch: Epoch) -> bool { - self.da_check_required_for_epoch(block_epoch) - && self.spec.is_peer_das_enabled_for_epoch(block_epoch) - } - /// Gets the `LightClientBootstrap` object for a requested block root. /// /// Returns `None` when the state or block is not found in the database. @@ -7649,7 +7552,7 @@ impl BeaconChain { Some(StoreOp::PutBlobs(block_root, blobs)) } AvailableBlockData::DataColumns(mut data_columns) => { - let columns_to_custody = self.custody_columns_for_epoch(Some( + let columns_to_custody = self.custody_context.custody_columns_for_epoch(Some( block_slot.epoch(T::EthSpec::slots_per_epoch()), )); // Supernodes need to persist all sampled custody columns @@ -7695,25 +7598,6 @@ impl BeaconChain { roots.reverse(); roots } - - /// Returns a list of column indices that should be sampled for a given epoch. - /// Used for data availability sampling in PeerDAS. - pub fn sampling_columns_for_epoch(&self, epoch: Epoch) -> &[ColumnIndex] { - self.data_availability_checker - .custody_context() - .sampling_columns_for_epoch(epoch, &self.spec) - } - - /// Returns a list of column indices that the node is expected to custody for a given epoch. - /// i.e. the node must have validated and persisted the column samples and should be able to - /// serve them to peers. - /// - /// If epoch is `None`, this function computes the custody columns at head. - pub fn custody_columns_for_epoch(&self, epoch_opt: Option) -> &[ColumnIndex] { - self.data_availability_checker - .custody_context() - .custody_columns_for_epoch(epoch_opt, &self.spec) - } } impl Drop for BeaconChain { diff --git a/beacon_node/beacon_chain/src/beacon_proposer_cache.rs b/beacon_node/beacon_chain/src/beacon_proposer_cache.rs index b258d7471f..d35c9f003f 100644 --- a/beacon_node/beacon_chain/src/beacon_proposer_cache.rs +++ b/beacon_node/beacon_chain/src/beacon_proposer_cache.rs @@ -10,21 +10,19 @@ use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; use fork_choice::ExecutionStatus; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use once_cell::sync::OnceCell; use parking_lot::Mutex; use safe_arith::SafeArith; use smallvec::SmallVec; use state_processing::state_advance::partial_state_advance; -use std::num::NonZeroUsize; use std::sync::Arc; use tracing::{debug, instrument}; use typenum::Unsigned; -use types::new_non_zero_usize; use types::{BeaconState, BeaconStateError, ChainSpec, Epoch, EthSpec, Fork, Hash256, Slot}; /// The number of sets of proposer indices that should be cached. -const CACHE_SIZE: NonZeroUsize = new_non_zero_usize(16); +const CACHE_SIZE: usize = 16; /// This value is fairly unimportant, it's used to avoid heap allocations. The result of it being /// incorrect is non-substantial from a consensus perspective (and probably also from a @@ -138,7 +136,8 @@ impl BeaconProposerCache { ) -> Arc> { let key = (epoch, shuffling_decision_block); self.cache - .get_or_insert(key, || Arc::new(OnceCell::new())) + .entry(key) + .or_insert_with(|| Arc::new(OnceCell::new())) .clone() } @@ -155,10 +154,10 @@ impl BeaconProposerCache { fork: Fork, ) -> Result<(), BeaconStateError> { let key = (epoch, shuffling_decision_block); - if !self.cache.contains(&key) { + if !self.cache.contains_key(&key) { let epoch_proposers = EpochBlockProposers::new(epoch, fork, proposers); self.cache - .put(key, Arc::new(OnceCell::with_value(epoch_proposers))); + .insert(key, Arc::new(OnceCell::with_value(epoch_proposers))); } Ok(()) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 82dad6f6ad..90fc60524a 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -163,7 +163,7 @@ impl BeaconChain { let should_build_on_full = self .canonical_head .fork_choice_read_lock() - .should_build_on_full(&parent_root, parent_payload_status) + .should_build_on_full(&parent_root, parent_payload_status, produce_at_slot) .map_err(|e| { BlockProductionError::BeaconChain(Box::new(BeaconChainError::ForkChoiceError(e))) })?; diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 84fb886b8f..1f29a47f69 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -4,7 +4,7 @@ use fork_choice::PayloadStatus; use proto_array::{ProposerHeadError, ReOrgThreshold}; use slot_clock::SlotClock; use tracing::{debug, error, info, instrument, warn}; -use types::{BeaconState, Epoch, Hash256, SignedExecutionPayloadEnvelope, Slot}; +use types::{BeaconState, Epoch, EthSpec, Hash256, SignedExecutionPayloadEnvelope, Slot}; use crate::{ BeaconChain, BeaconChainTypes, BlockProductionError, StateSkipConfig, @@ -14,13 +14,21 @@ use crate::{ mod gloas; /// State loaded from the database for block production. -pub(crate) struct BlockProductionState { +pub(crate) struct BlockProductionState { pub state: BeaconState, pub state_root: Option, pub parent_payload_status: PayloadStatus, pub parent_envelope: Option>>, } +/// Inputs assembled for producing a block via a proposer re-org. +struct ReOrgInputs { + state: BeaconState, + state_root: Hash256, + parent_payload_status: PayloadStatus, + parent_envelope: Option>>, +} + impl BeaconChain { /// Load a beacon state from the database for block production. This is a long-running process /// that should not be performed in an `async` context. @@ -50,39 +58,32 @@ impl BeaconChain { head.snapshot.execution_envelope.clone(), ) }; + let result = if head_slot < slot { // Attempt an aggressive re-org if configured and the conditions are right. - // TODO(gloas): re-enable reorgs - let gloas_enabled = self - .spec - .fork_name_at_slot::(slot) - .gloas_enabled(); - if !gloas_enabled - && let Some((re_org_state, re_org_state_root)) = - self.get_state_for_re_org(slot, head_slot, head_block_root) - { + if let Some(inputs) = self.get_state_for_re_org(slot, head_slot, head_block_root) { info!( %slot, head_to_reorg = %head_block_root, "Proposing block to re-org current head" ); - // TODO(gloas): ensure we use a sensible payload status when we enable reorgs - // for Gloas BlockProductionState { - state: re_org_state, - state_root: Some(re_org_state_root), - parent_payload_status: PayloadStatus::Pending, - parent_envelope: None, + state: inputs.state, + state_root: Some(inputs.state_root), + parent_payload_status: inputs.parent_payload_status, + parent_envelope: inputs.parent_envelope, } } else { - // Fetch the head state advanced through to `slot`, which should be present in the - // state cache thanks to the state advance timer. + // Continuation: the new block builds on the current head. Fetch the head state + // advanced through to `slot`, which should be present in the state cache thanks to + // the state advance timer. let parent_state_root = head_state_root; let (state_root, state) = self .store .get_advanced_hot_state(head_block_root, slot, parent_state_root) .map_err(BlockProductionError::FailedToLoadState)? .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; + BlockProductionState { state, state_root: Some(state_root), @@ -100,13 +101,11 @@ impl BeaconChain { .state_at_slot(slot - 1, StateSkipConfig::WithStateRoots) .map_err(|_| BlockProductionError::UnableToProduceAtSlot(slot))?; - // TODO(gloas): update this to read payload canonicity from fork choice once ready - let parent_payload_status = PayloadStatus::Pending; BlockProductionState { state, state_root: None, - parent_payload_status, - parent_envelope: None, + parent_payload_status: head_payload_status, + parent_envelope: head_envelope, } }; @@ -173,7 +172,7 @@ impl BeaconChain { slot: Slot, head_slot: Slot, canonical_head: Hash256, - ) -> Option<(BeaconState, Hash256)> { + ) -> Option> { let re_org_head_threshold = ReOrgThreshold(self.spec.reorg_head_weight_threshold); let re_org_parent_threshold = ReOrgThreshold(self.spec.reorg_parent_weight_threshold); let re_org_max_epochs_since_finalization = @@ -237,9 +236,15 @@ impl BeaconChain { } }) .ok()?; + drop(proposer_head_timer); let re_org_parent_block = proposer_head.parent_node.root(); + // The head uniquely determines the parent payload status for the re-org block, whichever + // variant (full or empty) it builds on must have more weight, or else we would have already + // re-orged away from this block naturally, and it would not be the head, by definition. + let parent_payload_status = proposer_head.head_node.get_parent_payload_status(); + let (state_root, state) = self .store .get_advanced_hot_state_from_cache(re_org_parent_block, slot) @@ -248,6 +253,25 @@ impl BeaconChain { None })?; + let parent_envelope = if parent_payload_status == PayloadStatus::Full { + let envelope = self + .store + .get_payload_envelope(&re_org_parent_block) + .ok() + .flatten() + .map(Arc::new) + .or_else(|| { + warn!( + reason = "missing execution payload envelope", + "Not attempting re-org" + ); + None + })?; + Some(envelope) + } else { + None + }; + info!( weak_head = ?canonical_head, parent = ?re_org_parent_block, @@ -256,6 +280,11 @@ impl BeaconChain { "Attempting re-org due to weak head" ); - Some((state, state_root)) + Some(ReOrgInputs { + state, + state_root, + parent_payload_status, + parent_envelope, + }) } } diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 1c1fdeafb3..9d74c090cd 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -1236,8 +1236,7 @@ impl SignatureVerifiedBlock { AvailableBlock::new( block, AvailableBlockData::NoData, - &chain.data_availability_checker, - chain.spec.clone(), + &chain.custody_context, ) .map_err(BlockError::AvailabilityCheck)?, ) diff --git a/beacon_node/beacon_chain/src/block_verification_types.rs b/beacon_node/beacon_chain/src/block_verification_types.rs index 18e95f58f3..51feb12a69 100644 --- a/beacon_node/beacon_chain/src/block_verification_types.rs +++ b/beacon_node/beacon_chain/src/block_verification_types.rs @@ -1,18 +1,18 @@ -use crate::data_availability_checker::{AvailabilityCheckError, DataAvailabilityChecker}; +use crate::data_availability_checker::AvailabilityCheckError; pub use crate::data_availability_checker::{ AvailableBlock, AvailableBlockData, MaybeAvailableBlock, }; use crate::payload_envelope_verification::AvailableEnvelope; use crate::payload_envelope_verification::gossip_verified_envelope::verify_envelope_consistency; -use crate::{BeaconChainTypes, PayloadVerificationOutcome}; +use crate::{BeaconChainTypes, CustodyContext, PayloadVerificationOutcome}; use state_processing::ConsensusContext; use std::fmt::{Debug, Formatter}; use std::hash::{Hash, Hasher}; use std::sync::Arc; use types::data::BlobIdentifier; use types::{ - BeaconBlockRef, BeaconState, BlindedPayload, ChainSpec, Epoch, EthSpec, Hash256, - SignedBeaconBlock, SignedBeaconBlockHeader, Slot, + BeaconBlockRef, BeaconState, BlindedPayload, Epoch, EthSpec, Hash256, SignedBeaconBlock, + SignedBeaconBlockHeader, Slot, }; /// A wrapper around a `SignedBeaconBlock`. This varaint is constructed @@ -118,8 +118,7 @@ impl RangeSyncBlock { pub fn new( block: Arc>, block_data: AvailableBlockData, - da_checker: &DataAvailabilityChecker, - spec: Arc, + custody_context: &CustodyContext, ) -> Result where T: BeaconChainTypes, @@ -127,7 +126,7 @@ impl RangeSyncBlock { if block.fork_name_unchecked().gloas_enabled() { return Err(AvailabilityCheckError::InvalidVariant); } - let available_block = AvailableBlock::new(block, block_data, da_checker, spec)?; + let available_block = AvailableBlock::new(block, block_data, custody_context)?; Ok(Self::Base(available_block)) } diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index c24c7b6fc6..3b137f6faa 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -908,6 +908,7 @@ where let shuffling_cache_size = self.chain_config.shuffling_cache_size; let complete_blob_backfill = self.chain_config.complete_blob_backfill; let enable_partial_columns = self.chain_config.enable_partial_columns; + let disable_get_blobs = self.chain_config.disable_get_blobs; // Calculate the weak subjectivity point in which to backfill blocks to. let genesis_backfill_slot = if self.chain_config.genesis_backfill { @@ -950,14 +951,18 @@ where self.node_custody_type, head_epoch, ordered_custody_column_indices, - &self.spec, + slot_clock.clone(), + complete_blob_backfill, + self.spec.clone(), ) } else { ( CustodyContext::new( self.node_custody_type, ordered_custody_column_indices, - &self.spec, + slot_clock.clone(), + complete_blob_backfill, + self.spec.clone(), ), None, ) @@ -1035,24 +1040,20 @@ where slasher: self.slasher.clone(), validator_monitor: RwLock::new(validator_monitor), genesis_backfill_slot, + custody_context: custody_context.clone(), data_availability_checker: Arc::new( DataAvailabilityChecker::new( - complete_blob_backfill, - slot_clock.clone(), self.kzg.clone(), custody_context.clone(), self.spec.clone(), enable_partial_columns, + disable_get_blobs, ) .map_err(|e| format!("Error initializing DataAvailabilityChecker: {:?}", e))?, ), pending_payload_cache: Arc::new( - PendingPayloadCache::new( - self.kzg.clone(), - custody_context.clone(), - self.spec.clone(), - ) - .map_err(|e| format!("Error initializing PendingPayloadCache: {:?}", e))?, + PendingPayloadCache::new(self.kzg.clone(), custody_context, self.spec.clone()) + .map_err(|e| format!("Error initializing PendingPayloadCache: {:?}", e))?, ), kzg: self.kzg.clone(), rng: Arc::new(Mutex::new(rng)), @@ -1123,7 +1124,9 @@ where } // Prune blobs older than the blob data availability boundary in the background. - if let Some(data_availability_boundary) = beacon_chain.data_availability_boundary() { + if let Some(data_availability_boundary) = + beacon_chain.custody_context.data_availability_boundary() + { beacon_chain .store_migrator .process_prune_blobs(data_availability_boundary); diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 1eab7ccf7a..5d7a7f1527 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -1065,7 +1065,8 @@ impl BeaconChain { )?; // Prune blobs in the background. - if let Some(data_availability_boundary) = self.data_availability_boundary() { + if let Some(data_availability_boundary) = self.custody_context.data_availability_boundary() + { self.store_migrator .process_prune_blobs(data_availability_boundary); } diff --git a/beacon_node/beacon_chain/src/custody_context.rs b/beacon_node/beacon_chain/src/custody_context.rs index 72f62db1b4..4a1dfdbe71 100644 --- a/beacon_node/beacon_chain/src/custody_context.rs +++ b/beacon_node/beacon_chain/src/custody_context.rs @@ -1,13 +1,18 @@ +use crate::BeaconChainTypes; +use educe::Educe; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; +use slot_clock::SlotClock; use ssz_derive::{Decode, Encode}; -use std::marker::PhantomData; use std::{ collections::{BTreeMap, HashMap}, + sync::Arc, sync::atomic::{AtomicU64, Ordering}, }; use tracing::{debug, warn}; -use types::{ChainSpec, ColumnIndex, Epoch, EthSpec, Slot}; +use types::{ + ChainSpec, ColumnIndex, Epoch, EthSpec, SignedBeaconBlock, SignedExecutionPayloadBid, Slot, +}; /// A delay before making the CGC change effective to the data availability checker. pub const CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS: u64 = 30; @@ -236,8 +241,9 @@ impl NodeCustodyType { /// Contains all the information the node requires to calculate the /// number of columns to be custodied when checking for DA. -#[derive(Debug)] -pub struct CustodyContext { +#[derive(Educe)] +#[educe(Debug(bound(T: BeaconChainTypes)))] +pub struct CustodyContext { /// The Number of custody groups required based on the number of validators /// that is attached to this node. /// @@ -250,10 +256,15 @@ pub struct CustodyContext { /// Stores an immutable, ordered list of all data column indices as determined by the node's NodeID /// on startup. This used to determine the node's custody columns. ordered_custody_column_indices: Vec, - _phantom_data: PhantomData, + #[educe(Debug(ignore))] + slot_clock: T::SlotClock, + /// backfill blobs and data columns beyond the data availability window. + complete_blob_backfill: bool, + #[educe(Debug(ignore))] + spec: Arc, } -impl CustodyContext { +impl CustodyContext { /// Create a new custody default custody context object when no persisted object /// exists. /// @@ -261,9 +272,11 @@ impl CustodyContext { pub fn new( node_custody_type: NodeCustodyType, ordered_custody_column_indices: Vec, - spec: &ChainSpec, + slot_clock: T::SlotClock, + complete_blob_backfill: bool, + spec: Arc, ) -> Self { - let cgc_override = node_custody_type.get_custody_count_override(spec); + let cgc_override = node_custody_type.get_custody_count_override(&spec); // If there's no override, we initialise `validator_custody_count` to 0. This has been the // existing behaviour and we maintain this for now to avoid a semantic schema change until // a later release. @@ -271,7 +284,9 @@ impl CustodyContext { validator_custody_count: AtomicU64::new(cgc_override.unwrap_or(0)), validator_registrations: RwLock::new(ValidatorRegistrations::new(cgc_override)), ordered_custody_column_indices, - _phantom_data: PhantomData, + slot_clock, + complete_blob_backfill, + spec, } } @@ -294,7 +309,9 @@ impl CustodyContext { node_custody_type: NodeCustodyType, head_epoch: Epoch, ordered_custody_column_indices: Vec, - spec: &ChainSpec, + slot_clock: T::SlotClock, + complete_blob_backfill: bool, + spec: Arc, ) -> (Self, Option) { let CustodyContextSsz { mut validator_custody_at_head, @@ -304,7 +321,7 @@ impl CustodyContext { let mut custody_count_changed = None; - if let Some(cgc_from_cli) = node_custody_type.get_custody_count_override(spec) { + if let Some(cgc_from_cli) = node_custody_type.get_custody_count_override(&spec) { debug!( ?node_custody_type, persisted_custody_count = validator_custody_at_head, @@ -360,7 +377,9 @@ impl CustodyContext { .collect(), }), ordered_custody_column_indices, - _phantom_data: PhantomData, + slot_clock, + complete_blob_backfill, + spec, }; (custody_context, custody_count_changed) @@ -376,13 +395,15 @@ impl CustodyContext { &self, validators_and_balance: ValidatorsAndBalances, current_slot: Slot, - spec: &ChainSpec, ) -> Option { let Some((effective_epoch, new_validator_custody)) = self .validator_registrations .write() - .register_validators::(validators_and_balance, current_slot, spec) - else { + .register_validators::( + validators_and_balance, + current_slot, + &self.spec, + ) else { return None; }; @@ -397,7 +418,7 @@ impl CustodyContext { self.validator_custody_count .store(new_validator_custody, Ordering::Relaxed); - let updated_cgc = self.custody_group_count_at_head(spec); + let updated_cgc = self.custody_group_count_at_head(); // Send the message to network only if there are more columns subnets to subscribe to if updated_cgc > current_cgc { debug!( @@ -407,7 +428,7 @@ impl CustodyContext { return Some(CustodyCountChanged { new_custody_group_count: updated_cgc, old_custody_group_count: current_cgc, - sampling_count: self.num_of_custody_groups_to_sample(effective_epoch, spec), + sampling_count: self.num_of_custody_groups_to_sample(effective_epoch), effective_epoch, }); } @@ -419,14 +440,14 @@ impl CustodyContext { /// This function is used to determine the custody group count at head ONLY. /// Do NOT use this directly for data availability check, use `self.sampling_size` instead as /// CGC can change over epochs. - pub fn custody_group_count_at_head(&self, spec: &ChainSpec) -> u64 { + pub fn custody_group_count_at_head(&self) -> u64 { let validator_custody_count_at_head = self.validator_custody_count.load(Ordering::Relaxed); // If there are no validators, return the minimum custody_requirement if validator_custody_count_at_head > 0 { validator_custody_count_at_head } else { - spec.custody_requirement + self.spec.custody_requirement } } @@ -436,33 +457,35 @@ impl CustodyContext { /// minimum sampling size which may exceed the custody group count (CGC). /// /// See also: [`Self::num_of_custody_groups_to_sample`]. - pub fn custody_group_count_at_epoch(&self, epoch: Epoch, spec: &ChainSpec) -> u64 { + pub fn custody_group_count_at_epoch(&self, epoch: Epoch) -> u64 { self.validator_registrations .read() .custody_requirement_at_epoch(epoch) - .unwrap_or(spec.custody_requirement) + .unwrap_or(self.spec.custody_requirement) } /// Returns the count of custody groups this node must _sample_ for a block at `epoch` to import. - pub fn num_of_custody_groups_to_sample(&self, epoch: Epoch, spec: &ChainSpec) -> u64 { - let custody_group_count = self.custody_group_count_at_epoch(epoch, spec); - spec.sampling_size_custody_groups(custody_group_count) + pub fn num_of_custody_groups_to_sample(&self, epoch: Epoch) -> u64 { + let custody_group_count = self.custody_group_count_at_epoch(epoch); + self.spec + .sampling_size_custody_groups(custody_group_count) .expect("should compute node sampling size from valid chain spec") } /// Returns the count of columns this node must _sample_ for a block at `epoch` to import. - pub fn num_of_data_columns_to_sample(&self, epoch: Epoch, spec: &ChainSpec) -> usize { - let custody_group_count = self.custody_group_count_at_epoch(epoch, spec); - spec.sampling_size_columns::(custody_group_count) + pub fn num_of_data_columns_to_sample(&self, epoch: Epoch) -> usize { + let custody_group_count = self.custody_group_count_at_epoch(epoch); + self.spec + .sampling_size_columns::(custody_group_count) .expect("should compute node sampling size from valid chain spec") } /// Returns whether the node should attempt reconstruction at a given epoch. - pub fn should_attempt_reconstruction(&self, epoch: Epoch, spec: &ChainSpec) -> bool { - let min_columns_for_reconstruction = E::number_of_columns() / 2; + pub fn should_attempt_reconstruction(&self, epoch: Epoch) -> bool { + let min_columns_for_reconstruction = T::EthSpec::number_of_columns() / 2; // performing reconstruction is not necessary if sampling column count is exactly 50%, // because the node doesn't need the remaining columns. - self.num_of_data_columns_to_sample(epoch, spec) > min_columns_for_reconstruction + self.num_of_data_columns_to_sample(epoch) > min_columns_for_reconstruction } /// Returns the ordered list of column indices that should be sampled for data availability checking at the given epoch. @@ -473,8 +496,8 @@ impl CustodyContext { /// /// # Returns /// A slice of ordered column indices that should be sampled for this epoch based on the node's custody configuration - pub fn sampling_columns_for_epoch(&self, epoch: Epoch, spec: &ChainSpec) -> &[ColumnIndex] { - let num_of_columns_to_sample = self.num_of_data_columns_to_sample(epoch, spec); + pub fn sampling_columns_for_epoch(&self, epoch: Epoch) -> &[ColumnIndex] { + let num_of_columns_to_sample = self.num_of_data_columns_to_sample(epoch); &self.ordered_custody_column_indices[..num_of_columns_to_sample] } @@ -491,19 +514,15 @@ impl CustodyContext { /// /// # Returns /// A slice of ordered custody column indices for this epoch based on the node's custody configuration - pub fn custody_columns_for_epoch( - &self, - epoch_opt: Option, - spec: &ChainSpec, - ) -> &[ColumnIndex] { + pub fn custody_columns_for_epoch(&self, epoch_opt: Option) -> &[ColumnIndex] { let custody_group_count = if let Some(epoch) = epoch_opt { - self.custody_group_count_at_epoch(epoch, spec) as usize + self.custody_group_count_at_epoch(epoch) as usize } else { - self.custody_group_count_at_head(spec) as usize + self.custody_group_count_at_head() as usize }; // This is an unnecessary conversion for spec compliance, basically just multiplying by 1. - let columns_per_custody_group = spec.data_columns_per_group::() as usize; + let columns_per_custody_group = self.spec.data_columns_per_group::() as usize; let custody_column_count = columns_per_custody_group * custody_group_count; &self.ordered_custody_column_indices[..custody_column_count] @@ -528,6 +547,61 @@ impl CustodyContext { .write() .reset_validator_custody_requirements(effective_epoch); } + + /// The epoch at which we require a data availability check in block processing. + /// `None` if the `Deneb` fork is disabled. + pub fn data_availability_boundary(&self) -> Option { + let fork_epoch = self.spec.deneb_fork_epoch?; + + if self.complete_blob_backfill { + Some(fork_epoch) + } else { + let current_epoch = self.slot_clock.now()?.epoch(T::EthSpec::slots_per_epoch()); + self.spec + .min_epoch_data_availability_boundary(current_epoch) + } + } + + /// Returns true if the given epoch lies within the da boundary and false otherwise. + pub fn da_check_required_for_epoch(&self, block_epoch: Epoch) -> bool { + self.data_availability_boundary() + .is_some_and(|da_epoch| block_epoch >= da_epoch) + } + + /// If the epoch is from prior to the data availability boundary, no blobs are required. + pub fn blobs_required_for_epoch(&self, epoch: Epoch) -> bool { + self.da_check_required_for_epoch(epoch) && !self.spec.is_peer_das_enabled_for_epoch(epoch) + } + + /// If the epoch is from prior to the data availability boundary, no data columns are required. + pub fn data_columns_required_for_epoch(&self, epoch: Epoch) -> bool { + self.da_check_required_for_epoch(epoch) && self.spec.is_peer_das_enabled_for_epoch(epoch) + } + + /// See `Self::blobs_required_for_epoch` + pub fn blobs_required_for_block(&self, block: &SignedBeaconBlock) -> bool { + block.num_expected_blobs() > 0 && self.blobs_required_for_epoch(block.epoch()) + } + + /// See `Self::data_columns_required_for_epoch` + pub fn data_columns_required_for_block(&self, block: &SignedBeaconBlock) -> bool { + block.num_expected_blobs() > 0 && self.data_columns_required_for_epoch(block.epoch()) + } + + pub fn data_columns_required_for_bid( + &self, + bid: &SignedExecutionPayloadBid, + ) -> bool { + bid.num_blobs_expected() > 0 && self.data_columns_required_for_epoch(bid.epoch()) + } + + /// The data availability boundary for custodying columns. It will just be the + /// regular data availability boundary unless we are near the Fulu fork epoch. + pub fn column_data_availability_boundary(&self) -> Option { + let da_boundary = self.data_availability_boundary()?; + let fulu_epoch = self.spec.fulu_fork_epoch?; + Some(da_boundary.max(fulu_epoch)) + } } /// Indicates that the custody group count (CGC) has increased. @@ -553,8 +627,8 @@ pub struct CustodyContextSsz { pub epoch_validator_custody_requirements: Vec<(Epoch, u64)>, } -impl From<&CustodyContext> for CustodyContextSsz { - fn from(context: &CustodyContext) -> Self { +impl From<&CustodyContext> for CustodyContextSsz { + fn from(context: &CustodyContext) -> Self { CustodyContextSsz { validator_custody_at_head: context.validator_custody_count.load(Ordering::Relaxed), // This field is deprecated and has no effect @@ -573,16 +647,27 @@ impl From<&CustodyContext> for CustodyContextSsz { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::generate_data_column_indices_rand_order; + use crate::test_utils::{EphemeralHarnessType, generate_data_column_indices_rand_order}; + use slot_clock::{SlotClock, TestingSlotClock}; + use std::time::Duration; use types::MainnetEthSpec; type E = MainnetEthSpec; + type T = EphemeralHarnessType; + + fn testing_slot_clock(spec: &ChainSpec) -> TestingSlotClock { + TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ) + } fn setup_custody_context( - spec: &ChainSpec, + spec: Arc, head_epoch: Epoch, epoch_and_cgc_tuples: Vec<(Epoch, u64)>, - ) -> CustodyContext { + ) -> CustodyContext { let cgc_at_head = epoch_and_cgc_tuples.last().unwrap().1; let ssz_context = CustodyContextSsz { validator_custody_at_head: cgc_at_head, @@ -590,11 +675,14 @@ mod tests { epoch_validator_custody_requirements: epoch_and_cgc_tuples, }; - let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( + let complete_blob_backfill = false; + let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( ssz_context, NodeCustodyType::Fullnode, head_epoch, generate_data_column_indices_rand_order::(), + testing_slot_clock(&spec), + complete_blob_backfill, spec, ); @@ -602,7 +690,7 @@ mod tests { } fn complete_backfill_for_epochs( - custody_context: &CustodyContext, + custody_context: &CustodyContext, start_epoch: Epoch, end_epoch: Epoch, expected_cgc: u64, @@ -623,26 +711,29 @@ mod tests { target_node_custody_type: NodeCustodyType, expected_new_cgc: u64, head_epoch: Epoch, - spec: &ChainSpec, + spec: Arc, ) { let ssz_context = CustodyContextSsz { validator_custody_at_head: persisted_cgc, persisted_is_supernode: false, epoch_validator_custody_requirements: vec![(Epoch::new(0), persisted_cgc)], }; + let complete_blob_backfill = false; let (custody_context, custody_count_changed) = - CustodyContext::::new_from_persisted_custody_context( + CustodyContext::::new_from_persisted_custody_context( ssz_context, target_node_custody_type, head_epoch, generate_data_column_indices_rand_order::(), - spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); // Verify CGC increased assert_eq!( - custody_context.custody_group_count_at_head(spec), + custody_context.custody_group_count_at_head(), expected_new_cgc, "cgc should increase from {} to {}", persisted_cgc, @@ -675,13 +766,13 @@ mod tests { // Verify custody_group_count_at_epoch returns correct values assert_eq!( - custody_context.custody_group_count_at_epoch(head_epoch, spec), + custody_context.custody_group_count_at_epoch(head_epoch), persisted_cgc, "current epoch should still use old cgc ({})", persisted_cgc ); assert_eq!( - custody_context.custody_group_count_at_epoch(head_epoch + 1, spec), + custody_context.custody_group_count_at_epoch(head_epoch + 1), expected_new_cgc, "next epoch should use new cgc ({})", expected_new_cgc @@ -694,26 +785,29 @@ mod tests { persisted_cgc: u64, target_node_custody_type: NodeCustodyType, head_epoch: Epoch, - spec: &ChainSpec, + spec: Arc, ) { let ssz_context = CustodyContextSsz { validator_custody_at_head: persisted_cgc, persisted_is_supernode: false, epoch_validator_custody_requirements: vec![(Epoch::new(0), persisted_cgc)], }; + let complete_blob_backfill = false; let (custody_context, custody_count_changed) = - CustodyContext::::new_from_persisted_custody_context( + CustodyContext::::new_from_persisted_custody_context( ssz_context, target_node_custody_type, head_epoch, generate_data_column_indices_rand_order::(), - spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); // Verify CGC stays at persisted value (no reduction) assert_eq!( - custody_context.custody_group_count_at_head(spec), + custody_context.custody_group_count_at_head(), persisted_cgc, "cgc should remain at {} (reduction not supported)", persisted_cgc @@ -728,66 +822,78 @@ mod tests { #[test] fn no_validators_supernode_default() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Supernode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), spec.number_of_custody_groups ); assert_eq!( - custody_context.num_of_custody_groups_to_sample(Epoch::new(0), &spec), + custody_context.num_of_custody_groups_to_sample(Epoch::new(0)), spec.number_of_custody_groups ); } #[test] fn no_validators_semi_supernode_default() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::SemiSupernode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), spec.number_of_custody_groups / 2 ); assert_eq!( - custody_context.num_of_custody_groups_to_sample(Epoch::new(0), &spec), + custody_context.num_of_custody_groups_to_sample(Epoch::new(0)), spec.number_of_custody_groups / 2 ); } #[test] fn no_validators_fullnode_default() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), spec.custody_requirement, "head custody count should be minimum spec custody requirement" ); assert_eq!( - custody_context.num_of_custody_groups_to_sample(Epoch::new(0), &spec), + custody_context.num_of_custody_groups_to_sample(Epoch::new(0)), spec.samples_per_slot ); } #[test] fn register_single_validator_should_update_cgc() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let bal_per_additional_group = spec.balance_per_additional_custody_group; let min_val_custody_requirement = spec.validator_custody_requirement; @@ -802,20 +908,22 @@ mod tests { (vec![(0, 10 * bal_per_additional_group)], Some(10)), ]; - register_validators_and_assert_cgc::( + register_validators_and_assert_cgc::( &custody_context, validators_and_expected_cgc_change, - &spec, ); } #[test] fn register_multiple_validators_should_update_cgc() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let bal_per_additional_group = spec.balance_per_additional_custody_group; let min_val_custody_requirement = spec.validator_custody_requirement; @@ -843,20 +951,19 @@ mod tests { ), ]; - register_validators_and_assert_cgc::( - &custody_context, - validators_and_expected_cgc, - &spec, - ); + register_validators_and_assert_cgc::(&custody_context, validators_and_expected_cgc); } #[test] fn register_validators_should_not_update_cgc_for_supernode() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Supernode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let bal_per_additional_group = spec.balance_per_additional_custody_group; @@ -880,30 +987,28 @@ mod tests { ), ]; - register_validators_and_assert_cgc::( - &custody_context, - validators_and_expected_cgc, - &spec, - ); + register_validators_and_assert_cgc::(&custody_context, validators_and_expected_cgc); let current_epoch = Epoch::new(2); assert_eq!( - custody_context.num_of_custody_groups_to_sample(current_epoch, &spec), + custody_context.num_of_custody_groups_to_sample(current_epoch), spec.number_of_custody_groups ); } #[test] fn cgc_change_should_be_effective_to_sampling_after_delay() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let current_slot = Slot::new(10); let current_epoch = current_slot.epoch(E::slots_per_epoch()); - let default_sampling_size = - custody_context.num_of_custody_groups_to_sample(current_epoch, &spec); + let default_sampling_size = custody_context.num_of_custody_groups_to_sample(current_epoch); let validator_custody_units = 10; let _cgc_changed = custody_context.register_validators( @@ -912,28 +1017,30 @@ mod tests { validator_custody_units * spec.balance_per_additional_custody_group, )], current_slot, - &spec, ); // CGC update is not applied for `current_epoch`. assert_eq!( - custody_context.num_of_custody_groups_to_sample(current_epoch, &spec), + custody_context.num_of_custody_groups_to_sample(current_epoch), default_sampling_size ); // CGC update is applied for the next epoch. assert_eq!( - custody_context.num_of_custody_groups_to_sample(current_epoch + 1, &spec), + custody_context.num_of_custody_groups_to_sample(current_epoch + 1), validator_custody_units ); } #[test] fn validator_dropped_after_no_registrations_within_expiry_should_not_reduce_cgc() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let current_slot = Slot::new(10); let val_custody_units_1 = 10; @@ -952,7 +1059,6 @@ mod tests { ), ], current_slot, - &spec, ); // WHEN val_1 re-registered, but val_2 did not re-register after `VALIDATOR_REGISTRATION_EXPIRY_SLOTS + 1` slots @@ -962,24 +1068,26 @@ mod tests { val_custody_units_1 * spec.balance_per_additional_custody_group, )], current_slot + VALIDATOR_REGISTRATION_EXPIRY_SLOTS + 1, - &spec, ); // THEN the reduction from dropping val_2 balance should NOT result in a CGC reduction assert!(cgc_changed_opt.is_none(), "CGC should remain unchanged"); assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), val_custody_units_1 + val_custody_units_2 ) } #[test] fn validator_dropped_after_no_registrations_within_expiry() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let current_slot = Slot::new(10); let val_custody_units_1 = 10; @@ -999,7 +1107,6 @@ mod tests { ), ], current_slot, - &spec, ); // WHEN val_1 and val_3 registered, but val_3 did not re-register after `VALIDATOR_REGISTRATION_EXPIRY_SLOTS + 1` slots @@ -1015,7 +1122,6 @@ mod tests { ), ], current_slot + VALIDATOR_REGISTRATION_EXPIRY_SLOTS + 1, - &spec, ); // THEN CGC should increase, BUT val_2 balance should NOT be included in CGC @@ -1028,10 +1134,9 @@ mod tests { } /// Update the validator every epoch and assert cgc against expected values. - fn register_validators_and_assert_cgc( - custody_context: &CustodyContext, + fn register_validators_and_assert_cgc( + custody_context: &CustodyContext, validators_and_expected_cgc_changed: Vec<(ValidatorsAndBalances, Option)>, - spec: &ChainSpec, ) { for (idx, (validators_and_balance, expected_cgc_change)) in validators_and_expected_cgc_changed.into_iter().enumerate() @@ -1040,8 +1145,7 @@ mod tests { let updated_custody_count_opt = custody_context .register_validators( validators_and_balance, - epoch.start_slot(E::slots_per_epoch()), - spec, + epoch.start_slot(T::EthSpec::slots_per_epoch()), ) .map(|c| c.new_custody_group_count); @@ -1051,44 +1155,53 @@ mod tests { #[test] fn custody_columns_for_epoch_no_validators_fullnode() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); - let custody_context = CustodyContext::::new( + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, ordered_custody_column_indices, - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_columns_for_epoch(None, &spec).len(), + custody_context.custody_columns_for_epoch(None).len(), spec.custody_requirement as usize ); } #[test] fn custody_columns_for_epoch_no_validators_supernode() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); - let custody_context = CustodyContext::::new( + let custody_context = CustodyContext::::new( NodeCustodyType::Supernode, ordered_custody_column_indices, - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_columns_for_epoch(None, &spec).len(), + custody_context.custody_columns_for_epoch(None).len(), spec.number_of_custody_groups as usize ); } #[test] fn custody_columns_for_epoch_with_validators_should_match_cgc() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); - let custody_context = CustodyContext::::new( + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, ordered_custody_column_indices, - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let val_custody_units = 10; @@ -1098,30 +1211,32 @@ mod tests { val_custody_units * spec.balance_per_additional_custody_group, )], Slot::new(10), - &spec, ); assert_eq!( - custody_context.custody_columns_for_epoch(None, &spec).len(), + custody_context.custody_columns_for_epoch(None).len(), val_custody_units as usize ); } #[test] fn custody_columns_for_epoch_specific_epoch_uses_epoch_cgc() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); - let custody_context = CustodyContext::::new( + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, ordered_custody_column_indices, - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let test_epoch = Epoch::new(5); - let expected_cgc = custody_context.custody_group_count_at_epoch(test_epoch, &spec); + let expected_cgc = custody_context.custody_group_count_at_epoch(test_epoch); assert_eq!( custody_context - .custody_columns_for_epoch(Some(test_epoch), &spec) + .custody_columns_for_epoch(Some(test_epoch)) .len(), expected_cgc as usize ); @@ -1129,23 +1244,26 @@ mod tests { #[test] fn restore_from_persisted_fullnode_no_validators() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let ssz_context = CustodyContextSsz { validator_custody_at_head: 0, // no validators persisted_is_supernode: false, epoch_validator_custody_requirements: vec![], }; - let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( + let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( ssz_context, NodeCustodyType::Fullnode, Epoch::new(0), generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), spec.custody_requirement, "restored custody group count should match fullnode default" ); @@ -1155,7 +1273,7 @@ mod tests { /// CGC should increase and trigger backfill via CustodyCountChanged. #[test] fn restore_fullnode_then_switch_to_supernode_increases_cgc() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let head_epoch = Epoch::new(10); let supernode_cgc = spec.number_of_custody_groups; @@ -1164,7 +1282,7 @@ mod tests { NodeCustodyType::Supernode, supernode_cgc, head_epoch, - &spec, + spec, ); } @@ -1172,17 +1290,20 @@ mod tests { /// Semi-supernode can exceed 64 when validator effective balance increases CGC. #[test] fn restore_semi_supernode_with_validators_can_exceed_64() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let semi_supernode_cgc = spec.number_of_custody_groups / 2; // 64 - let custody_context = CustodyContext::::new( + let custody_context = CustodyContext::::new( NodeCustodyType::SemiSupernode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); // Verify initial CGC is 64 (semi-supernode) assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), semi_supernode_cgc, "initial cgc should be 64" ); @@ -1196,7 +1317,6 @@ mod tests { validator_custody_units * spec.balance_per_additional_custody_group, )], current_slot, - &spec, ); // Verify CGC increased from 64 to 70 @@ -1216,7 +1336,7 @@ mod tests { // Verify the custody context reflects the new CGC assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), validator_custody_units, "custody_group_count_at_head should be 70" ); @@ -1226,14 +1346,14 @@ mod tests { /// CGC reduction is not supported - persisted value is retained. #[test] fn restore_supernode_then_switch_to_fullnode_uses_persisted() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let supernode_cgc = spec.number_of_custody_groups; assert_custody_type_switch_unchanged_cgc( supernode_cgc, NodeCustodyType::Fullnode, Epoch::new(0), - &spec, + spec, ); } @@ -1241,7 +1361,7 @@ mod tests { /// CGC reduction is not supported - persisted value is retained. #[test] fn restore_supernode_then_switch_to_semi_supernode_keeps_supernode_cgc() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let supernode_cgc = spec.number_of_custody_groups; let head_epoch = Epoch::new(10); @@ -1249,7 +1369,7 @@ mod tests { supernode_cgc, NodeCustodyType::SemiSupernode, head_epoch, - &spec, + spec, ); } @@ -1257,7 +1377,7 @@ mod tests { /// CGC should increase and trigger backfill via CustodyCountChanged. #[test] fn restore_fullnode_with_validators_then_switch_to_semi_supernode() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let persisted_cgc = 32u64; let semi_supernode_cgc = spec.number_of_custody_groups / 2; let head_epoch = Epoch::new(10); @@ -1267,7 +1387,7 @@ mod tests { NodeCustodyType::SemiSupernode, semi_supernode_cgc, head_epoch, - &spec, + spec, ); } @@ -1275,7 +1395,7 @@ mod tests { /// CGC should increase and trigger backfill via CustodyCountChanged. #[test] fn restore_semi_supernode_then_switch_to_supernode() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let semi_supernode_cgc = spec.number_of_custody_groups / 2; let supernode_cgc = spec.number_of_custody_groups; let head_epoch = Epoch::new(10); @@ -1285,7 +1405,7 @@ mod tests { NodeCustodyType::Supernode, supernode_cgc, head_epoch, - &spec, + spec, ); } @@ -1293,7 +1413,7 @@ mod tests { /// CGC should increase and trigger backfill via CustodyCountChanged. #[test] fn restore_with_cli_flag_increases_cgc_from_nonzero() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let persisted_cgc = 32u64; let supernode_cgc = spec.number_of_custody_groups; let head_epoch = Epoch::new(10); @@ -1303,13 +1423,13 @@ mod tests { NodeCustodyType::Supernode, supernode_cgc, head_epoch, - &spec, + spec, ); } #[test] fn restore_with_validator_custody_history_across_epochs() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let initial_cgc = 8u64; let increased_cgc = 16u64; let final_cgc = 32u64; @@ -1324,45 +1444,45 @@ mod tests { ], }; - let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( + let complete_blob_backfill = false; + let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( ssz_context, NodeCustodyType::Fullnode, Epoch::new(20), generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); // Verify head uses latest value - assert_eq!( - custody_context.custody_group_count_at_head(&spec), - final_cgc - ); + assert_eq!(custody_context.custody_group_count_at_head(), final_cgc); // Verify historical epoch lookups work correctly assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(5), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(5)), initial_cgc, "epoch 5 should use initial cgc" ); assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(15), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(15)), increased_cgc, "epoch 15 should use increased cgc" ); assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(25), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(25)), final_cgc, "epoch 25 should use final cgc" ); // Verify sampling size calculation uses correct historical values assert_eq!( - custody_context.num_of_custody_groups_to_sample(Epoch::new(5), &spec), + custody_context.num_of_custody_groups_to_sample(Epoch::new(5)), spec.samples_per_slot, "sampling at epoch 5 should use spec minimum since cgc is at minimum" ); assert_eq!( - custody_context.num_of_custody_groups_to_sample(Epoch::new(25), &spec), + custody_context.num_of_custody_groups_to_sample(Epoch::new(25)), final_cgc, "sampling at epoch 25 should match final cgc" ); @@ -1370,16 +1490,16 @@ mod tests { #[test] fn backfill_single_cgc_increase_updates_past_epochs() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let final_cgc = 32u64; let default_cgc = spec.custody_requirement; // Setup: Node restart after validators were registered, causing CGC increase to 32 at epoch 20 let head_epoch = Epoch::new(20); let epoch_and_cgc_tuples = vec![(head_epoch, final_cgc)]; - let custody_context = setup_custody_context(&spec, head_epoch, epoch_and_cgc_tuples); + let custody_context = setup_custody_context(spec.clone(), head_epoch, epoch_and_cgc_tuples); assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(15), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(15)), default_cgc, ); @@ -1388,26 +1508,26 @@ mod tests { // After backfilling to epoch 15, it should use latest CGC (32) assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(15), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(15)), final_cgc, ); assert_eq!( custody_context - .custody_columns_for_epoch(Some(Epoch::new(15)), &spec) + .custody_columns_for_epoch(Some(Epoch::new(15))) .len(), final_cgc as usize, ); // Prior epoch should still return the original CGC assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(14), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(14)), default_cgc, ); } #[test] fn backfill_with_multiple_cgc_increases_prunes_map_correctly() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let initial_cgc = 8u64; let mid_cgc = 16u64; let final_cgc = 32u64; @@ -1419,7 +1539,7 @@ mod tests { (Epoch::new(10), mid_cgc), (head_epoch, final_cgc), ]; - let custody_context = setup_custody_context(&spec, head_epoch, epoch_and_cgc_tuples); + let custody_context = setup_custody_context(spec.clone(), head_epoch, epoch_and_cgc_tuples); // Backfill to epoch 15 (between the two CGC increases) complete_backfill_for_epochs(&custody_context, Epoch::new(20), Epoch::new(15), final_cgc); @@ -1427,7 +1547,7 @@ mod tests { // Verify epochs 15 - 20 return latest CGC (32) for epoch in 15..=20 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), final_cgc, ); } @@ -1435,7 +1555,7 @@ mod tests { // Verify epochs 10-14 still return mid_cgc (16) for epoch in 10..14 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), mid_cgc, ); } @@ -1443,7 +1563,7 @@ mod tests { #[test] fn attempt_backfill_with_invalid_cgc() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let initial_cgc = 8u64; let mid_cgc = 16u64; let final_cgc = 32u64; @@ -1455,7 +1575,7 @@ mod tests { (Epoch::new(10), mid_cgc), (head_epoch, final_cgc), ]; - let custody_context = setup_custody_context(&spec, head_epoch, epoch_and_cgc_tuples); + let custody_context = setup_custody_context(spec.clone(), head_epoch, epoch_and_cgc_tuples); // Backfill to epoch 15 (between the two CGC increases) complete_backfill_for_epochs(&custody_context, Epoch::new(20), Epoch::new(15), final_cgc); @@ -1463,7 +1583,7 @@ mod tests { // Verify epochs 15 - 20 return latest CGC (32) for epoch in 15..=20 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), final_cgc, ); } @@ -1479,7 +1599,7 @@ mod tests { // Verify epochs 15 - 20 still return latest CGC (32) for epoch in 15..=20 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), final_cgc, ); } @@ -1487,7 +1607,7 @@ mod tests { // Verify epochs 10-14 still return mid_cgc (16) for epoch in 10..14 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), mid_cgc, ); } @@ -1495,7 +1615,7 @@ mod tests { #[test] fn reset_validator_custody_requirements() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let minimum_cgc = 4u64; let initial_cgc = 8u64; let mid_cgc = 16u64; @@ -1508,7 +1628,7 @@ mod tests { (Epoch::new(10), mid_cgc), (head_epoch, final_cgc), ]; - let custody_context = setup_custody_context(&spec, head_epoch, epoch_and_cgc_tuples); + let custody_context = setup_custody_context(spec.clone(), head_epoch, epoch_and_cgc_tuples); // Backfill from epoch 20 to 9 complete_backfill_for_epochs(&custody_context, Epoch::new(20), Epoch::new(9), final_cgc); @@ -1519,14 +1639,14 @@ mod tests { // Verify epochs 0 - 19 return the minimum cgc requirement because of the validator custody requirement reset for epoch in 0..=19 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), minimum_cgc, ); } // Verify epoch 20 returns a CGC of 32 assert_eq!( - custody_context.custody_group_count_at_epoch(head_epoch, &spec), + custody_context.custody_group_count_at_epoch(head_epoch), final_cgc ); @@ -1536,7 +1656,7 @@ mod tests { // Verify epochs 0 - 20 return the final cgc requirements for epoch in 0..=20 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), final_cgc, ); } diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index a0b117f072..d129072ff9 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -11,7 +11,6 @@ use slot_clock::SlotClock; use std::collections::HashSet; use std::fmt; use std::fmt::Debug; -use std::num::NonZeroUsize; use std::sync::Arc; use std::time::Duration; use task_executor::TaskExecutor; @@ -19,8 +18,8 @@ use tracing::{debug, error, instrument}; use types::data::{BlobIdentifier, FixedBlobSidecarList, PartialDataColumn}; use types::{ BlobSidecar, BlobSidecarList, BlockImportSource, ChainSpec, DataColumnSidecar, - DataColumnSidecarList, Epoch, EthSpec, Hash256, PartialDataColumnSidecarError, - PartialDataColumnSidecarRef, SignedBeaconBlock, Slot, new_non_zero_usize, + DataColumnSidecarList, EthSpec, Hash256, PartialDataColumnSidecarError, + PartialDataColumnSidecarRef, SignedBeaconBlock, Slot, }; mod error; @@ -49,7 +48,7 @@ pub use error::{Error as AvailabilityCheckError, ErrorCategory as AvailabilityCh /// /// `PendingComponents` are now never removed from the cache manually are only removed via LRU /// eviction to prevent race conditions (#7961), so we expect this cache to be full all the time. -const OVERFLOW_LRU_CAPACITY_NON_ZERO: NonZeroUsize = new_non_zero_usize(32); +const OVERFLOW_LRU_CAPACITY: usize = 32; /// Cache to hold fully valid data that can't be imported to fork-choice yet. After Dencun hard-fork /// blocks have a sidecar of data that is received separately from the network. We call the concept @@ -76,12 +75,9 @@ const OVERFLOW_LRU_CAPACITY_NON_ZERO: NonZeroUsize = new_non_zero_usize(32); /// proposer. Having a capacity > 1 is an optimization to prevent sync lookup from having re-fetch /// data during moments of unstable network conditions. pub struct DataAvailabilityChecker { - complete_blob_backfill: bool, availability_cache: Arc>, partial_assembler: Option>>, - slot_clock: T::SlotClock, kzg: Arc, - custody_context: Arc>, spec: Arc, } @@ -116,38 +112,35 @@ impl Debug for Availability { impl DataAvailabilityChecker { pub fn new( - complete_blob_backfill: bool, - slot_clock: T::SlotClock, kzg: Arc, - custody_context: Arc>, + custody_context: Arc>, spec: Arc, enable_partial_columns: bool, + disable_get_blobs: bool, ) -> Result { let inner = DataAvailabilityCheckerInner::new( - OVERFLOW_LRU_CAPACITY_NON_ZERO, + OVERFLOW_LRU_CAPACITY, custody_context.clone(), spec.clone(), )?; let partial_assembler = if enable_partial_columns { Some(Arc::new(PartialDataColumnAssembler::new( - OVERFLOW_LRU_CAPACITY_NON_ZERO, + OVERFLOW_LRU_CAPACITY, + disable_get_blobs, ))) } else { None }; Ok(Self { - complete_blob_backfill, partial_assembler, availability_cache: Arc::new(inner), - slot_clock, kzg, - custody_context, spec, }) } - pub fn custody_context(&self) -> &Arc> { - &self.custody_context + fn custody_context(&self) -> &Arc> { + self.availability_cache.custody_context() } pub fn partial_assembler(&self) -> Option<&Arc>> { @@ -309,9 +302,9 @@ impl DataAvailabilityChecker { &self, block_root: Hash256, blobs: FixedBlobSidecarList, + slot_clock: &T::SlotClock, ) -> Result, AvailabilityCheckError> { - let seen_timestamp = self - .slot_clock + let seen_timestamp = slot_clock .now_duration() .ok_or(AvailabilityCheckError::SlotClockError)?; @@ -349,9 +342,7 @@ impl DataAvailabilityChecker { // not be yet effective for data availability check, as CGC changes are only effecive from // a new epoch. let epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - let sampling_columns = self - .custody_context - .sampling_columns_for_epoch(epoch, &self.spec); + let sampling_columns = self.custody_context().sampling_columns_for_epoch(epoch); let verified_custody_columns = kzg_verified_columns .into_iter() .filter(|col| sampling_columns.contains(&col.index())) @@ -389,9 +380,7 @@ impl DataAvailabilityChecker { data_columns: I, ) -> Result, AvailabilityCheckError> { let epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - let sampling_columns = self - .custody_context - .sampling_columns_for_epoch(epoch, &self.spec); + let sampling_columns = self.custody_context().sampling_columns_for_epoch(epoch); let custody_columns = data_columns .into_iter() .filter(|col| sampling_columns.contains(&col.index())) @@ -506,59 +495,6 @@ impl DataAvailabilityChecker { Ok(()) } - /// Determines the blob requirements for a block. If the block is pre-deneb, no blobs are required. - /// If the epoch is from prior to the data availability boundary, no blobs are required. - pub fn blobs_required_for_epoch(&self, epoch: Epoch) -> bool { - self.da_check_required_for_epoch(epoch) && !self.spec.is_peer_das_enabled_for_epoch(epoch) - } - - /// Determines the data column requirements for an epoch. - /// - If the epoch is pre-peerdas, no data columns are required. - /// - If the epoch is from prior to the data availability boundary, no data columns are required. - pub fn data_columns_required_for_epoch(&self, epoch: Epoch) -> bool { - self.da_check_required_for_epoch(epoch) && self.spec.is_peer_das_enabled_for_epoch(epoch) - } - - /// See `Self::blobs_required_for_epoch` - fn blobs_required_for_block(&self, block: &SignedBeaconBlock) -> bool { - block.num_expected_blobs() > 0 && self.blobs_required_for_epoch(block.epoch()) - } - - /// See `Self::data_columns_required_for_epoch` - fn data_columns_required_for_block(&self, block: &SignedBeaconBlock) -> bool { - block.num_expected_blobs() > 0 && self.data_columns_required_for_epoch(block.epoch()) - } - - /// The epoch at which we require a data availability check in block processing. - /// `None` if the `Deneb` fork is disabled. - pub fn data_availability_boundary(&self) -> Option { - let fork_epoch = self.spec.deneb_fork_epoch?; - - if self.complete_blob_backfill { - Some(fork_epoch) - } else { - let current_epoch = self.slot_clock.now()?.epoch(T::EthSpec::slots_per_epoch()); - self.spec - .min_epoch_data_availability_boundary(current_epoch) - } - } - - /// Returns true if the given epoch lies within the da boundary and false otherwise. - pub fn da_check_required_for_epoch(&self, block_epoch: Epoch) -> bool { - self.data_availability_boundary() - .is_some_and(|da_epoch| block_epoch >= da_epoch) - } - - /// Returns `true` if the current epoch is greater than or equal to the `Deneb` epoch. - pub fn is_deneb(&self) -> bool { - self.slot_clock.now().is_some_and(|slot| { - self.spec.deneb_fork_epoch.is_some_and(|deneb_epoch| { - let now_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - now_epoch >= deneb_epoch - }) - }) - } - /// Collects metrics from the data availability checker. pub fn metrics(&self) -> DataAvailabilityCheckerMetrics { DataAvailabilityCheckerMetrics { @@ -628,7 +564,7 @@ impl DataAvailabilityChecker { let columns_to_sample = self .custody_context() - .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch()), &self.spec); + .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch())); // We only need to import and publish columns that we need to sample // and columns that we haven't already received @@ -885,15 +821,14 @@ impl AvailableBlock { pub fn new( block: Arc>, block_data: AvailableBlockData, - da_checker: &DataAvailabilityChecker, - spec: Arc, + custody_context: &CustodyContext, ) -> Result where T: BeaconChainTypes, { // Ensure block availability - let blobs_required = da_checker.blobs_required_for_block(&block); - let columns_required = da_checker.data_columns_required_for_block(&block); + let blobs_required = custody_context.blobs_required_for_block(&block); + let columns_required = custody_context.data_columns_required_for_block(&block); match &block_data { AvailableBlockData::NoData => { @@ -934,9 +869,8 @@ impl AvailableBlock { return Err(AvailabilityCheckError::InvalidAvailableBlockData); } - let mut column_indices = da_checker - .custody_context - .sampling_columns_for_epoch(block.epoch(), &spec) + let mut column_indices = custody_context + .sampling_columns_for_epoch(block.epoch()) .iter() .collect::>(); @@ -1080,7 +1014,8 @@ mod test { use std::time::Duration; use types::data::DataColumn; use types::{ - ChainSpec, ColumnIndex, DataColumnSidecarFulu, EthSpec, ForkName, MainnetEthSpec, Slot, + ChainSpec, ColumnIndex, DataColumnSidecarFulu, Epoch, EthSpec, ForkName, MainnetEthSpec, + Slot, }; type E = MainnetEthSpec; @@ -1095,7 +1030,7 @@ mod test { let mut u = types::test_utils::test_unstructured(); let da_checker = new_da_checker(spec.clone()); - let custody_context = &da_checker.custody_context; + let custody_context = da_checker.custody_context(); // GIVEN a single 32 ETH validator is attached slot 0 let epoch = Epoch::new(0); @@ -1103,10 +1038,9 @@ mod test { custody_context.register_validators( vec![(validator_0, 32_000_000_000)], epoch.start_slot(E::slots_per_epoch()), - &spec, ); assert_eq!( - custody_context.num_of_data_columns_to_sample(epoch, &spec), + custody_context.num_of_data_columns_to_sample(epoch), spec.validator_custody_requirement as usize, "sampling size should be the minimal custody requirement == 8" ); @@ -1114,11 +1048,8 @@ mod test { // WHEN additional attached validators result in a CGC increase to 10 at the end slot of the same epoch let validator_1 = 1; let cgc_change_slot = epoch.end_slot(E::slots_per_epoch()); - custody_context.register_validators( - vec![(validator_1, 32_000_000_000 * 9)], - cgc_change_slot, - &spec, - ); + custody_context + .register_validators(vec![(validator_1, 32_000_000_000 * 9)], cgc_change_slot); // AND custody columns (8) and any new extra columns (2) are received via RPC responses. // NOTE: block lookup uses the **latest** CGC (10) instead of the effective CGC (8) as the slot is unknown. let (_, data_columns) = generate_rand_block_and_data_columns::( @@ -1133,7 +1064,7 @@ mod test { // The CGC change becomes effective after CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS, // which is typically epoch 2+ for MinimalEthSpec. let future_epoch = Epoch::new(10); // Far enough in the future to have the CGC change effective - let requested_columns = custody_context.sampling_columns_for_epoch(future_epoch, &spec); + let requested_columns = custody_context.sampling_columns_for_epoch(future_epoch); assert_eq!( requested_columns.len(), 10, @@ -1151,7 +1082,7 @@ mod test { .expect("should put rpc custody columns"); // THEN the sampling size for the end slot of the same epoch remains unchanged - let sampling_columns = custody_context.sampling_columns_for_epoch(epoch, &spec); + let sampling_columns = custody_context.sampling_columns_for_epoch(epoch); assert_eq!( sampling_columns.len(), spec.validator_custody_requirement as usize // 8 @@ -1182,7 +1113,7 @@ mod test { let mut u = types::test_utils::test_unstructured(); let da_checker = new_da_checker(spec.clone()); - let custody_context = &da_checker.custody_context; + let custody_context = da_checker.custody_context(); // GIVEN a single 32 ETH validator is attached slot 0 let epoch = Epoch::new(0); @@ -1190,10 +1121,9 @@ mod test { custody_context.register_validators( vec![(validator_0, 32_000_000_000)], epoch.start_slot(E::slots_per_epoch()), - &spec, ); assert_eq!( - custody_context.num_of_data_columns_to_sample(epoch, &spec), + custody_context.num_of_data_columns_to_sample(epoch), spec.validator_custody_requirement as usize, "sampling size should be the minimal custody requirement == 8" ); @@ -1201,11 +1131,8 @@ mod test { // WHEN additional attached validators result in a CGC increase to 10 at the end slot of the same epoch let validator_1 = 1; let cgc_change_slot = epoch.end_slot(E::slots_per_epoch()); - custody_context.register_validators( - vec![(validator_1, 32_000_000_000 * 9)], - cgc_change_slot, - &spec, - ); + custody_context + .register_validators(vec![(validator_1, 32_000_000_000 * 9)], cgc_change_slot); // AND custody columns (8) and any new extra columns (2) are received via gossip. // NOTE: CGC updates results in new topics subscriptions immediately, and extra columns may start to // arrive via gossip. @@ -1221,7 +1148,7 @@ mod test { // The CGC change becomes effective after CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS, // which is typically epoch 2+ for MinimalEthSpec. let future_epoch = Epoch::new(10); // Far enough in the future to have the CGC change effective - let requested_columns = custody_context.sampling_columns_for_epoch(future_epoch, &spec); + let requested_columns = custody_context.sampling_columns_for_epoch(future_epoch); assert_eq!( requested_columns.len(), 10, @@ -1237,7 +1164,7 @@ mod test { .expect("should put gossip custody columns"); // THEN the sampling size for the end slot of the same epoch remains unchanged - let sampling_columns = custody_context.sampling_columns_for_epoch(epoch, &spec); + let sampling_columns = custody_context.sampling_columns_for_epoch(epoch); assert_eq!( sampling_columns.len(), spec.validator_custody_requirement as usize // 8 @@ -1304,8 +1231,7 @@ mod test { }; let block_data = AvailableBlockData::new_with_data_columns(custody_columns); - let da_checker = Arc::new(new_da_checker(spec.clone())); - RangeSyncBlock::new(Arc::new(block), block_data, &da_checker, spec.clone()) + RangeSyncBlock::new(Arc::new(block), block_data, da_checker.custody_context()) .expect("should create RPC block with custody columns") }) .collect::>(); @@ -1330,16 +1256,15 @@ mod test { let mut u = types::test_utils::test_unstructured(); let da_checker = new_da_checker(spec.clone()); - let custody_context = &da_checker.custody_context; + let custody_context = da_checker.custody_context(); // Set custody requirement to 65 columns (enough to trigger reconstruction) let epoch = Epoch::new(1); custody_context.register_validators( vec![(0, 2_048_000_000_000), (1, 32_000_000_000)], // 64 + 1 Slot::new(0), - &spec, ); - let sampling_requirement = custody_context.num_of_data_columns_to_sample(epoch, &spec); + let sampling_requirement = custody_context.num_of_data_columns_to_sample(epoch); assert_eq!( sampling_requirement, 65, "sampling requirement should be 65" @@ -1361,7 +1286,7 @@ mod test { // Add 64 columns to the da checker (enough to be able to reconstruct) // Order by all_column_indices_ordered, then take first 64 - let custody_columns = custody_context.sampling_columns_for_epoch(epoch, &spec); + let custody_columns = custody_context.sampling_columns_for_epoch(epoch); let custody_columns = custody_columns .iter() .filter_map(|&col_idx| data_columns.iter().find(|d| *d.index() == col_idx).cloned()) @@ -1399,7 +1324,7 @@ mod test { ); // Only the columns required for custody (65) should be imported into the cache - let sampling_columns = custody_context.sampling_columns_for_epoch(epoch, &spec); + let sampling_columns = custody_context.sampling_columns_for_epoch(epoch); let actual_cached: HashSet = da_checker .cached_data_column_indexes(&block_root) .expect("should have cached data columns") @@ -1420,20 +1345,15 @@ mod test { ); let kzg = get_kzg(&spec); let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); + let complete_blob_backfill = false; let custody_context = Arc::new(CustodyContext::new( NodeCustodyType::Fullnode, ordered_custody_column_indices, - &spec, - )); - let complete_blob_backfill = false; - DataAvailabilityChecker::new( - complete_blob_backfill, slot_clock, - kzg, - custody_context, - spec, - true, - ) - .expect("should initialise data availability checker") + complete_blob_backfill, + spec.clone(), + )); + DataAvailabilityChecker::new(kzg, custody_context, spec, true, false) + .expect("should initialise data availability checker") } } diff --git a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs index 2254728850..dc59d614df 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs @@ -7,11 +7,10 @@ use crate::block_verification_types::{ use crate::data_availability_checker::{Availability, AvailabilityCheckError}; use crate::data_column_verification::KzgVerifiedCustodyDataColumn; use crate::{BeaconChainTypes, BlockProcessStatus}; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; use ssz_types::RuntimeFixedVector; use std::cmp::Ordering; -use std::num::NonZeroUsize; use std::sync::Arc; use tracing::{Span, debug, debug_span}; use types::data::BlobIdentifier; @@ -350,7 +349,7 @@ impl PendingComponents { pub struct DataAvailabilityCheckerInner { /// Contains all the data we keep in memory, protected by an RwLock critical: RwLock>>, - custody_context: Arc>, + custody_context: Arc>, spec: Arc, } @@ -365,8 +364,8 @@ pub(crate) enum ReconstructColumnsDecision { impl DataAvailabilityCheckerInner { pub fn new( - capacity: NonZeroUsize, - custody_context: Arc>, + capacity: usize, + custody_context: Arc>, spec: Arc, ) -> Result { Ok(Self { @@ -435,6 +434,10 @@ impl DataAvailabilityCheckerInner { f(self.critical.read().peek(block_root)) } + pub fn custody_context(&self) -> &Arc> { + &self.custody_context + } + /// Puts the KZG verified blobs into the availability cache as pending components. pub fn put_kzg_verified_blobs>>( &self, @@ -501,9 +504,7 @@ impl DataAvailabilityCheckerInner { pending_components.merge_data_columns(kzg_verified_data_columns) })?; - let num_expected_columns = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); + let num_expected_columns = self.custody_context.num_of_data_columns_to_sample(epoch); pending_components.span.in_scope(|| { debug!( @@ -565,7 +566,7 @@ impl DataAvailabilityCheckerInner { let mut write_lock = self.critical.write(); { - let pending_components = write_lock.get_or_insert_mut(block_root, || { + let pending_components = write_lock.entry(block_root).or_insert_with(|| { PendingComponents::empty(block_root, self.spec.max_blobs_per_block(epoch) as usize) }); update_fn(pending_components)? @@ -607,9 +608,7 @@ impl DataAvailabilityCheckerInner { }; let total_column_count = T::EthSpec::number_of_columns(); - let sampling_column_count = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); + let sampling_column_count = self.custody_context.num_of_data_columns_to_sample(epoch); let received_column_count = pending_components.verified_data_columns.len(); if pending_components.reconstruction_started { @@ -672,7 +671,7 @@ impl DataAvailabilityCheckerInner { if let Some(BlockProcessStatus::NotValidated(_, _)) = self.get_cached_block(block_root) { // If the block is execution invalid, this status is permanent and idempotent to this // block_root. We drop its components (e.g. columns) because they will never be useful. - self.critical.write().pop(block_root); + self.critical.write().remove(block_root); } } @@ -710,9 +709,7 @@ impl DataAvailabilityCheckerInner { fn get_num_expected_columns(&self, epoch: Epoch) -> Option { if self.spec.is_peer_das_enabled_for_epoch(epoch) { - let num_of_column_samples = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); + let num_of_column_samples = self.custody_context.num_of_data_columns_to_sample(epoch); Some(num_of_column_samples) } else { None @@ -733,7 +730,7 @@ impl DataAvailabilityCheckerInner { } // Now remove keys for key in keys_to_remove { - write_lock.pop(&key); + write_lock.remove(&key); } Ok(()) @@ -761,11 +758,11 @@ mod test { }; use fork_choice::PayloadVerificationStatus; use logging::create_test_tracing_subscriber; + use slot_clock::TestingSlotClock; use state_processing::ConsensusContext; use store::{HotColdDB, ItemStore, StoreConfig, database::interface::BeaconNodeBackend}; use tempfile::{TempDir, tempdir}; use tracing::info; - use types::new_non_zero_usize; use types::{DataColumnSubnetId, MinimalEthSpec}; const LOW_VALIDATOR_COUNT: usize = 32; @@ -924,25 +921,26 @@ mod test { HotStore = BeaconNodeBackend, ColdStore = BeaconNodeBackend, EthSpec = E, + SlotClock = TestingSlotClock, >, { create_test_tracing_subscriber(); let chain_db_path = tempdir().expect("should get temp dir"); let harness = get_fulu_chain(&chain_db_path).await; let spec = harness.spec.clone(); - let capacity_non_zero = new_non_zero_usize(capacity); + let complete_blob_backfill = false; + let slot_clock = harness.chain.slot_clock.clone(); + let custody_context = Arc::new(CustodyContext::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + slot_clock, + complete_blob_backfill, + spec.clone(), )); let cache = Arc::new( - DataAvailabilityCheckerInner::::new( - capacity_non_zero, - custody_context, - spec.clone(), - ) - .expect("should create cache"), + DataAvailabilityCheckerInner::::new(capacity, custody_context, spec) + .expect("should create cache"), ); (harness, cache, chain_db_path) } @@ -959,9 +957,7 @@ mod test { let epoch = pending_block.block.epoch(); let num_blobs_expected = pending_block.num_blobs_expected(); - let columns_expected = cache - .custody_context - .num_of_data_columns_to_sample(epoch, &harness.spec); + let columns_expected = cache.custody_context.num_of_data_columns_to_sample(epoch); // All columns are returned from availability_pending_block (E::number_of_columns()) // but we only need custody columns @@ -1001,9 +997,7 @@ mod test { } // Get sampling column indices for this epoch - let sampling_column_indices = cache - .custody_context - .sampling_columns_for_epoch(epoch, &harness.spec); + let sampling_column_indices = cache.custody_context.sampling_columns_for_epoch(epoch); // Filter to only sampling columns let sampling_columns: Vec<_> = columns @@ -1039,9 +1033,7 @@ mod test { let root = pending_block.import_data.block_root; // Get sampling column indices for this epoch - let sampling_column_indices = cache - .custody_context - .sampling_columns_for_epoch(epoch, &harness.spec); + let sampling_column_indices = cache.custody_context.sampling_columns_for_epoch(epoch); // Filter to only sampling columns let sampling_columns: Vec<_> = columns diff --git a/beacon_node/beacon_chain/src/events.rs b/beacon_node/beacon_chain/src/events.rs index 80667cd399..dd08c59c76 100644 --- a/beacon_node/beacon_chain/src/events.rs +++ b/beacon_node/beacon_chain/src/events.rs @@ -29,6 +29,7 @@ pub struct ServerSentEventHandler { execution_payload_gossip_tx: Sender>, execution_payload_available_tx: Sender>, execution_payload_bid_tx: Sender>, + proposer_preferences_tx: Sender>, payload_attestation_message_tx: Sender>, } @@ -60,6 +61,7 @@ impl ServerSentEventHandler { let (execution_payload_gossip_tx, _) = broadcast::channel(capacity); let (execution_payload_available_tx, _) = broadcast::channel(capacity); let (execution_payload_bid_tx, _) = broadcast::channel(capacity); + let (proposer_preferences_tx, _) = broadcast::channel(capacity); let (payload_attestation_message_tx, _) = broadcast::channel(capacity); Self { @@ -85,6 +87,7 @@ impl ServerSentEventHandler { execution_payload_gossip_tx, execution_payload_available_tx, execution_payload_bid_tx, + proposer_preferences_tx, payload_attestation_message_tx, } } @@ -186,6 +189,10 @@ impl ServerSentEventHandler { .execution_payload_bid_tx .send(kind) .map(|count| log_count("execution payload bid", count)), + EventKind::ProposerPreferences(_) => self + .proposer_preferences_tx + .send(kind) + .map(|count| log_count("proposer preferences", count)), EventKind::PayloadAttestationMessage(_) => self .payload_attestation_message_tx .send(kind) @@ -284,6 +291,10 @@ impl ServerSentEventHandler { self.execution_payload_bid_tx.subscribe() } + pub fn subscribe_proposer_preferences(&self) -> Receiver> { + self.proposer_preferences_tx.subscribe() + } + pub fn subscribe_payload_attestation_message(&self) -> Receiver> { self.payload_attestation_message_tx.subscribe() } @@ -368,6 +379,10 @@ impl ServerSentEventHandler { self.execution_payload_bid_tx.receiver_count() > 0 } + pub fn has_proposer_preferences_subscribers(&self) -> bool { + self.proposer_preferences_tx.receiver_count() > 0 + } + pub fn has_payload_attestation_message_subscribers(&self) -> bool { self.payload_attestation_message_tx.receiver_count() > 0 } diff --git a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs index 99cb4b5a78..8b805a8da3 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs @@ -9,7 +9,6 @@ use eth2::types::BlobsBundle; use execution_layer::json_structures::{BlobAndProof, BlobAndProofV1, BlobAndProofV2}; use execution_layer::test_utils::generate_blobs; use maplit::hashset; -use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; use task_executor::test_utils::TestRuntime; use types::{ @@ -201,7 +200,10 @@ mod get_blobs_v2 { .returning(|_, _| None); mock_process_engine_blobs_result( &mut mock_adapter, - Ok(AvailabilityProcessingStatus::Imported(block_root)), + Ok(AvailabilityProcessingStatus::Imported( + block.slot(), + block_root, + )), ); // Trigger fetch blobs on the block @@ -218,7 +220,10 @@ mod get_blobs_v2 { assert_eq!( processing_status, - Some(AvailabilityProcessingStatus::Imported(block_root)) + Some(AvailabilityProcessingStatus::Imported( + block.slot(), + block_root + )) ); let published_columns = extract_published_blobs(publish_fn_args); @@ -339,7 +344,7 @@ fn mock_beacon_adapter(fork_name: ForkName, get_blobs_v3: bool) -> MockFetchBlob let test_runtime = TestRuntime::default(); let spec = Arc::new(fork_name.make_genesis_spec(E::default_spec())); let kzg = get_kzg(&spec); - let partial_assembler = PartialDataColumnAssembler::new(NonZeroUsize::new(32).unwrap()); + let partial_assembler = PartialDataColumnAssembler::new(32, false); let mut mock_adapter = MockFetchBlobsBeaconAdapter::default(); mock_adapter.expect_spec().return_const(spec.clone()); diff --git a/beacon_node/beacon_chain/src/historical_data_columns.rs b/beacon_node/beacon_chain/src/historical_data_columns.rs index d6977d9985..1089c801ff 100644 --- a/beacon_node/beacon_chain/src/historical_data_columns.rs +++ b/beacon_node/beacon_chain/src/historical_data_columns.rs @@ -131,8 +131,7 @@ impl BeaconChain { ); } - self.data_availability_checker - .custody_context() + self.custody_context .update_and_backfill_custody_count_at_epoch(epoch, expected_cgc); self.safely_backfill_data_column_custody_info(epoch) diff --git a/beacon_node/beacon_chain/src/invariants.rs b/beacon_node/beacon_chain/src/invariants.rs index b365f37a0a..369b968e78 100644 --- a/beacon_node/beacon_chain/src/invariants.rs +++ b/beacon_node/beacon_chain/src/invariants.rs @@ -29,14 +29,12 @@ impl BeaconChain { .collect() }; - let custody_context = self.data_availability_checker.custody_context(); + let custody_context = self.custody_context.clone(); let ctx = InvariantContext { fork_choice_blocks, state_cache_roots: self.store.state_cache.lock().state_roots(), - custody_columns: custody_context - .custody_columns_for_epoch(None, &self.spec) - .to_vec(), + custody_columns: custody_context.custody_columns_for_epoch(None).to_vec(), pubkey_cache_pubkeys: { let cache = self.validator_pubkey_cache.read(); (0..cache.len()) diff --git a/beacon_node/beacon_chain/src/light_client_server_cache.rs b/beacon_node/beacon_chain/src/light_client_server_cache.rs index 5b405234e7..d9d3fdd63e 100644 --- a/beacon_node/beacon_chain/src/light_client_server_cache.rs +++ b/beacon_node/beacon_chain/src/light_client_server_cache.rs @@ -1,15 +1,14 @@ use crate::errors::BeaconChainError; use crate::{BeaconChainTypes, BeaconStore, metrics}; +use hashlink::lru_cache::LruCache; use parking_lot::{Mutex, RwLock}; use safe_arith::SafeArith; use ssz::Decode; -use std::num::NonZeroUsize; use std::sync::Arc; use store::DBColumn; use store::KeyValueStore; use tracing::debug; use tree_hash::TreeHash; -use types::new_non_zero_usize; use types::{ BeaconBlockRef, BeaconState, ChainSpec, Checkpoint, EthSpec, ForkName, Hash256, LightClientBootstrap, LightClientFinalityUpdate, LightClientOptimisticUpdate, @@ -19,7 +18,7 @@ use types::{ /// A prev block cache miss requires to re-generate the state of the post-parent block. Items in the /// prev block cache are very small 32 * (6 + 1) = 224 bytes. 32 is an arbitrary number that /// represents unlikely re-orgs, while keeping the cache very small. -const PREV_BLOCK_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(32); +const PREV_BLOCK_CACHE_SIZE: usize = 32; /// This cache computes light client messages ahead of time, required to satisfy p2p and API /// requests. These messages include proofs on historical states, so on-demand computation is @@ -39,7 +38,7 @@ pub struct LightClientServerCache { /// Caches the current sync committee, latest_written_current_sync_committee: RwLock>>>, /// Caches state proofs by block root - prev_block_cache: Mutex>>, + prev_block_cache: Mutex>>, /// Tracks the latest broadcasted finality update latest_broadcasted_finality_update: RwLock>>, /// Tracks the latest broadcasted optimistic update @@ -55,7 +54,7 @@ impl LightClientServerCache { latest_written_current_sync_committee: None.into(), latest_broadcasted_finality_update: None.into(), latest_broadcasted_optimistic_update: None.into(), - prev_block_cache: lru::LruCache::new(PREV_BLOCK_CACHE_SIZE).into(), + prev_block_cache: LruCache::new(PREV_BLOCK_CACHE_SIZE).into(), } } @@ -74,7 +73,7 @@ impl LightClientServerCache { if fork_name.altair_enabled() { // Persist in memory cache for a descendent block let cached_data = LightClientCachedData::from_state(block_post_state)?; - self.prev_block_cache.lock().put(block_root, cached_data); + self.prev_block_cache.lock().insert(block_root, cached_data); } Ok(()) @@ -335,7 +334,7 @@ impl LightClientServerCache { // Insert value and return owned self.prev_block_cache .lock() - .put(*block_root, new_value.clone()); + .insert(*block_root, new_value.clone()); Ok(new_value) } diff --git a/beacon_node/beacon_chain/src/partial_data_column_assembler.rs b/beacon_node/beacon_chain/src/partial_data_column_assembler.rs index ee59102cfd..0d21cb7621 100644 --- a/beacon_node/beacon_chain/src/partial_data_column_assembler.rs +++ b/beacon_node/beacon_chain/src/partial_data_column_assembler.rs @@ -1,10 +1,9 @@ use crate::data_column_verification::{ KzgVerifiedCustodyDataColumn, KzgVerifiedCustodyPartialDataColumn, }; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use parking_lot::RwLock; use std::collections::HashMap; -use std::num::NonZeroUsize; use std::sync::Arc; use tracing::error; use types::core::{Epoch, EthSpec, Hash256}; @@ -14,6 +13,9 @@ use types::data::{ColumnIndex, PartialDataColumnHeader}; pub struct PartialDataColumnAssembler { /// Cache of assemblies keyed by block root assemblies: RwLock>>, + /// Whether getBlobs is disabled. If so, always set `has_local_blobs` to true, as we will never + /// retrieve blobs from the EL and therefore should immediately request cells from the network. + disable_get_blobs: bool, } /// Tracks partial columns being assembled for a single block @@ -44,9 +46,10 @@ pub struct PartialMergeResult { } impl PartialDataColumnAssembler { - pub fn new(capacity: NonZeroUsize) -> Self { + pub fn new(capacity: usize, disable_get_blobs: bool) -> Self { Self { assemblies: RwLock::new(LruCache::new(capacity)), + disable_get_blobs, } } @@ -55,17 +58,17 @@ impl PartialDataColumnAssembler { pub fn init(&self, block_root: Hash256, header: Arc>) -> bool { let mut assemblies = self.assemblies.write(); - if assemblies.contains(&block_root) { + if assemblies.contains_key(&block_root) { return false; } let assembly = PartialAssembly { header, - has_local_blobs: false, + has_local_blobs: self.disable_get_blobs, columns: HashMap::new(), }; - assemblies.put(block_root, assembly); + assemblies.insert(block_root, assembly); true } @@ -79,11 +82,13 @@ impl PartialDataColumnAssembler { header: Arc>, ) -> Option> { let mut assemblies = self.assemblies.write(); - let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { - header: header.clone(), - has_local_blobs: false, - columns: HashMap::new(), - }); + let assembly = assemblies + .entry(block_root) + .or_insert_with(|| PartialAssembly { + header: header.clone(), + has_local_blobs: self.disable_get_blobs, + columns: HashMap::new(), + }); let mut full_columns = Vec::new(); let mut updated_partials = Vec::new(); @@ -165,15 +170,17 @@ impl PartialDataColumnAssembler { }; let mut assemblies = self.assemblies.write(); - let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { - header: Arc::new(PartialDataColumnHeader { - kzg_commitments: fulu.kzg_commitments.clone(), - signed_block_header: fulu.signed_block_header.clone(), - kzg_commitments_inclusion_proof: fulu.kzg_commitments_inclusion_proof.clone(), - }), - has_local_blobs: false, - columns: Default::default(), - }); + let assembly = assemblies + .entry(block_root) + .or_insert_with(|| PartialAssembly { + header: Arc::new(PartialDataColumnHeader { + kzg_commitments: fulu.kzg_commitments.clone(), + signed_block_header: fulu.signed_block_header.clone(), + kzg_commitments_inclusion_proof: fulu.kzg_commitments_inclusion_proof.clone(), + }), + has_local_blobs: self.disable_get_blobs, + columns: Default::default(), + }); let prev = assembly .columns .insert(column.index(), AssemblyColumn::Complete(column.clone())); @@ -215,11 +222,13 @@ impl PartialDataColumnAssembler { header: &Arc>, ) -> Vec> { let mut assemblies = self.assemblies.write(); - let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { - header: header.clone(), - has_local_blobs: true, - columns: Default::default(), - }); + let assembly = assemblies + .entry(block_root) + .or_insert_with(|| PartialAssembly { + header: header.clone(), + has_local_blobs: true, + columns: Default::default(), + }); assembly.has_local_blobs = true; @@ -253,7 +262,7 @@ impl PartialDataColumnAssembler { } for root in to_remove { - assemblies.pop(&root); + assemblies.remove(&root); } } } @@ -362,7 +371,7 @@ mod tests { } fn make_assembler() -> PartialDataColumnAssembler { - PartialDataColumnAssembler::new(NonZeroUsize::new(16).unwrap()) + PartialDataColumnAssembler::new(16, false) } // -- init and get_header tests -- diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs index 3e9f9e4b60..f8a1143b2e 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs @@ -70,13 +70,21 @@ impl VerifiedPayloadAttestationMessage { // 2. Blocks we've seen that are invalid (REJECT). // Presently both cases return IGNORE. let beacon_block_root = payload_attestation_message.data.beacon_block_root; - if ctx + let block = ctx .canonical_head .fork_choice_read_lock() .get_block(&beacon_block_root) - .is_none() - { - return Err(Error::UnknownHeadBlock { beacon_block_root }); + .ok_or(Error::UnknownHeadBlock { beacon_block_root })?; + + // [IGNORE] The block referenced by `data.beacon_block_root` is at slot `data.slot`, i.e. + // the block has `block.slot == data.slot`. A PTC member assigned to an empty slot must not + // attest, so ignore messages that reference an earlier block. + if block.slot != slot { + return Err(Error::BlockNotAtSlot { + beacon_block_root, + block_slot: block.slot, + data_slot: slot, + }); } let message_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs index 89ae1bbbdd..d1fa7f52fb 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs @@ -60,6 +60,18 @@ pub enum Error { /// The attestation points to a block we have not yet imported. It's unclear if the /// attestation is valid or not. UnknownHeadBlock { beacon_block_root: Hash256 }, + /// The block referenced by `data.beacon_block_root` is not at slot `data.slot`, i.e. the + /// PTC member's assigned slot was likely empty. + /// + /// ## Peer scoring + /// + /// PTC members should not attest for empty slots, so we + /// ignore the message. + BlockNotAtSlot { + beacon_block_root: Hash256, + block_slot: Slot, + data_slot: Slot, + }, /// The validator index is not a member of the PTC for this slot. /// /// ## Peer scoring diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs index d4b82c41fc..01cee2cdb6 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs @@ -3,7 +3,6 @@ use std::time::Duration; use bls::Signature; use slot_clock::{SlotClock, TestingSlotClock}; -use state_processing::AllCaches; use types::{ Domain, Epoch, EthSpec, ForkName, Hash256, MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, SignedRoot, Slot, @@ -16,7 +15,10 @@ use crate::{ GossipVerificationContext, VerifiedPayloadAttestationMessage, }, }, - test_utils::{BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, test_spec}, + test_utils::{ + BeaconChainHarness, EphemeralHarnessType, MakePayloadAttestationOptions, + PayloadAttestationVote, fork_name_from_env, test_spec, + }, }; type E = MinimalEthSpec; @@ -31,6 +33,10 @@ struct TestContext { impl TestContext { fn new() -> Self { + Self::with_validator_count(NUM_VALIDATORS) + } + + fn with_validator_count(num_validators: usize) -> Self { let spec = Arc::new(test_spec::()); let slot_clock = TestingSlotClock::new( Slot::new(0), @@ -39,8 +45,9 @@ impl TestContext { ); let harness = BeaconChainHarness::builder(E::default()) .spec(spec) - .deterministic_keypairs(NUM_VALIDATORS) + .deterministic_keypairs(num_validators) .fresh_ephemeral_store() + .mock_execution_layer() .testing_slot_clock(slot_clock) .build(); @@ -167,6 +174,25 @@ fn unknown_head_block() { ); } +#[test] +fn block_not_at_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + // The genesis block is at slot 0, but the message claims slot 1. A PTC member assigned to an + // empty slot must not attest, so this must be ignored (per consensus-specs #5281). + let msg = make_payload_attestation(Slot::new(1), 0, ctx.genesis_block_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!( + matches!(result, Err(PayloadAttestationError::BlockNotAtSlot { .. })), + "expected BlockNotAtSlot, got: {:?}", + result + ); +} + #[test] fn not_in_ptc() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { @@ -174,7 +200,7 @@ fn not_in_ptc() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(1); + let slot = Slot::new(0); let ptc_members = ctx.ptc_members(slot); let non_ptc_validator = (0..NUM_VALIDATORS as u64) @@ -196,7 +222,7 @@ fn invalid_signature() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(1); + let slot = Slot::new(0); let ptc_members = ctx.ptc_members(slot); let validator_index = ptc_members[0] as u64; @@ -216,7 +242,7 @@ fn valid_payload_attestation() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(1); + let slot = Slot::new(0); let ptc_members = ctx.ptc_members(slot); let validator_index = ptc_members[0] as u64; @@ -243,7 +269,7 @@ fn duplicate_after_valid() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(1); + let slot = Slot::new(0); let ptc_members = ctx.ptc_members(slot); let validator_index = ptc_members[0] as u64; @@ -271,6 +297,161 @@ fn duplicate_after_valid() { )); } +#[tokio::test] +async fn harness_builds_and_imports_payload_attestation_messages() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let slot = Slot::new(1); + let beacon_block_root = ctx.harness.extend_to_slot(slot).await; + let state = &ctx.harness.chain.head_snapshot().beacon_state; + assert_eq!(state.slot(), slot); + let ptc = state.get_ptc(slot, &ctx.harness.spec).unwrap(); + let mut ptc_weights = std::collections::HashMap::new(); + for validator_index in ptc.0.iter().copied() { + *ptc_weights.entry(validator_index).or_insert(0usize) += 1; + } + let votes = vec![ + PayloadAttestationVote { + validator_count: 2, + payload_present: true, + blob_data_available: true, + }, + PayloadAttestationVote { + validator_count: 3, + payload_present: false, + blob_data_available: false, + }, + ]; + + let (messages, attesters) = ctx.harness.make_payload_attestation_messages_with_opts( + &ctx.harness.get_all_validators(), + state, + beacon_block_root, + slot, + MakePayloadAttestationOptions { + votes, + fork: state.fork(), + }, + ); + + assert_eq!(messages.len(), attesters.len()); + assert_eq!( + attesters + .iter() + .copied() + .collect::>() + .len(), + attesters.len() + ); + assert_eq!( + messages + .iter() + .filter(|message| message.data.payload_present && message.data.blob_data_available) + .map(|message| ptc_weights[&(message.validator_index as usize)]) + .sum::(), + 2 + ); + assert_eq!( + messages + .iter() + .filter(|message| !message.data.payload_present && !message.data.blob_data_available) + .map(|message| ptc_weights[&(message.validator_index as usize)]) + .sum::(), + 3 + ); + + let pool_count_before = ctx.harness.chain.op_pool.num_payload_attestation_messages(); + ctx.harness + .import_payload_attestation_messages(messages) + .expect("payload attestation messages should import"); + assert_eq!( + ctx.harness.chain.op_pool.num_payload_attestation_messages(), + pool_count_before + attesters.len() + ); +} + +#[tokio::test] +async fn harness_packs_payload_attestation_messages_by_ptc_weight() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let slot = Slot::new(1); + let beacon_block_root = ctx.harness.extend_to_slot(slot).await; + let state = &ctx.harness.chain.head_snapshot().beacon_state; + assert_eq!(state.slot(), slot); + let ptc = state.get_ptc(slot, &ctx.harness.spec).unwrap(); + let mut ptc_weights = std::collections::HashMap::new(); + let mut ptc_validator_order = vec![]; + for validator_index in ptc.0.iter().copied() { + if let Some(weight) = ptc_weights.get_mut(&validator_index) { + *weight += 1; + } else { + ptc_weights.insert(validator_index, 1usize); + ptc_validator_order.push(validator_index); + } + } + let mut sorted_ptc_validators = ptc_validator_order + .into_iter() + .enumerate() + .map(|(order, validator_index)| (validator_index, ptc_weights[&validator_index], order)) + .collect::>(); + sorted_ptc_validators.sort_by(|(_, weight_a, order_a), (_, weight_b, order_b)| { + weight_b.cmp(weight_a).then(order_a.cmp(order_b)) + }); + let first_weight = sorted_ptc_validators + .first() + .map(|(_, weight, _)| *weight) + .expect("PTC should have at least one validator"); + assert!(first_weight > 1, "test requires a duplicate PTC member"); + let second_weight = sorted_ptc_validators + .iter() + .skip(1) + .map(|(_, weight, _)| *weight) + .next() + .expect("PTC should have at least two distinct validators"); + let requested_weight = first_weight + second_weight; + + let (messages, attesters) = ctx.harness.make_payload_attestation_messages_with_opts( + &ctx.harness.get_all_validators(), + state, + beacon_block_root, + slot, + MakePayloadAttestationOptions { + votes: vec![PayloadAttestationVote { + validator_count: requested_weight, + payload_present: true, + blob_data_available: true, + }], + fork: state.fork(), + }, + ); + + assert!( + messages.len() < requested_weight, + "duplicate PTC positions should pack into fewer messages" + ); + assert_eq!(messages.len(), attesters.len()); + assert_eq!( + attesters + .iter() + .map(|validator_index| ptc_weights[validator_index]) + .sum::(), + requested_weight + ); + assert!( + attesters + .iter() + .any(|validator_index| ptc_weights[validator_index] > 1) + ); + + ctx.harness + .import_payload_attestation_messages(messages) + .expect("weighted payload attestation messages should import"); +} + #[tokio::test] async fn ptc_cache_is_primed_at_gloas_fork_boundary() { // Only run this test once, when FORK_NAME=gloas exactly. @@ -300,10 +481,8 @@ async fn ptc_cache_is_primed_at_gloas_fork_boundary() { .mock_execution_layer() .build(); - harness.extend_to_slot(fork_boundary_slot).await; - for slot in test_slots { - harness.chain.slot_clock.set_slot(slot.as_u64()); + harness.extend_to_slot(slot).await; assert!( harness .chain @@ -350,10 +529,9 @@ async fn ptc_cache_is_primed_at_gloas_fork_boundary() { } } -/// Exercises payload attestation gossip verification when the message epoch is ahead of the -/// canonical head due to many missed slots. +/// Check that a payload attestation whose assigned slot is empty is ignored. #[tokio::test] -async fn stale_head_payload_attestation() { +async fn stale_head_empty_slot_payload_attestation_ignored() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } @@ -363,9 +541,9 @@ async fn stale_head_payload_attestation() { let head_slot = Slot::new(slots_per_epoch); let missed_epochs = 4; let target_slot = Slot::new(slots_per_epoch * (1 + missed_epochs)); - let target_epoch = target_slot.epoch(slots_per_epoch); - // GIVEN a chain with blocks through epoch 1 (so the store has states). + // Given a chain with blocks through epoch 1, then a slot clock advanced 4 epochs without + // producing blocks (simulating missed slots). let harness = BeaconChainHarness::builder(E::default()) .default_spec() .deterministic_keypairs(64) @@ -373,71 +551,30 @@ async fn stale_head_payload_attestation() { .mock_execution_layer() .build(); harness.extend_to_slot(head_slot).await; - - let head = harness.chain.canonical_head.cached_head(); - let head_epoch = head.snapshot.beacon_state.current_epoch(); - assert!( - target_epoch > head_epoch + harness.spec.min_seed_lookahead, - "precondition: message epoch must exceed head + min_seed_lookahead" - ); - - // GIVEN a slot clock advanced to epoch 5 without producing blocks - // (simulating missed slots during a liveness failure). harness.chain.slot_clock.set_slot(target_slot.as_u64()); - // Advance a reference state to compute the PTC at the target slot. - let mut reference_state = head.snapshot.beacon_state.clone(); - state_processing::state_advance::partial_state_advance( - &mut reference_state, - Some(head.snapshot.beacon_state_root()), - target_slot, - &harness.spec, - ) - .expect("should advance reference state"); - reference_state - .build_all_caches(&harness.spec) - .expect("should build caches"); + let head = harness.chain.canonical_head.cached_head(); - let ptc = reference_state - .get_ptc(target_slot, &harness.spec) - .expect("should get PTC from reference state"); - let validator_index = *ptc.0.first().expect("PTC should have at least one member") as u64; - - // WHEN a properly-signed payload attestation from a PTC member is verified. The signature - // domain should come from the spec fork schedule and genesis validators root, not a loaded - // state in the verifier. - let domain = harness.spec.get_domain( - target_epoch, - Domain::PTCAttester, - &reference_state.fork(), - reference_state.genesis_validators_root(), - ); + // When a payload attestation for empty target slot references a stale block root + // it is ignored because target_slot != block.slot let data = PayloadAttestationData { beacon_block_root: head.head_block_root(), slot: target_slot, payload_present: true, blob_data_available: true, }; - let message = data.signing_root(domain); - let signature = harness.validator_keypairs[validator_index as usize] - .sk - .sign(message); let msg = PayloadAttestationMessage { - validator_index, + validator_index: 0, data, - signature, + signature: Signature::empty(), }; - // THEN verification succeeds despite the head being 4 epochs stale. let result = harness .chain .verify_payload_attestation_message_for_gossip(msg); assert!( - result.is_ok(), - "expected Ok (head epoch {}, message epoch {}), got: {:?}", - head_epoch, - target_epoch, - result.unwrap_err() + matches!(result, Err(PayloadAttestationError::BlockNotAtSlot { .. })), + "expected BlockNotAtSlot, got: {result:?}" ); } diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs index 354705b92c..1687760d25 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs @@ -144,9 +144,17 @@ impl GossipVerifiedPayloadBid { let fork_choice = ctx.canonical_head.fork_choice_read_lock(); // TODO(gloas) reprocess bids whose parent_block_root becomes known & canonical after a reorg? - if !fork_choice.contains_block(&bid_parent_block_root) { - return Err(PayloadBidError::ParentBlockRootUnknown { + let parent_block = fork_choice.get_block(&bid_parent_block_root).ok_or( + PayloadBidError::ParentBlockRootUnknown { parent_block_root: bid_parent_block_root, + }, + )?; + + // [REJECT] The bid is for a higher slot than its parent block. + if bid_slot <= parent_block.slot { + return Err(PayloadBidError::BidNotDescendantOfParent { + bid_slot, + parent_slot: parent_block.slot, }); } diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs index a40fd14872..e23c537e18 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -37,6 +37,8 @@ pub enum PayloadBidError { }, /// The bids slot is not the current slot or the next slot. InvalidBidSlot { bid_slot: Slot }, + /// The bid's slot is not greater than the slot of its parent block. + BidNotDescendantOfParent { bid_slot: Slot, parent_slot: Slot }, /// The slot clock cannot be read. UnableToReadSlot, /// No proposer preferences for the current slot. diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index ccdf64d41d..e764f0beb5 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -223,6 +223,7 @@ impl TestContext { execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), execution_payload_block_hash: Some(ExecutionBlockHash::repeat_byte(0xab)), proposer_index: Some(0), + payload_received: false, }, Slot::new(1), &self.spec, @@ -310,7 +311,7 @@ fn builder_already_seen_for_slot() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid = make_signed_bid(slot, 42, Address::ZERO, 30_000_000, 100, Hash256::ZERO); @@ -336,7 +337,7 @@ fn bid_value_below_cached() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let high_bid = GossipVerifiedPayloadBid { @@ -384,7 +385,7 @@ fn fee_recipient_mismatch() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::repeat_byte(0xaa), 30_000_000); let bid = make_signed_bid( @@ -406,7 +407,7 @@ fn gas_limit_mismatch() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid = make_signed_bid( @@ -428,7 +429,7 @@ fn execution_payment_nonzero() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid = Arc::new(SignedExecutionPayloadBid { @@ -455,7 +456,7 @@ fn unknown_builder_index() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); // Use a builder_index that doesn't exist in the registry. @@ -483,7 +484,7 @@ fn inactive_builder() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid = make_signed_bid( @@ -508,7 +509,7 @@ fn builder_cant_cover_bid() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); // Builder index 0 exists but bid value far exceeds their balance. @@ -534,7 +535,7 @@ fn parent_block_root_unknown() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); // Parent block root not in fork choice. @@ -556,7 +557,9 @@ fn parent_block_root_not_canonical() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + // The non-canonical fork block is at slot 1, so use slot 2 to satisfy the `bid.slot > parent + // block slot` rule and exercise the bid descendant from parent check specifically. + let slot = Slot::new(2); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let fork_root = ctx.insert_non_canonical_block(); @@ -570,6 +573,36 @@ fn parent_block_root_not_canonical() { ); } +#[test] +fn bid_slot_not_after_parent() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + // The genesis (parent) block is at slot 0, so a bid at slot 0 is not for a higher slot than + // its parent and must be rejected. + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + matches!( + result, + Err(PayloadBidError::BidNotDescendantOfParent { .. }) + ), + "expected BidNotDescendantOfParent, got: {result:?}" + ); +} + #[test] fn invalid_blob_kzg_commitments() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { @@ -577,7 +610,7 @@ fn invalid_blob_kzg_commitments() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let max_blobs = ctx @@ -614,7 +647,7 @@ fn bad_signature() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); // All checks pass but signature is empty/invalid. @@ -643,7 +676,7 @@ fn valid_bid() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid = ctx.sign_bid(ExecutionPayloadBid { @@ -670,7 +703,7 @@ fn two_builders_coexist_in_cache() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid_0 = ctx.sign_bid(ExecutionPayloadBid { @@ -725,7 +758,7 @@ fn bid_equal_to_cached_value_rejected() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); // Seed a cached bid with value 100. diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs index 00806f0e17..29782b3294 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -108,18 +108,15 @@ impl BeaconChain { // Verify and import the payload envelope. match import_envelope.await { // The payload envelope was successfully verified and imported. - Ok(status @ AvailabilityProcessingStatus::Imported(block_root)) => { + Ok(status @ AvailabilityProcessingStatus::Imported(slot, block_root)) => { info!( ?block_root, - %block_slot, + %slot, source = %envelope_source, "Execution payload envelope imported" ); // TODO(gloas) do we need to send a `PayloadImported` event to the reprocess queue? - // TODO(gloas) do we need to recompute head? - // should canonical_head return the block and the payload now? - self.recompute_head_at_current_slot().await; metrics::inc_counter(&metrics::ENVELOPE_PROCESSING_SUCCESSES); @@ -198,6 +195,7 @@ impl BeaconChain { block_root, payload_verification_outcome, } = *envelope; + let slot = envelope.envelope.slot(); let block_root = { let chain = self.clone(); @@ -214,7 +212,7 @@ impl BeaconChain { .await?? }; - Ok(AvailabilityProcessingStatus::Imported(block_root)) + Ok(AvailabilityProcessingStatus::Imported(slot, block_root)) } /// Accepts a fully-verified and available envelope and imports it into the chain without performing any diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs index a0d34949c6..1dc8418420 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs @@ -17,20 +17,21 @@ //! ExecutedEnvelope //! //! ``` - +use crate::data_availability_checker::AvailabilityCheckError; +use crate::{ + BeaconChainError, BeaconChainTypes, BeaconStore, BlockError, CustodyContext, + ExecutionPayloadError, PayloadVerificationError, PayloadVerificationOutcome, +}; use state_processing::envelope_processing::EnvelopeProcessingError; +use std::collections::HashSet; use std::sync::Arc; use store::Error as DBError; use strum::AsRefStr; -use tracing::instrument; +use tracing::{instrument, warn}; use types::{ BeaconState, BeaconStateError, DataColumnSidecarList, EthSpec, ExecutionBlockHash, - ExecutionPayloadEnvelope, Hash256, SignedExecutionPayloadEnvelope, Slot, -}; - -use crate::{ - BeaconChainError, BeaconChainTypes, BeaconStore, BlockError, ExecutionPayloadError, - PayloadVerificationError, PayloadVerificationOutcome, + ExecutionPayloadEnvelope, Hash256, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + Slot, }; pub mod execution_pending_envelope; @@ -47,11 +48,76 @@ pub struct AvailableEnvelope { } impl AvailableEnvelope { - pub fn new( + /// Constructs an `AvailableEnvelope` from an envelope and custody column data. + /// + /// This function validates that: + /// - All required custody columns are present + /// + /// If more columns are provided than necessary, a warning is logged and the extra + /// columns are filtered out of the list. + /// + /// Returns `AvailabilityCheckError` if: + /// - `MissingCustodyColumns`: Required custody columns are missing or incomplete + pub fn new( envelope: Arc>, columns: DataColumnSidecarList, - ) -> Self { - Self { envelope, columns } + bid: &SignedExecutionPayloadBid, + custody_context: &CustodyContext, + ) -> Result + where + T: BeaconChainTypes, + { + if custody_context.data_columns_required_for_bid(bid) { + let columns_expected = custody_context.num_of_data_columns_to_sample(bid.epoch()); + + // Get required custody column indices + let required_indices = custody_context + .sampling_columns_for_epoch(bid.epoch()) + .iter() + .copied() + .collect::>(); + + // Filter to only the columns we need (deduplicates if there are duplicates) + let mut filtered_columns = Vec::new(); + let mut seen_indices = HashSet::new(); + let num_provided_columns = columns.len(); + for column in columns { + if required_indices.contains(column.index()) && seen_indices.insert(*column.index()) + { + filtered_columns.push(column); + } + } + + // Check if we have all required columns + if filtered_columns.len() != columns_expected { + return Err(AvailabilityCheckError::MissingCustodyColumns); + } + + if num_provided_columns != filtered_columns.len() { + warn!( + message = "More columns provided than expected", + envelope = %envelope.message.payload.block_hash, + num_provided_columns = %num_provided_columns, + columns_expected = %columns_expected, + ); + } + + Ok(Self { + envelope, + columns: filtered_columns, + }) + } else if columns.is_empty() { + Ok(Self { envelope, columns }) + } else { + warn!( + message = "Custody columns provided for envelope that does not require them", + envelope = %envelope.message.payload.block_hash, + ); + Ok(Self { + envelope, + columns: vec![], + }) + } } pub fn envelope(&self) -> &Arc> { diff --git a/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs b/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs index 2100a5fe9f..33271d6d0e 100644 --- a/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs +++ b/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs @@ -15,13 +15,12 @@ use crate::payload_envelope_verification::{ AvailabilityPendingExecutedEnvelope, AvailableExecutedEnvelope, }; use crate::{BeaconChainTypes, CustodyContext, metrics}; +use hashlink::lru_cache::LruCache; use kzg::Kzg; -use lru::LruCache; use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; use std::collections::HashMap; use std::fmt; use std::fmt::Debug; -use std::num::NonZeroUsize; use std::sync::Arc; use tracing::{Span, debug, error, instrument}; use types::{ @@ -41,7 +40,6 @@ use crate::metrics::{ use crate::observed_data_sidecars::ObservationStrategy; use pending_components::{PendingComponents, ReconstructColumnsDecision}; use types::SignedExecutionPayloadBid; -use types::new_non_zero_usize; /// The LRU Cache stores `PendingComponents`, which store the block root, the execution payload bid, and its associated column data. /// The execution payload bid stores the kzg commitments which we use to verify against incoming column data. @@ -49,7 +47,7 @@ use types::new_non_zero_usize; /// /// `PendingComponents` are now never removed from the cache manually and are only removed via LRU /// eviction to prevent race conditions (#7961), so we expect this cache to be full all the time. -const AVAILABILITY_CACHE_CAPACITY: NonZeroUsize = new_non_zero_usize(32); +const AVAILABILITY_CACHE_CAPACITY: usize = 32; /// This type is returned after adding a bid / column to the `DataAvailabilityChecker`. /// @@ -94,14 +92,14 @@ pub struct PendingPayloadCache { /// Contains all the data we keep in memory, protected by an RwLock availability_cache: RwLock>>, kzg: Arc, - custody_context: Arc>, + custody_context: Arc>, spec: Arc, } impl PendingPayloadCache { pub fn new( kzg: Arc, - custody_context: Arc>, + custody_context: Arc>, spec: Arc, ) -> Result { Ok(Self { @@ -112,7 +110,7 @@ impl PendingPayloadCache { }) } - pub fn custody_context(&self) -> &Arc> { + pub fn custody_context(&self) -> &Arc> { &self.custody_context } @@ -176,7 +174,6 @@ impl PendingPayloadCache { &self, executed_envelope: AvailabilityPendingExecutedEnvelope, ) -> Result, AvailabilityCheckError> { - let epoch = executed_envelope.envelope.epoch(); let beacon_block_root = executed_envelope.envelope.beacon_block_root(); let bid = self .get_bid(&beacon_block_root) @@ -187,26 +184,24 @@ impl PendingPayloadCache { pending_components.insert_executed_payload_envelope(executed_envelope); })?; - let num_expected_columns = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); - pending_components.span.in_scope(|| { debug!( component = "executed envelope", - status = pending_components.status_str(num_expected_columns), + status = pending_components.status_str(&self.custody_context), "Component added to data availability checker" ); }); - self.check_availability(beacon_block_root, pending_components, num_expected_columns) + self.check_availability(beacon_block_root, pending_components) } /// Inserts a bid into the pending payload cache. /// This will silently drop the bid if a bid for this block root already exists in the cache. pub fn insert_bid(&self, block_root: Hash256, bid: Arc>) { let mut write_lock = self.availability_cache.write(); - write_lock.get_or_insert_mut(block_root, || PendingComponents::new(block_root, bid)); + write_lock + .entry(block_root) + .or_insert_with(|| PendingComponents::new(block_root, bid)); } /// Perform KZG verification on RPC custody columns and insert them into the cache. @@ -228,9 +223,7 @@ impl PendingPayloadCache { .map_err(AvailabilityCheckError::InvalidColumn)?; let epoch = bid.message.slot.epoch(T::EthSpec::slots_per_epoch()); - let sampling_columns = self - .custody_context - .sampling_columns_for_epoch(epoch, &self.spec); + let sampling_columns = self.custody_context.sampling_columns_for_epoch(epoch); let verified_custody_columns = kzg_verified_columns .into_iter() .filter(|col| sampling_columns.contains(&col.index())) @@ -252,9 +245,7 @@ impl PendingPayloadCache { .get_bid(&block_root) .ok_or(AvailabilityCheckError::MissingBid(block_root))?; let epoch = bid.message.slot.epoch(T::EthSpec::slots_per_epoch()); - let sampling_columns = self - .custody_context - .sampling_columns_for_epoch(epoch, &self.spec); + let sampling_columns = self.custody_context.sampling_columns_for_epoch(epoch); let custody_columns = data_columns .into_iter() .filter(|col| sampling_columns.contains(&col.index())) @@ -280,21 +271,15 @@ impl PendingPayloadCache { pending_components.merge_data_columns(kzg_verified_data_columns) })?; - let epoch = bid.message.slot.epoch(T::EthSpec::slots_per_epoch()); - - let num_expected_columns = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); - pending_components.span.in_scope(|| { debug!( component = "data_columns", - status = pending_components.status_str(num_expected_columns), + status = pending_components.status_str(&self.custody_context), "Component added to data availability checker" ); }); - self.check_availability(block_root, pending_components, num_expected_columns) + self.check_availability(block_root, pending_components) } #[instrument(skip_all, level = "debug")] @@ -340,7 +325,7 @@ impl PendingPayloadCache { let slot = bid.message.slot; let columns_to_sample = self .custody_context() - .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch()), &self.spec); + .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch())); let data_columns_to_import_and_publish = all_data_columns .into_iter() @@ -388,9 +373,10 @@ impl PendingPayloadCache { &self, block_root: Hash256, pending_components: MappedRwLockReadGuard<'_, PendingComponents>, - num_expected_columns: usize, ) -> Result, AvailabilityCheckError> { - if let Some(available_envelope) = pending_components.make_available(num_expected_columns)? { + if let Some(available_envelope) = + pending_components.make_available(&self.custody_context)? + { // Explicitly drop read lock before acquiring write lock drop(pending_components); if let Some(components) = self.availability_cache.write().get_mut(&block_root) { @@ -423,7 +409,8 @@ impl PendingPayloadCache { { let pending_components = write_lock - .get_or_insert_mut(block_root, || PendingComponents::new(block_root, bid)); + .entry(block_root) + .or_insert_with(|| PendingComponents::new(block_root, bid)); update_fn(pending_components) } @@ -457,9 +444,7 @@ impl PendingPayloadCache { let epoch = pending_components.bid.epoch(); let total_column_count = T::EthSpec::number_of_columns(); - let sampling_column_count = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); + let sampling_column_count = self.custody_context.num_of_data_columns_to_sample(epoch); if pending_components.reconstruction_started { return ReconstructColumnsDecision::No("already started"); @@ -496,7 +481,7 @@ impl PendingPayloadCache { } } for key in keys_to_remove { - write_lock.pop(&key); + write_lock.remove(&key); } Ok(()) @@ -515,10 +500,12 @@ mod data_availability_checker_tests { }; use fork_choice::PayloadVerificationStatus; use logging::create_test_tracing_subscriber; + use slot_clock::{SlotClock, TestingSlotClock}; + use std::time::Duration; use types::test_utils::test_unstructured; use types::{ ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, ForkName, - MinimalEthSpec, SignedExecutionPayloadEnvelope, + MinimalEthSpec, SignedExecutionPayloadEnvelope, Slot, }; type E = MinimalEthSpec; @@ -540,10 +527,18 @@ mod data_availability_checker_tests { create_test_tracing_subscriber(); let spec = Arc::new(ForkName::Gloas.make_genesis_spec(E::default_spec())); let kzg = get_kzg(&spec); - let custody_context = Arc::new(CustodyContext::::new( + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + let complete_blob_backfill = false; + let custody_context = Arc::new(CustodyContext::::new( node_custody, generate_data_column_indices_rand_order::(), - &spec, + slot_clock, + complete_blob_backfill, + spec.clone(), )); let cache = Arc::new( PendingPayloadCache::::new(kzg, custody_context, spec.clone()) @@ -566,9 +561,7 @@ mod data_availability_checker_tests { cache.insert_bid(block_root, bid.clone()); let epoch = bid.message.slot.epoch(E::slots_per_epoch()); - let sampling = cache - .custody_context() - .sampling_columns_for_epoch(epoch, &cache.spec); + let sampling = cache.custody_context().sampling_columns_for_epoch(epoch); let custody = columns .into_iter() .filter(|c| sampling.contains(c.index())) diff --git a/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs b/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs index e7b9009577..dac0180be8 100644 --- a/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs +++ b/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs @@ -1,3 +1,5 @@ +use crate::beacon_chain::BeaconChainTypes; +use crate::custody_context::CustodyContext; use crate::data_availability_checker::AvailabilityCheckError; use crate::data_column_verification::KzgVerifiedCustodyDataColumn; use crate::payload_envelope_verification::AvailabilityPendingExecutedEnvelope; @@ -27,8 +29,15 @@ pub struct PendingComponents { } impl PendingComponents { - pub fn num_blobs_expected(&self) -> usize { - self.bid.message.blob_kzg_commitments.len() + pub fn num_columns_required(&self, custody_context: &CustodyContext) -> usize + where + T: BeaconChainTypes, + { + if custody_context.data_columns_required_for_bid(&self.bid) { + custody_context.num_of_data_columns_to_sample(self.bid.epoch()) + } else { + 0 + } } /// Returns columns that have all cells present. @@ -59,7 +68,7 @@ impl PendingComponents { &mut self, kzg_verified_data_columns: &[KzgVerifiedCustodyDataColumn], ) { - let num_blobs_expected = self.num_blobs_expected(); + let num_blobs_expected = self.bid.num_blobs_expected(); for data_column in kzg_verified_data_columns { let data_column = data_column.as_data_column(); // The Vec-backed `PendingColumn` keys cells by index, so we have to allocate up to @@ -95,10 +104,13 @@ impl PendingComponents { } /// Returns `Some` if the envelope and all required data columns have been received. - pub fn make_available( + pub fn make_available( &self, - num_expected_columns: usize, - ) -> Result>, AvailabilityCheckError> { + custody_context: &CustodyContext, + ) -> Result>, AvailabilityCheckError> + where + T: BeaconChainTypes, + { // Check if the payload has been received and executed let Some(envelope) = &self.envelope else { return Ok(None); @@ -110,17 +122,24 @@ impl PendingComponents { payload_verification_outcome, } = envelope; - let columns = if self.num_blobs_expected() == 0 { - self.span.in_scope(|| { - debug!("Bid has no blobs, data is available"); - }); + let num_columns_required = self.num_columns_required(custody_context); + let columns = if num_columns_required == 0 { + if self.bid.num_blobs_expected() == 0 { + self.span.in_scope(|| { + debug!("Bid has no blobs, data is available"); + }); + } else { + self.span.in_scope(|| { + debug!("No data columns required for this epoch"); + }); + } vec![] } else { let columns = self.get_cached_data_columns(); - match columns.len().cmp(&num_expected_columns) { + match columns.len().cmp(&num_columns_required) { Ordering::Greater => { return Err(AvailabilityCheckError::Unexpected(format!( - "too many columns: got {} expected {num_expected_columns}", + "too many columns: got {} expected {num_columns_required}", columns.len() ))); } @@ -137,7 +156,8 @@ impl PendingComponents { } }; - let available_envelope = AvailableEnvelope::new(envelope.clone(), columns); + let available_envelope = + AvailableEnvelope::new(envelope.clone(), columns, &self.bid, custody_context)?; Ok(Some(AvailableExecutedEnvelope { envelope: available_envelope, @@ -160,12 +180,16 @@ impl PendingComponents { } } - pub fn status_str(&self, num_expected_columns: usize) -> String { + pub fn status_str(&self, custody_context: &CustodyContext) -> String + where + T: BeaconChainTypes, + { + let num_columns_required = self.num_columns_required(custody_context); format!( "envelope {}, data_columns {}/{}", self.envelope.is_some(), self.num_completed_columns(), - num_expected_columns + num_columns_required ) } } diff --git a/beacon_node/beacon_chain/src/pre_finalization_cache.rs b/beacon_node/beacon_chain/src/pre_finalization_cache.rs index 54bd5c1940..b90d02db5e 100644 --- a/beacon_node/beacon_chain/src/pre_finalization_cache.rs +++ b/beacon_node/beacon_chain/src/pre_finalization_cache.rs @@ -1,15 +1,13 @@ use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; +use hashlink::lru_cache::LruCache; use itertools::process_results; -use lru::LruCache; use parking_lot::Mutex; -use std::num::NonZeroUsize; use std::time::Duration; use tracing::debug; use types::Hash256; -use types::new_non_zero_usize; -const BLOCK_ROOT_CACHE_LIMIT: NonZeroUsize = new_non_zero_usize(512); -const LOOKUP_LIMIT: NonZeroUsize = new_non_zero_usize(8); +const BLOCK_ROOT_CACHE_LIMIT: usize = 512; +const LOOKUP_LIMIT: usize = 8; const METRICS_TIMEOUT: Duration = Duration::from_millis(100); /// Cache for rejecting attestations to blocks from before finalization. @@ -49,13 +47,13 @@ impl BeaconChain { let mut cache = self.pre_finalization_block_cache.cache.lock(); // Check the cache to see if we already know this pre-finalization block root. - if cache.block_roots.contains(&block_root) { + if cache.block_roots.contains_key(&block_root) { return Ok(true); } // Avoid repeating the disk lookup for blocks that are already subject to a network lookup. // Sync will take care of de-duplicating the single block lookups. - if cache.in_progress_lookups.contains(&block_root) { + if cache.in_progress_lookups.contains_key(&block_root) { return Ok(false); } @@ -68,19 +66,19 @@ impl BeaconChain { .map_err(BeaconChainError::BeaconStateError) })?; if is_recent_finalized_block { - cache.block_roots.put(block_root, ()); + cache.block_roots.insert(block_root, ()); return Ok(true); } // 2. Check on disk. if self.store.get_blinded_block(&block_root)?.is_some() { - cache.block_roots.put(block_root, ()); + cache.block_roots.insert(block_root, ()); return Ok(true); } // 3. Check the network with a single block lookup. - cache.in_progress_lookups.put(block_root, ()); - if cache.in_progress_lookups.len() == LOOKUP_LIMIT.get() { + cache.in_progress_lookups.insert(block_root, ()); + if cache.in_progress_lookups.len() == LOOKUP_LIMIT { // NOTE: we expect this to occur sometimes if a lot of blocks that we look up fail to be // imported for reasons other than being pre-finalization. The cache will eventually // self-repair in this case by replacing old entries with new ones until all the failed @@ -95,8 +93,8 @@ impl BeaconChain { pub fn pre_finalization_block_rejected(&self, block_root: Hash256) { // Future requests can know that this block is invalid without having to look it up again. let mut cache = self.pre_finalization_block_cache.cache.lock(); - cache.in_progress_lookups.pop(&block_root); - cache.block_roots.put(block_root, ()); + cache.in_progress_lookups.remove(&block_root); + cache.block_roots.insert(block_root, ()); } } @@ -104,11 +102,11 @@ impl PreFinalizationBlockCache { pub fn block_processed(&self, block_root: Hash256) { // Future requests will find this block in fork choice, so no need to cache it in the // ongoing lookup cache any longer. - self.cache.lock().in_progress_lookups.pop(&block_root); + self.cache.lock().in_progress_lookups.remove(&block_root); } pub fn contains(&self, block_root: Hash256) -> bool { - self.cache.lock().block_roots.contains(&block_root) + self.cache.lock().block_roots.contains_key(&block_root) } pub fn metrics(&self) -> Option<(usize, usize)> { diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs index 586721d8c1..4dc1646ec4 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs @@ -6,6 +6,7 @@ use crate::{ ProposerPreferencesError, proposer_preference_cache::GossipVerifiedProposerPreferenceCache, }, }; +use eth2::types::{EventKind, ForkVersionedResponse}; use slot_clock::SlotClock; use state_processing::signature_sets::{get_pubkey_from_state, proposer_preferences_signature_set}; use tracing::debug; @@ -145,6 +146,19 @@ impl BeaconChain { %validator_index, "Successfully verified gossip proposer preferences" ); + + if let Some(event_handler) = self.event_handler.as_ref() + && event_handler.has_proposer_preferences_subscribers() + { + event_handler.register(EventKind::ProposerPreferences(Box::new( + ForkVersionedResponse { + version: self.spec.fork_name_at_slot::(proposal_slot), + metadata: Default::default(), + data: (*verified.signed_preferences).clone(), + }, + ))); + } + Ok(verified) } Err(e) => { diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 62c7fb3a45..ce51a82cec 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -43,6 +43,7 @@ use logging::create_test_tracing_subscriber; use merkle_proof::MerkleTree; use operation_pool::ReceivedPreCapella; use parking_lot::{Mutex, RwLockWriteGuard}; +use proto_array::PayloadStatus; use rand::Rng; use rand::SeedableRng; use rand::rngs::StdRng; @@ -225,28 +226,30 @@ pub fn test_da_checker( spec: Arc, node_custody_type: NodeCustodyType, ) -> DataAvailabilityChecker> { + let kzg = get_kzg(&spec); + let custody_context = test_custody_context(node_custody_type, spec.clone()); + DataAvailabilityChecker::new(kzg, custody_context, spec, true, false) + .expect("should initialise data availability checker") +} + +pub fn test_custody_context( + node_custody_type: NodeCustodyType, + spec: Arc, +) -> Arc>> { + let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); + let complete_blob_backfill = false; let slot_clock = TestingSlotClock::new( Slot::new(0), Duration::from_secs(0), spec.get_slot_duration(), ); - let kzg = get_kzg(&spec); - let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); - let custody_context = Arc::new(CustodyContext::new( + Arc::new(CustodyContext::new( node_custody_type, ordered_custody_column_indices, - &spec, - )); - let complete_blob_backfill = false; - DataAvailabilityChecker::new( - complete_blob_backfill, slot_clock, - kzg, - custody_context, + complete_blob_backfill, spec, - true, - ) - .expect("should initialise data availability checker") + )) } pub struct Builder { @@ -752,11 +755,37 @@ pub type HarnessSingleAttestations = Vec<( Option>, )>; +pub type HarnessPayloadAttestationMessages = Vec; + pub type HarnessSyncContributions = Vec<( Vec<(SyncCommitteeMessage, usize)>, Option>, )>; +fn pack_payload_attestation_vote( + available_ptc_validators: &[(usize, usize, usize)], + requested_weight: usize, +) -> Option> { + let mut packs = vec![None::>; requested_weight.checked_add(1)?]; + packs[0] = Some(vec![]); + + for (offset, (_, weight, _)) in available_ptc_validators.iter().enumerate() { + if *weight > requested_weight { + continue; + } + + for weight_so_far in (0..=requested_weight - *weight).rev() { + if packs[weight_so_far].is_some() && packs[weight_so_far + *weight].is_none() { + let mut pack = packs[weight_so_far].as_ref()?.clone(); + pack.push(offset); + packs[weight_so_far + *weight] = Some(pack); + } + } + } + + packs.pop().flatten() +} + impl BeaconChainHarness> where E: EthSpec, @@ -1164,9 +1193,33 @@ where /// /// For pre-Gloas forks, the envelope is `None` and this behaves like `make_block`. pub async fn make_block_with_envelope( + &self, + state: BeaconState, + slot: Slot, + ) -> ( + SignedBlockContentsTuple, + Option>, + BeaconState, + ) { + let parent_payload_status = self + .chain + .canonical_head + .cached_head() + .head_payload_status(); + self.make_block_with_envelope_on(state, slot, parent_payload_status) + .await + } + + /// Returns a newly created block built with the given parent payload status, + /// signed by the proposer for the given slot, along with the execution + /// payload envelope (for Gloas) and the post-block state. + /// + /// For pre-Gloas forks, the envelope is `None` and this behaves like `make_block`. + pub async fn make_block_with_envelope_on( &self, mut state: BeaconState, slot: Slot, + parent_payload_status: PayloadStatus, ) -> ( SignedBlockContentsTuple, Option>, @@ -1189,15 +1242,21 @@ where GraffitiSettings::new(Some(graffiti), Some(GraffitiPolicy::PreserveUserGraffiti)); let randao_reveal = self.sign_randao_reveal(&state, proposer_index, slot); - // Load the parent's payload envelope and status from the cached head. - // TODO(gloas): we may want to pass these as arguments to support cases where we build - // on alternate chains to the head. - let (parent_payload_status, parent_envelope) = { - let head = self.chain.canonical_head.cached_head(); - ( - head.head_payload_status(), - head.snapshot.execution_envelope.clone(), - ) + let parent_envelope = if parent_payload_status == PayloadStatus::Full { + let parent_root = if state.slot() > 0 { + *state + .get_block_root(state.slot() - 1) + .expect("should get parent block root") + } else { + state.latest_block_header().canonical_root() + }; + self.chain + .store + .get_payload_envelope(&parent_root) + .expect("should load parent payload envelope") + .map(Arc::new) + } else { + None }; let (block, post_block_state, _consensus_block_value) = self @@ -2151,6 +2210,169 @@ where ) } + pub fn make_payload_attestation_message( + &self, + validator_index: usize, + data: PayloadAttestationData, + fork: &Fork, + ) -> PayloadAttestationMessage { + let epoch = data.slot.epoch(E::slots_per_epoch()); + let domain = self.spec.get_domain( + epoch, + Domain::PTCAttester, + fork, + self.chain.genesis_validators_root, + ); + let signing_root = data.signing_root(domain); + let signature = self.validator_keypairs[validator_index] + .sk + .sign(signing_root); + + PayloadAttestationMessage { + validator_index: validator_index as u64, + data, + signature, + } + } + + pub fn make_payload_attestation_messages( + &self, + state: &BeaconState, + beacon_block_root: Hash256, + slot: Slot, + votes: Vec, + ) -> (HarnessPayloadAttestationMessages, Vec) { + let fork = self.spec.fork_at_epoch(slot.epoch(E::slots_per_epoch())); + self.make_payload_attestation_messages_with_opts( + &self.get_all_validators(), + state, + beacon_block_root, + slot, + MakePayloadAttestationOptions { votes, fork }, + ) + } + + pub fn make_payload_attestation_messages_with_opts( + &self, + attesting_validators: &[usize], + state: &BeaconState, + beacon_block_root: Hash256, + slot: Slot, + opts: MakePayloadAttestationOptions, + ) -> (HarnessPayloadAttestationMessages, Vec) { + let MakePayloadAttestationOptions { votes, fork } = opts; + let ptc = state + .get_ptc(slot, &self.spec) + .expect("should get payload timeliness committee"); + + debug!("PTC is {:?}", ptc.0.to_vec()); + + let attesting_validators = attesting_validators.iter().copied().collect::>(); + let mut ptc_weights = HashMap::new(); + let mut ptc_validator_order = vec![]; + for validator_index in ptc + .0 + .iter() + .copied() + .filter(|validator_index| attesting_validators.contains(validator_index)) + { + if let Some(weight) = ptc_weights.get_mut(&validator_index) { + *weight += 1; + } else { + ptc_weights.insert(validator_index, 1usize); + ptc_validator_order.push(validator_index); + } + } + + let mut available_ptc_validators = ptc_validator_order + .into_iter() + .enumerate() + .map(|(order, validator_index)| { + let weight = ptc_weights[&validator_index]; + (validator_index, weight, order) + }) + .collect::>(); + available_ptc_validators.sort_by(|(_, weight_a, order_a), (_, weight_b, order_b)| { + weight_b.cmp(weight_a).then(order_a.cmp(order_b)) + }); + + let mut messages = Vec::new(); + let mut attesters = Vec::new(); + + for vote in votes { + let data = PayloadAttestationData { + beacon_block_root, + slot, + payload_present: vote.payload_present, + blob_data_available: vote.blob_data_available, + }; + + let Some(packed_validator_offsets) = + pack_payload_attestation_vote(&available_ptc_validators, vote.validator_count) + else { + let available_weights = available_ptc_validators + .iter() + .map(|(validator_index, weight, _)| (*validator_index, *weight)) + .collect::>(); + panic!( + "requested packing couldn't be formed for payload attestation vote {vote:?}; \ + requested PTC weight {}, available PTC weights {:?}", + vote.validator_count, available_weights + ); + }; + + for &offset in &packed_validator_offsets { + let validator_index = available_ptc_validators[offset].0; + messages.push(self.make_payload_attestation_message( + validator_index, + data.clone(), + &fork, + )); + attesters.push(validator_index); + } + + for offset in packed_validator_offsets.into_iter().rev() { + available_ptc_validators.remove(offset); + } + } + + (messages, attesters) + } + + pub fn import_payload_attestation_message( + &self, + message: PayloadAttestationMessage, + ) -> Result<(), PayloadAttestationImportError> { + let verified = self + .chain + .verify_payload_attestation_message_for_gossip(message) + .map_err(PayloadAttestationImportError::Verification)?; + + self.chain + .apply_payload_attestation_to_fork_choice( + verified.indexed_payload_attestation(), + verified.ptc(), + ) + .map_err(|e| PayloadAttestationImportError::ForkChoice(Box::new(e)))?; + + self.chain + .add_payload_attestation_to_pool(&verified) + .map_err(|e| PayloadAttestationImportError::Pool(Box::new(e)))?; + + Ok(()) + } + + pub fn import_payload_attestation_messages( + &self, + messages: impl IntoIterator, + ) -> Result<(), PayloadAttestationImportError> { + for message in messages { + self.import_payload_attestation_message(message)?; + } + + Ok(()) + } + pub fn make_sync_contributions( &self, state: &BeaconState, @@ -2158,6 +2380,21 @@ where slot: Slot, relative_sync_committee: RelativeSyncCommittee, ) -> HarnessSyncContributions { + // Resolve the committee for aggregator selection using the same relative committee as the + // messages. Selecting from `current_sync_committee` unconditionally would pick an + // aggregator outside the verifying committee at sync committee period boundaries (where + // `Next` is used), causing `AggregatorNotInCommittee`. + let sync_committee: Arc> = match relative_sync_committee { + RelativeSyncCommittee::Current => state + .current_sync_committee() + .expect("should be called on altair beacon state") + .clone(), + RelativeSyncCommittee::Next => state + .next_sync_committee() + .expect("should be called on altair beacon state") + .clone(), + }; + let sync_messages = self.make_sync_committee_messages(state, block_hash, slot, relative_sync_committee); @@ -2167,10 +2404,7 @@ where .map(|(subnet_id, committee_messages)| { // If there are any sync messages in this committee, create an aggregate. if let Some((sync_message, subcommittee_position)) = committee_messages.first() { - let sync_committee: Arc> = state - .current_sync_committee() - .expect("should be called on altair beacon state") - .clone(); + let sync_committee = sync_committee.clone(); let aggregator_index = sync_committee .get_subcommittee_pubkeys(subnet_id) @@ -2930,26 +3164,28 @@ where block: Arc>, ) -> RangeSyncBlock { let block_root = block_root.unwrap_or_else(|| get_block_root(&block)); - let is_gloas = block.fork_name_unchecked().gloas_enabled(); // For Gloas, kzg commitments live in the bid (`signed_execution_payload_bid`), so the // body's `blob_kzg_commitments()` accessor returns Err. `num_expected_blobs` already // handles both shapes. let has_blobs = block.num_expected_blobs() > 0; if !has_blobs { - return if is_gloas { + return if let Ok(bid) = block.message().body().signed_execution_payload_bid() { let envelope = self .chain .get_payload_envelope(&block_root) .unwrap() .map(Arc::new) - .map(|envelope| AvailableEnvelope::new(envelope, vec![])); + .map(|envelope| { + AvailableEnvelope::new(envelope, vec![], bid, &self.chain.custody_context) + }) + .transpose() + .unwrap(); RangeSyncBlock::new_gloas(block, envelope).unwrap() } else { RangeSyncBlock::new( block, AvailableBlockData::NoData, - &self.chain.data_availability_checker, - self.chain.spec.clone(), + &self.chain.custody_context, ) .unwrap() }; @@ -2964,23 +3200,26 @@ where .unwrap() .unwrap(); let custody_columns = columns.into_iter().collect::>(); - if is_gloas { + if let Ok(bid) = block.message().body().signed_execution_payload_bid() { let envelope = self .chain .get_payload_envelope(&block_root) .unwrap() .map(Arc::new) - .map(|envelope| AvailableEnvelope::new(envelope, custody_columns)); + .map(|envelope| { + AvailableEnvelope::new( + envelope, + custody_columns, + bid, + &self.chain.custody_context, + ) + }) + .transpose() + .unwrap(); RangeSyncBlock::new_gloas(block, envelope).unwrap() } else { let block_data = AvailableBlockData::new_with_data_columns(custody_columns); - RangeSyncBlock::new( - block, - block_data, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - ) - .unwrap() + RangeSyncBlock::new(block, block_data, &self.chain.custody_context).unwrap() } } else { let blobs = self.chain.get_blobs(&block_root).unwrap().blobs(); @@ -2990,13 +3229,7 @@ where AvailableBlockData::NoData }; - RangeSyncBlock::new( - block, - block_data, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - ) - .unwrap() + RangeSyncBlock::new(block, block_data, &self.chain.custody_context).unwrap() } } @@ -3006,7 +3239,7 @@ where block: Arc>>, blob_items: Option<(KzgProofs, BlobsList)>, ) -> Result, BlockError> { - if block.fork_name_unchecked().gloas_enabled() { + if let Ok(bid) = block.message().body().signed_execution_payload_bid() { let columns = blob_items .map(|_| generate_data_column_sidecars_from_block(&block, &self.spec)) .unwrap_or_default(); @@ -3015,13 +3248,17 @@ where .get_payload_envelope(&block.canonical_root()) .map_err(|e| BlockError::BeaconChainError(Box::new(e)))? .map(Arc::new) - .map(|envelope| AvailableEnvelope::new(envelope, columns)); + .map(|envelope| { + AvailableEnvelope::new(envelope, columns, bid, &self.chain.custody_context) + }) + .transpose() + .unwrap(); return RangeSyncBlock::new_gloas(block, envelope).map_err(BlockError::InternalError); } Ok(if self.spec.is_peer_das_enabled_for_epoch(block.epoch()) { let epoch = block.slot().epoch(E::slots_per_epoch()); - let sampling_columns = self.chain.sampling_columns_for_epoch(epoch); + let sampling_columns = self.chain.custody_context.sampling_columns_for_epoch(epoch); if blob_items.is_some_and(|(kzg_proofs, _)| !kzg_proofs.is_empty()) { // Note: this method ignores the actual custody columns and just take the first @@ -3032,18 +3269,12 @@ where .filter(|d| sampling_columns.contains(d.index())) .collect::>(); let block_data = AvailableBlockData::new_with_data_columns(columns); - RangeSyncBlock::new( - block, - block_data, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - )? + RangeSyncBlock::new(block, block_data, &self.chain.custody_context)? } else { RangeSyncBlock::new( block, AvailableBlockData::NoData, - &self.chain.data_availability_checker, - self.chain.spec.clone(), + &self.chain.custody_context, )? } } else { @@ -3059,12 +3290,7 @@ where AvailableBlockData::NoData }; - RangeSyncBlock::new( - block, - block_data, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - )? + RangeSyncBlock::new(block, block_data, &self.chain.custody_context)? }) } @@ -3239,14 +3465,24 @@ where if sync_committee_strategy == SyncCommitteeStrategy::AllValidators && new_state.current_sync_committee().is_ok() { + // A sync message for `slot` is verified against the committee of `epoch(slot + 1)` + // (see `BeaconChain::sync_committee_at_next_slot`), so we must sign with `Next` only + // when `slot + 1` crosses into a new sync committee period, not for the whole first + // epoch of the period. + let slots_per_epoch = E::slots_per_epoch(); + let crosses_period = slot + .epoch(slots_per_epoch) + .sync_committee_period(&self.spec) + .unwrap() + != (slot + 1) + .epoch(slots_per_epoch) + .sync_committee_period(&self.spec) + .unwrap(); self.sync_committee_sign_block( &new_state, block_hash.into(), slot, - if (slot + 1).epoch(E::slots_per_epoch()) - % self.spec.epochs_per_sync_committee_period - == 0 - { + if crosses_period { RelativeSyncCommittee::Next } else { RelativeSyncCommittee::Current @@ -3764,6 +4000,7 @@ where let custody_columns = custody_columns_opt.unwrap_or_else(|| { let epoch = block.slot().epoch(E::slots_per_epoch()); self.chain + .custody_context .sampling_columns_for_epoch(epoch) .iter() .copied() @@ -3806,6 +4043,28 @@ pub struct MakeAttestationOptions { pub payload_present_override: Option, } +#[derive(Debug, Clone, Copy)] +pub struct PayloadAttestationVote { + /// Amount of PTC weight to produce messages for this vote. + pub validator_count: usize, + pub payload_present: bool, + pub blob_data_available: bool, +} + +pub struct MakePayloadAttestationOptions { + /// Vote groups to produce. Each group becomes `validator_count` individual messages. + pub votes: Vec, + /// Fork to use for signing payload attestation messages. + pub fork: Fork, +} + +#[derive(Debug)] +pub enum PayloadAttestationImportError { + Verification(crate::payload_attestation_verification::Error), + ForkChoice(Box), + Pool(Box), +} + pub enum NumBlobs { Random, Number(usize), diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index 03b8ae58ac..ad369c79ee 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -1970,6 +1970,176 @@ async fn gloas_aggregated_attestation_same_slot_index_must_be_zero() { ); } +/// [New in Gloas]: An unaggregated attestation claiming payload-present (`data.index == 1`) for a +/// block whose payload envelope has not yet been seen (`payload_received == false`) must be +/// rejected with `UnknownPayloadEnvelope`, so it can be parked for re-processing once the envelope +/// arrives. +#[tokio::test] +async fn gloas_unaggregated_attestation_unknown_payload_envelope() { + if !test_spec::() + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } + + let harness = get_harness(VALIDATOR_COUNT); + + // Build some chain depth. `extend_chain` imports each block's payload envelope, so every block + // produced so far has `payload_received == true`. + harness + .extend_chain( + MainnetEthSpec::slots_per_epoch() as usize * 2, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce one more block but do NOT import its payload envelope, leaving the head block with + // `payload_received == false`. + let head = harness.chain.head_snapshot(); + let block_slot = head.beacon_block.slot() + 1; + let ((signed_block, blobs), _envelope, _post_state) = harness + .make_block_with_envelope(head.beacon_state.clone(), block_slot) + .await; + let block_root = signed_block.canonical_root(); + harness + .process_block(block_slot, block_root, (signed_block, blobs)) + .await + .expect("payload-less block should import"); + + // The block should be the head, and its payload envelope should not be recorded. + assert!( + !harness + .chain + .canonical_head + .fork_choice_read_lock() + .get_block(&block_root) + .expect("block should be in fork choice") + .payload_received, + "block should not have its payload envelope recorded" + ); + + // Advance a slot so the attestation slot is later than the (payload-less) head block's slot, + // which avoids the same-slot `index == 0` requirement. + harness.advance_slot(); + + // Produce a valid attestation for the head block, then claim payload-present (`index == 1`). + // The gloas payload-envelope check runs before signature verification, so mutating the index + // is sufficient to exercise the arm. + let (mut attestation, _attester_sk, subnet_id) = + get_valid_unaggregated_attestation(&harness.chain); + assert_eq!( + attestation.data.beacon_block_root, block_root, + "attestation should be for the payload-less head block" + ); + attestation.data.index = 1; + + let result = harness + .chain + .verify_unaggregated_attestation_for_gossip(&attestation, Some(subnet_id)); + assert!( + matches!( + result, + Err(AttnError::UnknownPayloadEnvelope { beacon_block_root }) + if beacon_block_root == block_root + ), + "gloas: payload-present attestation for a block with an unseen payload envelope should be \ + rejected with UnknownPayloadEnvelope, got {:?}", + result.err() + ); +} + +/// [New in Gloas]: The aggregate counterpart of +/// `gloas_unaggregated_attestation_unknown_payload_envelope`. An aggregate claiming payload-present +/// (`data.index == 1`) for a block whose payload envelope has not been seen must be rejected with +/// `UnknownPayloadEnvelope`. +#[tokio::test] +async fn gloas_aggregated_attestation_unknown_payload_envelope() { + // Skip unless running with the gloas fork, before paying for harness setup. + if !test_spec::() + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } + + let harness = get_harness(VALIDATOR_COUNT); + + // Build some chain depth. `extend_chain` imports each block's payload envelope, so every block + // produced so far has `payload_received == true`. + harness + .extend_chain( + MainnetEthSpec::slots_per_epoch() as usize * 2, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce one more block but do NOT import its payload envelope, leaving the head block with + // `payload_received == false`. + let head = harness.chain.head_snapshot(); + let block_slot = head.beacon_block.slot() + 1; + let ((signed_block, blobs), _envelope, _post_state) = harness + .make_block_with_envelope(head.beacon_state.clone(), block_slot) + .await; + let block_root = signed_block.canonical_root(); + harness + .process_block(block_slot, block_root, (signed_block, blobs)) + .await + .expect("payload-less block should import"); + + // Advance a slot so the attestation slot is later than the (payload-less) head block's slot, + // which avoids the same-slot `index == 0` requirement. + harness.advance_slot(); + + let head = harness.chain.head_snapshot(); + let current_slot = harness.chain.slot().expect("should get slot"); + + // Build a valid aggregate for the head block, then claim payload-present (`index == 1`). The + // gloas payload-envelope check runs before signature verification, so mutating the index is + // sufficient to exercise the arm. + let (valid_attestation, _, _) = get_valid_unaggregated_attestation(&harness.chain); + assert_eq!( + valid_attestation.data.beacon_block_root, block_root, + "attestation should be for the payload-less head block" + ); + let committee = head + .beacon_state + .get_beacon_committee(current_slot, valid_attestation.committee_index) + .expect("should get committee"); + let fork_name = harness + .spec + .fork_name_at_slot::(valid_attestation.data.slot); + let aggregate_attestation = + single_attestation_to_attestation(&valid_attestation, committee.committee, fork_name) + .unwrap(); + let (mut valid_aggregate, _, _) = + get_valid_aggregated_attestation(&harness.chain, aggregate_attestation); + + valid_aggregate + .as_electra_mut() + .unwrap() + .message + .aggregate + .data + .index = 1; + + let result = harness + .chain + .verify_aggregated_attestation_for_gossip(&valid_aggregate); + assert!( + matches!( + result, + Err(AttnError::UnknownPayloadEnvelope { beacon_block_root }) + if beacon_block_root == block_root + ), + "gloas: payload-present aggregate for a block with an unseen payload envelope should be \ + rejected with UnknownPayloadEnvelope, got {:?}", + result.err() + ); +} + /// Regression test: a SingleAttestation with a huge bogus attester_index must not be forwarded to /// the slasher. Previously the slasher received the IndexedAttestation before committee-membership /// validation, causing an OOM when the slasher tried to allocate based on the untrusted index. diff --git a/beacon_node/beacon_chain/tests/block_verification.rs b/beacon_node/beacon_chain/tests/block_verification.rs index fd164a0f8c..09b1572799 100644 --- a/beacon_node/beacon_chain/tests/block_verification.rs +++ b/beacon_node/beacon_chain/tests/block_verification.rs @@ -1,6 +1,7 @@ #![cfg(not(debug_assertions))] // TODO(gloas) we probably need similar test for payload envelope verification use beacon_chain::block_verification_types::{AsBlock, ExecutedBlock, LookupBlock, RangeSyncBlock}; +use beacon_chain::custody_context::CustodyContext; use beacon_chain::data_availability_checker::{AvailabilityCheckError, AvailableBlockData}; use beacon_chain::data_column_verification::CustodyDataColumn; use beacon_chain::payload_envelope_verification::AvailableEnvelope; @@ -10,7 +11,7 @@ use beacon_chain::{ custody_context::NodeCustodyType, test_utils::{ AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, - MakeAttestationOptions, test_spec, + MakeAttestationOptions, generate_data_column_sidecars_from_block, test_spec, }, }; use beacon_chain::{ @@ -178,7 +179,7 @@ fn build_range_sync_block( where T: BeaconChainTypes, { - if block.fork_name_unchecked().gloas_enabled() { + if let Ok(bid) = block.message().body().signed_execution_payload_bid() { let columns = match data_sidecars { Some(DataSidecars::DataColumns(columns)) => columns .iter() @@ -186,20 +187,17 @@ where .collect::>(), Some(DataSidecars::Blobs(_)) | None => vec![], }; - let envelope = execution_envelope.map(|envelope| AvailableEnvelope::new(envelope, columns)); + let envelope = execution_envelope + .map(|envelope| AvailableEnvelope::new(envelope, columns, bid, &chain.custody_context)) + .transpose() + .unwrap(); return RangeSyncBlock::new_gloas(block, envelope).unwrap(); } match data_sidecars { Some(DataSidecars::Blobs(blobs)) => { let block_data = AvailableBlockData::new_with_blobs(blobs.clone()); - RangeSyncBlock::new( - block, - block_data, - &chain.data_availability_checker, - chain.spec.clone(), - ) - .unwrap() + RangeSyncBlock::new(block, block_data, &chain.custody_context).unwrap() } Some(DataSidecars::DataColumns(columns)) => { let block_data = AvailableBlockData::new_with_data_columns( @@ -208,21 +206,11 @@ where .map(|c| c.as_data_column().clone()) .collect::>(), ); - RangeSyncBlock::new( - block, - block_data, - &chain.data_availability_checker, - chain.spec.clone(), - ) - .unwrap() + RangeSyncBlock::new(block, block_data, &chain.custody_context).unwrap() + } + None => { + RangeSyncBlock::new(block, AvailableBlockData::NoData, &chain.custody_context).unwrap() } - None => RangeSyncBlock::new( - block, - AvailableBlockData::NoData, - &chain.data_availability_checker, - chain.spec.clone(), - ) - .unwrap(), } } @@ -522,8 +510,7 @@ async fn chain_segment_non_linear_parent_roots() { RangeSyncBlock::new( mutated_block, blocks[3].block_data().clone(), - &harness.chain.data_availability_checker, - harness.spec.clone(), + &harness.chain.custody_context, ) .unwrap() }; @@ -567,8 +554,7 @@ async fn chain_segment_non_linear_slots() { RangeSyncBlock::new( mutated_block, blocks[3].block_data().clone(), - &harness.chain.data_availability_checker, - harness.spec.clone(), + &harness.chain.custody_context, ) .unwrap() }; @@ -602,8 +588,7 @@ async fn chain_segment_non_linear_slots() { RangeSyncBlock::new( mutated_block, blocks[3].block_data().clone(), - &harness.chain.data_availability_checker, - harness.chain.spec.clone(), + &harness.chain.custody_context, ) .unwrap() }; @@ -1809,8 +1794,7 @@ async fn add_base_block_to_altair_chain() { let base_range_sync_block = RangeSyncBlock::new( Arc::new(base_block.clone()), AvailableBlockData::NoData, - &harness.chain.data_availability_checker, - harness.spec.clone(), + &harness.chain.custody_context, ) .unwrap(); assert!(matches!( @@ -1840,8 +1824,7 @@ async fn add_base_block_to_altair_chain() { RangeSyncBlock::new( Arc::new(base_block), AvailableBlockData::NoData, - &harness.chain.data_availability_checker, - harness.spec.clone() + &harness.chain.custody_context, ) .unwrap() ], @@ -1985,8 +1968,7 @@ async fn add_altair_block_to_base_chain() { RangeSyncBlock::new( Arc::new(altair_block), AvailableBlockData::NoData, - &harness.chain.data_availability_checker, - harness.spec.clone() + &harness.chain.custody_context, ) .unwrap() ], @@ -2205,8 +2187,7 @@ async fn import_duplicate_block_unrealized_justification() { RangeSyncBlock::new( block.clone(), AvailableBlockData::NoData, - &harness.chain.data_availability_checker, - harness.spec.clone(), + &harness.chain.custody_context, ) .unwrap() }; @@ -2284,8 +2265,12 @@ async fn import_execution_pending_block( } } -async fn make_gloas_range_sync_block_inputs() --> Option<(Arc>, SignedExecutionPayloadEnvelope)> { +async fn make_gloas_range_sync_block_inputs() -> Option<( + Arc>, + SignedExecutionPayloadEnvelope, + Arc>>, + DataColumnSidecarList, +)> { let spec = test_spec::(); if !spec.fork_name_at_slot::(Slot::new(1)).gloas_enabled() { return None; @@ -2304,16 +2289,37 @@ async fn make_gloas_range_sync_block_inputs() let state = harness.get_current_state(); let slot = harness.get_current_slot(); let ((block, _), envelope, _) = harness.make_block_with_envelope(state, slot).await; - Some((block, envelope.expect("gloas block should have envelope"))) + let custody_context = harness.chain.custody_context.clone(); + let columns = generate_data_column_sidecars_from_block(&block, &harness.chain.spec) + .into_iter() + .filter(|column| { + custody_context + .sampling_columns_for_epoch(block.epoch()) + .contains(column.index()) + }) + .collect(); + Some(( + block, + envelope.expect("gloas block should have envelope"), + custody_context, + columns, + )) } #[tokio::test] async fn range_sync_block_new_gloas_accepts_matching_envelope() { - let Some((block, envelope)) = make_gloas_range_sync_block_inputs().await else { + let Some((block, envelope, custody_context, columns)) = + make_gloas_range_sync_block_inputs().await + else { return; }; - - let available_envelope = AvailableEnvelope::new(Arc::new(envelope), vec![]); + let bid = block + .message() + .body() + .signed_execution_payload_bid() + .unwrap(); + let available_envelope = + AvailableEnvelope::new(Arc::new(envelope), columns, bid, &custody_context).unwrap(); let result = RangeSyncBlock::new_gloas(block, Some(available_envelope)); assert!( @@ -2325,7 +2331,7 @@ async fn range_sync_block_new_gloas_accepts_matching_envelope() { #[tokio::test] async fn range_sync_block_new_gloas_allows_missing_envelope() { - let Some((block, _)) = make_gloas_range_sync_block_inputs().await else { + let Some((block, _, _, _)) = make_gloas_range_sync_block_inputs().await else { return; }; @@ -2340,12 +2346,20 @@ async fn range_sync_block_new_gloas_allows_missing_envelope() { #[tokio::test] async fn range_sync_block_new_gloas_rejects_slot_mismatch() { - let Some((block, mut envelope)) = make_gloas_range_sync_block_inputs().await else { + let Some((block, mut envelope, custody_context, columns)) = + make_gloas_range_sync_block_inputs().await + else { return; }; envelope.message.payload.slot_number += 1; - let available_envelope = AvailableEnvelope::new(Arc::new(envelope), vec![]); + let bid = block + .message() + .body() + .signed_execution_payload_bid() + .unwrap(); + let available_envelope = + AvailableEnvelope::new(Arc::new(envelope), columns, bid, &custody_context).unwrap(); let result = RangeSyncBlock::new_gloas(block, Some(available_envelope)); assert!( @@ -2357,12 +2371,20 @@ async fn range_sync_block_new_gloas_rejects_slot_mismatch() { #[tokio::test] async fn range_sync_block_new_gloas_rejects_builder_index_mismatch() { - let Some((block, mut envelope)) = make_gloas_range_sync_block_inputs().await else { + let Some((block, mut envelope, custody_context, columns)) = + make_gloas_range_sync_block_inputs().await + else { return; }; envelope.message.builder_index += 1; - let available_envelope = AvailableEnvelope::new(Arc::new(envelope), vec![]); + let bid = block + .message() + .body() + .signed_execution_payload_bid() + .unwrap(); + let available_envelope = + AvailableEnvelope::new(Arc::new(envelope), columns, bid, &custody_context).unwrap(); let result = RangeSyncBlock::new_gloas(block, Some(available_envelope)); assert!( @@ -2374,12 +2396,20 @@ async fn range_sync_block_new_gloas_rejects_builder_index_mismatch() { #[tokio::test] async fn range_sync_block_new_gloas_rejects_block_hash_mismatch() { - let Some((block, mut envelope)) = make_gloas_range_sync_block_inputs().await else { + let Some((block, mut envelope, custody_context, columns)) = + make_gloas_range_sync_block_inputs().await + else { return; }; envelope.message.payload.block_hash = ExecutionBlockHash::repeat_byte(0x22); - let available_envelope = AvailableEnvelope::new(Arc::new(envelope), vec![]); + let bid = block + .message() + .body() + .signed_execution_payload_bid() + .unwrap(); + let available_envelope = + AvailableEnvelope::new(Arc::new(envelope), columns, bid, &custody_context).unwrap(); let result = RangeSyncBlock::new_gloas(block, Some(available_envelope)); assert!( @@ -2585,12 +2615,8 @@ async fn range_sync_block_construction_fails_with_wrong_blob_count() { let block_data = AvailableBlockData::new_with_blobs(wrong_blobs); // Try to create RpcBlock with wrong blob count - let result = RangeSyncBlock::new( - Arc::new(block), - block_data, - &harness.chain.data_availability_checker, - harness.chain.spec.clone(), - ); + let result = + RangeSyncBlock::new(Arc::new(block), block_data, &harness.chain.custody_context); // Should fail with MissingBlobs assert!( @@ -2666,8 +2692,7 @@ async fn range_sync_block_rejects_missing_custody_columns() { let result = RangeSyncBlock::new( Arc::new(block), block_data, - &harness.chain.data_availability_checker, - harness.chain.spec.clone(), + &harness.chain.custody_context, ); // Should fail with MissingCustodyColumns @@ -2742,6 +2767,7 @@ async fn rpc_block_allows_construction_past_da_boundary() { // Now verify the block is past the DA boundary let da_boundary = harness .chain + .custody_context .data_availability_boundary() .expect("DA boundary should be set"); assert!( @@ -2756,8 +2782,7 @@ async fn rpc_block_allows_construction_past_da_boundary() { let result = RangeSyncBlock::new( Arc::new(block), AvailableBlockData::NoData, - &harness.chain.data_availability_checker, - harness.chain.spec.clone(), + &harness.chain.custody_context, ); assert!( diff --git a/beacon_node/beacon_chain/tests/column_verification.rs b/beacon_node/beacon_chain/tests/column_verification.rs index 06a5f44e5f..180e187e90 100644 --- a/beacon_node/beacon_chain/tests/column_verification.rs +++ b/beacon_node/beacon_chain/tests/column_verification.rs @@ -274,5 +274,8 @@ async fn verify_header_signature_fork_block_bug() { .process_rpc_custody_columns(data_column_sidecars) .await .unwrap(); - assert_eq!(status, AvailabilityProcessingStatus::Imported(block_root)); + assert_eq!( + status, + AvailabilityProcessingStatus::Imported(signed_block.slot(), block_root) + ); } diff --git a/beacon_node/beacon_chain/tests/events.rs b/beacon_node/beacon_chain/tests/events.rs index baa6975303..537e8f40ee 100644 --- a/beacon_node/beacon_chain/tests/events.rs +++ b/beacon_node/beacon_chain/tests/events.rs @@ -7,9 +7,9 @@ use eth2::types::{EventKind, SseBlobSidecar, SseDataColumnSidecar}; use std::sync::Arc; use types::data::FixedBlobSidecarList; use types::{ - BlobSidecar, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, Domain, EthSpec, - MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, SignedExecutionPayloadBid, - SignedRoot, Slot, + Address, BlobSidecar, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, Domain, + EthSpec, Hash256, MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, + ProposerPreferences, SignedExecutionPayloadBid, SignedProposerPreferences, SignedRoot, Slot, }; type E = MinimalEthSpec; @@ -43,7 +43,10 @@ async fn data_column_sidecar_event_on_process_gossip_data_column() { let mut random_sidecar = DataColumnSidecarGloas::arbitrary(&mut u).unwrap(); let epoch = slot.epoch(E::slots_per_epoch()); random_sidecar.slot = slot; - random_sidecar.index = harness.chain.sampling_columns_for_epoch(epoch)[0]; + random_sidecar.index = harness + .chain + .custody_context + .sampling_columns_for_epoch(epoch)[0]; // For gloas, the bid must be known, e.g. in the pending payload cache let mut bid = SignedExecutionPayloadBid::::empty(); @@ -58,7 +61,10 @@ async fn data_column_sidecar_event_on_process_gossip_data_column() { let mut random_sidecar = DataColumnSidecarFulu::arbitrary(&mut u).unwrap(); let epoch = slot.epoch(E::slots_per_epoch()); random_sidecar.signed_block_header.message.slot = slot; - random_sidecar.index = harness.chain.sampling_columns_for_epoch(epoch)[0]; + random_sidecar.index = harness + .chain + .custody_context + .sampling_columns_for_epoch(epoch)[0]; DataColumnSidecar::Fulu(random_sidecar) } }; @@ -401,3 +407,80 @@ async fn payload_attestation_message_event_on_gossip_verification() { panic!("Expected PayloadAttestationMessage event, got {:?}", event); } } + +/// Verifies that a `proposer_preferences` SSE event is emitted when signed proposer preferences +/// pass gossip verification. +#[tokio::test] +async fn proposer_preferences_event_on_gossip_verification() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(E::default()) + .default_spec() + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + let head = harness.chain.canonical_head.cached_head(); + let head_state = &head.snapshot.beacon_state; + let genesis_validators_root = harness.chain.genesis_validators_root; + + // Pick a proposal slot in the next epoch so it is always a valid, future slot. The lookahead + // covers 2 epochs: index = epoch_offset * slots_per_epoch + slot_in_epoch. + let slots_per_epoch = E::slots_per_epoch() as usize; + let proposer_lookahead = head_state + .proposer_lookahead() + .expect("gloas state should have proposer lookahead"); + let next_epoch_start = (head_state.current_epoch() + 1).start_slot(E::slots_per_epoch()); + let proposal_slot = next_epoch_start + 1; + let lookahead_index = slots_per_epoch + 1; + let validator_index = *proposer_lookahead + .get(lookahead_index) + .expect("lookahead index should be in range"); + + // Build and sign proposer preferences for the proposer of `proposal_slot`. + let preferences = ProposerPreferences { + dependent_root: Hash256::ZERO, + proposal_slot, + validator_index, + fee_recipient: Address::repeat_byte(0xaa), + target_gas_limit: 30_000_000, + }; + let domain = harness.spec.get_domain( + proposal_slot.epoch(E::slots_per_epoch()), + Domain::ProposerPreferences, + &head_state.fork(), + genesis_validators_root, + ); + let signature = harness.validator_keypairs[validator_index as usize] + .sk + .sign(preferences.signing_root(domain)); + let signed = SignedProposerPreferences { + message: preferences.clone(), + signature, + }; + + // Subscribe before verification. + let event_handler = harness.chain.event_handler.as_ref().unwrap(); + let mut receiver = event_handler.subscribe_proposer_preferences(); + + // Verify the preferences through the gossip path. + harness + .chain + .verify_proposer_preferences_for_gossip(Arc::new(signed)) + .expect("verification should succeed"); + + // Assert the event was emitted with the expected data. + let event = receiver.try_recv().expect("should receive event"); + if let EventKind::ProposerPreferences(versioned) = event { + assert_eq!(versioned.data.message, preferences); + assert_eq!( + versioned.version, + harness.spec.fork_name_at_slot::(proposal_slot) + ); + } else { + panic!("Expected ProposerPreferences event, got {:?}", event); + } +} diff --git a/beacon_node/beacon_chain/tests/main.rs b/beacon_node/beacon_chain/tests/main.rs index d31db128c5..e84f561fac 100644 --- a/beacon_node/beacon_chain/tests/main.rs +++ b/beacon_node/beacon_chain/tests/main.rs @@ -12,4 +12,5 @@ mod schema_stability; mod store_tests; mod sync_committee_verification; mod tests; +mod unrealized_checkpoints; mod validator_monitor; diff --git a/beacon_node/beacon_chain/tests/op_verification.rs b/beacon_node/beacon_chain/tests/op_verification.rs index adc14541a9..d1df72234f 100644 --- a/beacon_node/beacon_chain/tests/op_verification.rs +++ b/beacon_node/beacon_chain/tests/op_verification.rs @@ -299,6 +299,67 @@ async fn proposer_slashing_duplicate_in_state() { )); } +#[tokio::test] +async fn slashings_cache_matches_state_after_block_import() { + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), VALIDATOR_COUNT); + + // Slash a spread of validators by importing proposer slashings into the op pool, exactly as + // they would arrive over gossip. + let slashed_validators = [0u64, 7, VALIDATOR_COUNT as u64 - 1]; + for &validator_index in &slashed_validators { + let slashing = harness.make_proposer_slashing(validator_index); + let ObservationOutcome::New(verified_slashing) = harness + .chain + .verify_proposer_slashing_for_gossip(slashing) + .unwrap() + else { + panic!("slashing should verify"); + }; + harness.chain.import_proposer_slashing(verified_slashing); + } + + // Produce and import a block that includes the slashings. This drives the production flow: + // `per_block_processing` -> `slash_validator` -> `SlashingsCache::record_validator_slashing`. + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let state = harness.get_current_state(); + + // The block processing above should have left the slashings cache initialized for the head. + assert!( + state.slashings_cache_is_initialized(), + "slashings cache should be initialized after block import" + ); + + // The targeted validators must actually be slashed in the state (i.e. the slashings were + // included and applied, not silently dropped). + for &validator_index in &slashed_validators { + assert!( + state + .get_validator(validator_index as usize) + .unwrap() + .slashed, + "validator {validator_index} should be slashed in the state" + ); + } + + // The cache must agree with the `slashed` flag of *every* validator in the state. + for index in 0..state.validators().len() { + assert_eq!( + state.slashings_cache().is_slashed(index), + state.get_validator(index).unwrap().slashed, + "slashings cache disagrees with state at validator {index}" + ); + } +} + #[test] fn attester_slashing() { let db_path = tempdir().unwrap(); diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index 42a78d740f..c89be0f5dd 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1077,7 +1077,6 @@ async fn invalid_parent() { Duration::from_secs(0), &state, PayloadVerificationStatus::Optimistic, - block.message().proposer_index(), &rig.harness.chain.spec, ), Err(ForkChoiceError::ProtoArrayStringError(message)) diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 4d392ef524..f15beb8197 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -3322,13 +3322,8 @@ async fn weak_subjectivity_sync_test( let (_, block, data) = clone_block(&available_blocks[0]).deconstruct(); let mut corrupt_block = (*block).clone(); *corrupt_block.signature_mut() = Signature::empty(); - AvailableBlock::new( - Arc::new(corrupt_block), - data, - &beacon_chain.data_availability_checker, - Arc::new(spec), - ) - .expect("available block") + AvailableBlock::new(Arc::new(corrupt_block), data, &beacon_chain.custody_context) + .expect("available block") }; // Importing the invalid batch should error. @@ -4878,7 +4873,10 @@ async fn test_column_da_boundary() { // The column da boundary should be the fulu fork epoch assert_eq!( - harness.chain.column_data_availability_boundary(), + harness + .chain + .custody_context + .column_data_availability_boundary(), Some(fulu_fork_epoch) ); } @@ -5293,6 +5291,7 @@ async fn test_custody_column_filtering_regular_node() { // Get custody columns for this epoch - regular nodes only store a subset let expected_custody_columns: HashSet<_> = harness .chain + .custody_context .custody_columns_for_epoch(Some(current_slot.epoch(E::slots_per_epoch()))) .iter() .copied() @@ -5374,8 +5373,6 @@ async fn test_missing_columns_after_cgc_change() { return; } - let custody_context = harness.chain.data_availability_checker.custody_context(); - harness.advance_slot(); harness .extend_chain( @@ -5397,7 +5394,10 @@ async fn test_missing_columns_after_cgc_change() { let epoch_after_increase = Epoch::new(num_epochs_before_increase + 2); let cgc_change_slot = epoch_before_increase.end_slot(E::slots_per_epoch()); - custody_context.register_validators(vec![(1, 32_000_000_000 * 9)], cgc_change_slot, &spec); + harness + .chain + .custody_context + .register_validators(vec![(1, 32_000_000_000 * 9)], cgc_change_slot); harness.advance_slot(); harness @@ -5444,8 +5444,6 @@ async fn test_safely_backfill_data_column_custody_info() { return; } - let custody_context = harness.chain.data_availability_checker.custody_context(); - harness.advance_slot(); harness .extend_chain( @@ -5461,7 +5459,10 @@ async fn test_safely_backfill_data_column_custody_info() { let cgc_change_slot = epoch_before_increase.end_slot(E::slots_per_epoch()); - custody_context.register_validators(vec![(1, 32_000_000_000 * 16)], cgc_change_slot, &spec); + harness + .chain + .custody_context + .register_validators(vec![(1, 32_000_000_000 * 16)], cgc_change_slot); let epoch_after_increase = (cgc_change_slot + effective_delay_slots).epoch(E::slots_per_epoch()); diff --git a/beacon_node/beacon_chain/tests/unrealized_checkpoints.rs b/beacon_node/beacon_chain/tests/unrealized_checkpoints.rs new file mode 100644 index 0000000000..01ae86c44d --- /dev/null +++ b/beacon_node/beacon_chain/tests/unrealized_checkpoints.rs @@ -0,0 +1,342 @@ +#![cfg(not(debug_assertions))] + +//! This file contains regression tests for a bug in fork choice whereby the unrealized justified +//! and finalized checkpoints of a block were assumed to carry over to its child. This is NOT TRUE +//! in general, as the child block may contain slashings which invalidate the +//! justification/finalization from the parent. The tests in this file reproduce this scenario using +//! both attester slashings and proposer slashings. + +use beacon_chain::{ + StateSkipConfig, + test_utils::{ + AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, test_spec, + }, +}; +use state_processing::per_epoch_processing; +use std::sync::Arc; +use types::{Checkpoint, Epoch, EthSpec, MinimalEthSpec, consts::altair::TIMELY_TARGET_FLAG_INDEX}; + +type E = MinimalEthSpec; + +// Proposer slashings are limited to MaxProposerSlashings (16) per block. With 32 validators, +// dropping below the 2/3 justification threshold requires only ~11 slashes, which fits. +const VALIDATOR_COUNT: usize = 32; + +fn ceil_two_thirds(value: u64) -> u64 { + (2 * value).div_ceil(3) +} + +struct SameEpochSlashingChild { + harness: BeaconChainHarness>, + stored_parent_justified: Checkpoint, + stored_parent_finalized: Checkpoint, + stored_child_justified: Checkpoint, + stored_child_finalized: Checkpoint, + expected_child_justified: Checkpoint, + expected_child_finalized: Checkpoint, + parent_epoch: Epoch, +} + +/// Basic test checking that the child has correct unrealized justified/finalized checkpoints in the +/// case where the slashings are attester slashings. +#[tokio::test] +async fn child_unrealized_checkpoints_recomputed_after_same_epoch_slashing() { + let scenario = same_epoch_attester_slashing_child().await; + + assert_eq!( + scenario.stored_child_justified, scenario.expected_child_justified, + "child unrealized justified checkpoint should be recomputed from the child state" + ); + assert_eq!( + scenario.stored_child_finalized, scenario.expected_child_finalized, + "child unrealized finalized checkpoint should be recomputed from the child state" + ); +} + +/// Variation on the attester slashing test, checking that the inferior justification of the child +/// results in fork choice correctly reverting to the justified checkpoint once the child block is +/// no longer considered viable. +#[tokio::test] +async fn child_with_stale_voting_source_not_head_at_epoch_plus_two() { + let scenario = same_epoch_attester_slashing_child().await; + let slots_per_epoch = E::slots_per_epoch(); + let divergence_slot = scenario + .parent_epoch + .saturating_add(2u64) + .start_slot(slots_per_epoch); + + assert_eq!( + scenario.stored_child_justified, scenario.expected_child_justified, + "child unrealized justified checkpoint should be recomputed from the child state" + ); + assert_eq!( + scenario.stored_child_finalized, scenario.expected_child_finalized, + "child unrealized finalized checkpoint should be recomputed from the child state" + ); + assert!( + scenario.expected_child_justified.epoch.saturating_add(2u64) + < divergence_slot.epoch(slots_per_epoch), + "with the spec-computed checkpoint the child is outside the viability window at epoch N + 2" + ); + + while scenario.harness.get_current_slot() < divergence_slot { + scenario.harness.advance_slot(); + } + + let mut fork_choice = scenario + .harness + .chain + .canonical_head + .fork_choice_write_lock(); + let head_result = fork_choice.get_head(divergence_slot, &scenario.harness.chain.spec); + + assert_eq!( + fork_choice.justified_checkpoint(), + scenario.stored_parent_justified, + "the store should realize the parent's unrealized justification at the epoch boundary" + ); + assert_eq!( + fork_choice.finalized_checkpoint(), + scenario.stored_parent_finalized, + "the store should realize the parent's unrealized finalization at the epoch boundary" + ); + + // No epoch N + 1 blocks were produced after the slashing child. Under the spec-computed child + // checkpoint, the child is the only leaf below the justified root and is outside the viability + // window. The spec-correct result is to set the justified checkpoint as the head. + assert_eq!( + head_result.unwrap().0, + fork_choice.justified_checkpoint().root + ); +} + +/// Basic test checking child checkpoints but with proposer slashings instead of attester slashings. +#[tokio::test] +async fn child_unrealized_checkpoints_recomputed_after_same_epoch_proposer_slashing() { + let scenario = same_epoch_proposer_slashing_child().await; + + assert_eq!( + scenario.stored_child_justified, scenario.expected_child_justified, + "child unrealized justified checkpoint should be recomputed from the child state" + ); + assert_eq!( + scenario.stored_child_finalized, scenario.expected_child_finalized, + "child unrealized finalized checkpoint should be recomputed from the child state" + ); +} + +async fn same_epoch_attester_slashing_child() -> SameEpochSlashingChild { + same_epoch_slashing_child(VALIDATOR_COUNT, |harness, slash_indices| { + harness + .add_attester_slashing(slash_indices.to_vec()) + .expect("should add attester slashing to operation pool"); + }) + .await +} + +async fn same_epoch_proposer_slashing_child() -> SameEpochSlashingChild { + same_epoch_slashing_child(VALIDATOR_COUNT, |harness, slash_indices| { + for &index in slash_indices { + harness + .add_proposer_slashing(index) + .expect("should add proposer slashing to operation pool"); + } + }) + .await +} + +/// Generic scenario builder with `inject_slashings` capable of injecting attester or proposer +/// slashings. +async fn same_epoch_slashing_child( + validator_count: usize, + inject_slashings: F, +) -> SameEpochSlashingChild +where + F: FnOnce(&BeaconChainHarness>, &[u64]), +{ + let spec = test_spec::(); + + let harness: BeaconChainHarness> = + BeaconChainHarness::builder(E::default()) + .spec(Arc::new(spec)) + .deterministic_keypairs(validator_count) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + let slots_per_epoch = E::slots_per_epoch(); + + // Minimum warm-up for the parent to reach FFG steady state (justified == epoch, finalized == + // epoch - 1); 2 epochs is too few. + let warmup_epochs: u64 = 3; + harness.advance_slot(); + harness + .extend_chain( + slots_per_epoch as usize * warmup_epochs as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let parent_epoch = Epoch::new(warmup_epochs); + // ceil(Minimal::slots_per_epoch() * 2/3) = 6 + let parent_slot = parent_epoch.start_slot(slots_per_epoch) + 6; + let parent_root = harness.extend_to_slot(parent_slot).await; + + let mut parent_state = harness + .chain + .state_at_slot(parent_slot, StateSkipConfig::WithStateRoots) + .expect("should load parent state"); + parent_state + .build_caches(&harness.chain.spec) + .expect("should build parent state caches"); + + let total_active_balance = parent_state + .get_total_active_balance() + .expect("should get parent total active balance"); + let required_balance = ceil_two_thirds(total_active_balance); + let effective_balance = parent_state + .validators() + .get(0) + .expect("validator 0 should exist") + .effective_balance; + let slash_count_needed = total_active_balance + .checked_sub(required_balance) + .expect("total active balance should be at least the required balance") + / effective_balance + + 1; + + let child_slot = parent_slot + 1; + let child_proposer = parent_state + .get_beacon_proposer_index(child_slot, &harness.chain.spec) + .expect("should get child proposer") as u64; + + let (_, _, _, current_participation, _, _, _, _) = parent_state + .mutable_validator_fields() + .expect("parent state should have Altair validator fields"); + // Slash this epoch's timely-target voters (they count toward the current-epoch target balance), + // excluding the child proposer, to drop target balance below the 2/3 justification threshold. + let slash_indices = current_participation + .iter() + .enumerate() + .filter_map(|(index, flags)| { + flags + .has_flag(TIMELY_TARGET_FLAG_INDEX) + .ok() + .and_then(|has_flag| has_flag.then_some(index as u64)) + }) + .filter(|index| *index != child_proposer) + .take(slash_count_needed as usize) + .collect::>(); + + assert_eq!( + slash_indices.len(), + slash_count_needed as usize, + "should have enough current target attesters to slash" + ); + + inject_slashings(&harness, &slash_indices); + + harness.advance_slot(); + let child_root = harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let ( + stored_parent_justified, + stored_parent_finalized, + stored_child_justified, + stored_child_finalized, + child_slot, + ) = { + let fork_choice = harness.chain.canonical_head.fork_choice_read_lock(); + let parent_block = fork_choice + .get_block(&parent_root) + .expect("parent should be in fork choice"); + let child_block = fork_choice + .get_block(&child_root) + .expect("child should be in fork choice"); + + let parent_justified = parent_block + .unrealized_justified_checkpoint + .expect("parent should have unrealized justified checkpoint"); + let parent_finalized = parent_block + .unrealized_finalized_checkpoint + .expect("parent should have unrealized finalized checkpoint"); + + assert_eq!(parent_block.slot, parent_slot); + assert_eq!(parent_justified.epoch, parent_epoch); + assert_eq!(parent_finalized.epoch.saturating_add(1u64), parent_epoch); + assert_eq!(child_block.slot, parent_slot + 1); + assert_eq!(child_block.slot.epoch(slots_per_epoch), parent_epoch); + + ( + parent_justified, + parent_finalized, + child_block + .unrealized_justified_checkpoint + .expect("child should have unrealized justified checkpoint"), + child_block + .unrealized_finalized_checkpoint + .expect("child should have unrealized finalized checkpoint"), + child_block.slot, + ) + }; + + let mut child_state = harness + .chain + .state_at_slot(child_slot, StateSkipConfig::WithStateRoots) + .expect("should load child state"); + child_state + .build_caches(&harness.chain.spec) + .expect("should build child state caches"); + + let slashed = child_state + .validators() + .iter() + .enumerate() + .filter_map(|(index, validator)| validator.slashed.then_some(index as u64)) + .collect::>(); + let child_total_active_balance = child_state + .get_total_active_balance() + .expect("should get child total active balance"); + let child_current_target_balance = child_state + .progressive_balances_cache() + .current_epoch_target_attesting_balance() + .expect("should get child current target balance"); + let child_justification_and_finalization = + per_epoch_processing::altair::process_justification_and_finalization(&child_state) + .expect("should recompute child justification and finalization"); + let expected_child_justified = + child_justification_and_finalization.current_justified_checkpoint(); + let expected_child_finalized = child_justification_and_finalization.finalized_checkpoint(); + + assert_eq!(slashed, slash_indices); + assert!( + child_current_target_balance < ceil_two_thirds(child_total_active_balance), + "slashings should reduce current target balance below the justification threshold" + ); + assert_ne!( + expected_child_justified, stored_parent_justified, + "test setup should make the child justified checkpoint differ from the parent's" + ); + assert_ne!( + expected_child_finalized, stored_parent_finalized, + "test setup should make the child finalized checkpoint differ from the parent's" + ); + + SameEpochSlashingChild { + harness, + stored_parent_justified, + stored_parent_finalized, + stored_child_justified, + stored_child_finalized, + expected_child_justified, + expected_child_finalized, + parent_epoch, + } +} diff --git a/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs index 62ed86fbad..dddf2a740d 100644 --- a/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs +++ b/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs @@ -115,10 +115,10 @@ pub enum ReprocessQueueMessage { RpcBlock(QueuedRpcBlock), /// A block that was successfully processed. We use this to handle attestations updates /// for unknown blocks. - BlockImported { - block_root: Hash256, - parent_root: Hash256, - }, + BlockImported { block_root: Hash256 }, + /// A block's execution payload envelope was imported. We use this to release attestations that + /// claim payload-present (`index == 1`) for a block whose payload had not yet been seen. + PayloadEnvelopeImported { block_root: Hash256 }, /// A new `LightClientOptimisticUpdate` has been produced. We use this to handle light client /// updates for unknown parent blocks. NewLightClientOptimisticUpdate { parent_root: Hash256 }, @@ -126,6 +126,12 @@ pub enum ReprocessQueueMessage { UnknownBlockUnaggregate(QueuedUnaggregate), /// An aggregated attestation that references an unknown block. UnknownBlockAggregate(QueuedAggregate), + /// An unaggregated attestation (`index == 1`) whose block's execution payload envelope has not + /// been seen yet. + UnknownPayloadUnaggregate(QueuedUnaggregate), + /// An aggregated attestation (`index == 1`) whose block's execution payload envelope has not + /// been seen yet. + UnknownPayloadAggregate(QueuedAggregate), /// A light client optimistic update that references a parent root that has not been seen as a parent. UnknownLightClientOptimisticUpdate(QueuedLightClientUpdate), /// A new backfill batch that needs to be scheduled for processing. @@ -296,6 +302,9 @@ struct ReprocessQueue { queued_unaggregates: FnvHashMap, /// Attestations (aggregated and unaggregated) per root. awaiting_attestations_per_root: HashMap>, + /// Attestations (aggregated and unaggregated) awaiting a block's execution payload envelope, + /// keyed by block root. Released on `PayloadEnvelopeImported`. + awaiting_attestations_per_payload: HashMap>, /// Queued Light Client Updates. queued_lc_updates: FnvHashMap, /// Light Client Updates per parent_root. @@ -331,6 +340,20 @@ enum QueuedAttestationId { Unaggregate(usize), } +/// An attestation queued for re-processing, of either aggregation kind. +enum QueuedAttestation { + Aggregate(QueuedAggregate), + Unaggregate(QueuedUnaggregate), +} + +/// The component an attestation is waiting on before it can be re-processed. +enum AwaitingComponent { + /// The attestation's head block has not been seen. + Block, + /// The block's execution payload envelope has not been seen (`index == 1`, post-Gloas). + Payload, +} + impl QueuedAggregate { pub fn beacon_block_root(&self) -> &Hash256 { &self.beacon_block_root @@ -494,6 +517,7 @@ impl ReprocessQueue { queued_aggregates: FnvHashMap::default(), queued_unaggregates: FnvHashMap::default(), awaiting_attestations_per_root: HashMap::new(), + awaiting_attestations_per_payload: HashMap::new(), awaiting_lc_updates_per_parent_root: HashMap::new(), queued_backfill_batches: Vec::new(), queued_column_reconstructions: HashMap::new(), @@ -512,6 +536,65 @@ impl ReprocessQueue { } } + /// Queue an attestation for re-processing once the component it is waiting on (`awaiting`) is + /// imported. Shared by the unknown-block and unknown-payload paths for both aggregate and + /// unaggregate attestations. + fn queue_awaiting_attestation( + &mut self, + attestation: QueuedAttestation, + awaiting: AwaitingComponent, + ) { + if self.attestations_delay_queue.len() >= MAXIMUM_QUEUED_ATTESTATIONS { + if self.attestation_delay_debounce.elapsed() { + error!( + queue_size = MAXIMUM_QUEUED_ATTESTATIONS, + msg = "system resources may be saturated", + "Attestation delay queue is full" + ); + } + // Drop the attestation. + return; + } + + let id = self.next_attestation; + let (att_id, beacon_block_root) = match &attestation { + QueuedAttestation::Aggregate(a) => { + (QueuedAttestationId::Aggregate(id), *a.beacon_block_root()) + } + QueuedAttestation::Unaggregate(u) => { + (QueuedAttestationId::Unaggregate(id), *u.beacon_block_root()) + } + }; + + // Register the delay. + let delay_key = self + .attestations_delay_queue + .insert(att_id, QUEUED_ATTESTATION_DELAY); + + // Register this attestation against the component it awaits. + match awaiting { + AwaitingComponent::Block => &mut self.awaiting_attestations_per_root, + AwaitingComponent::Payload => &mut self.awaiting_attestations_per_payload, + } + .entry(beacon_block_root) + .or_default() + .push(att_id); + + // Store the attestation and its info. + match attestation { + QueuedAttestation::Aggregate(queued_aggregate) => { + self.queued_aggregates + .insert(id, (queued_aggregate, delay_key)); + } + QueuedAttestation::Unaggregate(queued_unaggregate) => { + self.queued_unaggregates + .insert(id, (queued_unaggregate, delay_key)); + } + } + + self.next_attestation += 1; + } + fn handle_message(&mut self, msg: InboundEvent) { use ReprocessQueueMessage::*; match msg { @@ -654,70 +737,26 @@ impl ReprocessQueue { error!("Failed to send rpc block to beacon processor"); } } - InboundEvent::Msg(UnknownBlockAggregate(queued_aggregate)) => { - if self.attestations_delay_queue.len() >= MAXIMUM_QUEUED_ATTESTATIONS { - if self.attestation_delay_debounce.elapsed() { - error!( - queue_size = MAXIMUM_QUEUED_ATTESTATIONS, - msg = "system resources may be saturated", - "Aggregate attestation delay queue is full" - ); - } - // Drop the attestation. - return; - } - - let att_id = QueuedAttestationId::Aggregate(self.next_attestation); - - // Register the delay. - let delay_key = self - .attestations_delay_queue - .insert(att_id, QUEUED_ATTESTATION_DELAY); - - // Register this attestation for the corresponding root. - self.awaiting_attestations_per_root - .entry(*queued_aggregate.beacon_block_root()) - .or_default() - .push(att_id); - - // Store the attestation and its info. - self.queued_aggregates - .insert(self.next_attestation, (queued_aggregate, delay_key)); - - self.next_attestation += 1; - } - InboundEvent::Msg(UnknownBlockUnaggregate(queued_unaggregate)) => { - if self.attestations_delay_queue.len() >= MAXIMUM_QUEUED_ATTESTATIONS { - if self.attestation_delay_debounce.elapsed() { - error!( - queue_size = MAXIMUM_QUEUED_ATTESTATIONS, - msg = "system resources may be saturated", - "Attestation delay queue is full" - ); - } - // Drop the attestation. - return; - } - - let att_id = QueuedAttestationId::Unaggregate(self.next_attestation); - - // Register the delay. - let delay_key = self - .attestations_delay_queue - .insert(att_id, QUEUED_ATTESTATION_DELAY); - - // Register this attestation for the corresponding root. - self.awaiting_attestations_per_root - .entry(*queued_unaggregate.beacon_block_root()) - .or_default() - .push(att_id); - - // Store the attestation and its info. - self.queued_unaggregates - .insert(self.next_attestation, (queued_unaggregate, delay_key)); - - self.next_attestation += 1; - } + InboundEvent::Msg(UnknownBlockAggregate(queued_aggregate)) => self + .queue_awaiting_attestation( + QueuedAttestation::Aggregate(queued_aggregate), + AwaitingComponent::Block, + ), + InboundEvent::Msg(UnknownBlockUnaggregate(queued_unaggregate)) => self + .queue_awaiting_attestation( + QueuedAttestation::Unaggregate(queued_unaggregate), + AwaitingComponent::Block, + ), + InboundEvent::Msg(UnknownPayloadAggregate(queued_aggregate)) => self + .queue_awaiting_attestation( + QueuedAttestation::Aggregate(queued_aggregate), + AwaitingComponent::Payload, + ), + InboundEvent::Msg(UnknownPayloadUnaggregate(queued_unaggregate)) => self + .queue_awaiting_attestation( + QueuedAttestation::Unaggregate(queued_unaggregate), + AwaitingComponent::Payload, + ), InboundEvent::Msg(UnknownBlockDataColumn(queued_data_column)) => { let block_root = queued_data_column.beacon_block_root; @@ -785,10 +824,7 @@ impl ReprocessQueue { self.next_lc_update += 1; } - InboundEvent::Msg(BlockImported { - block_root, - parent_root, - }) => { + InboundEvent::Msg(BlockImported { block_root }) => { // Unqueue the envelope we have for this root, if any. if let Some((envelope, delay_key)) = self.awaiting_envelopes_per_root.remove(&block_root) @@ -853,7 +889,6 @@ impl ReprocessQueue { if failed_to_send_count > 0 { error!( hint = "system may be overloaded", - ?parent_root, ?block_root, failed_count = failed_to_send_count, sent_count, @@ -881,6 +916,59 @@ impl ReprocessQueue { } } } + InboundEvent::Msg(PayloadEnvelopeImported { block_root }) => { + // Release attestations that were awaiting this block's execution payload envelope. + if let Some(queued_ids) = self.awaiting_attestations_per_payload.remove(&block_root) + { + let mut failed_to_send_count = 0; + + for id in queued_ids { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_REPROCESSING_QUEUE_MATCHED_ATTESTATIONS, + ); + + if let Some((work, delay_key)) = match id { + QueuedAttestationId::Aggregate(id) => self + .queued_aggregates + .remove(&id) + .map(|(aggregate, delay_key)| { + (ReadyWork::Aggregate(aggregate), delay_key) + }), + QueuedAttestationId::Unaggregate(id) => self + .queued_unaggregates + .remove(&id) + .map(|(unaggregate, delay_key)| { + (ReadyWork::Unaggregate(unaggregate), delay_key) + }), + } { + // Remove the delay. + self.attestations_delay_queue.remove(&delay_key); + + // Send the work. + if self.ready_work_tx.try_send(work).is_err() { + failed_to_send_count += 1; + } + } else { + // There is a mismatch between the attestation ids registered for this + // root and the queued attestations. This should never happen. + error!( + ?block_root, + att_id = ?id, + "Unknown queued attestation for payload envelope" + ); + } + } + + if failed_to_send_count > 0 { + error!( + hint = "system may be overloaded", + ?block_root, + failed_count = failed_to_send_count, + "Ignored scheduled attestation(s) for payload envelope" + ); + } + } + } InboundEvent::Msg(NewLightClientOptimisticUpdate { parent_root }) => { // Unqueue the light client optimistic updates we have for this root, if any. if let Some(queued_lc_id) = self @@ -1033,18 +1121,25 @@ impl ReprocessQueue { ); } - if let Entry::Occupied(mut queued_atts) = - self.awaiting_attestations_per_root.entry(root) - && let Some(index) = - queued_atts.get().iter().position(|&id| id == queued_id) - { - let queued_atts_mut = queued_atts.get_mut(); - queued_atts_mut.swap_remove(index); + // The attestation is awaiting either its block or its payload envelope; prune it + // from whichever map holds it (the other lookup is a no-op) to avoid leaking the + // entry on expiry. + for awaiting in [ + &mut self.awaiting_attestations_per_root, + &mut self.awaiting_attestations_per_payload, + ] { + if let Entry::Occupied(mut queued_atts) = awaiting.entry(root) + && let Some(index) = + queued_atts.get().iter().position(|&id| id == queued_id) + { + let queued_atts_mut = queued_atts.get_mut(); + queued_atts_mut.swap_remove(index); - // If the vec is empty after this attestation's removal, we need to delete - // the entry to prevent bloating the hashmap indefinitely. - if queued_atts_mut.is_empty() { - queued_atts.remove_entry(); + // If the vec is empty after this attestation's removal, we need to + // delete the entry to prevent bloating the hashmap indefinitely. + if queued_atts_mut.is_empty() { + queued_atts.remove_entry(); + } } } } @@ -1412,6 +1507,131 @@ mod tests { assert!(queue.awaiting_attestations_per_root.is_empty()); } + // Regression test for the same memory leak as `prune_awaiting_attestations_per_root`, but for + // attestations awaiting a block's execution payload envelope. + #[tokio::test] + async fn prune_awaiting_attestations_per_payload() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + + // Pause time so it only advances manually + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + // Insert a payload-present attestation awaiting its payload envelope. + let att = ReprocessQueueMessage::UnknownPayloadUnaggregate(QueuedUnaggregate { + beacon_block_root, + process_fn: Box::new(|| {}), + }); + queue.handle_message(InboundEvent::Msg(att)); + + // Check that it is queued. + assert_eq!(queue.awaiting_attestations_per_payload.len(), 1); + assert!( + queue + .awaiting_attestations_per_payload + .contains_key(&beacon_block_root) + ); + + // Advance time to expire the attestation. + advance_time(&queue.slot_clock, 2 * QUEUED_ATTESTATION_DELAY).await; + let ready_msg = queue.next().await.unwrap(); + assert!(matches!(ready_msg, InboundEvent::ReadyAttestation(_))); + queue.handle_message(ready_msg); + + // The entry should be pruned on expiry. + assert!(queue.awaiting_attestations_per_payload.is_empty()); + } + + // The payload envelope import releases attestations awaiting that block's payload. + #[tokio::test] + async fn release_awaiting_attestations_on_payload_envelope_imported() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + let att = ReprocessQueueMessage::UnknownPayloadUnaggregate(QueuedUnaggregate { + beacon_block_root, + process_fn: Box::new(|| {}), + }); + queue.handle_message(InboundEvent::Msg(att)); + assert_eq!(queue.awaiting_attestations_per_payload.len(), 1); + + // Importing the payload envelope drains the awaiting attestations for that root. + queue.handle_message(InboundEvent::Msg( + ReprocessQueueMessage::PayloadEnvelopeImported { + block_root: beacon_block_root, + }, + )); + assert!(queue.awaiting_attestations_per_payload.is_empty()); + } + + // As `prune_awaiting_attestations_per_payload`, but for an aggregated payload-present + // attestation (`UnknownPayloadAggregate`). + #[tokio::test] + async fn prune_awaiting_attestations_per_payload_aggregate() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + let att = ReprocessQueueMessage::UnknownPayloadAggregate(QueuedAggregate { + beacon_block_root, + process_fn: Box::new(|| {}), + }); + queue.handle_message(InboundEvent::Msg(att)); + + assert_eq!(queue.awaiting_attestations_per_payload.len(), 1); + assert!( + queue + .awaiting_attestations_per_payload + .contains_key(&beacon_block_root) + ); + + // Advance time to expire the attestation. + advance_time(&queue.slot_clock, 2 * QUEUED_ATTESTATION_DELAY).await; + let ready_msg = queue.next().await.unwrap(); + assert!(matches!(ready_msg, InboundEvent::ReadyAttestation(_))); + queue.handle_message(ready_msg); + + // The entry should be pruned on expiry. + assert!(queue.awaiting_attestations_per_payload.is_empty()); + } + + // As `release_awaiting_attestations_on_payload_envelope_imported`, but for an aggregated + // payload-present attestation (`UnknownPayloadAggregate`). + #[tokio::test] + async fn release_awaiting_aggregate_on_payload_envelope_imported() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + let att = ReprocessQueueMessage::UnknownPayloadAggregate(QueuedAggregate { + beacon_block_root, + process_fn: Box::new(|| {}), + }); + queue.handle_message(InboundEvent::Msg(att)); + assert_eq!(queue.awaiting_attestations_per_payload.len(), 1); + + // Importing the payload envelope drains the awaiting attestations for that root. + queue.handle_message(InboundEvent::Msg( + ReprocessQueueMessage::PayloadEnvelopeImported { + block_root: beacon_block_root, + }, + )); + assert!(queue.awaiting_attestations_per_payload.is_empty()); + } + // This is a regression test for a memory leak in `awaiting_lc_updates_per_parent_root`. // See: https://github.com/sigp/lighthouse/pull/8065 #[tokio::test] @@ -1622,7 +1842,6 @@ mod tests { tokio::time::pause(); let beacon_block_root = Hash256::repeat_byte(0xaf); - let parent_root = Hash256::repeat_byte(0xab); // Insert an envelope. let msg = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { @@ -1640,7 +1859,6 @@ mod tests { // Simulate block import. let imported = ReprocessQueueMessage::BlockImported { block_root: beacon_block_root, - parent_root, }; queue.handle_message(InboundEvent::Msg(imported)); @@ -1716,7 +1934,6 @@ mod tests { // Simulate block import. queue.handle_message(InboundEvent::Msg(ReprocessQueueMessage::BlockImported { block_root: beacon_block_root, - parent_root: Hash256::repeat_byte(0x00), })); // Internal state should be cleaned up. diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index 0a3c414632..1624f73e9b 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -36,7 +36,6 @@ use rand::SeedableRng; use rand::rngs::{OsRng, StdRng}; use slasher::Slasher; use slasher_service::SlasherService; -use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -641,8 +640,7 @@ where beacon_processor_send: Some(beacon_processor_channels.beacon_processor_tx.clone()), sse_logging_components: runtime_context.sse_logging_components.clone(), historical_committee_cache: Arc::new(http_api::HistoricalCommitteeCache::new( - NonZeroUsize::new(self.http_api_config.historical_committee_cache_size) - .unwrap_or(NonZeroUsize::MIN), + self.http_api_config.historical_committee_cache_size, )), }); diff --git a/beacon_node/client/src/notifier.rs b/beacon_node/client/src/notifier.rs index bdb4228765..7b7c7beb9b 100644 --- a/beacon_node/client/src/notifier.rs +++ b/beacon_node/client/src/notifier.rs @@ -171,7 +171,9 @@ pub fn spawn_notifier( Ok(data_column_custody_info) => { if let Some(earliest_data_column_slot) = data_column_custody_info .and_then(|info| info.earliest_data_column_slot) - && let Some(da_boundary) = beacon_chain.get_column_da_boundary() + && let Some(da_boundary) = beacon_chain + .custody_context + .column_data_availability_boundary() { sync_distance = earliest_data_column_slot.saturating_sub( da_boundary.start_slot(T::EthSpec::slots_per_epoch()), @@ -295,7 +297,9 @@ pub fn spawn_notifier( let speed = speedo.slots_per_second(); let display_speed = speed.is_some_and(|speed| speed != 0.0); let est_time_in_secs = if let (Some(da_boundary_epoch), Some(original_slot)) = ( - beacon_chain.get_column_da_boundary(), + beacon_chain + .custody_context + .column_data_availability_boundary(), original_earliest_data_column_slot, ) { let target = original_slot.saturating_sub( diff --git a/beacon_node/execution_layer/Cargo.toml b/beacon_node/execution_layer/Cargo.toml index a23ea948e4..91eb74e621 100644 --- a/beacon_node/execution_layer/Cargo.toml +++ b/beacon_node/execution_layer/Cargo.toml @@ -20,13 +20,13 @@ fixed_bytes = { workspace = true } fork_choice = { workspace = true } hash-db = "0.15.2" hash256-std-hasher = "0.15.2" +hashlink = { workspace = true } hex = { workspace = true } jsonwebtoken = "9" keccak-hash = "0.10.0" kzg = { workspace = true } lighthouse_version = { workspace = true } logging = { workspace = true } -lru = { workspace = true } metrics = { workspace = true } parking_lot = { workspace = true } pretty_reqwest_error = { workspace = true } diff --git a/beacon_node/execution_layer/src/engines.rs b/beacon_node/execution_layer/src/engines.rs index 3e6f78abbe..aac170d48c 100644 --- a/beacon_node/execution_layer/src/engines.rs +++ b/beacon_node/execution_layer/src/engines.rs @@ -5,9 +5,8 @@ use crate::engine_api::{ PayloadId, }; use crate::{ClientVersionV1, HttpJsonRpc}; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use std::future::Future; -use std::num::NonZeroUsize; use std::sync::Arc; use std::time::Duration; use task_executor::TaskExecutor; @@ -15,12 +14,11 @@ use tokio::sync::{Mutex, RwLock, watch}; use tokio_stream::wrappers::WatchStream; use tracing::{debug, error, info, warn}; use types::ExecutionBlockHash; -use types::new_non_zero_usize; /// The number of payload IDs that will be stored for each `Engine`. /// /// Since the size of each value is small (~800 bytes) a large number is used for safety. -const PAYLOAD_ID_LRU_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(512); +const PAYLOAD_ID_LRU_CACHE_SIZE: usize = 512; const CACHED_RESPONSE_AGE_LIMIT: Duration = Duration::from_secs(900); // 15 minutes /// Stores the remembered state of a engine. @@ -175,7 +173,7 @@ impl Engine { if let Some(key) = payload_attributes .map(|pa| PayloadIdCacheKey::new(&forkchoice_state.head_block_hash, &pa)) { - self.payload_id_cache.lock().await.put(key, payload_id); + self.payload_id_cache.lock().await.insert(key, payload_id); } else { debug!(?payload_id, "Engine returned unexpected payload_id"); } diff --git a/beacon_node/execution_layer/src/payload_cache.rs b/beacon_node/execution_layer/src/payload_cache.rs index ce65a53ef1..958bf12dc2 100644 --- a/beacon_node/execution_layer/src/payload_cache.rs +++ b/beacon_node/execution_layer/src/payload_cache.rs @@ -1,12 +1,10 @@ use eth2::types::FullPayloadContents; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use parking_lot::Mutex; -use std::num::NonZeroUsize; use tree_hash::TreeHash; -use types::new_non_zero_usize; use types::{EthSpec, Hash256}; -pub const DEFAULT_PAYLOAD_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(10); +pub const DEFAULT_PAYLOAD_CACHE_SIZE: usize = 10; /// A cache mapping execution payloads by tree hash roots. pub struct PayloadCache { @@ -27,11 +25,11 @@ impl Default for PayloadCache { impl PayloadCache { pub fn put(&self, payload: FullPayloadContents) -> Option> { let root = payload.payload_ref().tree_hash_root(); - self.payloads.lock().put(PayloadCacheId(root), payload) + self.payloads.lock().insert(PayloadCacheId(root), payload) } pub fn pop(&self, root: &Hash256) -> Option> { - self.payloads.lock().pop(&PayloadCacheId(*root)) + self.payloads.lock().remove(&PayloadCacheId(*root)) } pub fn get(&self, hash: &Hash256) -> Option> { diff --git a/beacon_node/http_api/Cargo.toml b/beacon_node/http_api/Cargo.toml index dd15a76c7a..fb01f655d9 100644 --- a/beacon_node/http_api/Cargo.toml +++ b/beacon_node/http_api/Cargo.toml @@ -20,12 +20,12 @@ ethereum_ssz = { workspace = true } execution_layer = { workspace = true } fixed_bytes = { workspace = true } futures = { workspace = true } +hashlink = { workspace = true } health_metrics = { workspace = true } hex = { workspace = true } lighthouse_network = { workspace = true } lighthouse_version = { workspace = true } logging = { workspace = true } -lru = { workspace = true } metrics = { workspace = true } network = { workspace = true } network_utils = { workspace = true } diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs index f8ab8cddc8..456d371c71 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs @@ -8,7 +8,9 @@ use crate::version::{ }; use beacon_chain::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; use beacon_chain::payload_envelope_verification::EnvelopeError; -use beacon_chain::{BeaconChain, BeaconChainTypes, NotifyExecutionLayer}; +use beacon_chain::{ + AvailabilityProcessingStatus, BeaconChain, BeaconChainTypes, NotifyExecutionLayer, +}; use bytes::Bytes; use eth2::types as api_types; use lighthouse_network::PubsubMessage; @@ -160,12 +162,16 @@ pub async fn publish_execution_payload_envelope( ) .await; - if let Err(e) = import_result { - warn!(%slot, error = ?e, "Failed to import execution payload envelope"); - return Err(warp_utils::reject::custom_server_error(format!( - "envelope import failed: {e}" - ))); - } + let mut envelope_imported = match &import_result { + Ok(AvailabilityProcessingStatus::Imported(_, _)) => true, + Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => false, + Err(e) => { + warn!(%slot, error = ?e, "Failed to import execution payload envelope"); + return Err(warp_utils::reject::custom_server_error(format!( + "envelope import failed: {e}" + ))); + } + }; // From here on the envelope is on the wire. `take_blobs` already consumed the cache // entry, so a retry would not republish columns; returning Err would mislead the @@ -194,26 +200,34 @@ pub async fn publish_execution_payload_envelope( } let epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - let sampling_column_indices = chain.sampling_columns_for_epoch(epoch); + let sampling_column_indices = chain.custody_context.sampling_columns_for_epoch(epoch); let sampling_columns = gossip_verified_columns .into_iter() .filter(|col| sampling_column_indices.contains(&col.index())) .collect::>(); // Local processing only — envelope already broadcast, so log and fall through. - if !sampling_columns.is_empty() - && let Err(e) = - Box::pin(chain.process_gossip_data_columns(sampling_columns, || Ok(()))).await - { - error!( - %slot, - error = ?e, - "Failed to process sampling data columns during envelope publication" - ); + if !sampling_columns.is_empty() { + match Box::pin(chain.process_gossip_data_columns(sampling_columns, || Ok(()))).await + { + Ok(AvailabilityProcessingStatus::Imported(_, _)) => envelope_imported = true, + Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => {} + Err(e) => { + error!( + %slot, + error = ?e, + "Failed to process sampling data columns during envelope publication" + ); + } + } } } } + if envelope_imported { + chain.recompute_head_at_current_slot().await; + } + Ok(warp::reply().into_response()) } diff --git a/beacon_node/http_api/src/block_id.rs b/beacon_node/http_api/src/block_id.rs index dce4713245..fe34863b77 100644 --- a/beacon_node/http_api/src/block_id.rs +++ b/beacon_node/http_api/src/block_id.rs @@ -602,7 +602,9 @@ mod tests { "precondition: test block must not be imported into fork choice yet" ); - let sampling_columns = chain.sampling_columns_for_epoch(block.epoch()); + let sampling_columns = chain + .custody_context + .sampling_columns_for_epoch(block.epoch()); let data_columns = generate_data_column_sidecars_from_block(&block, &chain.spec) .into_iter() .filter(|column| sampling_columns.contains(column.index())) @@ -615,16 +617,11 @@ mod tests { let available_block = AvailableBlock::new( block.clone(), AvailableBlockData::new_with_data_columns(data_columns), - &chain.data_availability_checker, - chain.spec.clone(), + &chain.custody_context, ) .unwrap(); let current_slot = harness.get_current_slot(); - let cached_head = chain.canonical_head.cached_head(); - let canonical_head_proposer_index = chain - .canonical_head_proposer_index(current_slot, &cached_head) - .unwrap(); chain .canonical_head @@ -636,7 +633,6 @@ mod tests { Duration::ZERO, &post_state, PayloadVerificationStatus::Verified, - canonical_head_proposer_index, &chain.spec, ) .unwrap(); diff --git a/beacon_node/http_api/src/caches.rs b/beacon_node/http_api/src/caches.rs index d92571594a..0f8c0ee6e0 100644 --- a/beacon_node/http_api/src/caches.rs +++ b/beacon_node/http_api/src/caches.rs @@ -1,6 +1,5 @@ -use lru::LruCache; +use hashlink::lru_cache::LruCache; use parking_lot::Mutex; -use std::num::NonZeroUsize; use std::sync::Arc; use types::{AttestationShufflingId, CommitteeCache, Epoch}; @@ -25,7 +24,7 @@ pub struct HistoricalCommitteeCache { } impl HistoricalCommitteeCache { - pub fn new(size: NonZeroUsize) -> Self { + pub fn new(size: usize) -> Self { Self { committees: Mutex::new(LruCache::new(size)), } @@ -38,6 +37,6 @@ impl HistoricalCommitteeCache { } pub fn insert(&self, id: HistoricalShufflingId, cache: Arc) { - self.committees.lock().put(id, cache); + self.committees.lock().insert(id, cache); } } diff --git a/beacon_node/http_api/src/custody.rs b/beacon_node/http_api/src/custody.rs index a43b55ceca..21004beb72 100644 --- a/beacon_node/http_api/src/custody.rs +++ b/beacon_node/http_api/src/custody.rs @@ -17,6 +17,7 @@ pub fn info( .map_err(|e| custom_server_error(format!("error reading DataColumnCustodyInfo: {e:?}")))?; let column_data_availability_boundary = chain + .custody_context .column_data_availability_boundary() .ok_or_else(|| custom_server_error("unreachable: Fulu should be enabled".to_string()))?; @@ -38,12 +39,13 @@ pub fn info( // Compute the custody columns and the CGC *at the earliest custodied slot*. The node might // have some columns prior to this, but this value is the most up-to-date view of the data the // node is custodying. - let custody_context = chain.data_availability_checker.custody_context(); - let custody_columns = custody_context - .custody_columns_for_epoch(Some(earliest_custodied_data_column_epoch), &chain.spec) + let custody_columns = chain + .custody_context + .custody_columns_for_epoch(Some(earliest_custodied_data_column_epoch)) .to_vec(); - let custody_group_count = custody_context - .custody_group_count_at_epoch(earliest_custodied_data_column_epoch, &chain.spec); + let custody_group_count = chain + .custody_context + .custody_group_count_at_epoch(earliest_custodied_data_column_epoch); Ok(CustodyInfo { earliest_custodied_data_column_slot, diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 94f2e3f1df..8e31e88ff8 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -3179,10 +3179,11 @@ pub fn serve( .head_slot() .epoch(T::EthSpec::slots_per_epoch()) + 1; - let custody_context = chain.data_availability_checker.custody_context(); // Reset validator custody requirements to `effective_epoch` with the latest // cgc requiremnets. - custody_context.reset_validator_custody_requirements(effective_epoch); + chain + .custody_context + .reset_validator_custody_requirements(effective_epoch); // Update `DataColumnCustodyInfo` to reflect the custody change. chain.update_data_column_custody_info(Some( effective_epoch.start_slot(T::EthSpec::slots_per_epoch()), @@ -3273,6 +3274,9 @@ pub fn serve( api_types::EventTopic::ExecutionPayloadBid => { event_handler.subscribe_execution_payload_bid() } + api_types::EventTopic::ProposerPreferences => { + event_handler.subscribe_proposer_preferences() + } api_types::EventTopic::PayloadAttestationMessage => { event_handler.subscribe_payload_attestation_message() } diff --git a/beacon_node/http_api/src/publish_attestations.rs b/beacon_node/http_api/src/publish_attestations.rs index b93f2a0b7b..c1ea241b79 100644 --- a/beacon_node/http_api/src/publish_attestations.rs +++ b/beacon_node/http_api/src/publish_attestations.rs @@ -189,6 +189,46 @@ pub async fn publish_attestations( PublishAttestationResult::Reprocessing(rx) } } + Err(Error::Validation(AttestationError::UnknownPayloadEnvelope { + beacon_block_root, + })) => { + if !allow_reprocess { + return PublishAttestationResult::Failure(Error::ReprocessDisabled); + }; + // Re-process once the block's payload envelope is seen (Gloas). + let (tx, rx) = oneshot::channel(); + let reprocess_chain = chain.clone(); + let reprocess_network_tx = network_tx.clone(); + let reprocess_fn = move || { + let result = verify_and_publish_attestation( + &reprocess_chain, + &attestation, + seen_timestamp, + &reprocess_network_tx, + ); + // Ignore failure on the oneshot that reports the result. This + // shouldn't happen unless some catastrophe befalls the waiting + // thread which causes it to drop. + let _ = tx.send(result); + }; + let reprocess_msg = ReprocessQueueMessage::UnknownPayloadUnaggregate( + QueuedUnaggregate { + beacon_block_root, + process_fn: Box::new(reprocess_fn), + }, + ); + if task_spawner + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(reprocess_msg), + }) + .is_err() + { + PublishAttestationResult::Failure(Error::ReprocessFull) + } else { + PublishAttestationResult::Reprocessing(rx) + } + } Err(Error::Validation(AttestationError::PriorAttestationKnown { .. })) => PublishAttestationResult::AlreadyKnown, diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index b46576ddad..41bb515a2b 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -14,7 +14,7 @@ use eth2::types::{ }; use execution_layer::{ProvenancedPayload, SubmitBlindedBlockResponse}; use futures::TryFutureExt; -use lighthouse_network::PubsubMessage; +use lighthouse_network::{PubsubMessage, PubsubPartialMessage}; use logging::crit; use network::NetworkMessage; use rand::prelude::SliceRandom; @@ -217,7 +217,7 @@ pub async fn publish_block>( warp_utils::reject::custom_server_error("unable to publish data column sidecars".into()) })?; let epoch = block.slot().epoch(T::EthSpec::slots_per_epoch()); - let sampling_columns_indices = chain.sampling_columns_for_epoch(epoch); + let sampling_columns_indices = chain.custody_context.sampling_columns_for_epoch(epoch); let sampling_columns = gossip_verified_columns .into_iter() .filter(|data_column| sampling_columns_indices.contains(&data_column.index())) @@ -266,7 +266,7 @@ pub async fn publish_block>( Err(BlockError::DuplicateFullyImported(root)) => { if publish_fn_completed.load(Ordering::SeqCst) { post_block_import_logging_and_response( - Ok(AvailabilityProcessingStatus::Imported(root)), + Ok(AvailabilityProcessingStatus::Imported(slot, root)), validation_level, block, is_locally_built_block, @@ -442,12 +442,22 @@ pub(crate) fn publish_column_sidecars( // Publish partial messages if !partial_columns.is_empty() { if let Some(header) = partial_header { + let header = Arc::new(header); + let messages = partial_columns + .into_iter() + .map(|column| { + let mut request_cells = column.sidecar.cells_present_bitmap.clone(); + request_cells.not_inplace(); + PubsubPartialMessage::DataColumnFulu { + column, + request_cells, + header: header.clone(), + } + }) + .collect(); crate::utils::publish_network_message( sender_clone, - NetworkMessage::PublishPartialColumns { - columns: partial_columns, - header: Arc::new(header), - }, + NetworkMessage::PublishPartialColumns { messages }, ) .map_err(|_| { BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish)) @@ -474,7 +484,7 @@ async fn post_block_import_logging_and_response( // result of the block being imported from gossip, OR it could be that it finished importing // after processing of a gossip blob. In the latter case we MUST run fork choice to // re-compute the head. - Ok(AvailabilityProcessingStatus::Imported(root)) + Ok(AvailabilityProcessingStatus::Imported(_, root)) | Err(BlockError::DuplicateFullyImported(root)) => { let delay = get_block_delay_ms(seen_timestamp, block.message(), &chain.slot_clock); info!( diff --git a/beacon_node/http_api/src/test_utils.rs b/beacon_node/http_api/src/test_utils.rs index 467a5216b1..9a705e4162 100644 --- a/beacon_node/http_api/src/test_utils.rs +++ b/beacon_node/http_api/src/test_utils.rs @@ -22,10 +22,10 @@ use lighthouse_network::{ }; use network::{NetworkReceivers, NetworkSenders}; use sensitive_url::SensitiveUrl; +use std::future::Future; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; -use std::{future::Future, num::NonZeroUsize}; use store::MemoryStore; use task_executor::test_utils::TestRuntime; use types::{ChainSpec, EthSpec}; @@ -294,7 +294,7 @@ pub async fn create_api_server_with_config( beacon_processor_send: Some(beacon_processor_send), sse_logging_components: None, historical_committee_cache: Arc::new(HistoricalCommitteeCache::new( - NonZeroUsize::new(http_config.historical_committee_cache_size).unwrap(), + http_config.historical_committee_cache_size, )), }); diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 8639914774..7f3d1cd721 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -277,8 +277,10 @@ pub fn get_validator_attestation_data( ))); } + // Always use committee_index 0 regardless of the query parameter, since + // attestation data does not depend on the committee index post-Electra. chain - .produce_unaggregated_attestation(query.slot, query.committee_index) + .produce_unaggregated_attestation(query.slot, 0) .map(|attestation| attestation.data().clone()) .map(GenericResponse::from) .map_err(warp_utils::reject::unhandled_error) @@ -853,9 +855,8 @@ pub fn post_validator_prepare_beacon_proposer( let current_slot = chain.slot().map_err(warp_utils::reject::unhandled_error)?; if let Some(cgc_change) = chain - .data_availability_checker - .custody_context() - .register_validators(validators_and_balances, current_slot, &chain.spec) + .custody_context + .register_validators(validators_and_balances, current_slot) { chain.update_data_column_custody_info(Some( cgc_change diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index 98629a1c5e..6d80344e94 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -2036,6 +2036,7 @@ fn get_custody_columns(tester: &InteractiveTester, slot: Slot) -> HashSet `EMPTY`) independently of the +//! beacon block, when later-slot voters attest the block with `payload_present = false`. +//! +use beacon_chain::{ + ChainConfig, + chain_config::DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR, + custody_context::NodeCustodyType, + test_utils::{ + AttestationStrategy, BlockStrategy, LightClientStrategy, MakeAttestationOptions, + MakePayloadAttestationOptions, PayloadAttestationVote, SyncCommitteeStrategy, test_spec, + }, +}; +use execution_layer::{ForkchoiceState, PayloadAttributes}; +use fixed_bytes::FixedBytesExtended; +use http_api::test_utils::InteractiveTester; +use parking_lot::Mutex; +use proto_array::PayloadStatus; +use slot_clock::SlotClock; +use state_processing::{ + per_block_processing::get_expected_withdrawals, state_advance::complete_state_advance, +}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use types::{ + Address, BeaconBlockRef, EthSpec, ExecutionBlockHash, Hash256, MinimalEthSpec, + ProposerPreparationData, Slot, +}; + +type E = MinimalEthSpec; + +// Must be at least PTC size to simplify PTC reasoning (unique PTC members per slot). +const ATTESTERS_PER_SLOT: usize = 20; + +/// Data structure for tracking fork choice updates received by the mock execution layer. +#[derive(Debug, Default)] +struct ForkChoiceUpdates { + updates: HashMap>, +} + +#[derive(Debug, Clone)] +struct ForkChoiceUpdateMetadata { + received_at: Duration, + state: ForkchoiceState, + payload_attributes: Option, +} + +impl ForkChoiceUpdates { + fn insert(&mut self, update: ForkChoiceUpdateMetadata) { + self.updates + .entry(update.state.head_block_hash) + .or_default() + .push(update); + } + + fn contains_update_for(&self, block_hash: ExecutionBlockHash) -> bool { + self.updates.contains_key(&block_hash) + } + + /// Find the first fork choice update for `head_block_hash` with payload attributes matching + /// the proposal and parent being tested. + fn first_update_with_payload_attributes( + &self, + head_block_hash: ExecutionBlockHash, + proposal_timestamp: u64, + parent_beacon_block_root: Option, + slot_number: Option, + ) -> Option { + self.updates + .get(&head_block_hash)? + .iter() + .find(|update| { + update + .payload_attributes + .as_ref() + .is_some_and(|payload_attributes| { + if payload_attributes.timestamp() != proposal_timestamp { + return false; + } + + if let Some(parent_beacon_block_root) = parent_beacon_block_root + && payload_attributes.parent_beacon_block_root().ok() + != Some(parent_beacon_block_root) + { + return false; + } + + if let Some(slot_number) = slot_number + && payload_attributes.slot_number().ok() != Some(slot_number) + { + return false; + } + + true + }) + }) + .cloned() + } +} + +#[derive(Clone, Copy)] +enum ExpectedFirstUpdateLookahead { + Payload, + ForkChoice, + BlockProduction, +} + +pub struct ReOrgTest { + head_slot: Slot, + /// Number of slots between parent block and canonical head. + parent_distance: u64, + /// Number of slots between head block and block proposal slot. + head_distance: u64, + /// Fraction of parent (A)'s committee that votes for A (always with payload_present=0). + percent_parent_votes: usize, + /// Fraction of B's committee that votes for A with payload_present=0. + percent_skip_empty_votes: usize, + /// Fraction of B's committee that votes for A with payload_present=1. + percent_skip_full_votes: usize, + /// Fraction of B's committee that votes for B (always with payload_present=0). + percent_head_votes: usize, + /// Parent payload status of block B. + head_parent_payload_status: PayloadStatus, + /// Fraction of A's PTC that vote for A's payload being present. + percent_parent_ptc_present_votes: usize, + /// Fraction of A's PTC that vote for A's payload being absent. + percent_parent_ptc_absent_votes: usize, + /// Expected parent payload status of our proposed block (C). + /// + /// This can be the payload status of A or B depending on whether we reorged or not. + expected_parent_payload_status: PayloadStatus, + should_re_org: bool, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead, + /// Whether to expect withdrawals to change on epoch boundaries. + expect_withdrawals_change_on_epoch: bool, +} + +impl Default for ReOrgTest { + /// Default config represents a regular easy re-org. + fn default() -> Self { + Self { + head_slot: Slot::new(E::slots_per_epoch() - 2), + parent_distance: 1, + head_distance: 1, + percent_parent_votes: 100, + percent_skip_empty_votes: 0, + percent_skip_full_votes: 100, + percent_head_votes: 0, + head_parent_payload_status: PayloadStatus::Full, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + expected_parent_payload_status: PayloadStatus::Full, + should_re_org: true, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::Payload, + expect_withdrawals_change_on_epoch: false, + } + } +} + +// This test doesn't actually exercise the re-org code path because the chain just naturally +// re-orgs to A-empty at the start of slot C anyway. That only happens after the 500ms +// pre-slot fork choice recompute. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn re_org_parent_is_empty_easy() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 100, + percent_skip_full_votes: 0, + expected_parent_payload_status: PayloadStatus::Empty, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::ForkChoice, + ..Default::default() + }) + .await; +} + +// A-Empty chain has 55% of one committee supporting it A-Full chain has 45% of one committee +// supporting it, including 15% for descendant B that is late and re-orgable. +// +// A-Full has 100% PTC support, but this should be completely ignored. +// +// We should re-org B and build on A-Empty. +// +// This test doesn't actually exercise the re-org code path because the chain just naturally +// re-orgs to A-empty at the start of slot C anyway. That only happens after the 500ms +// pre-slot fork choice recompute. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn re_org_parent_is_empty_marginal_win() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 55, + percent_skip_full_votes: 30, + percent_head_votes: 15, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + expected_parent_payload_status: PayloadStatus::Empty, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::ForkChoice, + ..Default::default() + }) + .await; +} + +// A-Empty chain has 45% of one committee supporting it A-Full chain has 55% of one committee +// supporting it, including 15% for descendant B that is late and re-orgable. +// +// A-Full has 100% PTC support, but this should be completely ignored. +// +// We should re-org B and build on A-Full. +// Since Gloas fork choice updates are not overridden for proposer re-orgs, the first fcU for this +// parent is sent during block production. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn re_org_parent_is_full_marginal_win() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 45, + percent_skip_full_votes: 40, + percent_head_votes: 15, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + expected_parent_payload_status: PayloadStatus::Full, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_parent_empty() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 55, + percent_skip_full_votes: 30, + percent_head_votes: 15, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + head_parent_payload_status: PayloadStatus::Empty, + expected_parent_payload_status: PayloadStatus::Empty, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +// Test that the beacon node will try to perform proposer boost re-orgs on late blocks when +// configured. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_zero_weight() { + proposer_boost_re_org_test(ReOrgTest { + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +// Since Fulu, proposer shuffling is stable across epoch boundaries, so re-orgs of the last block +// in an epoch are permitted. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_epoch_boundary() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 1), + should_re_org: true, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_epoch_boundary_skip1() { + // Proposing a block on a boundary after a skip will change the set of expected withdrawals + // sent in the payload attributes. + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(2 * E::slots_per_epoch() - 2), + head_distance: 2, + should_re_org: false, + expect_withdrawals_change_on_epoch: true, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_epoch_boundary_skip32() { + // Propose a block at 64 after a whole epoch of skipped slots. + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 1), + head_distance: E::slots_per_epoch() + 1, + should_re_org: false, + expect_withdrawals_change_on_epoch: true, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_slot_after_epoch_boundary() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(33), + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_bad_ffg() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(64 + 22), + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_no_finality() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(96), + percent_parent_votes: 100, + percent_skip_full_votes: 0, + percent_head_votes: 100, + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_finality() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(129), + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_parent_distance() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 2), + parent_distance: 2, + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_head_distance() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 3), + head_distance: 2, + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_very_unhealthy() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 1), + parent_distance: 2, + head_distance: 2, + percent_parent_votes: 10, + percent_skip_full_votes: 10, + percent_head_votes: 10, + should_re_org: false, + ..Default::default() + }) + .await; +} + +/// The head block is late but still receives 30% of the committee vote, making it strong enough +/// that we do not re-org it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_head_too_strong() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_full_votes: 70, + percent_head_votes: 30, + should_re_org: false, + ..Default::default() + }) + .await; +} + +/// Run a proposer boost re-org test. +/// +/// - `head_slot`: the slot of the canonical head to be reorged +/// - `reorg_threshold`: committee percentage value for reorging +/// - `num_empty_votes`: percentage of comm of attestations for the parent block +/// - `num_head_votes`: number of attestations for the head block +/// - `should_re_org`: whether the proposer should build on the parent rather than the head +#[allow(clippy::large_stack_frames)] +pub async fn proposer_boost_re_org_test( + ReOrgTest { + head_slot, + parent_distance, + head_distance, + percent_parent_votes, + percent_skip_empty_votes, + percent_skip_full_votes, + percent_head_votes, + head_parent_payload_status, + percent_parent_ptc_present_votes, + percent_parent_ptc_absent_votes, + expected_parent_payload_status, + should_re_org, + expected_first_update_lookahead, + expect_withdrawals_change_on_epoch, + }: ReOrgTest, +) { + assert!(head_slot > 0); + + let spec = test_spec::(); + + if !spec.is_gloas_scheduled() { + return; + } + + // Ensure there are enough validators to have `ATTESTERS_PER_SLOT`. + assert!(ATTESTERS_PER_SLOT >= E::ptc_size()); + let validator_count = E::slots_per_epoch() as usize * ATTESTERS_PER_SLOT; + let all_validators = (0..validator_count).collect::>(); + let num_initial = head_slot.as_u64().checked_sub(parent_distance + 1).unwrap(); + + // Check that the required vote percentages can be satisfied exactly using `ATTESTERS_PER_SLOT`. + assert_eq!(100 % ATTESTERS_PER_SLOT, 0); + let percent_per_attester = 100 / ATTESTERS_PER_SLOT; + assert_eq!(percent_parent_votes % percent_per_attester, 0); + assert_eq!(percent_skip_empty_votes % percent_per_attester, 0); + assert_eq!(percent_skip_full_votes % percent_per_attester, 0); + assert_eq!(percent_head_votes % percent_per_attester, 0); + let num_parent_votes = Some(ATTESTERS_PER_SLOT * percent_parent_votes / 100); + let num_skip_empty_votes = Some(ATTESTERS_PER_SLOT * percent_skip_empty_votes / 100); + let num_skip_full_votes = Some(ATTESTERS_PER_SLOT * percent_skip_full_votes / 100); + let num_head_votes = Some(ATTESTERS_PER_SLOT * percent_head_votes / 100); + + assert_eq!((percent_parent_ptc_present_votes * E::ptc_size()) % 100, 0); + let num_parent_ptc_present_votes = percent_parent_ptc_present_votes * E::ptc_size() / 100; + assert_eq!((percent_parent_ptc_absent_votes * E::ptc_size()) % 100, 0); + let num_parent_ptc_absent_votes = percent_parent_ptc_absent_votes * E::ptc_size() / 100; + + // We must configure the prepare payload lookahead so it scales with the minimal config, + // otherwise the late block reveal for A halfway through the slot can end up being *after* + // the payload lookahead, which messes up our measurement of timings. + let chain_config = ChainConfig { + prepare_payload_lookahead: spec.get_slot_duration() + / DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR, + ..Default::default() + }; + + let tester = InteractiveTester::::new_with_initializer_and_mutator( + Some(spec), + validator_count, + None, + Some(Box::new(move |builder| builder.chain_config(chain_config))), + Default::default(), + false, + NodeCustodyType::Fullnode, + ) + .await; + let harness = &tester.harness; + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + let execution_ctx = mock_el.server.ctx.clone(); + let slot_clock = &harness.chain.slot_clock; + + mock_el.server.all_payloads_valid(); + + // Send proposer preparation data for all validators. + let proposer_preparation_data = all_validators + .iter() + .map(|i| { + ( + ProposerPreparationData { + validator_index: *i as u64, + fee_recipient: Address::from_low_u64_be(*i as u64), + }, + None, + ) + }) + .collect::>(); + harness + .chain + .execution_layer + .as_ref() + .unwrap() + .update_proposer_preparation( + head_slot.epoch(E::slots_per_epoch()) + 1, + proposer_preparation_data.iter().map(|(a, b)| (a, b)), + ) + .await; + + // Create some chain depth. Sign sync committee signatures so validator balances don't dip + // below 32 ETH and become ineligible for withdrawals. + harness.advance_slot(); + harness + .extend_chain_with_sync( + num_initial as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + SyncCommitteeStrategy::AllValidators, + LightClientStrategy::Disabled, + ) + .await; + + // Start collecting fork choice updates. + let forkchoice_updates = Arc::new(Mutex::new(ForkChoiceUpdates::default())); + let forkchoice_updates_inner = forkchoice_updates.clone(); + let chain_inner = harness.chain.clone(); + + execution_ctx + .hook + .lock() + .set_forkchoice_updated_hook(Box::new(move |state, payload_attributes| { + let received_at = chain_inner.slot_clock.now_duration().unwrap(); + let state = ForkchoiceState::from(state); + let payload_attributes = payload_attributes.map(Into::into); + let update = ForkChoiceUpdateMetadata { + received_at, + state, + payload_attributes, + }; + forkchoice_updates_inner.lock().insert(update); + None + })); + + // We set up the following block graph, where B is a block that arrives late and is re-orged + // by C. + // + // A | B | - | + // ^ | - | C | + + let slot_a = Slot::new(num_initial + 1); + let slot_b = slot_a + parent_distance; + let slot_c = slot_b + head_distance; + + // We need to transition to at least epoch 2 in order to trigger + // `process_rewards_and_penalties`. This allows us to test withdrawals changes at epoch + // boundaries. + if expect_withdrawals_change_on_epoch { + assert!( + slot_c.epoch(E::slots_per_epoch()) >= 2, + "for withdrawals to change, test must end at an epoch >= 2" + ); + } + + harness.advance_slot(); + let (block_a_root, block_a, mut state_a) = harness + .add_block_at_slot(slot_a, harness.get_current_state()) + .await + .unwrap(); + let state_a_root = state_a.canonical_root().unwrap(); + + // Attest to block A during slot A. + let (block_a_parent_votes, _) = harness.make_attestations_with_limit( + &all_validators, + &state_a, + state_a_root, + block_a_root, + slot_a, + num_parent_votes, + ); + harness.process_attestations(block_a_parent_votes, &state_a); + + // Produce PTC messages for slot A. + let a_ptc_votes = vec![ + PayloadAttestationVote { + validator_count: num_parent_ptc_present_votes, + payload_present: true, + blob_data_available: true, + }, + PayloadAttestationVote { + validator_count: num_parent_ptc_absent_votes, + payload_present: false, + blob_data_available: false, + }, + ]; + let (a_ptc_messages, _) = harness.make_payload_attestation_messages_with_opts( + &all_validators, + &state_a, + block_a_root.into(), + slot_a, + MakePayloadAttestationOptions { + votes: a_ptc_votes, + fork: state_a.fork(), + }, + ); + harness + .import_payload_attestation_messages(a_ptc_messages) + .unwrap(); + + // Attest to block A during slot B. + for _ in 0..parent_distance { + harness.advance_slot(); + } + let (block_a_empty_votes, block_a_empty_attesters) = harness.make_attestations_with_opts( + &all_validators, + &state_a, + state_a_root, + block_a_root, + slot_b, + MakeAttestationOptions { + limit: num_skip_empty_votes, + fork: state_a.fork(), + payload_present_override: Some(false), + }, + ); + harness.process_attestations(block_a_empty_votes, &state_a); + let remaining_attesters_after_empty = all_validators + .iter() + .copied() + .filter(|index| !block_a_empty_attesters.contains(index)) + .collect::>(); + let (block_a_full_votes, block_a_full_attesters) = harness.make_attestations_with_opts( + &remaining_attesters_after_empty, + &state_a, + state_a_root, + block_a_root, + slot_b, + MakeAttestationOptions { + limit: num_skip_full_votes, + fork: state_a.fork(), + payload_present_override: Some(true), + }, + ); + harness.process_attestations(block_a_full_votes, &state_a); + + let remaining_attesters = remaining_attesters_after_empty + .iter() + .copied() + .filter(|index| !block_a_full_attesters.contains(index)) + .collect::>(); + + // Produce block B and process it halfway through the slot. + // When B is expected to remain canonical (no re-org), capture its Gloas payload envelope so we + // can reveal B's execution payload to fork choice below. Without this, B's payload status stays + // `Empty`/`Pending` and the forkchoiceUpdated head hash falls back to B's parent rather than B's + // own execution block hash. We skip this when B will be re-orged, since the execution layer + // must never be told about a block that is about to be re-orged away. + let reveal_block_b_payload = !should_re_org; + let (block_b, block_b_envelope, mut state_b) = harness + .make_block_with_envelope_on(state_a.clone(), slot_b, head_parent_payload_status) + .await; + let block_b_envelope = if reveal_block_b_payload { + block_b_envelope + } else { + None + }; + let state_b_root = state_b.canonical_root().unwrap(); + let block_b_root = block_b.0.canonical_root(); + + let obs_time = slot_clock.start_of(slot_b).unwrap() + slot_clock.slot_duration() / 2; + slot_clock.set_current_time(obs_time); + harness.chain.block_times_cache.write().set_time_observed( + block_b_root, + slot_b, + obs_time, + None, + None, + ); + harness.process_block_result(block_b.clone()).await.unwrap(); + + // Reveal B's execution payload so fork choice marks the payload as received and the + // forkchoiceUpdated head hash references B's own execution block hash. + if let Some(block_b_envelope) = block_b_envelope { + harness + .process_envelope(block_b_root, block_b_envelope, &state_b, state_b_root) + .await; + } + + // Add attestations to block B. + let (block_b_head_votes, _) = harness.make_attestations_with_limit( + &remaining_attesters, + &state_b, + state_b_root, + block_b_root.into(), + slot_b, + num_head_votes, + ); + harness.process_attestations(block_b_head_votes, &state_b); + + let payload_lookahead = harness.chain.config.prepare_payload_lookahead; + let fork_choice_lookahead = Duration::from_millis(500); + while harness.get_current_slot() != slot_c { + let current_slot = harness.get_current_slot(); + let next_slot = current_slot + 1; + + // Simulate the scheduled call to prepare proposers at 8 seconds into the slot. + harness.advance_to_slot_lookahead(next_slot, payload_lookahead); + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + // Simulate the scheduled call to fork choice + prepare proposers 500ms before the + // next slot. + harness.advance_to_slot_lookahead(next_slot, fork_choice_lookahead); + harness.chain.recompute_head_at_slot(next_slot).await; + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + harness.advance_slot(); + harness.chain.per_slot_task().await; + } + + // Produce block C. + // Advance state_b so we can get the proposer. + assert_eq!(state_b.slot(), slot_b); + let pre_advance_withdrawals = get_expected_withdrawals(&state_b, &harness.chain.spec) + .unwrap() + .withdrawals() + .to_vec(); + complete_state_advance(&mut state_b, None, slot_c, &harness.chain.spec).unwrap(); + + let proposer_index = state_b + .get_beacon_proposer_index(slot_c, &harness.chain.spec) + .unwrap(); + let randao_reveal = harness + .sign_randao_reveal(&state_b, proposer_index, slot_c) + .into(); + + let (block_c, block_c_blobs) = { + let (response, _) = tester + .client + .get_validator_blocks_v4::(slot_c, &randao_reveal, None, None, None, None) + .await + .unwrap(); + ( + Arc::new(harness.sign_beacon_block(response.data, &state_b)), + None, + ) + }; + + // Post-Gloas the execution payload is decoupled from the beacon block: the payload hash + // lives in the execution payload bid, and the payload timestamp is derived from the slot. + let exec_block_hash = |block: BeaconBlockRef| -> ExecutionBlockHash { + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message + .block_hash + }; + let exec_parent_hash = |block: BeaconBlockRef| -> ExecutionBlockHash { + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message + .parent_block_hash + }; + + let block_a_exec_hash = exec_block_hash(block_a.0.message()); + let block_b_exec_hash = exec_block_hash(block_b.0.message()); + + assert_eq!( + block_b.0.is_parent_block_full(block_a_exec_hash), + head_parent_payload_status == PayloadStatus::Full + ); + + if should_re_org { + // Block C should build on A. + assert_eq!(block_c.parent_root(), Hash256::from(block_a_root)); + + assert_eq!( + block_c.is_parent_block_full(block_a_exec_hash), + expected_parent_payload_status == PayloadStatus::Full + ); + } else { + // Block C should build on B. + assert_eq!(block_c.parent_root(), block_b_root); + + assert_eq!( + block_c.is_parent_block_full(block_b_exec_hash), + expected_parent_payload_status == PayloadStatus::Full + ); + } + + // Applying block C should cause it to become head regardless (re-org or continuation). + let block_root_c = Hash256::from( + harness + .process_block_result((block_c.clone(), block_c_blobs)) + .await + .unwrap(), + ); + + assert_eq!(harness.head_block_root(), block_root_c); + + // Check the fork choice updates that were sent. + let forkchoice_updates = forkchoice_updates.lock(); + + let block_c_timestamp = harness.chain.slot_clock.start_of(slot_c).unwrap().as_secs(); + + // If we re-orged then no fork choice update for B should have been sent. + assert_eq!( + should_re_org, + !forkchoice_updates.contains_update_for(block_b_exec_hash), + "{block_b_exec_hash:?}" + ); + + // Check the timing of the first fork choice update with payload attributes for block C. + let c_parent_block = if should_re_org { + block_a.0.message() + } else { + block_b.0.message() + }; + let c_parent_hash = if expected_parent_payload_status == PayloadStatus::Full { + exec_block_hash(c_parent_block) + } else { + exec_parent_hash(c_parent_block) + }; + let first_update = forkchoice_updates + .first_update_with_payload_attributes( + c_parent_hash, + block_c_timestamp, + Some(block_c.parent_root()), + Some(slot_c.as_u64()), + ) + .unwrap(); + let payload_attribs = first_update.payload_attributes.as_ref().unwrap(); + + // Check that withdrawals from the payload attributes match those computed from the state used + // by the path that produced the matching fcU. + let parent_state_advanced = if should_re_org { + let mut state = state_a.clone(); + complete_state_advance(&mut state, None, slot_c, &harness.chain.spec).unwrap(); + state + } else { + state_b.clone() + }; + let expected_withdrawals = if matches!( + expected_first_update_lookahead, + ExpectedFirstUpdateLookahead::BlockProduction + ) && expected_parent_payload_status == PayloadStatus::Empty + { + parent_state_advanced + .payload_expected_withdrawals() + .unwrap() + .to_vec() + } else { + get_expected_withdrawals(&parent_state_advanced, &harness.chain.spec) + .unwrap() + .withdrawals() + .to_vec() + }; + let payload_attribs_withdrawals = payload_attribs.withdrawals().unwrap(); + assert_eq!(expected_withdrawals, *payload_attribs_withdrawals); + // The validator withdrawal sweep is positional: it scans a rotating window of + // `max_validators_per_withdrawals_sweep` validators starting at `next_withdrawal_validator_index`. + // For a given proposal slot that window can legitimately contain no withdrawal-eligible + // validators (with empty partial/builder withdrawal queues), so an empty withdrawals list is + // valid. Withdrawal correctness is covered by the equality check above; we only assert the + // re-org/epoch-boundary withdrawals change when there are withdrawals to compare. + if !expected_withdrawals.is_empty() + && (should_re_org + || expect_withdrawals_change_on_epoch + && slot_c.epoch(E::slots_per_epoch()) != slot_b.epoch(E::slots_per_epoch())) + { + assert_ne!(expected_withdrawals, pre_advance_withdrawals); + } + + // Check that the `parent_beacon_block_root` of the payload attributes are correct. + if let Ok(parent_beacon_block_root) = payload_attribs.parent_beacon_block_root() { + assert_eq!(parent_beacon_block_root, block_c.parent_root()); + } + + let lookahead = slot_clock + .start_of(slot_c) + .unwrap() + .checked_sub(first_update.received_at) + .unwrap(); + + let expected_lookahead = match expected_first_update_lookahead { + ExpectedFirstUpdateLookahead::Payload => payload_lookahead, + ExpectedFirstUpdateLookahead::ForkChoice => fork_choice_lookahead, + ExpectedFirstUpdateLookahead::BlockProduction => Duration::ZERO, + }; + assert_eq!( + lookahead, + expected_lookahead, + "observed_lookahead={lookahead:?}, expected={expected_lookahead:?}, timestamp={}, prev_randao={:?}", + payload_attribs.timestamp(), + payload_attribs.prev_randao(), + ); +} diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index e6135a81c7..437da7d4ea 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -3,7 +3,8 @@ use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::{ ChainConfig, test_utils::{ - AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy, test_spec, + AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy, + fork_name_from_env, test_spec, }, }; use beacon_processor::{Work, WorkEvent, work_reprocessing_queue::ReprocessQueueMessage}; @@ -366,9 +367,13 @@ pub async fn proposer_boost_re_org_test( ) { assert!(head_slot > 0); - // TODO(EIP-7732): extend test for Gloas — `get_validator_blocks_v3` is missing the - // `Eth-Execution-Payload-Blinded` header for Gloas block production responses. - let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + // We don't run these test for post-Gloas forks because of the FcU changes that were + // applied in the gloas. Gloas adopted tests can be found in `gloas_re_org_test.rs` + if fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let spec = test_spec::(); // Ensure there are enough validators to have `attesters_per_slot`. let attesters_per_slot = 10; @@ -876,7 +881,6 @@ async fn queue_attestations_from_http() { // In parallel, apply the block. We need to manually notify the reprocess queue, because the // `beacon_chain` does not know about the queue and will not update it for us. - let parent_root = block.0.parent_root(); harness .process_block(attestation_slot, block_root, block) .await @@ -888,10 +892,7 @@ async fn queue_attestations_from_http() { .unwrap() .try_send(WorkEvent { drop_during_sync: false, - work: Work::Reprocess(ReprocessQueueMessage::BlockImported { - block_root, - parent_root, - }), + work: Work::Reprocess(ReprocessQueueMessage::BlockImported { block_root }), }) .unwrap(); @@ -1261,8 +1262,7 @@ async fn lighthouse_restart_custody_backfill() { let max_cgc = spec.number_of_custody_groups; let num_blocks = 2 * E::slots_per_epoch(); - - let custody_context = harness.chain.data_availability_checker.custody_context(); + let custody_context = &harness.chain.custody_context; harness.advance_slot(); harness @@ -1275,7 +1275,7 @@ async fn lighthouse_restart_custody_backfill() { ) .await; - let cgc_at_head = custody_context.custody_group_count_at_head(spec); + let cgc_at_head = custody_context.custody_group_count_at_head(); let earliest_data_column_epoch = harness.chain.earliest_custodied_data_column_epoch(); assert_eq!(cgc_at_head, max_cgc); @@ -1285,9 +1285,9 @@ async fn lighthouse_restart_custody_backfill() { .update_and_backfill_custody_count_at_epoch(harness.chain.epoch().unwrap(), cgc_at_head); client.post_lighthouse_custody_backfill().await.unwrap(); - let cgc_at_head = custody_context.custody_group_count_at_head(spec); + let cgc_at_head = custody_context.custody_group_count_at_head(); let cgc_at_previous_epoch = - custody_context.custody_group_count_at_epoch(harness.chain.epoch().unwrap() - 1, spec); + custody_context.custody_group_count_at_epoch(harness.chain.epoch().unwrap() - 1); let earliest_data_column_epoch = harness.chain.earliest_custodied_data_column_epoch(); // `DataColumnCustodyInfo` should have been updated to the head epoch diff --git a/beacon_node/http_api/tests/main.rs b/beacon_node/http_api/tests/main.rs index e0636424e4..35400a912e 100644 --- a/beacon_node/http_api/tests/main.rs +++ b/beacon_node/http_api/tests/main.rs @@ -2,6 +2,7 @@ pub mod broadcast_validation_tests; pub mod fork_tests; +pub mod gloas_reorg_tests; pub mod interactive_tests; pub mod status_tests; pub mod tests; diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 843e055827..4867852645 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4856,6 +4856,19 @@ impl ApiTester { assert_eq!(result, expected); } + // The committee_index in the response must always be 0 post-Electra, + // regardless of the query parameter. + let committee_count = state.get_committee_count_at_slot(slot).unwrap(); + if committee_count > 0 { + let result = self + .client + .get_validator_attestation_data(slot, 1) + .await + .unwrap() + .data; + assert_eq!(result.index, 0); + } + self } diff --git a/beacon_node/lighthouse_network/Cargo.toml b/beacon_node/lighthouse_network/Cargo.toml index 659886f0f1..f69f13612a 100644 --- a/beacon_node/lighthouse_network/Cargo.toml +++ b/beacon_node/lighthouse_network/Cargo.toml @@ -21,6 +21,7 @@ ethereum_ssz_derive = { workspace = true } fixed_bytes = { workspace = true } fnv = { workspace = true } futures = { workspace = true } +hashlink = { workspace = true } hex = { workspace = true } if-addrs = "0.14" itertools = { workspace = true } @@ -28,7 +29,6 @@ libp2p = { workspace = true } libp2p-mplex = { git = "https://github.com/libp2p/rust-libp2p.git" } lighthouse_version = { workspace = true } logging = { workspace = true } -lru = { workspace = true } lru_cache = { workspace = true } metrics = { workspace = true } network_utils = { workspace = true } diff --git a/beacon_node/lighthouse_network/src/config.rs b/beacon_node/lighthouse_network/src/config.rs index 8f7c1dd8de..f54a7ee5b9 100644 --- a/beacon_node/lighthouse_network/src/config.rs +++ b/beacon_node/lighthouse_network/src/config.rs @@ -125,7 +125,7 @@ pub struct Config { /// Whether light client protocols should be enabled. pub enable_light_client_server: bool, - /// Whether to enable the deprecated mplex multiplexer alongside yamux. + /// Whether to enable the mplex multiplexer alongside yamux. Enabled by default. pub enable_mplex: bool, /// Configuration for the outbound rate limiter (requests made by this node). @@ -365,7 +365,7 @@ impl Default for Config { proposer_only: false, metrics_enabled: false, enable_light_client_server: true, - enable_mplex: false, + enable_mplex: true, outbound_rate_limiter_config: None, invalid_block_storage: None, inbound_rate_limiter_config: None, diff --git a/beacon_node/lighthouse_network/src/discovery/mod.rs b/beacon_node/lighthouse_network/src/discovery/mod.rs index 21b1146aff..964c0cd5fb 100644 --- a/beacon_node/lighthouse_network/src/discovery/mod.rs +++ b/beacon_node/lighthouse_network/src/discovery/mod.rs @@ -18,6 +18,7 @@ use alloy_rlp::bytes::Bytes; use enr::{ATTESTATION_BITFIELD_ENR_KEY, ETH2_ENR_KEY, SYNC_COMMITTEE_BITFIELD_ENR_KEY}; use futures::prelude::*; use futures::stream::FuturesUnordered; +use hashlink::lru_cache::LruCache; use libp2p::core::transport::PortUse; use libp2p::multiaddr::Protocol; use libp2p::swarm::THandlerInEvent; @@ -31,10 +32,8 @@ pub use libp2p::{ }, }; use logging::crit; -use lru::LruCache; use network_utils::discovery_metrics; use ssz::Encode; -use std::num::NonZeroUsize; use std::{ collections::{HashMap, VecDeque}, net::{IpAddr, SocketAddr}, @@ -51,7 +50,6 @@ use types::{ChainSpec, EnrForkId, EthSpec}; mod subnet_predicate; use crate::discovery::enr::{NEXT_FORK_DIGEST_ENR_KEY, PEERDAS_CUSTODY_GROUP_COUNT_ENR_KEY}; pub use subnet_predicate::subnet_predicate; -use types::new_non_zero_usize; /// Local ENR storage filename. pub const ENR_FILENAME: &str = "enr.dat"; @@ -74,7 +72,7 @@ pub const FIND_NODE_QUERY_CLOSEST_PEERS: usize = 16; /// The threshold for updating `min_ttl` on a connected peer. const DURATION_DIFFERENCE: Duration = Duration::from_millis(1); /// The capacity of the Discovery ENR cache. -const ENR_CACHE_CAPACITY: NonZeroUsize = new_non_zero_usize(50); +const ENR_CACHE_CAPACITY: usize = 50; /// A query has completed. This result contains a mapping of discovered peer IDs to the `min_ttl` /// of the peer if it is specified. @@ -358,7 +356,7 @@ impl Discovery { /// Removes a cached ENR from the list. pub fn remove_cached_enr(&mut self, peer_id: &PeerId) -> Option { - self.cached_enrs.pop(peer_id) + self.cached_enrs.remove(peer_id) } /// This adds a new `FindPeers` query to the queue if one doesn't already exist. @@ -394,7 +392,7 @@ impl Discovery { /// Add an ENR to the routing table of the discovery mechanism. pub fn add_enr(&mut self, enr: Enr) { // add the enr to seen caches - self.cached_enrs.put(enr.peer_id(), enr.clone()); + self.cached_enrs.insert(enr.peer_id(), enr.clone()); if let Err(e) = self.discv5.add_enr(enr) { debug!( @@ -665,7 +663,7 @@ impl Discovery { } // Remove the peer from the cached list, to prevent redialing disconnected // peers. - self.cached_enrs.pop(peer_id); + self.cached_enrs.remove(peer_id); } /* Internal Functions */ @@ -875,7 +873,7 @@ impl Discovery { .into_iter() .map(|enr| { // cache the found ENR's - self.cached_enrs.put(enr.peer_id(), enr.clone()); + self.cached_enrs.insert(enr.peer_id(), enr.clone()); (enr, None) }) .collect(); @@ -910,7 +908,7 @@ impl Discovery { // cache the found ENR's for enr in r.iter().cloned() { - self.cached_enrs.put(enr.peer_id(), enr); + self.cached_enrs.insert(enr.peer_id(), enr); } // Map each subnet query's min_ttl to the set of ENR's returned for that subnet. diff --git a/beacon_node/lighthouse_network/src/lib.rs b/beacon_node/lighthouse_network/src/lib.rs index fdb6ff095e..7719ee8540 100644 --- a/beacon_node/lighthouse_network/src/lib.rs +++ b/beacon_node/lighthouse_network/src/lib.rs @@ -98,8 +98,8 @@ impl std::fmt::Display for ClearDialError<'_> { } pub use crate::types::{ - Enr, EnrSyncCommitteeBitfield, GossipTopic, NetworkGlobals, PubsubMessage, Subnet, - SubnetDiscovery, decode_partial, + Enr, EnrSyncCommitteeBitfield, GossipTopic, NetworkGlobals, PubsubMessage, + PubsubPartialMessage, Subnet, SubnetDiscovery, decode_partial, }; pub use prometheus_client; diff --git a/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs b/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs index 0a338bb011..693fdebb69 100644 --- a/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs +++ b/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs @@ -257,17 +257,9 @@ impl PeerDB { .iter() .filter(move |(_, info)| { info.is_connected() - && match info.sync_status() { - SyncStatus::Synced { info } => { - info.has_slot(epoch.start_slot(E::slots_per_epoch())) - } - SyncStatus::Advanced { info } => { - info.has_slot(epoch.start_slot(E::slots_per_epoch())) - } - SyncStatus::IrrelevantPeer - | SyncStatus::Behind { .. } - | SyncStatus::Unknown => false, - } + && info.is_synced_or_advanced_with_available_slot( + epoch.start_slot(E::slots_per_epoch()), + ) }) .map(|(peer_id, _)| peer_id) } @@ -301,10 +293,11 @@ impl PeerDB { } /// Returns an iterator of all good gossipsub peers that are supposed to be custodying - /// the given subnet id. + /// the given subnet id, with data available at the given slot. pub fn good_custody_subnet_peer( &self, subnet: DataColumnSubnetId, + slot: Slot, ) -> impl Iterator { self.peers .iter() @@ -314,7 +307,7 @@ impl PeerDB { info.is_connected() && info.is_good_gossipsub_peer() && is_custody_subnet_peer - && info.is_synced_or_advanced() + && info.is_synced_or_advanced_with_available_slot(slot) }) .map(|(peer_id, _)| peer_id) } @@ -330,14 +323,9 @@ impl PeerDB { let good_sync_peers_for_epoch = self.peers.values().filter(|&info| { info.is_connected() - && match info.sync_status() { - SyncStatus::Synced { info } | SyncStatus::Advanced { info } => { - info.has_slot(epoch.start_slot(E::slots_per_epoch())) - } - SyncStatus::IrrelevantPeer - | SyncStatus::Behind { .. } - | SyncStatus::Unknown => false, - } + && info.is_synced_or_advanced_with_available_slot( + epoch.start_slot(E::slots_per_epoch()), + ) }); for info in good_sync_peers_for_epoch { @@ -2211,6 +2199,89 @@ mod tests { ); } + #[test] + fn test_good_custody_subnet_peer_respects_earliest_available_slot() { + let mut pdb = get_db(); + let subnet = DataColumnSubnetId::new(0); + let request_slot = Slot::new(10); + + fn sync_info(earliest_available_slot: Option) -> SyncInfo { + SyncInfo { + head_slot: Slot::new(100), + head_root: Hash256::ZERO, + finalized_epoch: Epoch::new(0), + finalized_root: Hash256::ZERO, + earliest_available_slot, + } + } + + let add_custody_peer = |pdb: &mut PeerDB, sync_status: SyncStatus| { + let peer_id = PeerId::random(); + pdb.connect_ingoing(&peer_id, "/ip4/0.0.0.0".parse().unwrap(), None); + pdb.__set_custody_subnets(&peer_id, HashSet::from([subnet])) + .unwrap(); + pdb.update_sync_status(&peer_id, sync_status); + peer_id + }; + + let peer_with_data = add_custody_peer( + &mut pdb, + SyncStatus::Synced { + info: sync_info(Some(Slot::new(5))), + }, + ); + let peer_at_boundary = add_custody_peer( + &mut pdb, + SyncStatus::Synced { + info: sync_info(Some(request_slot)), + }, + ); + let peer_pruned = add_custody_peer( + &mut pdb, + SyncStatus::Synced { + info: sync_info(Some(Slot::new(11))), + }, + ); + let peer_no_eas = add_custody_peer( + &mut pdb, + SyncStatus::Synced { + info: sync_info(None), + }, + ); + let peer_behind = add_custody_peer( + &mut pdb, + SyncStatus::Behind { + info: sync_info(Some(Slot::new(0))), + }, + ); + + let good_peers = pdb + .good_custody_subnet_peer(subnet, request_slot) + .copied() + .collect::>(); + + assert!( + good_peers.contains(&peer_with_data), + "peer with earliest_available_slot before the request slot should be returned" + ); + assert!( + good_peers.contains(&peer_at_boundary), + "peer with earliest_available_slot equal to the request slot should be returned" + ); + assert!( + !good_peers.contains(&peer_pruned), + "peer with earliest_available_slot after the request slot should be excluded" + ); + assert!( + good_peers.contains(&peer_no_eas), + "peer without an advertised earliest_available_slot should be returned" + ); + assert!( + !good_peers.contains(&peer_behind), + "behind peer should be excluded regardless of earliest_available_slot" + ); + } + #[test] fn test_disable_peer_scoring() { let peer = PeerId::random(); diff --git a/beacon_node/lighthouse_network/src/peer_manager/peerdb/peer_info.rs b/beacon_node/lighthouse_network/src/peer_manager/peerdb/peer_info.rs index 8ad7d10a88..658a6355e3 100644 --- a/beacon_node/lighthouse_network/src/peer_manager/peerdb/peer_info.rs +++ b/beacon_node/lighthouse_network/src/peer_manager/peerdb/peer_info.rs @@ -15,7 +15,7 @@ use std::collections::HashSet; use std::net::IpAddr; use std::time::Instant; use strum::AsRefStr; -use types::{DataColumnSubnetId, EthSpec}; +use types::{DataColumnSubnetId, EthSpec, Slot}; /// Information about a given connected peer. #[derive(Clone, Debug, Serialize)] @@ -339,6 +339,14 @@ impl PeerInfo { ) } + /// Checks if the peer is synced or advanced, and has data available for the given slot. + pub fn is_synced_or_advanced_with_available_slot(&self, slot: Slot) -> bool { + match &self.sync_status { + SyncStatus::Synced { info } | SyncStatus::Advanced { info } => info.has_slot(slot), + SyncStatus::IrrelevantPeer | SyncStatus::Behind { .. } | SyncStatus::Unknown => false, + } + } + /// Checks if the status is connected. pub fn is_dialing(&self) -> bool { matches!(self.connection_status, PeerConnectionStatus::Dialing { .. }) diff --git a/beacon_node/lighthouse_network/src/rpc/mod.rs b/beacon_node/lighthouse_network/src/rpc/mod.rs index 7c43018af8..e9177586cb 100644 --- a/beacon_node/lighthouse_network/src/rpc/mod.rs +++ b/beacon_node/lighthouse_network/src/rpc/mod.rs @@ -46,7 +46,7 @@ mod response_limiter; mod self_limiter; // Maximum number of concurrent requests per protocol ID that a client may issue. -const MAX_CONCURRENT_REQUESTS: usize = 2; +pub const MAX_CONCURRENT_REQUESTS: usize = 2; /// Composite trait for a request id. pub trait ReqId: Send + 'static + std::fmt::Debug + Copy + Clone {} diff --git a/beacon_node/lighthouse_network/src/service/mod.rs b/beacon_node/lighthouse_network/src/service/mod.rs index 862281c910..9037975a0e 100644 --- a/beacon_node/lighthouse_network/src/service/mod.rs +++ b/beacon_node/lighthouse_network/src/service/mod.rs @@ -20,7 +20,9 @@ use crate::types::{ SubnetDiscovery, all_topics_at_fork, core_topics_to_subscribe, is_fork_non_core_topic, subnet_from_topic_hash, }; -use crate::{Enr, NetworkGlobals, PubsubMessage, TopicHash, decode_partial, metrics}; +use crate::{ + Enr, NetworkGlobals, PubsubMessage, PubsubPartialMessage, TopicHash, decode_partial, metrics, +}; use api_types::{AppRequestId, Response}; use futures::stream::StreamExt; use gossipsub_scoring_parameters::{PeerScoreSettings, lighthouse_gossip_thresholds}; @@ -43,8 +45,9 @@ use std::sync::Arc; use std::time::Duration; use tracing::{debug, error, info, trace, warn}; use types::{ - ChainSpec, DataColumnSubnetId, EnrForkId, EthSpec, ForkContext, ForkName, PartialDataColumn, - PartialDataColumnHeader, Slot, SubnetId, consts::altair::SYNC_COMMITTEE_SUBNET_COUNT, + CellBitmap, ChainSpec, DataColumnSubnetId, EnrForkId, EthSpec, ForkContext, ForkName, + PartialDataColumn, PartialDataColumnHeader, Slot, SubnetId, + consts::altair::SYNC_COMMITTEE_SUBNET_COUNT, }; use utils::{Context as ServiceContext, build_transport, strip_peer_id}; @@ -920,65 +923,73 @@ impl Network { } /// Publishes partial data column sidecars to the gossipsub network. - pub fn publish_partial( - &mut self, - columns: Vec>>, - header: Arc>, - ) { + pub fn publish_partial(&mut self, messages: Vec>) { if !self.network_globals.config.enable_partial_columns { return; } - debug!( - count = columns.len(), - "Sending partial data column sidecars" - ); + debug!(count = messages.len(), "Sending partial messages"); - for column in columns { - let subnet = - DataColumnSubnetId::from_column_index(column.index, &self.fork_context.spec); - let topic = GossipTopic::new( - GossipKind::DataColumnSidecar(subnet), - GossipEncoding::default(), - self.enr_fork_id.fork_digest, - ); - let header_sent_set = self - .partial_column_header_tracker - .get_for_block(column.block_root); - let partial_message = OutgoingPartialColumn::new(column, &header, header_sent_set); - let publish_topic: Topic = topic.clone().into(); - - if let Err(e) = self - .gossipsub_mut() - .publish_partial(publish_topic, partial_message) - { - match e { - PublishError::NoPeersSubscribedToTopic => { - debug!( - kind = %topic.kind(), - "No peers supporting partial messages" - ); - } - ref e => { - warn!( - error = ?e, - kind = %topic.kind(), - "Could not publish partial message" - ); - } - } - - // add to metrics - if let Some(v) = metrics::get_int_gauge( - &metrics::FAILED_PARTIAL_PUBLISHES_PER_MAIN_TOPIC, - &[&format!("{:?}", topic.kind())], - ) { - v.inc() - }; + for message in messages { + match message { + PubsubPartialMessage::DataColumnFulu { + column, + request_cells, + header, + } => self.publish_partial_data_column_fulu(column, request_cells, header), } } } + fn publish_partial_data_column_fulu( + &mut self, + column: Arc>, + request_cells: CellBitmap, + header: Arc>, + ) { + let subnet = DataColumnSubnetId::from_column_index(column.index, &self.fork_context.spec); + let topic = GossipTopic::new( + GossipKind::DataColumnSidecar(subnet), + GossipEncoding::default(), + self.enr_fork_id.fork_digest, + ); + let header_sent_set = self + .partial_column_header_tracker + .get_for_block(column.block_root); + let partial_message = + OutgoingPartialColumn::new(column, &header, header_sent_set, request_cells); + let publish_topic: Topic = topic.clone().into(); + + if let Err(e) = self + .gossipsub_mut() + .publish_partial(publish_topic, partial_message) + { + match e { + PublishError::NoPeersSubscribedToTopic => { + debug!( + kind = %topic.kind(), + "No peers supporting partial messages" + ); + } + ref e => { + warn!( + error = ?e, + kind = %topic.kind(), + "Could not publish partial message" + ); + } + } + + // add to metrics + if let Some(v) = metrics::get_int_gauge( + &metrics::FAILED_PARTIAL_PUBLISHES_PER_MAIN_TOPIC, + &[&format!("{:?}", topic.kind())], + ) { + v.inc() + }; + } + } + /// Informs the gossipsub about the result of a message validation. /// If the message is valid it will get propagated by gossipsub. pub fn report_message_validation_result( diff --git a/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs b/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs index bb588fe3d8..309674a885 100644 --- a/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs +++ b/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs @@ -1,12 +1,11 @@ use crate::types::HeaderSentSet; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use parking_lot::Mutex; use std::collections::HashSet; -use std::num::NonZeroUsize; use std::sync::Arc; use types::core::Hash256; -const MAX_BLOCKS: NonZeroUsize = NonZeroUsize::new(4).unwrap(); +const MAX_BLOCKS: usize = 4; pub struct PartialColumnHeaderTracker { blocks: LruCache, @@ -22,7 +21,8 @@ impl PartialColumnHeaderTracker { pub fn get_for_block(&mut self, hash: Hash256) -> HeaderSentSet { Arc::clone( self.blocks - .get_or_insert(hash, || Arc::new(Mutex::new(HashSet::new()))), + .entry(hash) + .or_insert_with(|| Arc::new(Mutex::new(HashSet::new()))), ) } } diff --git a/beacon_node/lighthouse_network/src/types/globals.rs b/beacon_node/lighthouse_network/src/types/globals.rs index df8dbdc559..1f770a5847 100644 --- a/beacon_node/lighthouse_network/src/types/globals.rs +++ b/beacon_node/lighthouse_network/src/types/globals.rs @@ -11,7 +11,7 @@ use std::collections::HashSet; use std::sync::Arc; use tracing::{debug, error}; use types::data::{compute_subnets_from_custody_group, get_custody_groups}; -use types::{ChainSpec, ColumnIndex, DataColumnSubnetId, EthSpec}; +use types::{ChainSpec, ColumnIndex, DataColumnSubnetId, EthSpec, Slot}; pub struct NetworkGlobals { /// The current local ENR. @@ -196,14 +196,19 @@ impl NetworkGlobals { /// Returns a connected peer that: /// 1. is connected /// 2. assigned to custody the column based on it's `custody_subnet_count` from ENR or metadata - /// 3. has a good score - pub fn custody_peers_for_column(&self, column_index: ColumnIndex) -> Vec { + /// 3. has data available past the given slot + /// 4. has a good score + pub fn custody_peers_for_column( + &self, + column_index: ColumnIndex, + block_slot: Slot, + ) -> Vec { self.peers .read() - .good_custody_subnet_peer(DataColumnSubnetId::from_column_index( - column_index, - &self.spec, - )) + .good_custody_subnet_peer( + DataColumnSubnetId::from_column_index(column_index, &self.spec), + block_slot, + ) .cloned() .collect::>() } diff --git a/beacon_node/lighthouse_network/src/types/mod.rs b/beacon_node/lighthouse_network/src/types/mod.rs index d0173e5b9a..8a20e9da94 100644 --- a/beacon_node/lighthouse_network/src/types/mod.rs +++ b/beacon_node/lighthouse_network/src/types/mod.rs @@ -16,7 +16,7 @@ pub use eth2::lighthouse::sync_state::{BackFillState, CustodyBackFillState, Sync pub use globals::NetworkGlobals; pub use partial::HeaderSentSet; pub use partial::OutgoingPartialColumn; -pub use pubsub::{PubsubMessage, SnappyTransform, decode_partial}; +pub use pubsub::{PubsubMessage, PubsubPartialMessage, SnappyTransform, decode_partial}; pub use subnet::{Subnet, SubnetDiscovery}; pub use topics::{ GossipEncoding, GossipKind, GossipTopic, TopicConfig, all_topics_at_fork, diff --git a/beacon_node/lighthouse_network/src/types/partial.rs b/beacon_node/lighthouse_network/src/types/partial.rs index 4b5dcd8ad6..0dd5c58ac6 100644 --- a/beacon_node/lighthouse_network/src/types/partial.rs +++ b/beacon_node/lighthouse_network/src/types/partial.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use tracing::{error, trace}; use types::core::{EthSpec, Hash256}; use types::data::{ - PartialDataColumn, PartialDataColumnHeader, PartialDataColumnPartsMetadata, + CellBitmap, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnPartsMetadata, PartialDataColumnSidecar, PartialDataColumnSidecarRef, }; @@ -30,10 +30,29 @@ impl OutgoingPartialColumn { partial_column: Arc>, header: &PartialDataColumnHeader, header_sent_set: HeaderSentSet, + requests: CellBitmap, ) -> Self { - // For now, always request all cells - let mut requests = partial_column.sidecar.cells_present_bitmap.clone_zeroed(); - requests.not_inplace(); + // Always set the request bit for available cells. + // + // Gossipsub applys certain optimisations to avoid sending redundant messages. This + // requires that we stay consistent with our metadata. Gossipsub uses the `Metadata` trait + // impl below to determine whether it can perform these optimisations. + // + // If we request a cell and then receive it, un-setting the request bit in the next + // published message may cause issues: + // Gossipsub tries to avoid the impact of application race conditions by checking newly + // published metadata against previously published metadata. This no longer functions + // correctly if request bits are unset between calls, as Gossipsub will consider a message + // with new requests as new info to be propagated, possibly overwriting previous messages + // with more cells (but fewer request bits). This is because gossipsub will see that both + // metadata have some bits that are not set in the other metadata and therefore cannot + // decide which actually carries more data. By always setting request bits for available + // cells, we avoid this issue, as requests will never be unset between calls. + // + // In other words, gossipsub relies on the fact that metadata is additive. The request bit + // is, therefore, to be seen as a "request if not available" bit. + let requests = requests.union(&partial_column.sidecar.cells_present_bitmap); + let metadata = PartialDataColumnPartsMetadata:: { available: partial_column.sidecar.cells_present_bitmap.clone(), requests, @@ -322,6 +341,14 @@ mod tests { }) } + fn make_all_one_bitmap(len: usize) -> CellBitmap { + let mut request_cells = CellBitmap::::with_capacity(len).unwrap(); + for idx in 0..request_cells.len() { + request_cells.set(idx, true).unwrap(); + } + request_cells + } + fn random_peer_id() -> PeerId { let keypair = Keypair::generate_ed25519(); PeerId::from(keypair.public()) @@ -422,7 +449,8 @@ mod tests { let header = make_header(4); let partial = make_partial_column(root, 4, &[0, 1]); let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); - let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + let requests = make_all_one_bitmap(4); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set, requests); let peer = random_peer_id(); @@ -442,7 +470,8 @@ mod tests { // We have cells [0, 2, 3] let partial = make_partial_column(root, 4, &[0, 2, 3]); let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); - let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + let requests = make_all_one_bitmap(4); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set, requests); let peer = random_peer_id(); @@ -474,7 +503,8 @@ mod tests { // We have cells [0] let partial = make_partial_column(root, 4, &[0]); let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); - let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + let requests = make_all_one_bitmap(4); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set, requests); let peer = random_peer_id(); diff --git a/beacon_node/lighthouse_network/src/types/pubsub.rs b/beacon_node/lighthouse_network/src/types/pubsub.rs index d486ca5129..00b2c42629 100644 --- a/beacon_node/lighthouse_network/src/types/pubsub.rs +++ b/beacon_node/lighthouse_network/src/types/pubsub.rs @@ -7,10 +7,10 @@ use ssz::{Decode, Encode}; use std::io::{Error, ErrorKind}; use std::sync::Arc; use types::{ - AttesterSlashing, AttesterSlashingBase, AttesterSlashingElectra, DataColumnSidecar, + AttesterSlashing, AttesterSlashingBase, AttesterSlashingElectra, CellBitmap, DataColumnSidecar, DataColumnSubnetId, EthSpec, ForkContext, ForkName, Hash256, LightClientFinalityUpdate, - LightClientOptimisticUpdate, PartialDataColumn, PartialDataColumnSidecar, - PayloadAttestationMessage, ProposerSlashing, SignedAggregateAndProof, + LightClientOptimisticUpdate, PartialDataColumn, PartialDataColumnHeader, + PartialDataColumnSidecar, PayloadAttestationMessage, ProposerSlashing, SignedAggregateAndProof, SignedAggregateAndProofBase, SignedAggregateAndProofElectra, SignedBeaconBlock, SignedBeaconBlockAltair, SignedBeaconBlockBase, SignedBeaconBlockBellatrix, SignedBeaconBlockCapella, SignedBeaconBlockDeneb, SignedBeaconBlockElectra, @@ -56,6 +56,24 @@ pub enum PubsubMessage { LightClientOptimisticUpdate(Box>), } +/// A message published via the partial gossipsub protocol. +#[derive(Debug, Clone)] +pub enum PubsubPartialMessage { + /// A partial data column sidecar from the Fulu fork. + DataColumnFulu { + /// The column to publish. Libp2p will cache it and treat it as the data to send if any peer + /// asks for data within it. + column: Arc>, + /// The cells we are requesting. Usually, this will be all-ones, as we need all cells. + /// However, while get_blobs is still in progress, blobs we expect from the EL should not be + /// requested to conserve bandwidth. + request_cells: CellBitmap, + /// The header associated with the column above. This is set separately here, as the column + /// to be published does not contain the header - it is stored without. + header: Arc>, + }, +} + // Implements the `DataTransform` trait of gossipsub to employ snappy compression pub struct SnappyTransform { /// Sets the maximum size we allow gossipsub messages to decompress to. diff --git a/beacon_node/network/src/metrics.rs b/beacon_node/network/src/metrics.rs index c043133cee..07e6d7fdb2 100644 --- a/beacon_node/network/src/metrics.rs +++ b/beacon_node/network/src/metrics.rs @@ -22,6 +22,13 @@ pub(crate) enum BlockSource { Rpc, } +/// The path through which a payload envelope was imported. +#[derive(Debug, Clone, Copy, AsRefStr)] +pub(crate) enum EnvelopeSource { + Gossip, + Rpc, +} + pub static BEACON_BLOCK_MESH_PEERS_PER_CLIENT: LazyLock> = LazyLock::new(|| { try_create_int_gauge_vec( @@ -658,22 +665,6 @@ pub static BEACON_BLOB_DELAY_FULL_VERIFICATION: LazyLock> = Laz ) }); -pub static BEACON_BLOB_RPC_SLOT_START_DELAY_TIME: LazyLock> = LazyLock::new( - || { - try_create_histogram_with_buckets( - "beacon_blob_rpc_slot_start_delay_time", - "Duration between when a blob is received over rpc and the start of the slot it belongs to.", - // Create a custom bucket list for greater granularity in block delay - Ok(vec![ - 0.1, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, - 6.0, 7.0, 8.0, 9.0, 10.0, 15.0, 20.0, - ]), // NOTE: Previous values, which we may want to switch back to. - // [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50] - //decimal_buckets(-1,2) - ) - }, -); - /* * Light client update reprocessing queue metrics. */ diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 98c143eaeb..d6873fbe9c 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -1,5 +1,5 @@ use crate::{ - metrics::{self, register_process_result_metrics}, + metrics::{self, EnvelopeSource, register_process_result_metrics}, network_beacon_processor::{InvalidBlockStorage, NetworkBeaconProcessor}, service::NetworkMessage, sync::SyncMessage, @@ -33,7 +33,7 @@ use beacon_chain::{ use beacon_processor::{Work, WorkEvent}; use lighthouse_network::{ Client, GossipTopic, MessageAcceptance, MessageId, PeerAction, PeerId, PubsubMessage, - ReportSource, + PubsubPartialMessage, ReportSource, }; use logging::crit; use operation_pool::ReceivedPreCapella; @@ -70,6 +70,45 @@ use beacon_processor::{ /// messages. const STRICT_LATE_MESSAGE_PENALTIES: bool = false; +/// Tracks which kinds of attestation re-processing are still permitted for a gossip attestation +/// or aggregate. +/// +/// A new attestation may be re-queued for an unknown block, then (post-Gloas) for an unknown +/// payload envelope, and finally not at all. Each re-queue narrows the allowance to the next +/// variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReprocessAllowance { + /// Re-queue for either an unknown block or an unknown payload envelope. + BlockAndPayload, + /// Re-queue only for an unknown payload envelope (already re-queued once for the block). + PayloadOnly, + /// Do not re-queue again. + None, +} + +impl ReprocessAllowance { + /// Whether the attestation may be re-queued for an unknown block. + fn allows_block(self) -> bool { + matches!(self, ReprocessAllowance::BlockAndPayload) + } + + /// Whether the attestation may be re-queued for an unknown payload envelope. + fn allows_payload(self) -> bool { + matches!( + self, + ReprocessAllowance::BlockAndPayload | ReprocessAllowance::PayloadOnly + ) + } + + /// Re-queuing always narrows the allowance so a message can't loop indefinitely. + fn next_requeue(self) -> Self { + match self { + ReprocessAllowance::BlockAndPayload => ReprocessAllowance::PayloadOnly, + ReprocessAllowance::PayloadOnly | ReprocessAllowance::None => ReprocessAllowance::None, + } + } +} + /// An attestation that has been validated by the `BeaconChain`. /// /// Since this struct implements `beacon_chain::VerifiedAttestation`, it would be a logic error to @@ -233,7 +272,7 @@ impl NetworkBeaconProcessor { attestation: Box, subnet_id: SubnetId, should_import: bool, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, seen_timestamp: Duration, ) { let result = match self @@ -256,7 +295,7 @@ impl NetworkBeaconProcessor { message_id, peer_id, subnet_id, - allow_reprocess, + reprocess_allowance, should_import, seen_timestamp, ); @@ -265,7 +304,7 @@ impl NetworkBeaconProcessor { pub fn process_gossip_attestation_batch( self: Arc, packages: GossipAttestationBatch, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, ) { let attestations_and_subnets = packages .iter() @@ -326,7 +365,7 @@ impl NetworkBeaconProcessor { package.message_id, package.peer_id, package.subnet_id, - allow_reprocess, + reprocess_allowance, package.should_import, package.seen_timestamp, ); @@ -342,7 +381,7 @@ impl NetworkBeaconProcessor { message_id: MessageId, peer_id: PeerId, subnet_id: SubnetId, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, should_import: bool, seen_timestamp: Duration, ) { @@ -426,7 +465,7 @@ impl NetworkBeaconProcessor { should_import, seen_timestamp, }, - allow_reprocess, + reprocess_allowance, error, seen_timestamp, ); @@ -446,7 +485,7 @@ impl NetworkBeaconProcessor { message_id: MessageId, peer_id: PeerId, aggregate: Box>, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, seen_timestamp: Duration, ) { let beacon_block_root = aggregate.message().aggregate().data().beacon_block_root; @@ -470,7 +509,7 @@ impl NetworkBeaconProcessor { beacon_block_root, message_id, peer_id, - allow_reprocess, + reprocess_allowance, seen_timestamp, ); } @@ -478,7 +517,7 @@ impl NetworkBeaconProcessor { pub fn process_gossip_aggregate_batch( self: Arc, packages: Vec>, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, ) { let aggregates = packages.iter().map(|package| package.aggregate.as_ref()); @@ -532,7 +571,7 @@ impl NetworkBeaconProcessor { package.beacon_block_root, package.message_id, package.peer_id, - allow_reprocess, + reprocess_allowance, package.seen_timestamp, ); } @@ -544,7 +583,7 @@ impl NetworkBeaconProcessor { beacon_block_root: Hash256, message_id: MessageId, peer_id: PeerId, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, seen_timestamp: Duration, ) { match result { @@ -624,7 +663,7 @@ impl NetworkBeaconProcessor { attestation: signed_aggregate, seen_timestamp, }, - allow_reprocess, + reprocess_allowance, error, seen_timestamp, ); @@ -898,9 +937,15 @@ impl NetworkBeaconProcessor { Ok(mut column) => { let header = column.sidecar.header.take(); if let Some(header) = header { + // Requesting cells is irrelevant as all cells are available, simply clone + // the `cells_present_bitmap`. + let request_cells = column.sidecar.cells_present_bitmap.clone(); self.send_network_message(NetworkMessage::PublishPartialColumns { - columns: vec![Arc::new(column)], - header: Arc::new(header), + messages: vec![PubsubPartialMessage::DataColumnFulu { + column: Arc::new(column), + request_cells, + header: Arc::new(header), + }], }); } else { crit!("Converting from full to partial yielded headerless partial") @@ -918,12 +963,13 @@ impl NetworkBeaconProcessor { match result { Ok(availability) => match availability { - AvailabilityProcessingStatus::Imported(block_root) => { + AvailabilityProcessingStatus::Imported(slot, block_root) => { debug!( %block_root, "Gossipsub data column processed, imported fully available block" ); self.chain.recompute_head_at_current_slot().await; + self.notify_import_after_column(slot, block_root); metrics::set_gauge( &metrics::BEACON_BLOB_DELAY_FULL_VERIFICATION, @@ -1037,8 +1083,10 @@ impl NetworkBeaconProcessor { debug!(block = %block_root, "Triggering getBlobs after receiving partial header"); // We want to publish immediately when this finishes let publish_blobs = true; - self.fetch_engine_blobs_and_publish(header.into_header(), block_root, publish_blobs) - .await + let header = header.into_header(); + self.fetch_engine_blobs_and_publish_full(header.clone(), block_root, publish_blobs) + .await; + self.publish_partial_data_columns(header, block_root).await; } } } @@ -1271,28 +1319,31 @@ impl NetworkBeaconProcessor { }); } - let only_send_completed_partials = - merge_result.local_blobs || self.chain.config.disable_get_blobs; - let columns = merge_result - .updated_partials - .into_iter() - .map(|partial| partial.into_inner()) - .filter(|partial| { - !only_send_completed_partials || partial.sidecar.is_complete() - }) - .collect::>(); - - if !columns.is_empty() { - if only_send_completed_partials { - debug!( - block = %block_root, - "Not publishing incomplete partials before getBlobs" - ); - } - self.send_network_message(NetworkMessage::PublishPartialColumns { - columns, - header: verified_header.into_header(), - }); + if !merge_result.updated_partials.is_empty() { + let header = verified_header.into_header(); + let messages = merge_result + .updated_partials + .into_iter() + .map(|partial| { + let column = partial.into_inner(); + let present_cells = &column.sidecar.cells_present_bitmap; + let request_cells = if merge_result.local_blobs { + // Request all cells that are not available locally. + let mut all_one = present_cells.clone_zeroed(); + all_one.not_inplace(); + all_one + } else { + // Do not request cells if we don't know the local blobs yet. + present_cells.clone_zeroed() + }; + PubsubPartialMessage::DataColumnFulu { + column, + request_cells, + header: header.clone(), + } + }) + .collect(); + self.send_network_message(NetworkMessage::PublishPartialColumns { messages }); } Ok(avail) } @@ -1311,12 +1362,13 @@ impl NetworkBeaconProcessor { match &result { Ok(availability) => match availability { - AvailabilityProcessingStatus::Imported(block_root) => { + AvailabilityProcessingStatus::Imported(slot, block_root) => { debug!( %block_root, "Data column from partial processed, imported fully available block" ); self.chain.recompute_head_at_current_slot().await; + self.notify_import_after_column(*slot, *block_root); metrics::set_gauge( &metrics::BEACON_BLOB_DELAY_FULL_VERIFICATION, @@ -1357,12 +1409,8 @@ impl NetworkBeaconProcessor { async fn check_reconstruction_trigger(self: &Arc, slot: Slot, block_root: &Hash256) { if self .chain - .data_availability_checker - .custody_context() - .should_attempt_reconstruction( - slot.epoch(T::EthSpec::slots_per_epoch()), - &self.chain.spec, - ) + .custody_context + .should_attempt_reconstruction(slot.epoch(T::EthSpec::slots_per_epoch())) { // Instead of triggering reconstruction immediately, schedule it to be run. If // another column arrives, it either completes availability or pushes @@ -1762,8 +1810,16 @@ impl NetworkBeaconProcessor { self.executor.spawn( async move { if let Ok(header) = PartialDataColumnHeader::try_from(block_clone.as_ref()) { + let header = Arc::new(header); self_clone - .fetch_engine_blobs_and_publish(Arc::new(header), block_root, publish_blobs) + .fetch_engine_blobs_and_publish_full( + header.clone(), + block_root, + publish_blobs, + ) + .await; + self_clone + .publish_partial_data_columns(header, block_root) .await } } @@ -1784,24 +1840,8 @@ impl NetworkBeaconProcessor { register_process_result_metrics(&result, metrics::BlockSource::Gossip, "block"); match &result { - Ok(AvailabilityProcessingStatus::Imported(block_root)) => { - if self - .beacon_processor_send - .try_send(WorkEvent { - drop_during_sync: false, - work: Work::Reprocess(ReprocessQueueMessage::BlockImported { - block_root: *block_root, - parent_root: block.message().parent_root(), - }), - }) - .is_err() - { - error!( - source = "gossip", - ?block_root, - "Failed to inform block import" - ) - }; + Ok(AvailabilityProcessingStatus::Imported(_, block_root)) => { + self.notify_block_imported(*block_root); debug!( ?block_root, @@ -2458,7 +2498,7 @@ impl NetworkBeaconProcessor { peer_id: PeerId, message_id: MessageId, failed_att: FailedAtt, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, error: AttnError, seen_timestamp: Duration, ) { @@ -2717,7 +2757,7 @@ impl NetworkBeaconProcessor { block = ?beacon_block_root, "Attestation for unknown block" ); - if allow_reprocess { + if reprocess_allowance.allows_block() { // We don't know the block, get the sync manager to handle the block lookup, and // send the attestation to be scheduled for re-processing. self.send_sync_message(SyncMessage::UnknownBlockHashFromAttestation( @@ -2740,7 +2780,7 @@ impl NetworkBeaconProcessor { message_id, peer_id, attestation, - false, // Do not allow this attestation to be re-processed beyond this point. + reprocess_allowance.next_requeue(), seen_timestamp, ) }), @@ -2765,7 +2805,7 @@ impl NetworkBeaconProcessor { attestation, subnet_id, should_import, - false, // Do not allow this attestation to be re-processed beyond this point. + reprocess_allowance.next_requeue(), seen_timestamp, ) }), @@ -2797,6 +2837,89 @@ impl NetworkBeaconProcessor { return; } + AttnError::UnknownPayloadEnvelope { beacon_block_root } => { + trace!( + %peer_id, + block = ?beacon_block_root, + "Payload-present attestation for block with unseen payload envelope" + ); + if reprocess_allowance.allows_payload() { + // We haven't seen the block's payload envelope yet. Ask the sync manager to + // retrieve it, and schedule the attestation for re-processing once it arrives. + self.send_sync_message(SyncMessage::UnknownPayloadEnvelopeFromAttestation( + peer_id, + *beacon_block_root, + )); + let msg = match failed_att { + FailedAtt::Aggregate { + attestation, + seen_timestamp, + } => { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_AGGREGATED_ATTESTATION_REQUEUED_TOTAL, + ); + let processor = self.clone(); + ReprocessQueueMessage::UnknownPayloadAggregate(QueuedAggregate { + beacon_block_root: *beacon_block_root, + process_fn: Box::new(move || { + processor.process_gossip_aggregate( + message_id, + peer_id, + attestation, + reprocess_allowance.next_requeue(), + seen_timestamp, + ) + }), + }) + } + FailedAtt::Unaggregate { + attestation, + subnet_id, + should_import, + seen_timestamp, + } => { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_UNAGGREGATED_ATTESTATION_REQUEUED_TOTAL, + ); + let processor = self.clone(); + ReprocessQueueMessage::UnknownPayloadUnaggregate(QueuedUnaggregate { + beacon_block_root: *beacon_block_root, + process_fn: Box::new(move || { + processor.process_gossip_attestation( + message_id, + peer_id, + attestation, + subnet_id, + should_import, + reprocess_allowance.next_requeue(), + seen_timestamp, + ) + }), + }) + } + }; + + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(msg), + }) + .is_err() + { + error!("Failed to send attestation for re-processing") + } + } else { + // We shouldn't make any further attempts to process this attestation. + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Ignore, + ); + } + + return; + } AttnError::UnknownTargetRoot(_) => { /* * The block indicated by the target root is not known to us. @@ -3795,16 +3918,83 @@ impl NetworkBeaconProcessor { // TODO(gloas) metrics // register_process_result_metrics(&result, metrics::BlockSource::Gossip, "envelope"); - if let Err(e) = &result { - debug!( - ?beacon_block_root, - %peer_id, - error = ?e, - "Execution payload envelope processing failed" - ); + match &result { + Ok(AvailabilityProcessingStatus::Imported(_, block_root)) => { + self.chain.recompute_head_at_current_slot().await; + // The payload envelope is imported (`is_payload_received` is now true); release any + // attestations awaiting this block's payload so they can be re-processed. + self.notify_payload_envelope_imported(*block_root, EnvelopeSource::Gossip); + } + Ok(_) => {} + Err(e) => { + debug!( + ?beacon_block_root, + %peer_id, + error = ?e, + "Execution payload envelope processing failed" + ); + } } } + /// Inform the reprocess queue that a fully available block (or its payload envelope, post-gloas) + /// has been imported, so any attestations waiting on it can be released. + fn notify_import_after_column(&self, slot: Slot, block_root: Hash256) { + if self + .chain + .spec + .fork_name_at_slot::(slot) + .gloas_enabled() + { + self.notify_payload_envelope_imported(block_root, EnvelopeSource::Gossip); + } else { + self.notify_block_imported(block_root); + } + } + + /// Inform the reprocess queue that `block_root` has been imported as a full block. + fn notify_block_imported(&self, block_root: Hash256) { + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(ReprocessQueueMessage::BlockImported { block_root }), + }) + .is_err() + { + error!( + source = "gossip", + ?block_root, + "Failed to inform block import" + ) + }; + } + + /// Inform the reprocess queue that `block_root`'s payload envelope has been imported, releasing + /// any attestations awaiting the payload. `source` identifies the import path for logging. + pub(crate) fn notify_payload_envelope_imported( + &self, + block_root: Hash256, + source: EnvelopeSource, + ) { + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(ReprocessQueueMessage::PayloadEnvelopeImported { + block_root, + }), + }) + .is_err() + { + error!( + source = source.as_ref(), + ?block_root, + "Failed to inform payload envelope import" + ) + }; + } + #[instrument( name = "lh_process_execution_payload_bid", parent = None, @@ -3829,7 +4019,8 @@ impl NetworkBeaconProcessor { | PayloadBidError::InvalidBuilder { .. } | PayloadBidError::InvalidFeeRecipient | PayloadBidError::ExecutionPaymentNonZero { .. } - | PayloadBidError::InvalidBlobKzgCommitments { .. }, + | PayloadBidError::InvalidBlobKzgCommitments { .. } + | PayloadBidError::BidNotDescendantOfParent { .. }, ) => { self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); self.gossip_penalize_peer( @@ -4008,6 +4199,14 @@ impl NetworkBeaconProcessor { *beacon_block_root, )) } + PayloadAttestationError::BlockNotAtSlot { .. } => { + debug!( + %peer_id, + %message_slot, + "Payload attestation references block at wrong slot" + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } PayloadAttestationError::NotInPTC { .. } => { self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); self.gossip_penalize_peer( @@ -4044,3 +4243,42 @@ impl NetworkBeaconProcessor { } } } + +#[cfg(test)] +mod tests { + use super::ReprocessAllowance::{BlockAndPayload, None, PayloadOnly}; + + #[test] + fn reprocess_allowance_gates() { + // A block re-queue is only permitted for a freshly received attestation. + assert!(BlockAndPayload.allows_block()); + assert!(!PayloadOnly.allows_block()); + assert!(!None.allows_block()); + + // A payload-envelope re-queue is permitted until we've already re-queued for it. + assert!(BlockAndPayload.allows_payload()); + assert!(PayloadOnly.allows_payload()); + assert!(!None.allows_payload()); + } + + #[test] + fn reprocess_allowance_progression() { + // Each re-queue narrows the allowance to the next variant in the progression. + assert_eq!(BlockAndPayload.next_requeue(), PayloadOnly); + assert_eq!(PayloadOnly.next_requeue(), None); + assert_eq!(None.next_requeue(), None); + } + + #[test] + fn reprocess_allowance_is_bounded() { + // Safety property: from any starting state, re-queuing twice reaches the terminal `None`, + // so an attestation can never loop indefinitely. + for start in [BlockAndPayload, PayloadOnly, None] { + assert_eq!( + start.next_requeue().next_requeue(), + None, + "re-queuing twice from {start:?} should be terminal" + ); + } + } +} diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index a9579caaeb..8066d8c689 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -22,9 +22,13 @@ use lighthouse_network::rpc::methods::{ use lighthouse_network::service::api_types::CustodyBackfillBatchId; use lighthouse_network::{ Client, GossipTopic, MessageId, NetworkConfig, NetworkGlobals, PeerId, PubsubMessage, + PubsubPartialMessage, rpc::{BlocksByRangeRequest, BlocksByRootRequest, LightClientBootstrapRequest, StatusMessage}, }; +use logging::crit; use rand::prelude::SliceRandom; +use ssz_types::VariableList; +use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -39,6 +43,8 @@ use { pub use sync_methods::{BlockProcessingResult, ChainSegmentProcessId}; +use gossip_methods::ReprocessAllowance; + pub type Error = TrySendError>; mod gossip_methods; @@ -93,15 +99,17 @@ impl NetworkBeaconProcessor { package.attestation, package.subnet_id, package.should_import, - true, + ReprocessAllowance::BlockAndPayload, package.seen_timestamp, ) }; // Define a closure for processing batches of attestations. let processor = self.clone(); - let process_batch = - move |attestations| processor.process_gossip_attestation_batch(attestations, true); + let process_batch = move |attestations| { + processor + .process_gossip_attestation_batch(attestations, ReprocessAllowance::BlockAndPayload) + }; self.try_send(BeaconWorkEvent { drop_during_sync: true, @@ -135,15 +143,17 @@ impl NetworkBeaconProcessor { package.message_id, package.peer_id, package.aggregate, - true, + ReprocessAllowance::BlockAndPayload, package.seen_timestamp, ) }; // Define a closure for processing batches of attestations. let processor = self.clone(); - let process_batch = - move |aggregates| processor.process_gossip_aggregate_batch(aggregates, true); + let process_batch = move |aggregates| { + processor + .process_gossip_aggregate_batch(aggregates, ReprocessAllowance::BlockAndPayload) + }; let beacon_block_root = aggregate.message().aggregate().data().beacon_block_root; self.try_send(BeaconWorkEvent { @@ -518,15 +528,11 @@ impl NetworkBeaconProcessor { self: &Arc, block_root: Hash256, block: LookupBlock, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), Error> { - let process_fn = self.clone().generate_lookup_beacon_block_process_fn( - block_root, - block, - seen_timestamp, - process_type, - ); + let process_fn = + self.clone() + .generate_lookup_beacon_block_process_fn(block_root, block, process_type); self.try_send(BeaconWorkEvent { drop_during_sync: false, work: Work::RpcBlock { @@ -542,14 +548,13 @@ impl NetworkBeaconProcessor { self: &Arc, block_root: Hash256, envelope: Arc>, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), Error> { let s = self.clone(); self.try_send(BeaconWorkEvent { drop_during_sync: false, work: Work::RpcEnvelope(Box::pin(async move { - s.process_lookup_envelope(block_root, envelope, seen_timestamp, process_type) + s.process_lookup_envelope(block_root, envelope, process_type) .await; })), }) @@ -561,20 +566,14 @@ impl NetworkBeaconProcessor { self: &Arc, block_root: Hash256, custody_columns: DataColumnSidecarList, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), Error> { let s = self.clone(); self.try_send(BeaconWorkEvent { drop_during_sync: false, work: Work::RpcCustodyColumn(Box::pin(async move { - s.process_rpc_custody_columns( - block_root, - custody_columns, - seen_timestamp, - process_type, - ) - .await; + s.process_rpc_custody_columns(block_root, custody_columns, process_type) + .await; })), }) } @@ -901,7 +900,7 @@ impl NetworkBeaconProcessor { }); } - pub async fn fetch_engine_blobs_and_publish( + pub async fn fetch_engine_blobs_and_publish_full( self: &Arc, header: Arc>, block_root: Hash256, @@ -911,7 +910,7 @@ impl NetworkBeaconProcessor { return; } let epoch = header.slot().epoch(T::EthSpec::slots_per_epoch()); - let custody_columns = self.chain.sampling_columns_for_epoch(epoch); + let custody_columns = self.chain.custody_context.sampling_columns_for_epoch(epoch); let self_cloned = self.clone(); let publish_fn = move |columns: Vec>| { if publish_blobs { @@ -925,14 +924,14 @@ impl NetworkBeaconProcessor { match fetch_and_process_engine_blobs( self.chain.clone(), block_root, - header.clone(), + header, custody_columns, publish_fn, ) .await { Ok(Some(availability)) => match availability { - AvailabilityProcessingStatus::Imported(_) => { + AvailabilityProcessingStatus::Imported(..) => { debug!( result = "imported block and custody columns", %block_root, @@ -969,44 +968,108 @@ impl NetworkBeaconProcessor { ); } } + } - // Publish partial columns without eager send - // TODO(gloas): implement publish partial columns without eager send - if let Some(assembler) = self.chain.data_availability_checker.partial_assembler() { - let columns = assembler.get_columns_and_mark_as_local_fetched(block_root, &header); + pub async fn publish_partial_data_columns( + self: &Arc, + header: Arc>, + block_root: Hash256, + ) { + if header.kzg_commitments.is_empty() { + return; + } + + // TODO(gloas): implement publish partial columns + let Some(assembler) = self.chain.data_availability_checker.partial_assembler() else { + // Partials are disabled. + return; + }; + let epoch = header.slot().epoch(T::EthSpec::slots_per_epoch()); + let custody_columns = self.chain.custody_context.sampling_columns_for_epoch(epoch); + let columns = assembler.get_columns_and_mark_as_local_fetched(block_root, &header); + + let mut present_indices: HashSet = HashSet::with_capacity(columns.len()); + let mut messages: Vec> = Vec::with_capacity(columns.len()); + for column in columns { // Republish both complete and incomplete columns as partials - let columns: Vec<_> = columns - .into_iter() - .filter_map(|column| match column { - AssemblyColumn::Incomplete(partial) => Some(partial.into_inner()), - AssemblyColumn::Complete(full) => { - let DataColumnSidecar::Fulu(fulu) = full.as_data_column() else { - return None; - }; - match fulu.to_partial() { - Ok(partial) => Some(Arc::new(partial)), - Err(err) => { - error!( - %block_root, - column_index = %full.index(), - ?err, - "Failed to convert complete column to partial for re-seeding" - ); - None - } + let partial_column = match column { + AssemblyColumn::Incomplete(partial) => partial.into_inner(), + AssemblyColumn::Complete(full) => { + let DataColumnSidecar::Fulu(fulu) = full.as_data_column() else { + continue; + }; + match fulu.to_partial() { + Ok(partial) => Arc::new(partial), + Err(err) => { + error!( + %block_root, + column_index = %full.index(), + ?err, + "Failed to convert complete column to partial for re-seeding" + ); + continue; } } - }) - .collect(); - if !columns.is_empty() { - debug!(block = %block_root, "Publishing all partials after getBlobs"); - self.send_network_message(NetworkMessage::PublishPartialColumns { - columns, - header, - }); - } else { - debug!(block = %block_root, "No partials to publish after getBlobs"); + } + }; + + present_indices.insert(partial_column.index); + let mut request_cells = partial_column.sidecar.cells_present_bitmap.clone_zeroed(); + request_cells.not_inplace(); + messages.push(PubsubPartialMessage::DataColumnFulu { + column: partial_column, + request_cells, + header: header.clone(), + }); + } + + // For each custody column without any local partial, send an empty placeholder + // that requests all cells. + let num_cells = header.kzg_commitments.len(); + for col_idx in custody_columns { + if present_indices.contains(col_idx) { + continue; } + // `kzg_commitments.len()` is bounded by `MaxBlobCommitmentsPerBlock`, so the + // bitmap constructor is infallible. + let Ok(cells_present_bitmap) = CellBitmap::::with_capacity(num_cells) + else { + crit!( + %block_root, + num_cells, + column_index = %col_idx, + "CellBitmap construction failed despite being bounded by MaxBlobCommitmentsPerBlock" + ); + continue; + }; + let request_cells = cells_present_bitmap.not(); + messages.push(PubsubPartialMessage::DataColumnFulu { + column: Arc::new(PartialDataColumn { + block_root, + index: *col_idx, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap, + column: VariableList::empty(), + kzg_proofs: VariableList::empty(), + header: None.into(), + }, + }), + request_cells, + header: header.clone(), + }); + } + + if !messages.is_empty() { + debug!( + block = %block_root, + count = messages.len(), + "Publishing all partials" + ); + self.send_network_message(NetworkMessage::PublishPartialColumns { messages }); + } else { + // This should not happen, as any custody columns will have at least an empty + // partial published. + warn!(block = %block_root, "No partials to publish"); } } @@ -1020,7 +1083,7 @@ impl NetworkBeaconProcessor { Ok(Some((availability_processing_status, data_columns_to_publish))) => { self.publish_data_columns_gradually(data_columns_to_publish, block_root); match &availability_processing_status { - AvailabilityProcessingStatus::Imported(hash) => { + AvailabilityProcessingStatus::Imported(_, hash) => { debug!( result = "imported block and custody columns", block_hash = %hash, diff --git a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs index 37a6f3779a..fc451ebc05 100644 --- a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs @@ -791,7 +791,7 @@ impl NetworkBeaconProcessor { ) -> Result<(), (RpcErrorResponse, &'static str)> { let mut send_data_column_count = 0; // Only attempt lookups for columns the node has advertised and is responsible for maintaining custody of. - let available_columns = self.chain.custody_columns_for_epoch(None); + let available_columns = self.chain.custody_context.custody_columns_for_epoch(None); for data_column_ids_by_root in request.data_column_ids.as_slice() { let indices_to_retrieve = data_column_ids_by_root @@ -1595,13 +1595,14 @@ impl NetworkBeaconProcessor { req.count }; - let data_availability_boundary_slot = match self.chain.data_availability_boundary() { - Some(boundary) => boundary.start_slot(T::EthSpec::slots_per_epoch()), - None => { - debug!("Deneb fork is disabled"); - return Err((RpcErrorResponse::InvalidRequest, "Deneb fork is disabled")); - } - }; + let data_availability_boundary_slot = + match self.chain.custody_context.data_availability_boundary() { + Some(boundary) => boundary.start_slot(T::EthSpec::slots_per_epoch()), + None => { + debug!("Deneb fork is disabled"); + return Err((RpcErrorResponse::InvalidRequest, "Deneb fork is disabled")); + } + }; let oldest_blob_slot = self .chain @@ -1745,14 +1746,17 @@ impl NetworkBeaconProcessor { let request_start_slot = Slot::from(req.start_slot); - let column_data_availability_boundary_slot = - match self.chain.column_data_availability_boundary() { - Some(boundary) => boundary.start_slot(T::EthSpec::slots_per_epoch()), - None => { - debug!("Fulu fork is disabled"); - return Err((RpcErrorResponse::InvalidRequest, "Fulu fork is disabled")); - } - }; + let column_data_availability_boundary_slot = match self + .chain + .custody_context + .column_data_availability_boundary() + { + Some(boundary) => boundary.start_slot(T::EthSpec::slots_per_epoch()), + None => { + debug!("Fulu fork is disabled"); + return Err((RpcErrorResponse::InvalidRequest, "Fulu fork is disabled")); + } + }; let earliest_custodied_data_column_slot = match self.chain.earliest_custodied_data_column_epoch() { @@ -1798,6 +1802,7 @@ impl NetworkBeaconProcessor { let request_start_epoch = request_start_slot.epoch(T::EthSpec::slots_per_epoch()); let available_columns = self .chain + .custody_context .custody_columns_for_epoch(Some(request_start_epoch)); let indices_to_retrieve = req @@ -1916,9 +1921,8 @@ impl NetworkBeaconProcessor { let non_custody_indices = { let custody_columns = self .chain - .data_availability_checker - .custody_context() - .custody_columns_for_epoch(epoch_opt, &self.chain.spec); + .custody_context + .custody_columns_for_epoch(epoch_opt); requested_indices .iter() .filter(|subnet_id| !custody_columns.contains(subnet_id)) diff --git a/beacon_node/network/src/network_beacon_processor/sync_methods.rs b/beacon_node/network/src/network_beacon_processor/sync_methods.rs index e2226af094..c376f1844b 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -1,4 +1,4 @@ -use crate::metrics::{self, register_process_result_metrics}; +use crate::metrics::{self, EnvelopeSource, register_process_result_metrics}; use crate::network_beacon_processor::{FUTURE_SLOT_TOLERANCE, NetworkBeaconProcessor}; use crate::sync::BatchProcessResult; use crate::sync::manager::CustodyBatchProcessResult; @@ -14,7 +14,7 @@ use beacon_chain::data_availability_checker::{ use beacon_chain::historical_data_columns::HistoricalDataColumnError; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChainTypes, BlockError, ChainSegmentResult, - HistoricalBlockError, NotifyExecutionLayer, validator_monitor::get_slot_delay_ms, + HistoricalBlockError, NotifyExecutionLayer, }; use beacon_processor::{ AsyncFn, BlockingFn, DuplicateCache, @@ -26,7 +26,6 @@ use lighthouse_network::PeerId; use lighthouse_network::service::api_types::CustodyBackfillBatchId; use logging::crit; use std::sync::Arc; -use std::time::Duration; use tracing::{debug, debug_span, error, info, instrument, warn}; use types::{BlockImportSource, DataColumnSidecarList, Epoch, ExecutionBlockHash, Hash256}; @@ -56,19 +55,12 @@ impl NetworkBeaconProcessor { self: Arc, block_root: Hash256, block: LookupBlock, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> AsyncFn { let process_fn = async move { let duplicate_cache = self.duplicate_cache.clone(); - self.process_lookup_block( - block_root, - block, - seen_timestamp, - process_type, - duplicate_cache, - ) - .await; + self.process_lookup_block(block_root, block, process_type, duplicate_cache) + .await; }; Box::pin(process_fn) } @@ -78,14 +70,12 @@ impl NetworkBeaconProcessor { self: Arc, block_root: Hash256, block: LookupBlock, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> (AsyncFn, BlockingFn) { // An async closure which will import the block. let process_fn = self.clone().generate_lookup_beacon_block_process_fn( block_root, block, - seen_timestamp, process_type.clone(), ); // A closure which will ignore the block. @@ -119,7 +109,6 @@ impl NetworkBeaconProcessor { self: Arc>, block_root: Hash256, block: LookupBlock, - seen_timestamp: Duration, process_type: BlockProcessType, duplicate_cache: DuplicateCache, ) { @@ -133,12 +122,9 @@ impl NetworkBeaconProcessor { ); // Send message to work reprocess queue to retry the block - let (process_fn, ignore_fn) = self.clone().generate_lookup_beacon_block_fns( - block_root, - block, - seen_timestamp, - process_type, - ); + let (process_fn, ignore_fn) = + self.clone() + .generate_lookup_beacon_block_fns(block_root, block, process_type); let reprocess_msg = ReprocessQueueMessage::RpcBlock(QueuedRpcBlock { beacon_block_root: block_root, process_fn, @@ -158,8 +144,6 @@ impl NetworkBeaconProcessor { return; }; - let slot = block.slot(); - let parent_root = block.message().parent_root(); let commitments_formatted = block.as_block().commitments_formatted(); debug!( @@ -186,17 +170,14 @@ impl NetworkBeaconProcessor { // RPC block imported, regardless of process type match result.as_ref() { - Ok(AvailabilityProcessingStatus::Imported(hash)) => { + Ok(AvailabilityProcessingStatus::Imported(slot, hash)) => { info!( %slot, %hash, "New RPC block received", ); // Trigger processing for work referencing this block. - let reprocess_msg = ReprocessQueueMessage::BlockImported { - block_root: *hash, - parent_root, - }; + let reprocess_msg = ReprocessQueueMessage::BlockImported { block_root: *hash }; if self .beacon_processor_send .try_send(WorkEvent { @@ -211,13 +192,6 @@ impl NetworkBeaconProcessor { "Failed to inform block import" ); }; - self.chain.block_times_cache.write().set_time_observed( - *hash, - slot, - seen_timestamp, - None, - None, - ); self.chain.recompute_head_at_current_slot().await; } @@ -227,7 +201,7 @@ impl NetworkBeaconProcessor { // to be sent from the peers if we already have them. if let Ok(header) = signed_beacon_block.as_ref().try_into() { let publish_blobs = false; - self.fetch_engine_blobs_and_publish( + self.fetch_engine_blobs_and_publish_full( Arc::new(header), block_root, publish_blobs, @@ -259,7 +233,6 @@ impl NetworkBeaconProcessor { self: Arc>, block_root: Hash256, custody_columns: DataColumnSidecarList, - seen_timestamp: Duration, process_type: BlockProcessType, ) { // custody_columns must always have at least one element @@ -267,13 +240,6 @@ impl NetworkBeaconProcessor { return; }; - if let Ok(current_slot) = self.chain.slot() - && current_slot == slot - { - let delay = get_slot_delay_ms(seen_timestamp, slot, &self.chain.slot_clock); - metrics::observe_duration(&metrics::BEACON_BLOB_RPC_SLOT_START_DELAY_TIME, delay); - } - let mut indices = custody_columns .iter() .map(|d| *d.index()) @@ -294,7 +260,7 @@ impl NetworkBeaconProcessor { match &result { Ok(availability) => match availability { - AvailabilityProcessingStatus::Imported(hash) => { + AvailabilityProcessingStatus::Imported(_, hash) => { debug!( result = "imported block and custody columns", block_hash = %hash, @@ -337,7 +303,6 @@ impl NetworkBeaconProcessor { self: Arc>, block_root: Hash256, envelope: Arc>, - _seen_timestamp: Duration, process_type: BlockProcessType, ) { debug!( @@ -376,6 +341,13 @@ impl NetworkBeaconProcessor { let result: Result = result.map_err(|e| BlockError::InternalError(format!("envelope: {e}"))); + // The payload envelope is imported; release any attestations awaiting this block's payload + // so they can be re-processed (parity with the gossip import path). + if let Ok(AvailabilityProcessingStatus::Imported(_, block_root)) = &result { + self.chain.recompute_head_at_current_slot().await; + self.notify_payload_envelope_imported(*block_root, EnvelopeSource::Rpc); + } + self.send_sync_message(SyncMessage::BlockComponentProcessed { process_type, result: result.into(), @@ -1018,7 +990,7 @@ impl From> for BlockProcessingR )) } match result { - Ok(AvailabilityProcessingStatus::Imported(_)) => Self::Imported(true, "imported"), + Ok(AvailabilityProcessingStatus::Imported(..)) => Self::Imported(true, "imported"), Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => { Self::Imported(false, "missing_components") } diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index 6b7c623230..95f7d7649f 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -340,7 +340,7 @@ impl TestRig { if chain.spec.is_peer_das_enabled_for_epoch(block.epoch()) { let kzg = get_kzg(&chain.spec); let epoch = block.slot().epoch(E::slots_per_epoch()); - let sampling_indices = chain.sampling_columns_for_epoch(epoch); + let sampling_indices = chain.custody_context.sampling_columns_for_epoch(epoch); let custody_columns: DataColumnSidecarList = blobs_to_data_column_sidecars( &blobs.iter().collect_vec(), kzg_proofs.clone().into_iter().collect_vec(), @@ -422,7 +422,6 @@ impl TestRig { .send_lookup_beacon_block( block_root, LookupBlock::new(self.next_block.clone()), - std::time::Duration::default(), BlockProcessType::SingleBlock { id: 0 }, ) .unwrap(); @@ -434,7 +433,6 @@ impl TestRig { .send_lookup_beacon_block( block_root, LookupBlock::new(self.next_block.clone()), - std::time::Duration::default(), BlockProcessType::SingleBlock { id: 1 }, ) .unwrap(); @@ -446,7 +444,6 @@ impl TestRig { .send_rpc_custody_columns( self.next_block.canonical_root(), data_columns, - Duration::default(), BlockProcessType::SingleCustodyColumn(1), ) .unwrap(); @@ -1839,6 +1836,7 @@ async fn test_data_columns_by_range_request_only_returns_requested_columns() { let all_custody_columns = rig .chain + .custody_context .sampling_columns_for_epoch(rig.chain.epoch().unwrap()); let available_columns: Vec = all_custody_columns.to_vec(); @@ -1900,7 +1898,10 @@ async fn test_data_columns_by_range_no_duplicates_with_skip_slots() { let skip_slots: HashSet = [5, 6].into_iter().collect(); let mut rig = TestRig::new_with_skip_slots(128, &skip_slots).await; - let all_custody_columns = rig.chain.custody_columns_for_epoch(Some(Epoch::new(0))); + let all_custody_columns = rig + .chain + .custody_context + .custody_columns_for_epoch(Some(Epoch::new(0))); let requested_column = vec![all_custody_columns[0]]; // Request a range that spans the skip slots (slots 0 through 9). diff --git a/beacon_node/network/src/router.rs b/beacon_node/network/src/router.rs index 315ec9387d..d83068e337 100644 --- a/beacon_node/network/src/router.rs +++ b/beacon_node/network/src/router.rs @@ -659,7 +659,6 @@ impl Router { peer_id, sync_request_id, beacon_block, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -679,7 +678,6 @@ impl Router { peer_id, sync_request_id, blob_sidecar, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } else { crit!("All blobs by range responses should belong to sync"); @@ -716,7 +714,6 @@ impl Router { peer_id, sync_request_id, beacon_block, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -750,7 +747,6 @@ impl Router { sync_request_id, peer_id, data_column, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -770,7 +766,6 @@ impl Router { peer_id, sync_request_id, data_column, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } else { crit!("All data columns by range responses should belong to sync"); @@ -796,7 +791,6 @@ impl Router { sync_request_id, peer_id, envelope, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -819,7 +813,6 @@ impl Router { sync_request_id, peer_id, envelope, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } diff --git a/beacon_node/network/src/service.rs b/beacon_node/network/src/service.rs index c2e79fe9e8..cef3d5859e 100644 --- a/beacon_node/network/src/service.rs +++ b/beacon_node/network/src/service.rs @@ -19,7 +19,7 @@ use lighthouse_network::rpc::methods::RpcResponse; use lighthouse_network::service::Network; use lighthouse_network::types::GossipKind; use lighthouse_network::{ - Context, PeerAction, PubsubMessage, ReportSource, Response, Subnet, + Context, PeerAction, PubsubMessage, PubsubPartialMessage, ReportSource, Response, Subnet, rpc::{GoodbyeReason, RpcErrorResponse}, }; use lighthouse_network::{MessageAcceptance, prometheus_client::registry::Registry}; @@ -39,8 +39,8 @@ use tokio::time::Sleep; use tracing::{debug, error, info, trace, warn}; use typenum::Unsigned; use types::{ - EthSpec, ForkContext, PartialDataColumn, PartialDataColumnHeader, Slot, SubnetId, - SyncCommitteeSubscription, SyncSubnetId, ValidatorSubscription, + EthSpec, ForkContext, Slot, SubnetId, SyncCommitteeSubscription, SyncSubnetId, + ValidatorSubscription, }; mod tests; @@ -85,8 +85,7 @@ pub enum NetworkMessage { Publish { messages: Vec> }, /// Publish partial data column sidecars via the partial gossipsub protocol. PublishPartialColumns { - columns: Vec>>, - header: Arc>, + messages: Vec>, }, /// Validates a received gossipsub message. This will propagate the message on the network. ValidationResult { @@ -295,10 +294,7 @@ impl NetworkService { let (mut libp2p, network_globals) = Network::new( executor.clone(), service_context, - beacon_chain - .data_availability_checker - .custody_context() - .custody_group_count_at_head(&beacon_chain.spec), + beacon_chain.custody_context.custody_group_count_at_head(), local_keypair, ) .await?; @@ -683,8 +679,8 @@ impl NetworkService { ); self.libp2p.publish(messages); } - NetworkMessage::PublishPartialColumns { columns, header } => { - self.libp2p.publish_partial(columns, header); + NetworkMessage::PublishPartialColumns { messages } => { + self.libp2p.publish_partial(messages); } NetworkMessage::ReportPeer { peer_id, diff --git a/beacon_node/network/src/sync/backfill_sync/mod.rs b/beacon_node/network/src/sync/backfill_sync/mod.rs index c8bb17243e..edf358976a 100644 --- a/beacon_node/network/src/sync/backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/backfill_sync/mod.rs @@ -352,11 +352,14 @@ impl BackFillSync { debug!(?batch_id, msg, "Blob peer failure"); } CouplingError::EnvelopePeerFailure(msg) => { - debug!(?batch_id, msg, "Envelope peer failure"); + debug!(?batch_id, ?msg, "Envelope peer failure"); } CouplingError::InternalError(msg) => { error!(?batch_id, msg, "Block components coupling internal error"); } + CouplingError::AvailabilityCheckError(err) => { + error!(?batch_id, ?err, "Availability check error"); + } } } // A batch could be retried without the peer failing the request (disconnecting/ diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index d403382e9e..ef9807f037 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -114,6 +114,8 @@ pub(crate) struct BlockLookupSummary { pub block_root: Hash256, /// List of peers that claim to have imported this set of block components. pub peers: Vec, + /// Whether the lookup expects some future event to make progress. + pub is_awaiting_event: bool, } impl BlockLookups { @@ -150,6 +152,7 @@ impl BlockLookups { id: *id, block_root: l.block_root(), peers: l.all_peers(), + is_awaiting_event: l.is_awaiting_event(), }) .collect() } diff --git a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs index f03eed1638..09ffa50f9b 100644 --- a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs +++ b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs @@ -355,12 +355,16 @@ impl SingleBlockLookup { self.awaiting_parent.is_some() || self.block_request.state.is_awaiting_event() || match &self.data_request { - DataRequest::WaitingForBlock => true, + // Not awaiting an event itself; it's blocked on the block request, already covered + // by the `block_request` term above. Returning `true` kept a peerless lookup parked + // in `AwaitingDownload` from being pruned, so it got stuck. + DataRequest::WaitingForBlock => false, DataRequest::Request { state, .. } => state.is_awaiting_event(), DataRequest::NoData => false, } || match &self.payload_request { - PayloadRequest::WaitingForBlock => true, + // See `data_request` above: not awaiting an event itself, the block request covers it. + PayloadRequest::WaitingForBlock => false, PayloadRequest::Request { state, .. } => state.is_awaiting_event(), PayloadRequest::PreGloas => false, } @@ -381,7 +385,7 @@ impl SingleBlockLookup { if self.awaiting_parent.is_none() && let Some(data) = self.block_request.state.maybe_start_processing() { - cx.send_block_for_processing(self.id, self.block_root, data.value, data.seen_timestamp) + cx.send_block_for_processing(self.id, self.block_root, data.value) .map_err(LookupRequestError::SendFailedProcessor)?; } @@ -390,12 +394,11 @@ impl SingleBlockLookup { match &mut self.data_request { DataRequest::WaitingForBlock => { if let Some(block) = self.block_request.state.peek_downloaded_data() { - let block_epoch = block - .slot() - .epoch(::EthSpec::slots_per_epoch()); - self.data_request = if block.num_expected_blobs() == 0 { - DataRequest::NoData - } else if cx.chain.should_fetch_custody_columns(block_epoch) { + self.data_request = if cx + .chain + .custody_context + .data_columns_required_for_block(block) + { DataRequest::Request { slot: block.slot(), peers: self.get_data_peers(block.payload_bid_block_hash().ok()), @@ -422,7 +425,6 @@ impl SingleBlockLookup { self.id, self.block_root, data.value, - data.seen_timestamp, BlockProcessType::SingleCustodyColumn(self.id), ) .map_err(LookupRequestError::SendFailedProcessor)?; @@ -463,7 +465,6 @@ impl SingleBlockLookup { cx.send_payload_for_processing( self.block_root, data.value, - data.seen_timestamp, BlockProcessType::SinglePayloadEnvelope(self.id), ) .map_err(LookupRequestError::SendFailedProcessor)?; @@ -711,17 +712,12 @@ impl SingleBlockLookup { #[derive(Debug, Clone)] pub struct DownloadResult { pub value: T, - pub seen_timestamp: Duration, pub peer_group: PeerGroup, } impl DownloadResult { - pub fn new(value: T, peer_group: PeerGroup, seen_timestamp: Duration) -> Self { - Self { - value, - seen_timestamp, - peer_group, - } + pub fn new(value: T, peer_group: PeerGroup) -> Self { + Self { value, peer_group } } } diff --git a/beacon_node/network/src/sync/block_sidecar_coupling.rs b/beacon_node/network/src/sync/block_sidecar_coupling.rs index b64ae4a4c5..001fabb704 100644 --- a/beacon_node/network/src/sync/block_sidecar_coupling.rs +++ b/beacon_node/network/src/sync/block_sidecar_coupling.rs @@ -1,8 +1,9 @@ +use beacon_chain::data_availability_checker::AvailabilityCheckError; use beacon_chain::payload_envelope_verification::AvailableEnvelope; use beacon_chain::{ BeaconChainTypes, block_verification_types::{AvailableBlockData, RangeSyncBlock}, - data_availability_checker::DataAvailabilityChecker, + custody_context::CustodyContext, data_column_verification::CustodyDataColumn, get_block_root, }; @@ -81,6 +82,13 @@ pub enum CouplingError { }, BlobPeerFailure(String), EnvelopePeerFailure(String), + AvailabilityCheckError(AvailabilityCheckError), +} + +impl From for CouplingError { + fn from(e: AvailabilityCheckError) -> Self { + CouplingError::AvailabilityCheckError(e) + } } impl RangeBlockComponentsRequest { @@ -221,7 +229,7 @@ impl RangeBlockComponentsRequest { /// Returns `Some(Err(_))` if there are issues coupling blocks with their data. pub fn responses( &mut self, - da_checker: Arc>, + custody_context: &CustodyContext, spec: Arc, ) -> Option>, CouplingError>> where @@ -241,7 +249,7 @@ impl RangeBlockComponentsRequest { RangeBlockDataRequest::NoData => Some(Self::responses_with_blobs( blocks.to_vec(), vec![], - da_checker, + custody_context, spec, )), RangeBlockDataRequest::Blobs(request) => { @@ -251,7 +259,7 @@ impl RangeBlockComponentsRequest { Some(Self::responses_with_blobs( blocks.to_vec(), blobs.to_vec(), - da_checker, + custody_context, spec, )) } @@ -294,8 +302,7 @@ impl RangeBlockComponentsRequest { column_to_peer_id, expected_custody_columns, *attempt, - da_checker, - spec, + custody_context, payload_envelopes, ); @@ -321,7 +328,7 @@ impl RangeBlockComponentsRequest { fn responses_with_blobs( blocks: Vec>>, blobs: Vec>>, - da_checker: Arc>, + custody_context: &CustodyContext, spec: Arc, ) -> Result>, CouplingError> where @@ -370,7 +377,7 @@ impl RangeBlockComponentsRequest { })?; let block_data = AvailableBlockData::new_with_blobs(blobs); responses.push( - RangeSyncBlock::new(block, block_data, &da_checker, spec.clone()) + RangeSyncBlock::new(block, block_data, custody_context) .map_err(|e| CouplingError::BlobPeerFailure(format!("{e:?}")))?, ) } @@ -394,8 +401,7 @@ impl RangeBlockComponentsRequest { column_to_peer: HashMap, expects_custody_columns: &[ColumnIndex], attempt: usize, - da_checker: Arc>, - spec: Arc, + custody_context: &CustodyContext, payload_envelopes: Option>>>, ) -> Result>, CouplingError> where @@ -499,17 +505,24 @@ impl RangeBlockComponentsRequest { envelopes_by_block_root.as_mut() { let envelope = envelopes_by_block_root.remove(&block_root); - let available_envelope = - envelope.map(|env| AvailableEnvelope::new(env, custody_columns)); + let bid = block + .message() + .body() + .signed_execution_payload_bid() + // this really should never fail + .map_err(|_| AvailabilityCheckError::MissingBid(block_root))?; + let available_envelope = envelope + .map(|env| AvailableEnvelope::new(env, custody_columns, bid, custody_context)) + .transpose()?; RangeSyncBlock::new_gloas(block, available_envelope) .map_err(CouplingError::EnvelopePeerFailure)? } else if custody_columns.is_empty() { - RangeSyncBlock::new(block, AvailableBlockData::NoData, &da_checker, spec.clone()) + RangeSyncBlock::new(block, AvailableBlockData::NoData, custody_context) .map_err(|e| CouplingError::InternalError(format!("{:?}", e)))? } else { let block_data = AvailableBlockData::new_with_data_columns(custody_columns); - RangeSyncBlock::new(block, block_data, &da_checker, spec.clone()) + RangeSyncBlock::new(block, block_data, custody_context) .map_err(|e| CouplingError::InternalError(format!("{:?}", e)))? }; range_sync_blocks.push(range_sync_block); @@ -562,11 +575,10 @@ mod tests { use super::RangeBlockComponentsRequest; use beacon_chain::block_verification_types::RangeSyncBlock; - use beacon_chain::custody_context::NodeCustodyType; - use beacon_chain::data_availability_checker::DataAvailabilityChecker; + use beacon_chain::custody_context::{CustodyContext, NodeCustodyType}; use beacon_chain::test_utils::{ EphemeralHarnessType, NumBlobs, generate_rand_block_and_blobs, - generate_rand_block_and_data_columns, test_da_checker, test_spec, + generate_rand_block_and_data_columns, test_custody_context, test_spec, }; use bls::Signature; use lighthouse_network::{ @@ -594,15 +606,113 @@ mod tests { } } - /// The custody-column coupling tests below build Fulu data-column sidecars directly, which is - /// incompatible with a Gloas genesis (Gloas columns have a different structure). Skip them when - /// `FORK_NAME` schedules Gloas at genesis. TODO(gloas): port the harness to build Gloas columns. - fn skip_under_gloas() -> bool { + /// Returns true when `FORK_NAME` schedules Gloas at genesis. Used to make the custody-column + /// coupling tests fork-aware: under Gloas the columns are coupled into the payload envelope, so + /// these tests build Gloas blocks/columns/envelopes and complete the payloads request. + fn is_gloas_env() -> bool { test_spec::() .fork_name_at_epoch(Epoch::new(0)) .gloas_enabled() } + /// The fork to build blocks/columns for in the custody-column coupling tests. Under a Gloas + /// genesis we must build Gloas columns (and matching envelopes); otherwise we use Fulu. + fn custody_test_fork() -> ForkName { + if is_gloas_env() { + ForkName::Gloas + } else { + ForkName::Fulu + } + } + + /// A spec with custody-column (PeerDAS) coupling enabled at genesis, matching the env fork. + /// Under a Gloas env this enables Gloas at genesis (so envelopes are coupled); otherwise it + /// enables Fulu at genesis. + fn custody_test_spec() -> ChainSpec { + let mut spec = test_spec::(); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.fulu_fork_epoch = Some(Epoch::new(0)); + if is_gloas_env() { + spec.gloas_fork_epoch = Some(Epoch::new(0)); + } + spec + } + + /// A block, its data columns, and (under Gloas) its matching payload envelope. + type BlockColumnsEnvelope = ( + Arc>, + DataColumnSidecarList, + Option>>, + ); + + /// Builds `count` blocks with their data columns, plus a matching payload envelope under Gloas. + /// Under Fulu the envelope is `None`. + fn make_blocks_and_columns( + count: usize, + num_blobs: NumBlobs, + spec: &ChainSpec, + ) -> Vec { + let fork = custody_test_fork(); + let mut u = types::test_utils::test_unstructured(); + (0..count) + .map(|_| { + // `NumBlobs` isn't `Clone`, so rebuild a fresh value for each block. + let num_blobs = match &num_blobs { + NumBlobs::Random => NumBlobs::Random, + NumBlobs::Number(n) => NumBlobs::Number(*n), + NumBlobs::None => NumBlobs::None, + }; + let (block, data_columns) = + generate_rand_block_and_data_columns::(fork, num_blobs, &mut u, spec) + .unwrap(); + let block = Arc::new(block); + let envelope = is_gloas_env().then(|| matching_envelope(&block)); + (block, data_columns, envelope) + }) + .collect() + } + + /// Under Gloas, completes the payloads request with the envelopes from `blocks`. Under Fulu this + /// is a no-op (there is no payloads request). Pass the subset of blocks whose envelopes should + /// be supplied. + fn add_envelopes_if_gloas( + info: &mut RangeBlockComponentsRequest, + payloads_req_id: Option, + blocks: &[BlockColumnsEnvelope], + ) { + if let Some(payloads_req_id) = payloads_req_id { + info.add_payload_envelopes( + payloads_req_id, + blocks + .iter() + .filter_map(|(_, _, envelope)| envelope.clone()) + .collect(), + ) + .unwrap(); + } + } + + /// Asserts the coupled `responses` carry the expected data. Pre-Gloas only the count is checked; + /// under Gloas each block must additionally wrap an envelope holding `expected_columns` columns. + fn assert_custody_columns_coupled( + responses: &[RangeSyncBlock], + expected_blocks: usize, + expected_columns: usize, + ) { + assert_eq!(responses.len(), expected_blocks); + if is_gloas_env() { + for response in responses { + match response { + RangeSyncBlock::Gloas { + envelope: Some(envelope), + .. + } => assert_eq!(envelope.columns.len(), expected_columns), + other => panic!("expected Gloas block with envelope, got {other:?}"), + } + } + } + } + fn blocks_id(parent_request_id: ComponentsByRangeRequestId) -> BlocksByRangeRequestId { BlocksByRangeRequestId { id: 1, @@ -639,8 +749,8 @@ mod tests { fn is_finished(info: &mut RangeBlockComponentsRequest) -> bool { let spec = Arc::new(test_spec::()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - info.responses(da_checker, spec).is_some() + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + info.responses(&custody_context, spec).is_some() } fn gloas_spec() -> ChainSpec { @@ -728,7 +838,7 @@ mod tests { #[allow(clippy::type_complexity)] struct GloasSetup { info: RangeBlockComponentsRequest, - da_checker: Arc>>, + custody_context: Arc>>, spec: Arc, blocks: Vec<( Arc>, @@ -743,10 +853,9 @@ mod tests { /// ready for the per-test payload-envelope step. fn setup_gloas_coupling(count: usize) -> GloasSetup { let spec = Arc::new(gloas_spec()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expected_custody_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expected_custody_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); let blocks = make_gloas_blocks_and_columns(count, &spec); @@ -788,7 +897,7 @@ mod tests { GloasSetup { info, - da_checker, + custody_context, spec, blocks, payloads_req_id, @@ -798,38 +907,33 @@ mod tests { #[test] fn no_blobs_into_responses() { - // This exercises the pre-Gloas blobs/no-data coupling path. Gloas coupling is covered - // by the dedicated `setup_gloas_coupling` tests below. - if skip_under_gloas() { - return; - } - let spec = Arc::new(test_spec::()); + // Coupling of blocks that carry no data. Pre-Gloas there is simply no data request; under + // Gloas each block still couples to its (empty-column) payload envelope, so the envelope + // request is driven too. + let spec = Arc::new(custody_test_spec()); + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let blocks = make_blocks_and_columns(4, NumBlobs::None, &spec); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..4) - .map(|_| { - generate_rand_block_and_blobs::( - spec.fork_name_at_epoch(Epoch::new(0)), - NumBlobs::None, - &mut u, - ) - .unwrap() - .0 - .into() - }) - .collect::>>>(); + let components_id = components_id(); + let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); + let mut info = RangeBlockComponentsRequest::::new( + blocks_req_id, + None, + is_gloas_env().then(|| (vec![], vec![])), + payloads_req_id, + Span::none(), + ); - let blocks_req_id = blocks_id(components_id()); - let mut info = - RangeBlockComponentsRequest::::new(blocks_req_id, None, None, None, Span::none()); + info.add_blocks( + blocks_req_id, + blocks.iter().map(|(block, _, _)| block.clone()).collect(), + ) + .unwrap(); + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); - // Send blocks and complete terminate response - info.add_blocks(blocks_req_id, blocks).unwrap(); - - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - - // Assert response is finished and RpcBlocks can be constructed - info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(&custody_context, spec).unwrap().unwrap(); + assert_custody_columns_coupled(&responses, blocks.len(), 0); } #[test] @@ -867,41 +971,24 @@ mod tests { // FORK_NAME. spec.fulu_fork_epoch = None; let spec = Arc::new(spec); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); // Blobs are no longer required for availability, so the response succeeds without them. - let result = info.responses(da_checker, spec).unwrap(); + let result = info.responses(&custody_context, spec).unwrap(); assert!(result.is_ok()) } #[test] fn rpc_block_with_custody_columns() { - if skip_under_gloas() { - return; - } - let mut spec = test_spec::(); - spec.deneb_fork_epoch = Some(Epoch::new(0)); - spec.fulu_fork_epoch = Some(Epoch::new(0)); - let spec = Arc::new(spec); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expects_custody_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let spec = Arc::new(custody_test_spec()); + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expects_custody_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..4) - .map(|_| { - generate_rand_block_and_data_columns::( - ForkName::Fulu, - NumBlobs::Number(1), - &mut u, - &spec, - ) - .unwrap() - }) - .collect::>(); + let blocks = make_blocks_and_columns(4, NumBlobs::Number(1), &spec); let components_id = components_id(); let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); let columns_req_id = expects_custody_columns .iter() .enumerate() @@ -919,13 +1006,13 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expects_custody_columns.clone())), - None, + payloads_req_id, Span::none(), ); // Send blocks and complete terminate response info.add_blocks( blocks_req_id, - blocks.iter().map(|b| b.0.clone().into()).collect(), + blocks.iter().map(|(block, _, _)| block.clone()).collect(), ) .unwrap(); // Assert response is not finished @@ -938,7 +1025,12 @@ mod tests { *req, blocks .iter() - .flat_map(|b| b.1.iter().filter(|d| *d.index() == column_index).cloned()) + .flat_map(|(_, columns, _)| { + columns + .iter() + .filter(|d| *d.index() == column_index) + .cloned() + }) .collect(), ) .unwrap(); @@ -951,23 +1043,21 @@ mod tests { } } + // Under Gloas the columns are coupled into the payload envelope; supply the envelopes so + // the request can complete. + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); + // All completed construct response - info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(&custody_context, spec).unwrap().unwrap(); + assert_custody_columns_coupled(&responses, blocks.len(), expects_custody_columns.len()); } #[test] fn rpc_block_with_custody_columns_batched() { - if skip_under_gloas() { - return; - } - let mut spec = test_spec::(); - spec.deneb_fork_epoch = Some(Epoch::new(0)); - spec.fulu_fork_epoch = Some(Epoch::new(0)); - let spec = Arc::new(spec); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expected_sampling_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let spec = Arc::new(custody_test_spec()); + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expected_sampling_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); // Split sampling columns into two batches let mid = expected_sampling_columns.len() / 2; @@ -981,6 +1071,7 @@ mod tests { let components_id = components_id(); let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); let columns_req_id = batched_column_requests .iter() .enumerate() @@ -999,27 +1090,16 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), - None, + payloads_req_id, Span::none(), ); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..4) - .map(|_| { - generate_rand_block_and_data_columns::( - ForkName::Fulu, - NumBlobs::Number(1), - &mut u, - &spec, - ) - .unwrap() - }) - .collect::>(); + let blocks = make_blocks_and_columns(4, NumBlobs::Number(1), &spec); // Send blocks and complete terminate response info.add_blocks( blocks_req_id, - blocks.iter().map(|b| b.0.clone().into()).collect(), + blocks.iter().map(|(block, _, _)| block.clone()).collect(), ) .unwrap(); // Assert response is not finished @@ -1032,8 +1112,9 @@ mod tests { *req, blocks .iter() - .flat_map(|b| { - b.1.iter() + .flat_map(|(_, columns, _)| { + columns + .iter() .filter(|d| column_indices.contains(d.index())) .cloned() }) @@ -1049,28 +1130,33 @@ mod tests { } } + // Under Gloas the columns are coupled into the payload envelope; supply the envelopes so + // the request can complete. + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); + // All completed construct response - info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(&custody_context, spec).unwrap().unwrap(); + assert_custody_columns_coupled(&responses, blocks.len(), expected_sampling_columns.len()); } #[test] fn gloas_payload_envelopes_must_complete_before_responses() { let GloasSetup { mut info, - da_checker, + custody_context, spec, .. } = setup_gloas_coupling(2); // No payload envelopes added yet, so the request must not be complete. - assert!(info.responses(da_checker, spec).is_none()); + assert!(info.responses(&custody_context, spec).is_none()); } #[test] fn gloas_payload_envelopes_are_coupled_by_block_root() { let GloasSetup { mut info, - da_checker, + custody_context, spec, blocks, payloads_req_id, @@ -1088,7 +1174,7 @@ mod tests { ) .unwrap(); - let responses = info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(&custody_context, spec).unwrap().unwrap(); assert_eq!(responses.len(), blocks.len()); for response in responses { match response { @@ -1111,7 +1197,7 @@ mod tests { fn gloas_payload_envelopes_allow_missing_envelopes() { let GloasSetup { mut info, - da_checker, + custody_context, spec, blocks, payloads_req_id, @@ -1122,7 +1208,7 @@ mod tests { info.add_payload_envelopes(payloads_req_id, vec![blocks[0].2.clone()]) .unwrap(); - let responses = info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(&custody_context, spec).unwrap().unwrap(); let count_with = |with_envelope: bool| { responses .iter() @@ -1139,7 +1225,7 @@ mod tests { fn gloas_payload_envelope_mismatch_fails_coupling() { let GloasSetup { mut info, - da_checker, + custody_context, spec, blocks, payloads_req_id, @@ -1151,7 +1237,7 @@ mod tests { info.add_payload_envelopes(payloads_req_id, vec![Arc::new(bad_envelope)]) .unwrap(); - let result = info.responses(da_checker, spec).unwrap(); + let result = info.responses(&custody_context, spec).unwrap(); assert!( matches!( result, @@ -1164,31 +1250,17 @@ mod tests { #[test] fn missing_custody_columns_from_faulty_peers() { - if skip_under_gloas() { - return; - } // GIVEN: A request expecting sampling columns from multiple peers - let spec = Arc::new(test_spec::()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expected_sampling_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let spec = Arc::new(custody_test_spec()); + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expected_sampling_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..2) - .map(|_| { - generate_rand_block_and_data_columns::( - ForkName::Fulu, - NumBlobs::Number(1), - &mut u, - &spec, - ) - .unwrap() - }) - .collect::>(); + let blocks = make_blocks_and_columns(2, NumBlobs::Number(1), &spec); let components_id = components_id(); let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); let columns_req_id = expected_sampling_columns .iter() .enumerate() @@ -1206,16 +1278,19 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), - None, + payloads_req_id, Span::none(), ); // AND: All blocks are received successfully info.add_blocks( blocks_req_id, - blocks.iter().map(|b| b.0.clone().into()).collect(), + blocks.iter().map(|(block, _, _)| block.clone()).collect(), ) .unwrap(); + // Under Gloas the payloads request must be completed for `responses` to proceed; the + // faulty-peer detection happens before the envelope wrap and is fork-independent. + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); // AND: Only the first 2 sampling columns are received successfully for (i, &column_index) in expected_sampling_columns.iter().take(2).enumerate() { @@ -1224,7 +1299,12 @@ mod tests { *req, blocks .iter() - .flat_map(|b| b.1.iter().filter(|d| *d.index() == column_index).cloned()) + .flat_map(|(_, columns, _)| { + columns + .iter() + .filter(|d| *d.index() == column_index) + .cloned() + }) .collect(), ) .unwrap(); @@ -1237,7 +1317,7 @@ mod tests { } // WHEN: Attempting to construct RPC blocks - let result = info.responses(da_checker, spec).unwrap(); + let result = info.responses(&custody_context, spec).unwrap(); // THEN: Should fail with PeerFailure identifying the faulty peers assert!(result.is_err()); @@ -1263,34 +1343,17 @@ mod tests { #[test] fn retry_logic_after_peer_failures() { - if skip_under_gloas() { - return; - } // GIVEN: A request expecting sampling columns where some peers initially fail - let mut spec = test_spec::(); - spec.deneb_fork_epoch = Some(Epoch::new(0)); - spec.fulu_fork_epoch = Some(Epoch::new(0)); - let spec = Arc::new(spec); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expected_sampling_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let spec = Arc::new(custody_test_spec()); + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expected_sampling_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..2) - .map(|_| { - generate_rand_block_and_data_columns::( - ForkName::Fulu, - NumBlobs::Number(1), - &mut u, - &spec, - ) - .unwrap() - }) - .collect::>(); + let blocks = make_blocks_and_columns(2, NumBlobs::Number(1), &spec); let components_id = components_id(); let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); let columns_req_id = expected_sampling_columns .iter() .enumerate() @@ -1308,16 +1371,18 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), - None, + payloads_req_id, Span::none(), ); // AND: All blocks are received info.add_blocks( blocks_req_id, - blocks.iter().map(|b| b.0.clone().into()).collect(), + blocks.iter().map(|(block, _, _)| block.clone()).collect(), ) .unwrap(); + // Under Gloas the payloads request must be completed for `responses` to proceed. + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); // AND: Only partial sampling columns are received (first column but not others) let (req0, _) = columns_req_id.first().unwrap(); @@ -1325,8 +1390,9 @@ mod tests { *req0, blocks .iter() - .flat_map(|b| { - b.1.iter() + .flat_map(|(_, columns, _)| { + columns + .iter() .filter(|d| *d.index() == expected_sampling_columns[0]) .cloned() }) @@ -1343,7 +1409,7 @@ mod tests { let result: Result< Vec>, crate::sync::block_sidecar_coupling::CouplingError, - > = info.responses(da_checker.clone(), spec.clone()).unwrap(); + > = info.responses(&custody_context, spec.clone()).unwrap(); assert!(result.is_err()); // AND: We retry with a new peer for the failed columns @@ -1363,8 +1429,9 @@ mod tests { new_columns_req_id, blocks .iter() - .flat_map(|b| { - b.1.iter() + .flat_map(|(_, columns, _)| { + columns + .iter() .filter(|d| failed_column_indices.contains(d.index())) .cloned() }) @@ -1373,7 +1440,7 @@ mod tests { .unwrap(); // WHEN: Attempting to get responses again - let result = info.responses(da_checker, spec).unwrap(); + let result = info.responses(&custody_context, spec).unwrap(); // THEN: Should succeed with complete RangeSync blocks assert!(result.is_ok()); @@ -1383,34 +1450,17 @@ mod tests { #[test] fn max_retries_exceeded_behavior() { - if skip_under_gloas() { - return; - } // GIVEN: A request where peers consistently fail to provide required columns - let mut spec = test_spec::(); - spec.deneb_fork_epoch = Some(Epoch::new(0)); - spec.fulu_fork_epoch = Some(Epoch::new(0)); - let spec = Arc::new(spec); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expected_sampling_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let spec = Arc::new(custody_test_spec()); + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expected_sampling_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..1) - .map(|_| { - generate_rand_block_and_data_columns::( - ForkName::Fulu, - NumBlobs::Number(1), - &mut u, - &spec, - ) - .unwrap() - }) - .collect::>(); + let blocks = make_blocks_and_columns(1, NumBlobs::Number(1), &spec); let components_id = components_id(); let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); let columns_req_id = expected_sampling_columns .iter() .enumerate() @@ -1428,16 +1478,18 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), - None, + payloads_req_id, Span::none(), ); // AND: All blocks are received info.add_blocks( blocks_req_id, - blocks.iter().map(|b| b.0.clone().into()).collect(), + blocks.iter().map(|(block, _, _)| block.clone()).collect(), ) .unwrap(); + // Under Gloas the payloads request must be completed for `responses` to proceed. + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); // AND: Only the first sampling column is provided successfully let (req0, _) = columns_req_id.first().unwrap(); @@ -1445,8 +1497,9 @@ mod tests { *req0, blocks .iter() - .flat_map(|b| { - b.1.iter() + .flat_map(|(_, columns, _)| { + columns + .iter() .filter(|d| *d.index() == expected_sampling_columns[0]) .cloned() }) @@ -1462,7 +1515,7 @@ mod tests { // WHEN: Multiple retry attempts are made (up to max retries) for _ in 0..MAX_COLUMN_RETRIES { - let result = info.responses(da_checker.clone(), spec.clone()).unwrap(); + let result = info.responses(&custody_context, spec.clone()).unwrap(); assert!(result.is_err()); if let Err(super::CouplingError::DataColumnPeerFailure { @@ -1475,7 +1528,7 @@ mod tests { } // AND: One final attempt after exceeding max retries - let result = info.responses(da_checker, spec).unwrap(); + let result = info.responses(&custody_context, spec).unwrap(); // THEN: Should fail with exceeded_retries = true assert!(result.is_err()); diff --git a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs index c85610613c..2874bbebf1 100644 --- a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs @@ -162,7 +162,11 @@ impl CustodyBackFillSync { /// - The earliest data column epoch's custodied columns != previous epoch's custodied columns /// - The earliest data column epoch is a finalied epoch pub fn should_start_custody_backfill_sync(&mut self) -> bool { - let Some(da_boundary_epoch) = self.beacon_chain.get_column_da_boundary() else { + let Some(da_boundary_epoch) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return false; }; @@ -220,9 +224,8 @@ impl CustodyBackFillSync { fn restart_if_required(&mut self) -> bool { let cgc_at_head = self .beacon_chain - .data_availability_checker - .custody_context() - .custody_group_count_at_head(&self.beacon_chain.spec); + .custody_context + .custody_group_count_at_head(); if cgc_at_head != self.cgc { self.restart_sync(); @@ -290,7 +293,11 @@ impl CustodyBackFillSync { } } - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { + let Some(column_da_boundary) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return Ok(SyncStart::NotSyncing); }; @@ -309,9 +316,8 @@ impl CustodyBackFillSync { fn set_cgc(&mut self) { self.cgc = self .beacon_chain - .data_availability_checker - .custody_context() - .custody_group_count_at_head(&self.beacon_chain.spec); + .custody_context + .custody_group_count_at_head(); } fn set_start_epoch(&mut self) { @@ -379,9 +385,10 @@ impl CustodyBackFillSync { /// Creates the next required batch from the chain. If there are no more batches required, /// `None` is returned. fn include_next_batch(&mut self) -> Option { - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { - return None; - }; + let column_da_boundary = self + .beacon_chain + .custody_context + .column_data_availability_boundary()?; // Skip all batches (Epochs) that don't have missing columns. for epoch in Epoch::range_inclusive_rev(self.to_be_downloaded, column_da_boundary) { @@ -715,7 +722,11 @@ impl CustodyBackFillSync { self.advance_custody_backfill_sync(batch_id); - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { + let Some(column_da_boundary) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return Err(CustodyBackfillError::InvalidSyncState( "Can't calculate column data availability boundary".to_string(), )); @@ -889,7 +900,11 @@ impl CustodyBackFillSync { /// /// The `validating_epoch` must align with batch boundaries. fn advance_custody_backfill_sync(&mut self, validating_epoch: Epoch) { - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { + let Some(column_da_boundary) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return; }; // make sure this epoch produces an advancement, unless its at the column DA boundary @@ -986,7 +1001,11 @@ impl CustodyBackFillSync { return false; }; - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { + let Some(column_da_boundary) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return false; }; @@ -997,7 +1016,11 @@ impl CustodyBackFillSync { /// Checks if custody backfill would complete by syncing to `start_epoch`. fn would_complete(&self, start_epoch: Epoch) -> bool { - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { + let Some(column_da_boundary) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return false; }; start_epoch <= column_da_boundary diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 8e7b8cd05a..b9bea21b8c 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -113,7 +113,6 @@ pub enum SyncMessage { sync_request_id: SyncRequestId, peer_id: PeerId, beacon_block: Option>>, - seen_timestamp: Duration, }, /// A blob has been received from the RPC. @@ -121,7 +120,6 @@ pub enum SyncMessage { sync_request_id: SyncRequestId, peer_id: PeerId, blob_sidecar: Option>>, - seen_timestamp: Duration, }, /// A data columns has been received from the RPC @@ -129,7 +127,6 @@ pub enum SyncMessage { sync_request_id: SyncRequestId, peer_id: PeerId, data_column: Option>>, - seen_timestamp: Duration, }, /// A payload envelope has been received from the RPC. @@ -137,7 +134,6 @@ pub enum SyncMessage { sync_request_id: SyncRequestId, peer_id: PeerId, envelope: Option>>, - seen_timestamp: Duration, }, /// A block with an unknown parent has been received. @@ -156,6 +152,11 @@ pub enum SyncMessage { /// manager to attempt to find the block matching the unknown hash. UnknownBlockHashFromAttestation(PeerId, Hash256), + /// A peer has sent a payload-present attestation (`index == 1`) for a block whose execution + /// payload envelope we have not seen. This triggers the manager to fetch the payload envelope + /// for `block_root` via `ExecutionPayloadEnvelopesByRoot`. + UnknownPayloadEnvelopeFromAttestation(PeerId, Hash256), + /// A peer has disconnected. Disconnect(PeerId), @@ -260,6 +261,10 @@ pub struct SyncManager { /// may forward us thousands of a attestations, each one triggering an individual event. Only /// one event is useful, the rest generating log noise and wasted cycles notified_unknown_roots: LRUTimeCache<(PeerId, Hash256)>, + /// Debounce duplicated `UnknownPayloadEnvelopeFromAttestation` for the same root/peer tuple, + /// for the same reason as `notified_unknown_roots`: a peer may forward many payload-present + /// attestations for a block whose execution payload envelope we have not yet seen. + notified_unknown_payload_roots: LRUTimeCache<(PeerId, Hash256)>, } /// Spawns a new `SyncManager` thread which has a weak reference to underlying beacon @@ -320,6 +325,9 @@ impl SyncManager { notified_unknown_roots: LRUTimeCache::new(Duration::from_secs( NOTIFIED_UNKNOWN_ROOT_EXPIRY_SECONDS, )), + notified_unknown_payload_roots: LRUTimeCache::new(Duration::from_secs( + NOTIFIED_UNKNOWN_ROOT_EXPIRY_SECONDS, + )), } } @@ -823,35 +831,24 @@ impl SyncManager { sync_request_id, peer_id, beacon_block, - seen_timestamp, } => { - self.rpc_block_received(sync_request_id, peer_id, beacon_block, seen_timestamp); + self.rpc_block_received(sync_request_id, peer_id, beacon_block); } SyncMessage::RpcBlob { sync_request_id, peer_id, blob_sidecar, - seen_timestamp, - } => self.rpc_blob_received(sync_request_id, peer_id, blob_sidecar, seen_timestamp), + } => self.rpc_blob_received(sync_request_id, peer_id, blob_sidecar), SyncMessage::RpcDataColumn { sync_request_id, peer_id, data_column, - seen_timestamp, - } => { - self.rpc_data_column_received(sync_request_id, peer_id, data_column, seen_timestamp) - } + } => self.rpc_data_column_received(sync_request_id, peer_id, data_column), SyncMessage::RpcPayloadEnvelope { sync_request_id, peer_id, envelope, - seen_timestamp, - } => self.rpc_payload_envelope_received( - sync_request_id, - peer_id, - envelope, - seen_timestamp, - ), + } => self.rpc_payload_envelope_received(sync_request_id, peer_id, envelope), SyncMessage::UnknownParentBlock(peer_id, block, block_root) => { let block_slot = block.slot(); let parent_root = block.parent_root(); @@ -865,7 +862,6 @@ impl SyncManager { block_slot, BlockComponent::Block(DownloadResult { value: block.block_cloned(), - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), peer_group: PeerGroup::from_single(peer_id), }), ); @@ -895,6 +891,22 @@ impl SyncManager { self.handle_unknown_block_root(peer_id, block_root); } } + SyncMessage::UnknownPayloadEnvelopeFromAttestation(peer_id, block_root) => { + if !self + .notified_unknown_payload_roots + .contains(&(peer_id, block_root)) + { + self.notified_unknown_payload_roots + .insert((peer_id, block_root)); + // TODO(gloas): trigger a payload-envelope lookup for `block_root` via + // `ExecutionPayloadEnvelopesByRoot`. Wired up in the gloas lookup-sync PR (#9155). + debug!( + ?block_root, + ?peer_id, + "Received unknown payload envelope from attestation" + ); + } + } SyncMessage::Disconnect(peer_id) => { debug!(%peer_id, "Received disconnected message"); self.peer_disconnect(&peer_id); @@ -1094,19 +1106,14 @@ impl SyncManager { sync_request_id: SyncRequestId, peer_id: PeerId, block: Option>>, - seen_timestamp: Duration, ) { match sync_request_id { - SyncRequestId::SingleBlock { id } => self.on_single_block_response( - id, - peer_id, - RpcEvent::from_chunk(block, seen_timestamp), - ), - SyncRequestId::BlocksByRange(id) => self.on_blocks_by_range_response( - id, - peer_id, - RpcEvent::from_chunk(block, seen_timestamp), - ), + SyncRequestId::SingleBlock { id } => { + self.on_single_block_response(id, peer_id, RpcEvent::from_chunk(block)) + } + SyncRequestId::BlocksByRange(id) => { + self.on_blocks_by_range_response(id, peer_id, RpcEvent::from_chunk(block)) + } _ => { crit!(%peer_id, "bad request id for block"); } @@ -1122,9 +1129,7 @@ impl SyncManager { if let Some(resp) = self.network.on_single_block_response(id, peer_id, block) { self.block_lookups.on_block_download_response( id, - resp.map(|(value, seen_timestamp)| { - DownloadResult::new(value, PeerGroup::from_single(peer_id), seen_timestamp) - }), + resp.map(|value| DownloadResult::new(value, PeerGroup::from_single(peer_id))), &mut self.network, ) } @@ -1135,14 +1140,11 @@ impl SyncManager { sync_request_id: SyncRequestId, peer_id: PeerId, blob: Option>>, - seen_timestamp: Duration, ) { match sync_request_id { - SyncRequestId::BlobsByRange(id) => self.on_blobs_by_range_response( - id, - peer_id, - RpcEvent::from_chunk(blob, seen_timestamp), - ), + SyncRequestId::BlobsByRange(id) => { + self.on_blobs_by_range_response(id, peer_id, RpcEvent::from_chunk(blob)) + } _ => { crit!(%peer_id, "bad request id for blob"); } @@ -1154,20 +1156,15 @@ impl SyncManager { sync_request_id: SyncRequestId, peer_id: PeerId, envelope: Option>>, - seen_timestamp: Duration, ) { match sync_request_id { SyncRequestId::SinglePayloadEnvelope { id } => self - .on_single_payload_envelope_response( - id, - peer_id, - RpcEvent::from_chunk(envelope, seen_timestamp), - ), + .on_single_payload_envelope_response(id, peer_id, RpcEvent::from_chunk(envelope)), SyncRequestId::PayloadEnvelopesByRange(req_id) => { self.on_payload_envelopes_by_range_response( req_id, peer_id, - RpcEvent::from_chunk(envelope, seen_timestamp), + RpcEvent::from_chunk(envelope), ); } _ => { @@ -1181,21 +1178,20 @@ impl SyncManager { sync_request_id: SyncRequestId, peer_id: PeerId, data_column: Option>>, - seen_timestamp: Duration, ) { match sync_request_id { SyncRequestId::DataColumnsByRoot(req_id) => { self.on_data_columns_by_root_response( req_id, peer_id, - RpcEvent::from_chunk(data_column, seen_timestamp), + RpcEvent::from_chunk(data_column), ); } SyncRequestId::DataColumnsByRange(req_id) => { self.on_data_columns_by_range_response( req_id, peer_id, - RpcEvent::from_chunk(data_column, seen_timestamp), + RpcEvent::from_chunk(data_column), ); } _ => { @@ -1216,9 +1212,7 @@ impl SyncManager { { self.block_lookups.on_payload_download_response( id, - resp.map(|(value, seen_timestamp)| { - DownloadResult::new(value, PeerGroup::from_single(peer_id), seen_timestamp) - }), + resp.map(|value| DownloadResult::new(value, PeerGroup::from_single(peer_id))), &mut self.network, ) } diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index 8e8abd4fa6..100be9f4d7 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -27,7 +27,9 @@ use fnv::FnvHashMap; use lighthouse_network::rpc::methods::{ BlobsByRangeRequest, DataColumnsByRangeRequest, PayloadEnvelopesByRangeRequest, }; -use lighthouse_network::rpc::{BlocksByRangeRequest, GoodbyeReason, RPCError, RequestType}; +use lighthouse_network::rpc::{ + BlocksByRangeRequest, GoodbyeReason, MAX_CONCURRENT_REQUESTS, RPCError, RequestType, +}; pub use lighthouse_network::service::api_types::RangeRequestId; use lighthouse_network::service::api_types::{ AppRequestId, BlobsByRangeRequestId, BlocksByRangeRequestId, ComponentsByRangeRequestId, @@ -40,8 +42,8 @@ use lighthouse_network::{Client, NetworkGlobals, PeerAction, PeerId, ReportSourc use parking_lot::RwLock; pub use requests::LookupVerifyError; use requests::{ - ActiveRequests, BlobsByRangeRequestItems, BlocksByRangeRequestItems, BlocksByRootRequestItems, - DataColumnsByRangeRequestItems, DataColumnsByRootRequestItems, + ActiveRequestItems, ActiveRequests, BlobsByRangeRequestItems, BlocksByRangeRequestItems, + BlocksByRootRequestItems, DataColumnsByRangeRequestItems, DataColumnsByRootRequestItems, PayloadEnvelopesByRangeRequestItems, PayloadEnvelopesByRootRequestItems, }; #[cfg(test)] @@ -50,7 +52,6 @@ use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::sync::Arc; -use std::time::Duration; #[cfg(test)] use task_executor::TaskExecutor; use tokio::sync::mpsc; @@ -81,25 +82,48 @@ pub const MAX_COLUMN_RETRIES: usize = 3; #[derive(Debug)] pub enum RpcEvent { StreamTermination, - Response(T, Duration), + Response(T), RPCError(RPCError), } impl RpcEvent { - pub fn from_chunk(chunk: Option, seen_timestamp: Duration) -> Self { + pub fn from_chunk(chunk: Option) -> Self { match chunk { - Some(item) => RpcEvent::Response(item, seen_timestamp), + Some(item) => RpcEvent::Response(item), None => RpcEvent::StreamTermination, } } } -pub type RpcResponseResult = Result<(T, Duration), RpcResponseError>; +pub type RpcResponseResult = Result; -/// Duration = latest seen timestamp of all received data columns pub type CustodyByRootResult = Result>, RpcResponseError>; +/// Per-peer count of active requests for a single protocol, to keep peer selection within +/// `MAX_CONCURRENT_REQUESTS` concurrent requests per protocol ID. +struct ActiveRequestsPerPeer { + count_by_peer: HashMap, +} + +impl ActiveRequestsPerPeer { + fn new(requests: &ActiveRequests) -> Self + where + K: Copy + Eq + std::hash::Hash + std::fmt::Display, + T: ActiveRequestItems, + { + let mut count_by_peer = HashMap::::new(); + for peer_id in requests.iter_request_peers() { + *count_by_peer.entry(peer_id).or_default() += 1; + } + Self { count_by_peer } + } + + fn at_concurrency_limit(&self, peer_id: &PeerId) -> bool { + self.count_by_peer.get(peer_id).copied().unwrap_or(0) >= MAX_CONCURRENT_REQUESTS + } +} + #[derive(Debug)] #[allow(private_interfaces)] pub enum RpcResponseError { @@ -397,9 +421,9 @@ impl SyncNetworkContext { .collect() } - pub fn get_custodial_peers(&self, column_index: ColumnIndex) -> Vec { + pub fn get_custodial_peers(&self, column_index: ColumnIndex, block_slot: Slot) -> Vec { self.network_globals() - .custody_peers_for_column(column_index) + .custody_peers_for_column(column_index, block_slot) } pub fn network_globals(&self) -> &NetworkGlobals { @@ -440,47 +464,6 @@ impl SyncNetworkContext { } } - fn active_request_count_by_peer(&self) -> HashMap { - let Self { - network_send: _, - request_id: _, - blocks_by_root_requests, - payload_envelopes_by_root_requests, - data_columns_by_root_requests, - blocks_by_range_requests, - blobs_by_range_requests, - data_columns_by_range_requests, - payload_envelopes_by_range_requests, - // custody_by_root_requests is a meta request of data_columns_by_root_requests - custody_by_root_requests: _, - // components_by_range_requests is a meta request of various _by_range requests - components_by_range_requests: _, - custody_backfill_data_column_batch_requests: _, - execution_engine_state: _, - network_beacon_processor: _, - chain: _, - fork_context: _, - // Don't use a fallback match. We want to be sure that all requests are considered when - // adding new ones - } = self; - - let mut active_request_count_by_peer = HashMap::::new(); - - for peer_id in blocks_by_root_requests - .iter_request_peers() - .chain(payload_envelopes_by_root_requests.iter_request_peers()) - .chain(data_columns_by_root_requests.iter_request_peers()) - .chain(blocks_by_range_requests.iter_request_peers()) - .chain(blobs_by_range_requests.iter_request_peers()) - .chain(data_columns_by_range_requests.iter_request_peers()) - .chain(payload_envelopes_by_range_requests.iter_request_peers()) - { - *active_request_count_by_peer.entry(peer_id).or_default() += 1; - } - - active_request_count_by_peer - } - /// Retries only the specified failed columns by requesting them again. /// /// Note: This function doesn't retry the whole batch, but retries specific requests within @@ -507,8 +490,6 @@ impl SyncNetworkContext { return Err("request id not present".to_string()); }; - let active_request_count_by_peer = self.active_request_count_by_peer(); - debug!( ?failed_columns, ?id, @@ -518,12 +499,7 @@ impl SyncNetworkContext { // Attempt to find all required custody peers to request the failed columns from let columns_by_range_peers_to_request = self - .select_columns_by_range_peers_to_request( - failed_columns, - peers, - active_request_count_by_peer, - peers_to_deprioritize, - ) + .select_columns_by_range_peers_to_request(failed_columns, peers, peers_to_deprioritize) .map_err(|e| format!("{:?}", e))?; // Reuse the id for the request that received partially correct responses @@ -581,7 +557,7 @@ impl SyncNetworkContext { column_peers = column_peers.len() ); let _guard = range_request_span.clone().entered(); - let active_request_count_by_peer = self.active_request_count_by_peer(); + let blocks_by_range_per_peer = ActiveRequestsPerPeer::new(&self.blocks_by_range_requests); let Some(block_peer) = block_peers .iter() @@ -589,8 +565,8 @@ impl SyncNetworkContext { ( // If contains -> 1 (order after), not contains -> 0 (order first) peers_to_deprioritize.contains(peer), - // Prefer peers with less overall requests - active_request_count_by_peer.get(peer).copied().unwrap_or(0), + // Strictly de-prioritize peers already at the per-protocol concurrency limit + blocks_by_range_per_peer.at_concurrency_limit(peer), // Random factor to break ties, otherwise the PeerID breaks ties rand::random::(), peer, @@ -613,6 +589,7 @@ impl SyncNetworkContext { let epoch = Slot::new(*request.start_slot()).epoch(T::EthSpec::slots_per_epoch()); let column_indexes = self .chain + .custody_context .sampling_columns_for_epoch(epoch) .iter() .cloned() @@ -620,7 +597,6 @@ impl SyncNetworkContext { Some(self.select_columns_by_range_peers_to_request( &column_indexes, column_peers, - active_request_count_by_peer, peers_to_deprioritize, )?) } else { @@ -692,6 +668,9 @@ impl SyncNetworkContext { let payloads_req_id = if matches!(batch_type, ByRangeRequestType::BlocksAndEnvelopesAndColumns) { Some(self.send_payload_envelopes_by_range_request( + // Peer selection: for a given peer, the count of sent blocks_by_range requests + // equals the count of sent payloads_by_range requests. So we are under the + // concurrency limit for payloads_by_range requests block_peer, PayloadEnvelopesByRangeRequest { start_slot: *request.start_slot(), @@ -716,7 +695,10 @@ impl SyncNetworkContext { data_column_requests.map(|data_column_requests| { ( data_column_requests, - self.chain.sampling_columns_for_epoch(epoch).to_vec(), + self.chain + .custody_context + .sampling_columns_for_epoch(epoch) + .to_vec(), ) }), payloads_req_id, @@ -731,10 +713,11 @@ impl SyncNetworkContext { &self, custody_indexes: &HashSet, peers: &HashSet, - active_request_count_by_peer: HashMap, peers_to_deprioritize: &HashSet, ) -> Result>, RpcRequestSendError> { let mut columns_to_request_by_peer = HashMap::>::new(); + let data_columns_by_range_per_peer = + ActiveRequestsPerPeer::new(&self.data_columns_by_range_requests); for column_index in custody_indexes { // Strictly consider peers that are custodials of this column AND are part of this @@ -750,12 +733,10 @@ impl SyncNetworkContext { ( // If contains -> 1 (order after), not contains -> 0 (order first) peers_to_deprioritize.contains(peer), - // Prefer peers with less overall requests - // Also account for requests that are not yet issued tracked in peer_id_to_request_map - // We batch requests to the same peer, so count existance in the - // `columns_to_request_by_peer` as a single 1 request. - active_request_count_by_peer.get(peer).copied().unwrap_or(0) - + columns_to_request_by_peer.get(peer).map(|_| 1).unwrap_or(0), + // Strictly de-prioritize peers already at the per-protocol concurrency limit + // Note: do not account for to-be-sent requests on + // `data_columns_by_range_by_peer` as we always send at most one request + data_columns_by_range_per_peer.at_concurrency_limit(peer), // Random factor to break ties, otherwise the PeerID breaks ties rand::random::(), peer, @@ -797,14 +778,14 @@ impl SyncNetworkContext { if let Err(e) = { let request = entry.get_mut(); match range_block_component { - RangeBlockComponent::Block(req_id, resp) => resp.and_then(|(blocks, _)| { + RangeBlockComponent::Block(req_id, resp) => resp.and_then(|blocks| { request.add_blocks(req_id, blocks).map_err(|e| { RpcResponseError::BlockComponentCouplingError(CouplingError::InternalError( e, )) }) }), - RangeBlockComponent::Blob(req_id, resp) => resp.and_then(|(blobs, _)| { + RangeBlockComponent::Blob(req_id, resp) => resp.and_then(|blobs| { request.add_blobs(req_id, blobs).map_err(|e| { RpcResponseError::BlockComponentCouplingError(CouplingError::InternalError( e, @@ -812,7 +793,7 @@ impl SyncNetworkContext { }) }), RangeBlockComponent::CustodyColumns(req_id, resp) => { - resp.and_then(|(custody_columns, _)| { + resp.and_then(|custody_columns| { request .add_custody_columns(req_id, custody_columns) .map_err(|e| { @@ -822,17 +803,15 @@ impl SyncNetworkContext { }) }) } - RangeBlockComponent::PayloadEnvelope(req_id, resp) => { - resp.and_then(|(envelopes, _)| { - request - .add_payload_envelopes(req_id, envelopes) - .map_err(|e| { - RpcResponseError::BlockComponentCouplingError( - CouplingError::InternalError(e), - ) - }) - }) - } + RangeBlockComponent::PayloadEnvelope(req_id, resp) => resp.and_then(|envelopes| { + request + .add_payload_envelopes(req_id, envelopes) + .map_err(|e| { + RpcResponseError::BlockComponentCouplingError( + CouplingError::InternalError(e), + ) + }) + }), } } { entry.remove(); @@ -840,10 +819,9 @@ impl SyncNetworkContext { } let range_req = entry.get_mut(); - if let Some(blocks_result) = range_req.responses( - self.chain.data_availability_checker.clone(), - self.chain.spec.clone(), - ) { + if let Some(blocks_result) = + range_req.responses(&self.chain.custody_context, self.chain.spec.clone()) + { if let Err(CouplingError::DataColumnPeerFailure { error, faulty_peers: _, @@ -881,14 +859,14 @@ impl SyncNetworkContext { lookup_peers: Arc>>, block_root: Hash256, ) -> Result>>, RpcRequestSendError> { - let active_request_count_by_peer = self.active_request_count_by_peer(); + let blocks_by_root_per_peer = ActiveRequestsPerPeer::new(&self.blocks_by_root_requests); let Some(peer_id) = lookup_peers .read() .iter() .map(|peer| { ( - // Prefer peers with less overall requests - active_request_count_by_peer.get(peer).copied().unwrap_or(0), + // Strictly de-prioritize peers already at the per-protocol concurrency limit + blocks_by_root_per_peer.at_concurrency_limit(peer), // Random factor to break ties, otherwise the PeerID breaks ties rand::random::(), peer, @@ -1001,13 +979,15 @@ impl SyncNetworkContext { )); } - let active_request_count_by_peer = self.active_request_count_by_peer(); + let payload_envelopes_by_root_per_peer = + ActiveRequestsPerPeer::new(&self.payload_envelopes_by_root_requests); let Some(peer_id) = lookup_peers .read() .iter() .map(|peer| { ( - active_request_count_by_peer.get(peer).copied().unwrap_or(0), + // Strictly de-prioritize peers already at the per-protocol concurrency limit + payload_envelopes_by_root_per_peer.at_concurrency_limit(peer), rand::random::(), peer, ) @@ -1131,6 +1111,7 @@ impl SyncNetworkContext { // Include only the blob indexes not yet imported (received through gossip) let mut custody_indexes_to_fetch = self .chain + .custody_context .sampling_columns_for_epoch(block_slot.epoch(T::EthSpec::slots_per_epoch())) .iter() .copied() @@ -1161,6 +1142,7 @@ impl SyncNetworkContext { let requester = CustodyRequester(id); let mut request = ActiveCustodyRequest::new( block_root, + block_slot, CustodyId { requester }, &custody_indexes_to_fetch, lookup_peers, @@ -1446,15 +1428,11 @@ impl SyncNetworkContext { ByRangeRequestType::BlocksAndEnvelopesAndColumns } else if self .chain - .data_availability_checker + .custody_context .data_columns_required_for_epoch(epoch) { ByRangeRequestType::BlocksAndColumns - } else if self - .chain - .data_availability_checker - .blobs_required_for_epoch(epoch) - { + } else if self.chain.custody_context.blobs_required_for_epoch(epoch) { ByRangeRequestType::BlocksAndBlobs } else { ByRangeRequestType::Blocks @@ -1497,11 +1475,11 @@ impl SyncNetworkContext { ) -> Option>>> { let resp = self.blocks_by_root_requests.on_response(id, rpc_event); let resp = resp.map(|res| { - res.and_then(|(mut blocks, seen_timestamp)| { + res.and_then(|mut blocks| { // Enforce that exactly one chunk = one block is returned. ReqResp behavior limits the // response count to at most 1. match blocks.pop() { - Some(block) => Ok((block, seen_timestamp)), + Some(block) => Ok(block), // Should never happen, `blocks_by_root_requests` enforces that we receive at least // 1 chunk. None => Err(LookupVerifyError::NotEnoughResponsesReturned { actual: 0 }.into()), @@ -1521,9 +1499,9 @@ impl SyncNetworkContext { .payload_envelopes_by_root_requests .on_response(id, rpc_event); let resp = resp.map(|res| { - res.and_then(|(mut envelopes, seen_timestamp)| { + res.and_then(|mut envelopes| { match envelopes.pop() { - Some(envelope) => Ok((envelope, seen_timestamp)), + Some(envelope) => Ok(envelope), // Should never happen, we enforce at least 1 chunk. None => Err(LookupVerifyError::NotEnoughResponsesReturned { actual: 0 }.into()), } @@ -1664,7 +1642,6 @@ impl SyncNetworkContext { id: Id, block_root: Hash256, block: Arc>, - seen_timestamp: Duration, ) -> Result<(), SendErrorProcessor> { let beacon_processor = self .beacon_processor_if_enabled() @@ -1679,7 +1656,6 @@ impl SyncNetworkContext { .send_lookup_beacon_block( block_root, lookup_block, - seen_timestamp, BlockProcessType::SingleBlock { id }, ) .map_err(|e| { @@ -1695,7 +1671,6 @@ impl SyncNetworkContext { &self, block_root: Hash256, envelope: Arc>, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), SendErrorProcessor> { let beacon_processor = self @@ -1709,7 +1684,7 @@ impl SyncNetworkContext { ); beacon_processor - .send_lookup_envelope(block_root, envelope, seen_timestamp, process_type) + .send_lookup_envelope(block_root, envelope, process_type) .map_err(|e| { error!( error = ?e, @@ -1724,7 +1699,6 @@ impl SyncNetworkContext { _id: Id, block_root: Hash256, custody_columns: DataColumnSidecarList, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), SendErrorProcessor> { let beacon_processor = self @@ -1738,7 +1712,7 @@ impl SyncNetworkContext { ); beacon_processor - .send_rpc_custody_columns(block_root, custody_columns, seen_timestamp, process_type) + .send_rpc_custody_columns(block_root, custody_columns, process_type) .map_err(|e| { error!( error = ?e, @@ -1756,11 +1730,11 @@ impl SyncNetworkContext { peers: &HashSet, peers_to_deprioritize: &HashSet, ) -> Result { - let active_request_count_by_peer = self.active_request_count_by_peer(); // Attempt to find all required custody peers before sending any request or creating an ID let columns_by_range_peers_to_request = { let column_indexes = self .chain + .custody_context .sampling_columns_for_epoch(batch_id.epoch) .iter() .cloned() @@ -1769,7 +1743,6 @@ impl SyncNetworkContext { self.select_columns_by_range_peers_to_request( &column_indexes, peers, - active_request_count_by_peer, peers_to_deprioritize, )? }; @@ -1826,7 +1799,7 @@ impl SyncNetworkContext { if let Err(e) = { let request = entry.get_mut(); - data_columns.and_then(|(data_columns, _)| { + data_columns.and_then(|data_columns| { request .add_custody_columns(req_id, data_columns.clone()) .map_err(|e| { diff --git a/beacon_node/network/src/sync/network_context/custody.rs b/beacon_node/network/src/sync/network_context/custody.rs index b1a4b52867..29cb0a22e5 100644 --- a/beacon_node/network/src/sync/network_context/custody.rs +++ b/beacon_node/network/src/sync/network_context/custody.rs @@ -7,21 +7,23 @@ use fnv::FnvHashMap; use lighthouse_network::PeerId; use lighthouse_network::service::api_types::{CustodyId, DataColumnsByRootRequester}; use parking_lot::RwLock; -use slot_clock::SlotClock; use std::collections::HashSet; use std::hash::{BuildHasher, RandomState}; use std::time::{Duration, Instant}; use std::{collections::HashMap, marker::PhantomData, sync::Arc}; use tracing::{Span, debug, debug_span, warn}; -use types::{DataColumnSidecar, Hash256, data::ColumnIndex}; +use types::{DataColumnSidecar, Hash256, Slot, data::ColumnIndex}; use types::{DataColumnSidecarList, EthSpec}; -use super::{LookupRequestResult, PeerGroup, RpcResponseResult, SyncNetworkContext}; +use super::{ + ActiveRequestsPerPeer, LookupRequestResult, PeerGroup, RpcResponseResult, SyncNetworkContext, +}; const MAX_STALE_NO_PEERS_DURATION: Duration = Duration::from_secs(30); pub struct ActiveCustodyRequest { block_root: Hash256, + block_slot: Slot, custody_id: CustodyId, /// List of column indices this request needs to download to complete successfully column_requests: FnvHashMap>, @@ -62,6 +64,7 @@ pub type CustodyRequestResult = Result ActiveCustodyRequest { pub(crate) fn new( block_root: Hash256, + block_slot: Slot, custody_id: CustodyId, column_indices: &[ColumnIndex], lookup_peers: Arc>>, @@ -73,6 +76,7 @@ impl ActiveCustodyRequest { ); Self { block_root, + block_slot, custody_id, column_requests: HashMap::from_iter( column_indices @@ -114,7 +118,7 @@ impl ActiveCustodyRequest { let _guard = batch_request.span.clone().entered(); match resp { - Ok((data_columns, seen_timestamp)) => { + Ok(data_columns) => { debug!( block_root = ?self.block_root, %req_id, @@ -139,12 +143,7 @@ impl ActiveCustodyRequest { .ok_or(Error::BadState("unknown column_index".to_owned()))?; if let Some(data_column) = data_columns.remove(column_index) { - column_request.on_download_success( - req_id, - peer_id, - data_column, - seen_timestamp, - )?; + column_request.on_download_success(req_id, peer_id, data_column)?; } else { // Peer does not have the requested data. // TODO(das) do not consider this case a success. We know for sure the block has @@ -208,33 +207,24 @@ impl ActiveCustodyRequest { if completed_requests >= total_requests { // All requests have completed successfully. let mut peers = HashMap::>::new(); - let mut seen_timestamps = vec![]; let columns = std::mem::take(&mut self.column_requests) .into_values() .map(|request| { - let (peer, data_column, seen_timestamp) = request.complete()?; + let (peer, data_column) = request.complete()?; peers .entry(peer) .or_default() .push(*data_column.index() as usize); - seen_timestamps.push(seen_timestamp); Ok(data_column) }) .collect::, _>>()?; let peer_group = PeerGroup::from_set(peers); - let max_seen_timestamp = seen_timestamps - .into_iter() - .max() - .unwrap_or_else(|| cx.chain.slot_clock.now_duration().unwrap_or_default()); - return Ok(Some(DownloadResult::new( - columns, - peer_group, - max_seen_timestamp, - ))); + return Ok(Some(DownloadResult::new(columns, peer_group))); } - let active_request_count_by_peer = cx.active_request_count_by_peer(); + let data_columns_by_root_per_peer = + ActiveRequestsPerPeer::new(&cx.data_columns_by_root_requests); let mut columns_to_request_by_peer = HashMap::>::new(); let mut columns_without_peers = vec![]; let lookup_peers = self.lookup_peers.read(); @@ -252,7 +242,7 @@ impl ActiveCustodyRequest { let peer_to_request = self.select_column_peer( cx, - &active_request_count_by_peer, + &data_columns_by_root_per_peer, &lookup_peers, *column_index, &random_state, @@ -357,7 +347,7 @@ impl ActiveCustodyRequest { fn select_column_peer( &self, cx: &mut SyncNetworkContext, - active_request_count_by_peer: &HashMap, + data_columns_by_root_per_peer: &ActiveRequestsPerPeer, lookup_peers: &HashSet, column_index: ColumnIndex, random_state: &RandomState, @@ -365,7 +355,7 @@ impl ActiveCustodyRequest { // We draw from the total set of peers, but prioritize those peers who we have // received an attestation or a block from (`lookup_peers`), as the `lookup_peers` may take // time to build up and we are likely to not find any column peers initially. - let custodial_peers = cx.get_custodial_peers(column_index); + let custodial_peers = cx.get_custodial_peers(column_index, self.block_slot); let mut prioritized_peers = custodial_peers .iter() .filter(|peer| { @@ -374,12 +364,12 @@ impl ActiveCustodyRequest { }) .map(|peer| { ( + // Strictly de-prioritize peers already at the per-protocol concurrency limit + data_columns_by_root_per_peer.at_concurrency_limit(peer), // Prioritize peers that claim to know have imported this block if lookup_peers.contains(peer) { 0 } else { 1 }, // De-prioritize peers that we have already attempted to download from self.peer_attempts.get(peer).copied().unwrap_or(0), - // Prefer peers with fewer requests to load balance across peers. - active_request_count_by_peer.get(peer).copied().unwrap_or(0), // The hash ensures consistent peer ordering within this request // to avoid fragmentation while varying selection across different requests. random_state.hash_one(peer), @@ -410,7 +400,7 @@ struct ColumnRequest { enum Status { NotStarted(Instant), Downloading(DataColumnsByRootRequestId), - Downloaded(PeerId, Arc>, Duration), + Downloaded(PeerId, Arc>), } impl ColumnRequest { @@ -479,7 +469,6 @@ impl ColumnRequest { req_id: DataColumnsByRootRequestId, peer_id: PeerId, data_column: Arc>, - seen_timestamp: Duration, ) -> Result<(), Error> { match &self.status { Status::Downloading(expected_req_id) => { @@ -489,7 +478,7 @@ impl ColumnRequest { req_id, }); } - self.status = Status::Downloaded(peer_id, data_column, seen_timestamp); + self.status = Status::Downloaded(peer_id, data_column); Ok(()) } other => Err(Error::BadState(format!( @@ -498,11 +487,9 @@ impl ColumnRequest { } } - fn complete(self) -> Result<(PeerId, Arc>, Duration), Error> { + fn complete(self) -> Result<(PeerId, Arc>), Error> { match self.status { - Status::Downloaded(peer_id, data_column, seen_timestamp) => { - Ok((peer_id, data_column, seen_timestamp)) - } + Status::Downloaded(peer_id, data_column) => Ok((peer_id, data_column)), other => Err(Error::BadState(format!( "bad state complete expected Downloaded got {other:?}" ))), diff --git a/beacon_node/network/src/sync/network_context/requests.rs b/beacon_node/network/src/sync/network_context/requests.rs index cc74785098..b340064746 100644 --- a/beacon_node/network/src/sync/network_context/requests.rs +++ b/beacon_node/network/src/sync/network_context/requests.rs @@ -3,7 +3,6 @@ use std::{collections::hash_map::Entry, hash::Hash}; use fnv::FnvHashMap; use lighthouse_network::PeerId; -use slot_clock::timestamp_now; use strum::IntoStaticStr; use tracing::{Span, debug}; use types::{Hash256, Slot}; @@ -122,7 +121,7 @@ impl ActiveReque let result = match rpc_event { // Handler of a success ReqResp chunk. Adds the item to the request accumulator. // `ActiveRequestItems` validates the item before appending to its internal state. - RpcEvent::Response(item, seen_timestamp) => { + RpcEvent::Response(item) => { let request = &mut entry.get_mut(); let _guard = request.span.clone().entered(); match &mut request.state { @@ -133,7 +132,7 @@ impl ActiveReque Ok(true) => { let items = items.consume(); request.state = State::CompletedEarly; - Some(Ok((items, seen_timestamp, request.start_instant.elapsed()))) + Some(Ok((items, request.start_instant.elapsed()))) } // Received item, but we are still expecting more Ok(false) => None, @@ -170,11 +169,7 @@ impl ActiveReque } .into())) } else { - Some(Ok(( - items.consume(), - timestamp_now(), - request.start_instant.elapsed(), - ))) + Some(Ok((items.consume(), request.start_instant.elapsed()))) } } // Items already returned, ignore stream termination @@ -202,7 +197,7 @@ impl ActiveReque }; result.map(|result| match result { - Ok((items, seen_timestamp, duration)) => { + Ok((items, duration)) => { metrics::inc_counter_vec(&metrics::SYNC_RPC_REQUEST_SUCCESSES, &[self.name]); metrics::observe_timer_vec(&metrics::SYNC_RPC_REQUEST_TIME, &[self.name], duration); debug!( @@ -212,7 +207,7 @@ impl ActiveReque "Sync RPC request completed" ); - Ok((items, seen_timestamp)) + Ok(items) } Err(e) => { let err_str: &'static str = match &e { diff --git a/beacon_node/network/src/sync/range_sync/chain.rs b/beacon_node/network/src/sync/range_sync/chain.rs index 6292388339..e82bfa8a1a 100644 --- a/beacon_node/network/src/sync/range_sync/chain.rs +++ b/beacon_node/network/src/sync/range_sync/chain.rs @@ -958,6 +958,9 @@ impl SyncingChain { CouplingError::InternalError(msg) => { error!(?batch_id, msg, "Block components coupling internal error"); } + CouplingError::AvailabilityCheckError(err) => { + error!(?batch_id, ?err, "Availability check error"); + } } } // A batch could be retried without the peer failing the request (disconnecting/ diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index 13eeaee9aa..f9afb910d9 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -43,7 +43,16 @@ use types::{ SignedExecutionPayloadEnvelope, Slot, }; -const D: Duration = Duration::new(0, 0); +/// Extract the Gloas payload envelope (if any) carried by a stored `RangeSyncBlock`. +fn envelope_of(block: &RangeSyncBlock) -> Option>> { + match block { + RangeSyncBlock::Gloas { + envelope: Some(envelope), + .. + } => Some(envelope.envelope().clone()), + _ => None, + } +} /// Gloas genesis needs enough validators to populate `proposer_lookahead`. const TEST_RIG_VALIDATOR_COUNT: usize = 8; @@ -331,7 +340,6 @@ impl TestRig { fork_name, network_blocks_by_root: <_>::default(), network_blocks_by_slot: <_>::default(), - network_envelopes_by_root: <_>::default(), penalties: <_>::default(), seen_lookups: <_>::default(), requests: <_>::default(), @@ -669,7 +677,10 @@ impl TestRig { if self.complete_strategy.hold_envelope_for_block == Some(block_root) { return; } - let envelope = self.network_envelopes_by_root.get(&block_root).cloned(); + let envelope = self + .network_blocks_by_root + .get(&block_root) + .and_then(envelope_of); self.send_rpc_envelope_response(req_id, peer_id, envelope); } @@ -843,7 +854,7 @@ impl TestRig { if self.complete_strategy.hold_envelope_for_block == Some(block_root) { return None; } - self.network_envelopes_by_root.get(&block_root).cloned() + envelope_of(block) }) .collect::>(); self.send_rpc_envelopes_response(req_id, peer_id, &envelopes); @@ -873,14 +884,12 @@ impl TestRig { sync_request_id, peer_id, beacon_block: Some(block.clone()), - seen_timestamp: D, }); } self.push_sync_message(SyncMessage::RpcBlock { sync_request_id, peer_id, beacon_block: None, - seen_timestamp: D, }); } @@ -904,14 +913,12 @@ impl TestRig { sync_request_id, peer_id, blob_sidecar: Some(blob.clone()), - seen_timestamp: D, }); } self.push_sync_message(SyncMessage::RpcBlob { sync_request_id, peer_id, blob_sidecar: None, - seen_timestamp: D, }); } @@ -940,14 +947,12 @@ impl TestRig { sync_request_id, peer_id, data_column: Some(column.clone()), - seen_timestamp: D, }); } self.push_sync_message(SyncMessage::RpcDataColumn { sync_request_id, peer_id, data_column: None, - seen_timestamp: D, }); } @@ -966,14 +971,12 @@ impl TestRig { sync_request_id, peer_id, envelope: envelope.clone(), - seen_timestamp: D, }); // Stream termination self.push_sync_message(SyncMessage::RpcPayloadEnvelope { sync_request_id, peer_id, envelope: None, - seen_timestamp: D, }); } @@ -993,7 +996,6 @@ impl TestRig { sync_request_id, peer_id, envelope: Some(envelope.clone()), - seen_timestamp: D, }); } // Stream termination @@ -1001,7 +1003,6 @@ impl TestRig { sync_request_id, peer_id, envelope: None, - seen_timestamp: D, }); } @@ -1057,13 +1058,7 @@ impl TestRig { .await; let block = external_harness.get_full_block(&block_root); let block_slot = block.slot(); - self.insert_external_block( - block, - external_harness - .chain - .get_payload_envelope(&block_root) - .unwrap(), - ); + self.insert_external_block(block); blocks.push((block_slot, block_root)); } @@ -1171,8 +1166,7 @@ impl TestRig { // Cache every block through the single `get_full_block` + `insert_external_block2` path. for root in [g_root, a_root, c_root, b_root] { let block = external_harness.get_full_block(&root); - let envelope = external_harness.chain.get_payload_envelope(&root).unwrap(); - self.insert_external_block(block, envelope); + self.insert_external_block(block); } self.harness.set_current_slot(child_slot); @@ -1200,21 +1194,12 @@ impl TestRig { Some((r, fork)) } - fn insert_external_block( - &mut self, - block: RangeSyncBlock, - envelope: Option>, - ) { + fn insert_external_block(&mut self, block: RangeSyncBlock) { let block_root = block.canonical_root(); let block_slot = block.slot(); self.network_blocks_by_root .insert(block_root, block.clone()); self.network_blocks_by_slot.insert(block_slot, block); - // Cache Gloas envelopes for lookup RPCs. - if let Some(envelope) = envelope { - self.network_envelopes_by_root - .insert(block_root, envelope.into()); - } self.log(&format!( "Produced block {block_root:?} slot {block_slot} in external harness", )); @@ -1297,30 +1282,34 @@ impl TestRig { ) { let block_root = block.canonical_root(); let block_slot = block.slot(); - let range_sync_block = if block.fork_name_unchecked().gloas_enabled() { - // Gloas carries data columns in the payload envelope, not in `block_data`. - let envelope = self - .network_envelopes_by_root - .get(&block_root) - .cloned() - .map(|envelope| AvailableEnvelope::new(envelope, columns.unwrap_or_default())); - RangeSyncBlock::new_gloas(block, envelope).unwrap() - } else { - let block_data = if let Some(columns) = columns { - AvailableBlockData::new_with_data_columns(columns) - } else if let Some(blobs) = blobs { - AvailableBlockData::new_with_blobs(blobs) + let range_sync_block = + if let Ok(bid) = block.message().body().signed_execution_payload_bid() { + // Gloas carries data columns in the payload envelope, not in `block_data`. + let envelope = self + .network_blocks_by_root + .get(&block_root) + .and_then(envelope_of) + .map(|envelope| { + AvailableEnvelope::new( + envelope, + columns.unwrap_or_default(), + bid, + &self.harness.chain.custody_context, + ) + }) + .transpose() + .unwrap(); + RangeSyncBlock::new_gloas(block, envelope).unwrap() } else { - AvailableBlockData::NoData + let block_data = if let Some(columns) = columns { + AvailableBlockData::new_with_data_columns(columns) + } else if let Some(blobs) = blobs { + AvailableBlockData::new_with_blobs(blobs) + } else { + AvailableBlockData::NoData + }; + RangeSyncBlock::new(block, block_data, &self.harness.chain.custody_context).unwrap() }; - RangeSyncBlock::new( - block, - block_data, - &self.harness.chain.data_availability_checker, - self.harness.chain.spec.clone(), - ) - .unwrap() - }; self.network_blocks_by_slot .insert(block_slot, range_sync_block.clone()); self.network_blocks_by_root @@ -1370,6 +1359,8 @@ impl TestRig { .unwrap_or_else(|| panic!("No block at slot {slot}")) .clone(); let block_root = rpc_block.canonical_root(); + let block_state_root = rpc_block.as_block().state_root(); + let envelope = envelope_of(&rpc_block); self.harness .chain .process_block( @@ -1381,6 +1372,18 @@ impl TestRig { ) .await .unwrap(); + // Gloas: import the payload envelope so the block counts as full for its children. + if let Some(envelope) = envelope { + let state = self + .harness + .chain + .get_state(&block_state_root, Some(Slot::new(slot)), false) + .expect("should load state") + .expect("state should exist"); + self.harness + .process_envelope(block_root, (*envelope).clone(), &state, block_state_root) + .await; + } } self.harness.chain.recompute_head_at_current_slot().await; } @@ -1495,6 +1498,17 @@ impl TestRig { panic!("Some downscore events: {:?}", self.penalties); } } + + fn assert_no_block_requests(&self) { + assert_eq!( + self.requests + .iter() + .filter(|(request, _)| matches!(request, RequestType::BlocksByRoot(_))) + .collect::>(), + Vec::<&(RequestType, AppRequestId)>::new(), + "There should be no block requests" + ); + } fn assert_failed_lookup_sync(&mut self) { assert!(self.created_lookups() > 0, "no created lookups"); assert_eq!(self.completed_lookups(), 0, "some completed lookups"); @@ -1612,9 +1626,8 @@ impl TestRig { fn custody_columns(&self) -> &[ColumnIndex] { self.harness .chain - .data_availability_checker - .custody_context() - .custody_columns_for_epoch(None, &self.harness.spec) + .custody_context + .custody_columns_for_epoch(None) } // Test setup @@ -1623,6 +1636,10 @@ impl TestRig { genesis_fork().fulu_enabled().then(Self::default) } + fn new_after_gloas() -> Option { + genesis_fork().gloas_enabled().then(Self::default) + } + pub fn new_fulu_peer_test(fulu_test_type: FuluTestType) -> Option { genesis_fork().fulu_enabled().then(|| { Self::new(TestRigConfig { @@ -1989,7 +2006,7 @@ impl TestRig { block: Arc>, ) { match self.import_block_to_da_checker(block).await { - AvailabilityProcessingStatus::Imported(_) => { + AvailabilityProcessingStatus::Imported(..) => { panic!("block removed from da_checker, available") } AvailabilityProcessingStatus::MissingComponents(_, block_root) => { @@ -2129,7 +2146,8 @@ async fn happy_path_unknown_data_parent(depth: usize) { let Some(mut r) = TestRig::new_after_fulu() else { return; }; - // No unknown-parent data-column trigger post-Gloas. + // Fulu-only: the `UnknownDataColumnParent` trigger doesn't exist post-Gloas (columns ride in + // the payload envelope, not as standalone data columns). if r.is_after_gloas() { return; } @@ -2359,10 +2377,6 @@ async fn test_single_block_lookup_ignored_response() { /// Assert that if the beacon processor returns DuplicateFullyImported, the lookup completes successfully async fn test_single_block_lookup_duplicate_response() { let mut r = TestRig::default(); - // The mock only covers block processing; Gloas also needs real envelope/column results. - if r.is_after_gloas() { - return; - } r.build_chain_and_trigger_last_block(1).await; // Send a DuplicateFullyImported response, the lookup should complete successfully r.simulate( @@ -2396,6 +2410,21 @@ async fn peer_disconnected_then_rpc_error(depth: usize) { r.assert_single_lookups_count(1); } +#[tokio::test] +/// A lookup that loses its only peer while still waiting to download the block must not report +/// itself as awaiting an event, else `drop_lookups_without_peers` skips it and it gets stuck. +/// Regression for the "Notify the devs a sync lookup is stuck" report. +async fn peerless_lookup_awaiting_download_is_not_awaiting_event() { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(1).await; + r.disconnect_all_peers(); + r.simulate(SimulateConfig::new().return_rpc_error(RPCError::Disconnected)) + .await; + let lookup = &r.active_single_lookups()[0]; + assert_eq!(lookup.peers.len(), 0); + assert!(!lookup.is_awaiting_event); +} + #[tokio::test] /// Assert that when creating multiple lookups their parent-child relation is discovered and we add /// peers recursively from child to parent. @@ -2427,10 +2456,6 @@ async fn lookups_form_chain() { /// Assert that if a lookup chain (by appending ancestors) is too long we drop it async fn test_parent_lookup_too_deep_grow_ancestor_one() { let mut r = TestRig::default(); - // TODO(gloas): range sync does not fetch payload envelopes yet. - if r.is_after_gloas() { - return; - } r.build_chain(PARENT_DEPTH_TOLERANCE + 1).await; r.trigger_with_last_block(); r.simulate(SimulateConfig::happy_path()).await; @@ -2575,13 +2600,13 @@ async fn test_same_chain_race_condition() { } #[tokio::test] -/// Assert that if the lookup's block is in the da_checker we don't download it again -async fn block_in_da_checker_skips_download() { - // Only post-Fulu, as the block needs custody columns to remain in the da_checker +/// Assert that if the lookup's block is in the da_checker we don't download it again (pre-Gloas). +async fn block_in_da_checker_skips_download_fulu() { + // Only post-Fulu, as the block needs custody columns to remain in the da_checker. let Some(mut r) = TestRig::new_after_fulu() else { return; }; - // TODO(gloas): the helper does not populate the envelope missing-component path yet. + // Pre-Gloas only; the Gloas equivalent is `block_in_da_checker_skips_download_gloas`. if r.is_after_gloas() { return; } @@ -2594,14 +2619,28 @@ async fn block_in_da_checker_skips_download() { r.trigger_with_block_at_slot(1); r.simulate(SimulateConfig::happy_path()).await; r.assert_successful_lookup_sync(); - assert_eq!( - r.requests - .iter() - .filter(|(request, _)| matches!(request, RequestType::BlocksByRoot(_))) - .collect::>(), - Vec::<&(RequestType, AppRequestId)>::new(), - "There should be no block requests" - ); + r.assert_no_block_requests(); +} + +#[tokio::test] +/// Assert that if the lookup's block is in the da_checker we don't download it again (Gloas). +async fn block_in_da_checker_skips_download_gloas() { + let Some(mut r) = TestRig::new_after_gloas() else { + return; + }; + // A Gloas block carries no inline DA, so a lone block never sits in the da_checker awaiting + // components: only a FULL *child* proves the block published a payload and supplies the peers + // that serve its columns/envelope. Build a parent + FULL child, insert the PARENT into the + // da_checker, then trigger via the child (which is provided by the trigger, not downloaded). + // The parent lookup must then skip the parent's block download. + r.build_chain(2).await; + let parent = r.block_at_slot(1); + let child = r.block_at_slot(2); + r.import_block_to_da_checker(parent).await; + r.trigger_unknown_parent_blocks_from_all_peers(&[child]); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); + r.assert_no_block_requests(); } macro_rules! fulu_peer_matrix_tests { diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index 2f318bfb9a..4e185cc081 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -21,7 +21,7 @@ use tokio::sync::mpsc; use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; -use types::{ForkName, Hash256, MinimalEthSpec as E, SignedExecutionPayloadEnvelope, Slot}; +use types::{ForkName, Hash256, MinimalEthSpec as E, Slot}; mod lookups; mod range; @@ -77,10 +77,6 @@ struct TestRig { /// Blocks that will be used in the test but may not be known to `harness` yet. network_blocks_by_root: HashMap>, network_blocks_by_slot: HashMap>, - /// Gloas execution payload envelopes keyed by block root, populated during `build_chain` - /// from the external harness store. The rig serves these when a lookup issues a - /// `PayloadEnvelopesByRoot` request. - network_envelopes_by_root: HashMap>>, penalties: Vec, /// All seen lookups through the test run seen_lookups: HashMap, diff --git a/beacon_node/network/src/sync/tests/range.rs b/beacon_node/network/src/sync/tests/range.rs index e6890cf242..1499ae5016 100644 --- a/beacon_node/network/src/sync/tests/range.rs +++ b/beacon_node/network/src/sync/tests/range.rs @@ -34,13 +34,6 @@ use types::{Epoch, EthSpec, Hash256, MinimalEthSpec as E, Slot}; const SLOTS_PER_EPOCH: usize = 8; impl TestRig { - /// Range sync doesn't yet ingest Gloas blocks in these tests: the range harness doesn't serve - /// payload envelopes, so a Gloas block never becomes fully available and sync can't complete. - /// Skip the affected completion tests under a Gloas genesis. TODO(gloas): support range sync. - fn skip_range_sync_under_gloas(&self) -> bool { - self.fork_name.gloas_enabled() - } - fn add_head_peer(&mut self) -> PeerId { let local_info = self.local_info(); self.add_supernode_peer(SyncInfo { @@ -267,9 +260,6 @@ impl TestRig { #[tokio::test] async fn head_sync_completes() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_head_sync().await; r.simulate(SimulateConfig::happy_path()).await; r.assert_head_sync_completed(); @@ -281,9 +271,6 @@ async fn head_sync_completes() { #[tokio::test] async fn finalized_to_head_transition() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_and_head_sync().await; r.simulate(SimulateConfig::happy_path()).await; r.assert_range_sync_completed(); @@ -295,9 +282,6 @@ async fn finalized_to_head_transition() { #[tokio::test] async fn finalized_sync_completes() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path()).await; r.assert_range_sync_completed(); @@ -309,9 +293,6 @@ async fn finalized_sync_completes() { #[tokio::test] async fn batch_rpc_error_retries() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().return_rpc_error(RPCError::UnsupportedProtocol)) .await; @@ -380,9 +361,6 @@ async fn batch_peer_returns_partial_columns_then_succeeds() { #[tokio::test] async fn batch_non_faulty_failure_retries() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().with_range_non_faulty_failures(1)) .await; @@ -394,9 +372,6 @@ async fn batch_non_faulty_failure_retries() { #[tokio::test] async fn batch_faulty_failure_redownloads() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().with_range_faulty_failures(1)) .await; @@ -453,9 +428,6 @@ async fn late_response_for_removed_chain() { #[tokio::test] async fn ee_offline_then_online_resumes_sync() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().with_ee_offline_for_n_range_responses(2)) .await; @@ -468,9 +440,6 @@ async fn ee_offline_then_online_resumes_sync() { #[tokio::test] async fn finalized_sync_with_local_head_partial() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync_with_local_head(3).await; r.simulate(SimulateConfig::happy_path()).await; r.assert_range_sync_completed(); @@ -481,9 +450,6 @@ async fn finalized_sync_with_local_head_partial() { #[tokio::test] async fn finalized_sync_with_local_head_near_target() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } let target_epochs = 5; let local_slots = (target_epochs * SLOTS_PER_EPOCH) - 1; // all blocks except last r.build_chain(target_epochs * SLOTS_PER_EPOCH).await; @@ -502,7 +468,7 @@ async fn finalized_sync_with_local_head_near_target() { #[tokio::test] async fn not_enough_custody_peers_then_peers_arrive() { let mut r = TestRig::default(); - if !r.fork_name.fulu_enabled() || r.skip_range_sync_under_gloas() { + if !r.fork_name.fulu_enabled() { return; } let remote_info = r.setup_finalized_sync_insufficient_peers().await; @@ -529,7 +495,7 @@ async fn not_enough_custody_peers_then_peers_arrive() { #[tokio::test] async fn finalized_sync_not_enough_custody_peers_resume_after_peer_cgc_update() { let mut r = TestRig::default(); - if !r.fork_name.fulu_enabled() || r.skip_range_sync_under_gloas() { + if !r.fork_name.fulu_enabled() { return; } diff --git a/beacon_node/operation_pool/src/lib.rs b/beacon_node/operation_pool/src/lib.rs index a1789e3b19..45563c2ee9 100644 --- a/beacon_node/operation_pool/src/lib.rs +++ b/beacon_node/operation_pool/src/lib.rs @@ -546,9 +546,15 @@ impl OperationPool { } }); + let max_attester_slashings = if state.fork_name_unchecked().electra_enabled() { + E::max_attester_slashings_electra() + } else { + E::MaxAttesterSlashings::to_usize() + }; + maximum_cover( relevant_attester_slashings, - E::MaxAttesterSlashings::to_usize(), + max_attester_slashings, "attester_slashings", ) .into_iter() @@ -927,6 +933,29 @@ mod release_tests { harness } + /// The maximum number of attester slashings allowed in a block for the state's fork. + fn max_attester_slashings(state: &BeaconState) -> usize { + if state.fork_name_unchecked().electra_enabled() { + E::max_attester_slashings_electra() + } else { + E::MaxAttesterSlashings::to_usize() + } + } + + /// Given the candidate slashings ordered most-profitable first, return the prefix that a + /// block on the state's fork would actually include (i.e. the N most profitable, where N + /// is the per-block attester slashing limit). This keeps the max-cover assertions generic + /// across forks. + fn most_profitable_slashings( + state: &BeaconState, + ordered_by_profitability: Vec, + ) -> Vec { + ordered_by_profitability + .into_iter() + .take(max_attester_slashings(state)) + .collect() + } + /// Test state for attestation-related tests. fn attestation_test_state( num_committees: usize, @@ -1594,7 +1623,10 @@ mod release_tests { op_pool.insert_attester_slashing(slashing_4.clone().validate(&state, spec).unwrap()); let best_slashings = op_pool.get_slashings_and_exits(&state, &harness.spec); - assert_eq!(best_slashings.1, vec![slashing_4, slashing_3]); + assert_eq!( + best_slashings.1, + most_profitable_slashings(&state, vec![slashing_4, slashing_3]) + ); } // Check that we get maximum coverage for attester slashings with overlapping indices @@ -1616,7 +1648,10 @@ mod release_tests { op_pool.insert_attester_slashing(slashing_4.clone().validate(&state, spec).unwrap()); let best_slashings = op_pool.get_slashings_and_exits(&state, &harness.spec); - assert_eq!(best_slashings.1, vec![slashing_1, slashing_3]); + assert_eq!( + best_slashings.1, + most_profitable_slashings(&state, vec![slashing_1, slashing_3]) + ); } // Max coverage of attester slashings taking into account proposer slashings @@ -1638,7 +1673,10 @@ mod release_tests { op_pool.insert_attester_slashing(a_slashing_3.clone().validate(&state, spec).unwrap()); let best_slashings = op_pool.get_slashings_and_exits(&state, &harness.spec); - assert_eq!(best_slashings.1, vec![a_slashing_1, a_slashing_3]); + assert_eq!( + best_slashings.1, + most_profitable_slashings(&state, vec![a_slashing_1, a_slashing_3]) + ); } //Max coverage checking that non overlapping indices are still recognized for their value @@ -1661,7 +1699,10 @@ mod release_tests { op_pool.insert_attester_slashing(slashing_3.clone().validate(&state, spec).unwrap()); let best_slashings = op_pool.get_slashings_and_exits(&state, &harness.spec); - assert_eq!(best_slashings.1, vec![slashing_1, slashing_3]); + assert_eq!( + best_slashings.1, + most_profitable_slashings(&state, vec![slashing_1, slashing_3]) + ); } // Max coverage should be affected by the overall effective balances @@ -1684,7 +1725,10 @@ mod release_tests { op_pool.insert_attester_slashing(slashing_3.clone().validate(&state, spec).unwrap()); let best_slashings = op_pool.get_slashings_and_exits(&state, &harness.spec); - assert_eq!(best_slashings.1, vec![slashing_2, slashing_3]); + assert_eq!( + best_slashings.1, + most_profitable_slashings(&state, vec![slashing_2, slashing_3]) + ); } /// End-to-end test of basic sync contribution handling. @@ -2177,6 +2221,53 @@ mod release_tests { assert_eq!(op_pool.attester_slashings.read().len(), 1); } + /// Regression test to ensure that we are using the correct spec value for max attester slashings post-Electra. + #[tokio::test] + async fn attester_slashings_capped_at_electra_limit() { + let (harness, spec) = cross_fork_harness::(); + let slots_per_epoch = MainnetEthSpec::slots_per_epoch(); + let electra_fork_epoch = spec.electra_fork_epoch.unwrap(); + let deneb_fork_epoch = spec.deneb_fork_epoch.unwrap(); + + let op_pool = OperationPool::::new(); + + harness + .extend_to_slot(electra_fork_epoch.start_slot(slots_per_epoch)) + .await; + let electra_head = harness.chain.canonical_head.cached_head().snapshot; + assert!( + electra_head + .beacon_state + .fork_name_unchecked() + .electra_enabled() + ); + + // Create two slashings + for validators in [vec![0], vec![1]] { + let slashing = harness.make_attester_slashing_with_epochs( + validators, + Some(Epoch::new(0)), + Some(deneb_fork_epoch - 1), + Some(Epoch::new(0)), + Some(deneb_fork_epoch - 1), + ); + let verified = slashing + .validate(&electra_head.beacon_state, &harness.chain.spec) + .unwrap(); + op_pool.insert_attester_slashing(verified); + } + assert_eq!(op_pool.attester_slashings.read().len(), 2); + + // Despite two valid slashings being pending, only one may be extracted post-Electra. + let mut to_be_slashed = HashSet::new(); + let attester_slashings = + op_pool.get_attester_slashings(&electra_head.beacon_state, &mut to_be_slashed); + assert_eq!( + attester_slashings.len(), + MainnetEthSpec::max_attester_slashings_electra() + ); + } + fn make_payload_attestation_message( slot: Slot, validator_index: u64, diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index f06d0045b6..3cf6a6efd2 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -390,9 +390,12 @@ pub fn cli_app() -> Command { .arg( Arg::new("enable-mplex") .long("enable-mplex") - .action(ArgAction::SetTrue) - .help_heading(FLAG_HEADER) - .help("Enables mplex multiplexer alongside yamux. Yamux is preferred when both are available.") + .value_name("BOOLEAN") + .action(ArgAction::Set) + .num_args(0..=1) + .default_value("true") + .default_missing_value("true") + .help("Enables the mplex multiplexer alongside yamux. Yamux is preferred when both are available. Enabled by default; set to \"false\" to disable.") .display_order(0) ) .arg( @@ -681,21 +684,13 @@ pub fn cli_app() -> Command { .arg( Arg::new("enable-partial-columns") .long("enable-partial-columns") + .value_name("BOOLEAN") .help("Enable partial messages for data columns. This can reduce the amount of \ - data sent over the network. Enabled by default on Hoodi and Sepolia; use \ - --disable-partial-columns to opt out.") - .action(ArgAction::SetTrue) - .help_heading(FLAG_HEADER) - .display_order(0) - ) - .arg( - Arg::new("disable-partial-columns") - .long("disable-partial-columns") - .help("Disable partial messages for data columns. Use this on Hoodi or Sepolia \ - to opt out of the default-enabled behavior.") - .action(ArgAction::SetTrue) - .conflicts_with("enable-partial-columns") - .help_heading(FLAG_HEADER) + data sent over the network. Enabled by default on Hoodi and Sepolia; set to \ + \"false\" to opt out.") + .action(ArgAction::Set) + .num_args(0..=1) + .default_missing_value("true") .display_order(0) ) /* diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index 1793dfa091..d27909bddf 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -7,7 +7,7 @@ use beacon_chain::graffiti_calculator::GraffitiOrigin; use bls::PublicKeyBytes; use clap::{ArgMatches, Id, parser::ValueSource}; use clap_utils::flags::DISABLE_MALLOC_TUNING_FLAG; -use clap_utils::{parse_flag, parse_required}; +use clap_utils::{parse_flag, parse_optional, parse_required}; use client::{ClientConfig, ClientGenesis}; use directory::{DEFAULT_BEACON_NODE_DIR, DEFAULT_NETWORK_DIR, DEFAULT_ROOT_DIR}; use environment::RuntimeContext; @@ -112,10 +112,8 @@ pub fn get_config( .config_name .as_ref() .is_some_and(|name| matches!(name.as_str(), "hoodi" | "sepolia")); - let user_disable_partial_columns = parse_flag(cli_args, "disable-partial-columns"); - let user_enable_partial_columns = parse_flag(cli_args, "enable-partial-columns"); - let enable_partial_columns = !user_disable_partial_columns - && (user_enable_partial_columns || default_partial_columns_enabled); + let enable_partial_columns = clap_utils::parse_optional(cli_args, "enable-partial-columns")? + .unwrap_or(default_partial_columns_enabled); if enable_partial_columns { // Partial messages assume that each subnet maps to exactly one column. @@ -1434,8 +1432,8 @@ pub fn set_network_config( config.disable_quic_support = true; } - if parse_flag(cli_args, "enable-mplex") { - config.enable_mplex = true; + if let Some(enable_mplex) = parse_optional(cli_args, "enable-mplex")? { + config.enable_mplex = enable_mplex; } if parse_flag(cli_args, "disable-upnp") { diff --git a/beacon_node/store/Cargo.toml b/beacon_node/store/Cargo.toml index 50028fe73f..5f810ea76b 100644 --- a/beacon_node/store/Cargo.toml +++ b/beacon_node/store/Cargo.toml @@ -16,10 +16,10 @@ directory = { workspace = true } ethereum_ssz = { workspace = true } ethereum_ssz_derive = { workspace = true } fixed_bytes = { workspace = true } +hashlink = { workspace = true } itertools = { workspace = true } leveldb = { version = "0.8.6", optional = true, default-features = false } logging = { workspace = true } -lru = { workspace = true } metrics = { workspace = true } milhouse = { workspace = true } parking_lot = { workspace = true } diff --git a/beacon_node/store/src/historic_state_cache.rs b/beacon_node/store/src/historic_state_cache.rs index e5abb04c07..02eb68ad70 100644 --- a/beacon_node/store/src/historic_state_cache.rs +++ b/beacon_node/store/src/historic_state_cache.rs @@ -1,7 +1,6 @@ use crate::hdiff::{Error, HDiffBuffer}; use crate::metrics; -use lru::LruCache; -use std::num::NonZeroUsize; +use hashlink::lru_cache::LruCache; use types::{BeaconState, ChainSpec, EthSpec, Slot}; /// Holds a combination of finalized states in two formats: @@ -25,7 +24,7 @@ pub struct Metrics { } impl HistoricStateCache { - pub fn new(hdiff_buffer_cache_size: NonZeroUsize, state_cache_size: NonZeroUsize) -> Self { + pub fn new(hdiff_buffer_cache_size: usize, state_cache_size: usize) -> Self { Self { hdiff_buffers: LruCache::new(hdiff_buffer_cache_size), states: LruCache::new(state_cache_size), @@ -47,7 +46,7 @@ impl HistoricStateCache { ); let cloned = buffer.clone(); drop(_timer); - self.hdiff_buffers.put(slot, cloned); + self.hdiff_buffers.insert(slot, cloned); Some(buffer) } else { None @@ -63,7 +62,7 @@ impl HistoricStateCache { Ok(Some(state.clone())) } else if let Some(buffer) = self.hdiff_buffers.get(&slot) { let state = buffer.as_state(spec)?; - self.states.put(slot, state.clone()); + self.states.insert(slot, state.clone()); Ok(Some(state)) } else { Ok(None) @@ -71,11 +70,11 @@ impl HistoricStateCache { } pub fn put_state(&mut self, slot: Slot, state: BeaconState) { - self.states.put(slot, state); + self.states.insert(slot, state); } pub fn put_hdiff_buffer(&mut self, slot: Slot, buffer: HDiffBuffer) { - self.hdiff_buffers.put(slot, buffer); + self.hdiff_buffers.insert(slot, buffer); } pub fn put_both(&mut self, slot: Slot, state: BeaconState, buffer: HDiffBuffer) { diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index a625a97004..7484c271ae 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -19,8 +19,8 @@ use crate::{ parse_data_column_key, }; use fixed_bytes::FixedBytesExtended; +use hashlink::lru_cache::LruCache; use itertools::{Itertools, process_results}; -use lru::LruCache; use parking_lot::{Mutex, RwLock}; use safe_arith::SafeArith; use serde::{Deserialize, Serialize}; @@ -34,7 +34,6 @@ use std::cmp::{Ordering, min}; use std::collections::{HashMap, HashSet}; use std::io::{Read, Write}; use std::marker::PhantomData; -use std::num::NonZeroUsize; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -97,7 +96,7 @@ struct BlockCache { } impl BlockCache { - pub fn new(size: NonZeroUsize) -> Self { + pub fn new(size: usize) -> Self { Self { block_cache: LruCache::new(size), blob_cache: LruCache::new(size), @@ -106,14 +105,15 @@ impl BlockCache { } } pub fn put_block(&mut self, block_root: Hash256, block: SignedBeaconBlock) { - self.block_cache.put(block_root, block); + self.block_cache.insert(block_root, block); } pub fn put_blobs(&mut self, block_root: Hash256, blobs: BlobSidecarList) { - self.blob_cache.put(block_root, blobs); + self.blob_cache.insert(block_root, blobs); } pub fn put_data_column(&mut self, block_root: Hash256, data_column: Arc>) { self.data_column_cache - .get_or_insert_mut(block_root, Default::default) + .entry(block_root) + .or_insert_with(Default::default) .insert(*data_column.index(), data_column); } pub fn put_data_column_custody_info( @@ -143,13 +143,13 @@ impl BlockCache { self.data_column_custody_info_cache.clone() } pub fn delete_block(&mut self, block_root: &Hash256) { - let _ = self.block_cache.pop(block_root); + let _ = self.block_cache.remove(block_root); } pub fn delete_blobs(&mut self, block_root: &Hash256) { - let _ = self.blob_cache.pop(block_root); + let _ = self.blob_cache.remove(block_root); } pub fn delete_data_columns(&mut self, block_root: &Hash256) { - let _ = self.data_column_cache.pop(block_root); + let _ = self.data_column_cache.remove(block_root); } pub fn delete(&mut self, block_root: &Hash256) { self.delete_block(block_root); @@ -236,17 +236,16 @@ impl HotColdDB { cold_db: MemoryStore::open(), blobs_db: MemoryStore::open(), hot_db: MemoryStore::open(), - block_cache: NonZeroUsize::new(config.block_cache_size) - .map(BlockCache::new) - .map(Mutex::new), + block_cache: (config.block_cache_size > 0) + .then(|| Mutex::new(BlockCache::new(config.block_cache_size))), state_cache: Mutex::new(StateCache::new( config.state_cache_size, config.state_cache_headroom, config.hot_hdiff_buffer_cache_size, )), historic_state_cache: Mutex::new(HistoricStateCache::new( - config.cold_hdiff_buffer_cache_size, - config.historic_state_cache_size, + config.cold_hdiff_buffer_cache_size.get(), + config.historic_state_cache_size.get(), )), config, hierarchy, @@ -290,17 +289,16 @@ impl HotColdDB { blobs_db: BeaconNodeBackend::open(&config, blobs_db_path)?, cold_db: BeaconNodeBackend::open(&config, cold_path)?, hot_db, - block_cache: NonZeroUsize::new(config.block_cache_size) - .map(BlockCache::new) - .map(Mutex::new), + block_cache: (config.block_cache_size > 0) + .then(|| Mutex::new(BlockCache::new(config.block_cache_size))), state_cache: Mutex::new(StateCache::new( config.state_cache_size, config.state_cache_headroom, config.hot_hdiff_buffer_cache_size, )), historic_state_cache: Mutex::new(HistoricStateCache::new( - config.cold_hdiff_buffer_cache_size, - config.historic_state_cache_size, + config.cold_hdiff_buffer_cache_size.get(), + config.historic_state_cache_size.get(), )), config, hierarchy, diff --git a/beacon_node/store/src/state_cache.rs b/beacon_node/store/src/state_cache.rs index 6d159c9361..9a1f2524c1 100644 --- a/beacon_node/store/src/state_cache.rs +++ b/beacon_node/store/src/state_cache.rs @@ -3,7 +3,7 @@ use crate::{ Error, metrics::{self, HOT_METRIC}, }; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use std::collections::{BTreeMap, HashMap, HashSet}; use std::num::NonZeroUsize; use tracing::instrument; @@ -86,9 +86,9 @@ impl StateCache { ) -> Self { StateCache { finalized_state: None, - states: LruCache::new(state_capacity), + states: LruCache::new(state_capacity.get()), block_map: BlockMap::default(), - hdiff_buffers: HotHDiffBufferCache::new(hdiff_capacity), + hdiff_buffers: HotHDiffBufferCache::new(hdiff_capacity.get()), max_epoch: Epoch::new(0), head_block_root: Hash256::ZERO, headroom, @@ -100,7 +100,7 @@ impl StateCache { } pub fn capacity(&self) -> usize { - self.states.cap().get() + self.states.capacity() } pub fn num_hdiff_buffers(&self) -> usize { @@ -154,7 +154,7 @@ impl StateCache { // preferences older slots. // NOTE: This isn't perfect as it prunes by slot: there could be multiple buffers // at some slots in the case of long forks without finality. - let new_hdiff_cache = HotHDiffBufferCache::new(self.hdiff_buffers.cap()); + let new_hdiff_cache = HotHDiffBufferCache::new(self.hdiff_buffers.capacity()); let old_hdiff_cache = std::mem::replace(&mut self.hdiff_buffers, new_hdiff_cache); for (state_root, (slot, buffer)) in old_hdiff_cache.hdiff_buffers { if pre_finalized_slots_to_retain.contains(&slot) { @@ -164,7 +164,7 @@ impl StateCache { // Delete states. for state_root in state_roots_to_prune { - if let Some((_, state)) = self.states.pop(&state_root) { + if let Some((_, state)) = self.states.remove(&state_root) { // Add the hdiff buffer for this state to the hdiff cache if it is now part of // the pre-finalized grid. The `put` method will take care of keeping the most // useful buffers. @@ -260,7 +260,7 @@ impl StateCache { // Insert the full state into the cache. if let Some((deleted_state_root, _)) = - self.states.put(state_root, (state_root, state.clone())) + self.states.insert(state_root, (state_root, state.clone())) { deleted_states.push(deleted_state_root); } @@ -334,14 +334,14 @@ impl StateCache { } pub fn delete_state(&mut self, state_root: &Hash256) { - self.states.pop(state_root); + self.states.remove(state_root); self.block_map.delete(state_root); } pub fn delete_block_states(&mut self, block_root: &Hash256) { if let Some(slot_map) = self.block_map.delete_block_states(block_root) { for state_root in slot_map.slots.values() { - self.states.pop(state_root); + self.states.remove(state_root); } } } @@ -366,9 +366,10 @@ impl StateCache { let mut old_boundary_state_roots = vec![]; let mut good_boundary_state_roots = vec![]; - // Skip the `cull_exempt` most-recently used, then reverse the iterator to start at - // least-recently used states. - for (&state_root, (_, state)) in self.states.iter().skip(cull_exempt).rev() { + // Start at the least-recently used states, excluding the `cull_exempt` most-recently + // used (which are the final entries of the iterator). + let num_cull_candidates = self.states.len().saturating_sub(cull_exempt); + for (&state_root, (_, state)) in self.states.iter().take(num_cull_candidates) { let is_advanced = state.slot() > state.latest_block_header().slot; let is_boundary = state.slot() % E::slots_per_epoch() == 0; let could_finalize = @@ -450,7 +451,7 @@ impl BlockMap { } impl HotHDiffBufferCache { - pub fn new(capacity: NonZeroUsize) -> Self { + pub fn new(capacity: usize) -> Self { Self { hdiff_buffers: LruCache::new(capacity), } @@ -467,8 +468,8 @@ impl HotHDiffBufferCache { /// If the value was inserted then `true` is returned. pub fn put(&mut self, state_root: Hash256, slot: Slot, buffer: HDiffBuffer) -> bool { // If the cache is not full, simply insert the value. - if self.hdiff_buffers.len() != self.hdiff_buffers.cap().get() { - self.hdiff_buffers.put(state_root, (slot, buffer)); + if self.hdiff_buffers.len() != self.hdiff_buffers.capacity() { + self.hdiff_buffers.insert(state_root, (slot, buffer)); return true; } @@ -484,23 +485,23 @@ impl HotHDiffBufferCache { return false; }; - if self.hdiff_buffers.cap().get() > 1 || slot < min_slot { + if self.hdiff_buffers.capacity() > 1 || slot < min_slot { // Remove LRU value. Cache is now at size `cap - 1`. let Some((removed_state_root, (removed_slot, removed_buffer))) = - self.hdiff_buffers.pop_lru() + self.hdiff_buffers.remove_lru() else { // Unreachable: cache is full so should have at least one entry to pop. return false; }; // Insert new value. Cache size is now at size `cap`. - self.hdiff_buffers.put(state_root, (slot, buffer)); + self.hdiff_buffers.insert(state_root, (slot, buffer)); // If the removed value had the min slot and we didn't intend to replace it (cap=1) // then we reinsert it. if removed_slot == min_slot && slot >= min_slot { self.hdiff_buffers - .put(removed_state_root, (removed_slot, removed_buffer)); + .insert(removed_state_root, (removed_slot, removed_buffer)); } true } else { @@ -509,8 +510,8 @@ impl HotHDiffBufferCache { } } - pub fn cap(&self) -> NonZeroUsize { - self.hdiff_buffers.cap() + pub fn capacity(&self) -> usize { + self.hdiff_buffers.capacity() } #[allow(clippy::len_without_is_empty)] diff --git a/book/src/advanced_database_migrations.md b/book/src/advanced_database_migrations.md index 115a885878..59a8b7cff5 100644 --- a/book/src/advanced_database_migrations.md +++ b/book/src/advanced_database_migrations.md @@ -17,11 +17,9 @@ validator client or the slasher**. | Lighthouse version | Release date | Schema version | Downgrade available? | |--------------------|--------------|----------------|----------------------| -| v8.0.0 | Nov 2025 | v28 | yes before Fulu | -| v8.0.0-rc.0 | Sep 2025 | v28 | yes before Fulu | -| v7.1.0 | Jul 2025 | v26 | yes | -| v7.0.0 | Apr 2025 | v22 | no | -| v6.0.0 | Nov 2024 | v22 | no | +| v8.2.0 | Jun 2026 | v29 | yes before Gloas | +| v8.1.0 | Feb 2026 | v28 | no | +| v8.0.0 | Nov 2025 | v28 | no | > **Note**: All point releases (e.g. v4.4.1) are schema-compatible with the prior minor release > (e.g. v4.4.0). @@ -209,8 +207,11 @@ Here are the steps to prune historic states: | Lighthouse version | Release date | Schema version | Downgrade available? | |--------------------|--------------|----------------|-------------------------------------| -| v8.0.0-rc.0 | Sep 2025 | v28 | yes before Fulu | -| v7.1.0 | Jul 2025 | v26 | yes | +| v8.2.0 | Jun 2026 | v29 | yes before Gloas | +| v8.1.0 | Feb 2026 | v28 | yes before Fulu using <= v8.1.3 | +| v8.0.0 | Nov 2025 | v28 | yes before Fulu using <= v8.1.3 | +| v8.0.0-rc.0 | Sep 2025 | v28 | yes before Fulu using <= v8.1.3 | +| v7.1.0 | Jul 2025 | v26 | yes using <= v8.1.3 | | v7.0.0 | Apr 2025 | v22 | no | | v6.0.0 | Nov 2024 | v22 | no | | v5.3.0 | Aug 2024 | v21 | yes before Electra using <= v7.0.0 | diff --git a/book/src/help_bn.md b/book/src/help_bn.md index f02f1cb10e..45e10b0d11 100644 --- a/book/src/help_bn.md +++ b/book/src/help_bn.md @@ -84,6 +84,14 @@ Options: --discovery-port6 The UDP port that discovery will listen on over IPv6 if listening over both IPv4 and IPv6. Defaults to `port6` + --enable-mplex [] + Enables the mplex multiplexer alongside yamux. Yamux is preferred when + both are available. Enabled by default; set to "false" to disable. + [default: true] + --enable-partial-columns [] + Enable partial messages for data columns. This can reduce the amount + of data sent over the network. Enabled by default on Hoodi and + Sepolia; set to "false" to opt out. --enr-address
... The IP address/ DNS address to broadcast to other peers on how to reach this node. If a DNS address is provided, the enr-address is set @@ -471,9 +479,6 @@ Flags: --disable-packet-filter Disables the discovery packet filter. Useful for testing in smaller networks - --disable-partial-columns - Disable partial messages for data columns. Use this on Hoodi or - Sepolia to opt out of the default-enabled behavior. --disable-proposer-reorgs Do not attempt to reorg late blocks from other validators when proposing. @@ -489,13 +494,6 @@ Flags: Sets the local ENR IP address and port to match those set for lighthouse. Specifically, the IP address will be the value of --listen-address and the UDP port will be --discovery-port. - --enable-mplex - Enables mplex multiplexer alongside yamux. Yamux is preferred when - both are available. - --enable-partial-columns - Enable partial messages for data columns. This can reduce the amount - of data sent over the network. Enabled by default on Hoodi and - Sepolia; use --disable-partial-columns to opt out. --enable-private-discovery Lighthouse by default does not discover private IP addresses. Set this flag to enable connection attempts to local addresses. diff --git a/book/src/help_vc.md b/book/src/help_vc.md index f1a342197c..719b02a5a5 100644 --- a/book/src/help_vc.md +++ b/book/src/help_vc.md @@ -200,6 +200,11 @@ Flags: If present, do not configure the system allocator. Providing this flag will generally increase memory usage, it should only be provided when debugging specific memory allocation issues. + --disable-proposer-duties-v2 + Fetch proposer duties using the v1 beacon node endpoint instead of v2. + The v1 endpoint reports an incorrect dependent root which causes + spurious proposer duty re-org warnings. Only enable this flag if your + beacon node does not serve the v2 proposer duties endpoint. --disable-slashing-protection-web3signer Disable Lighthouse's slashing protection for all web3signer keys. This can reduce the I/O burden on the VC but is only safe if slashing diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 049ffba657..4d0bb48f54 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1164,6 +1164,7 @@ pub struct SseExtendedPayloadAttributesGeneric { pub type SseExtendedPayloadAttributes = SseExtendedPayloadAttributesGeneric; pub type VersionedSsePayloadAttributes = ForkVersionedResponse; pub type VersionedSseExecutionPayloadBid = ForkVersionedResponse>; +pub type VersionedSseProposerPreferences = ForkVersionedResponse; pub type VersionedSsePayloadAttestationMessage = ForkVersionedResponse; impl<'de> ContextDeserialize<'de, ForkName> for SsePayloadAttributes { @@ -1245,6 +1246,7 @@ pub enum EventKind { ExecutionPayloadGossip(SseExecutionPayloadGossip), ExecutionPayloadAvailable(SseExecutionPayloadAvailable), ExecutionPayloadBid(Box>), + ProposerPreferences(Box), PayloadAttestationMessage(Box), } @@ -1273,6 +1275,7 @@ impl EventKind { EventKind::ExecutionPayloadGossip(_) => "execution_payload_gossip", EventKind::ExecutionPayloadAvailable(_) => "execution_payload_available", EventKind::ExecutionPayloadBid(_) => "execution_payload_bid", + EventKind::ProposerPreferences(_) => "proposer_preferences", EventKind::PayloadAttestationMessage(_) => "payload_attestation_message", } } @@ -1389,6 +1392,11 @@ impl EventKind { ServerError::InvalidServerSentEvent(format!("Execution Payload Bid: {:?}", e)) })?, ))), + "proposer_preferences" => Ok(EventKind::ProposerPreferences(Box::new( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!("Proposer Preferences: {:?}", e)) + })?, + ))), "payload_attestation_message" => Ok(EventKind::PayloadAttestationMessage(Box::new( serde_json::from_str(data).map_err(|e| { ServerError::InvalidServerSentEvent(format!( @@ -1436,6 +1444,7 @@ pub enum EventTopic { ExecutionPayloadGossip, ExecutionPayloadAvailable, ExecutionPayloadBid, + ProposerPreferences, PayloadAttestationMessage, } @@ -1466,6 +1475,7 @@ impl FromStr for EventTopic { "execution_payload_gossip" => Ok(EventTopic::ExecutionPayloadGossip), "execution_payload_available" => Ok(EventTopic::ExecutionPayloadAvailable), "execution_payload_bid" => Ok(EventTopic::ExecutionPayloadBid), + "proposer_preferences" => Ok(EventTopic::ProposerPreferences), "payload_attestation_message" => Ok(EventTopic::PayloadAttestationMessage), _ => Err("event topic cannot be parsed.".to_string()), } @@ -1499,6 +1509,7 @@ impl fmt::Display for EventTopic { write!(f, "execution_payload_available") } EventTopic::ExecutionPayloadBid => write!(f, "execution_payload_bid"), + EventTopic::ProposerPreferences => write!(f, "proposer_preferences"), EventTopic::PayloadAttestationMessage => { write!(f, "payload_attestation_message") } diff --git a/consensus/fork_choice/Cargo.toml b/consensus/fork_choice/Cargo.toml index df47a5c9d1..03dace1f9f 100644 --- a/consensus/fork_choice/Cargo.toml +++ b/consensus/fork_choice/Cargo.toml @@ -20,5 +20,10 @@ types = { workspace = true } [dev-dependencies] beacon_chain = { workspace = true } bls = { workspace = true } +criterion = { workspace = true } store = { workspace = true } tokio = { workspace = true } + +[[bench]] +name = "benches" +harness = false diff --git a/consensus/fork_choice/benches/benches.rs b/consensus/fork_choice/benches/benches.rs new file mode 100644 index 0000000000..bf01b4c346 --- /dev/null +++ b/consensus/fork_choice/benches/benches.rs @@ -0,0 +1,62 @@ +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use fork_choice::{QueuedAttestation, dequeue_attestations}; +use std::collections::BTreeMap; +use types::{Epoch, Hash256, Slot}; + +fn att(slot: Slot) -> QueuedAttestation { + QueuedAttestation { + slot, + attesting_indices: vec![], + block_root: Hash256::ZERO, + target_epoch: Epoch::new(0), + payload_present: false, + } +} + +// Anticipated steady-state workload on mainnet: ~94k attestations spread over a small number of slots, then +// many iterations of dequeue (one slot's worth) + enqueue (one slot's worth for a future slot). +// The queue stays at a constant size, exercising the per-slot dequeue cost. +const NUM_ATTESTATIONS: usize = 1_500_000 / 16; +const UNIQUE_SLOTS: usize = 2; +const NUM_ITERATIONS: usize = 64; +const PER_SLOT: usize = NUM_ATTESTATIONS / UNIQUE_SLOTS; + +fn build_queue() -> BTreeMap> { + let mut queue: BTreeMap> = BTreeMap::new(); + for i in 0..NUM_ATTESTATIONS { + let slot = Slot::from(i / PER_SLOT); + queue.entry(slot).or_default().push(att(slot)); + } + queue +} + +fn all_benches(c: &mut Criterion) { + let initial = build_queue(); + + c.bench_with_input( + BenchmarkId::new("dequeue_attestations", NUM_ATTESTATIONS), + &initial, + |b, initial| { + b.iter(|| { + let mut queue = initial.clone(); + for i in 1..=NUM_ITERATIONS { + let dequeued = dequeue_attestations(Slot::from(i), &mut queue); + let dequeued_count: usize = dequeued.values().map(Vec::len).sum(); + assert_eq!(dequeued_count, PER_SLOT); + + let next_slot = Slot::from(UNIQUE_SLOTS + i - 1); + queue + .entry(next_slot) + .or_default() + .extend(std::iter::repeat_with(|| att(next_slot)).take(PER_SLOT)); + + let total: usize = queue.values().map(Vec::len).sum(); + assert_eq!(total, NUM_ATTESTATIONS); + } + }) + }, + ); +} + +criterion_group!(benches, all_benches); +criterion_main!(benches); diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 7816fb4483..95c8f70a04 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -11,7 +11,7 @@ use state_processing::{ per_block_processing::errors::AttesterSlashingValidationError, per_epoch_processing, }; use std::cmp::Ordering; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::marker::PhantomData; use std::time::Duration; use superstruct::superstruct; @@ -284,12 +284,12 @@ fn compute_start_slot_at_epoch(epoch: Epoch) -> Slot { /// information about the attestation. #[derive(Clone, PartialEq, Encode, Decode)] pub struct QueuedAttestation { - slot: Slot, - attesting_indices: Vec, - block_root: Hash256, - target_epoch: Epoch, + pub slot: Slot, + pub attesting_indices: Vec, + pub block_root: Hash256, + pub target_epoch: Epoch, /// Per Gloas spec: `payload_present = attestation.data.index == 1`. - payload_present: bool, + pub payload_present: bool, } /// Legacy queued attestation without payload_present (pre-Gloas, schema V28). @@ -313,25 +313,22 @@ impl<'a, E: EthSpec> From> for QueuedAttestation { } } -/// Returns all values in `self.queued_attestations` that have a slot that is earlier than the -/// current slot. Also removes those values from `self.queued_attestations`. -fn dequeue_attestations( +/// Returns all attestations in `queued_attestations` with a slot earlier than the current slot, +/// removing them from the queue. +pub fn dequeue_attestations( current_slot: Slot, - queued_attestations: &mut Vec, -) -> Vec { - let remaining = queued_attestations.split_off( - queued_attestations - .iter() - .position(|a| a.slot >= current_slot) - .unwrap_or(queued_attestations.len()), - ); + queued_attestations: &mut BTreeMap>, +) -> BTreeMap> { + let remaining = queued_attestations.split_off(¤t_slot); + let due = std::mem::replace(queued_attestations, remaining); + let dequeued_count: usize = due.values().map(Vec::len).sum(); metrics::inc_counter_by( &metrics::FORK_CHOICE_DEQUEUED_ATTESTATIONS, - queued_attestations.len() as u64, + dequeued_count as u64, ); - std::mem::replace(queued_attestations, remaining) + due } /// Denotes whether an attestation we are processing was received from a block or from gossip. @@ -376,8 +373,9 @@ pub struct ForkChoice { fc_store: T, /// The underlying representation of the block DAG. proto_array: ProtoArrayForkChoice, - /// Attestations that arrived at the current slot and must be queued for later processing. - queued_attestations: Vec, + /// Attestations that arrived at the current slot and must be queued for later processing, + /// keyed by their slot. + queued_attestations: BTreeMap>, /// Stores a cache of the values required to be sent to the execution layer. forkchoice_update_parameters: ForkchoiceUpdateParameters, _phantom: PhantomData, @@ -474,7 +472,7 @@ where let mut fork_choice = Self { fc_store, proto_array, - queued_attestations: vec![], + queued_attestations: BTreeMap::new(), // This will be updated during the next call to `Self::get_head`. forkchoice_update_parameters: ForkchoiceUpdateParameters { head_hash: None, @@ -542,6 +540,27 @@ where } } + /// Returns the dependent root for `block_root`, per the spec `get_dependent_root` helper. + fn get_dependent_root( + &self, + block_root: Hash256, + current_slot: Slot, + spec: &ChainSpec, + ) -> Result, Error> { + let epoch = current_slot.epoch(E::slots_per_epoch()); + + if epoch <= spec.min_seed_lookahead { + return Ok(Some(Hash256::zero())); + } + + let dependent_slot = epoch + .saturating_sub(spec.min_seed_lookahead) + .start_slot(E::slots_per_epoch()) + .saturating_sub(1_u64); + + self.get_ancestor(block_root, dependent_slot) + } + /// Run the fork choice rule to determine the head. /// /// ## Specification @@ -768,7 +787,6 @@ where block_delay: Duration, state: &BeaconState, payload_verification_status: PayloadVerificationStatus, - canonical_head_proposer_index: u64, spec: &ChainSpec, ) -> Result<(), Error> { let _timer = metrics::start_timer(&metrics::FORK_CHOICE_ON_BLOCK_TIMES); @@ -780,10 +798,17 @@ where return Ok(()); } - // Provide the slot (as per the system clock) to the `fc_store` and then return its view of - // the current slot. The `fc_store` will ensure that the `current_slot` is never - // decreasing, a property which we must maintain. - let current_slot = self.update_time(system_time_current_slot)?; + let head_root = if system_time_current_slot == self.fc_store.get_current_slot() { + // Fork choice has already run for the current slot, so we can safely use the cached + // head without recomputing it. + self.cached_fork_choice_view().head_block_root + } else { + // Fork choice hasn't run for the current slot yet: run it, updating the fork choice + // store's current slot in the process. + self.get_head(system_time_current_slot, spec)?.0 + }; + let current_slot = self.fc_store.get_current_slot(); + debug_assert_eq!(current_slot, system_time_current_slot); // Parent block must be known. let parent_block = self @@ -833,19 +858,24 @@ where let attestation_threshold = spec.get_attestation_due::(block.slot()); - // Add proposer score boost if the block is the first timely block for this slot and its - // proposer matches the expected proposer on the canonical chain (per spec - // `update_proposer_boost_root`, introduced in v1.7.0-alpha.5). + // Add proposer score boost if the block is the first timely block for this slot and it + // shares the same dependent root as the canonical chain head (per spec + // `update_proposer_boost_root`). let is_before_attesting_interval = block_delay < attestation_threshold; - + let is_timely = current_slot == block.slot() && is_before_attesting_interval; let is_first_block = self.fc_store.proposer_boost_root().is_zero(); - let is_canonical_proposer = block.proposer_index() == canonical_head_proposer_index; - if current_slot == block.slot() - && is_before_attesting_interval - && is_first_block - && is_canonical_proposer - { - self.fc_store.set_proposer_boost_root(block_root); + + if is_timely && is_first_block { + // The block isn't in fork choice so resolve its dependent root via its parent. + let block_dependent_root = + self.get_dependent_root(block.parent_root(), current_slot, spec)?; + let head_dependent_root = self.get_dependent_root(head_root, current_slot, spec)?; + + // Add proposer score boost if the block is timely, not conflicting with an + // existing block, with the same dependent root as the canonical chain head. + if block_dependent_root.is_some() && block_dependent_root == head_dependent_root { + self.fc_store.set_proposer_boost_root(block_root); + } } // Update store with checkpoints if necessary @@ -862,22 +892,29 @@ where // Update unrealized justified/finalized checkpoints. let block_epoch = block.slot().epoch(E::slots_per_epoch()); - // If the parent checkpoints are already at the same epoch as the block being imported, - // it's impossible for the unrealized checkpoints to differ from the parent's. This - // holds true because: + // If the block has no slashings and the parent checkpoints are already at the same epoch as + // the block being imported, it's impossible for the unrealized checkpoints to differ from + // the parent's. This holds true because: // // 1. A child block cannot have lower FFG checkpoints than its parent. // 2. A block in epoch `N` cannot contain attestations which would justify an epoch higher than `N`. // 3. A block in epoch `N` cannot contain attestations which would finalize an epoch higher than `N - 1`. // + // Slashings are excluded from this optimization because they can reduce unslashed + // participation in the child state and therefore lower the child's unrealized checkpoints. + // // This is an optimization. It should reduce the amount of times we run // `process_justification_and_finalization` by approximately 1/3rd when the chain is // performing optimally. + let has_slashings = !block.body().proposer_slashings().is_empty() + || block.body().attester_slashings_len() > 0; let parent_checkpoints = parent_block .unrealized_justified_checkpoint .zip(parent_block.unrealized_finalized_checkpoint) .filter(|(parent_justified, parent_finalized)| { - parent_justified.epoch == block_epoch && parent_finalized.epoch + 1 == block_epoch + !has_slashings + && parent_justified.epoch == block_epoch + && parent_finalized.epoch.saturating_add(1u64) == block_epoch }); let (unrealized_justified_checkpoint, unrealized_finalized_checkpoint) = @@ -1032,6 +1069,8 @@ where execution_payload_parent_hash, execution_payload_block_hash, proposer_index: Some(block.proposer_index()), + // Set on payload-envelope import, not block import. + payload_received: false, }, current_slot, spec, @@ -1312,8 +1351,11 @@ where // Attestations can only affect the fork choice of subsequent slots. // Delay consideration in the fork choice until their slot is in the past. // ``` + let queued_attestation = QueuedAttestation::from(attestation); self.queued_attestations - .push(QueuedAttestation::from(attestation)); + .entry(queued_attestation.slot) + .or_default() + .push(queued_attestation); } Ok(()) @@ -1505,10 +1547,11 @@ where /// Processes and removes from the queue any queued attestations which may now be eligible for /// processing due to the slot clock incrementing. fn process_attestation_queue(&mut self) -> Result<(), Error> { - for attestation in dequeue_attestations( + let dequeued = dequeue_attestations( self.fc_store.get_current_slot(), &mut self.queued_attestations, - ) { + ); + for attestation in dequeued.into_values().flatten() { for validator_index in attestation.attesting_indices.iter() { self.proto_array.process_attestation( *validator_index as usize, @@ -1581,9 +1624,10 @@ where &self, block_root: &Hash256, parent_payload_status: PayloadStatus, + current_slot: Slot, ) -> Result> { self.proto_array - .should_build_on_full::(block_root, parent_payload_status) + .should_build_on_full::(block_root, parent_payload_status, current_slot) .map_err(Error::ProtoArrayStringError) } @@ -1753,8 +1797,8 @@ where &self.fc_store } - /// Returns a reference to the currently queued attestations. - pub fn queued_attestations(&self) -> &[QueuedAttestation] { + /// Returns a reference to the currently queued attestations, keyed by slot. + pub fn queued_attestations(&self) -> &BTreeMap> { &self.queued_attestations } @@ -1839,7 +1883,7 @@ where let mut fork_choice = Self { fc_store, proto_array, - queued_attestations: vec![], + queued_attestations: BTreeMap::new(), // Will be updated in the following call to `Self::get_head`. forkchoice_update_parameters: ForkchoiceUpdateParameters { head_hash: None, @@ -1952,20 +1996,33 @@ mod tests { } } - fn get_queued_attestations() -> Vec { - (1..4) - .map(|i| QueuedAttestation { - slot: Slot::new(i), + fn queue_from_slots( + slots: impl IntoIterator, + ) -> BTreeMap> { + let mut queued: BTreeMap> = BTreeMap::new(); + for i in slots { + let slot = Slot::new(i); + queued.entry(slot).or_default().push(QueuedAttestation { + slot, attesting_indices: vec![], block_root: Hash256::zero(), target_epoch: Epoch::new(0), payload_present: false, - }) - .collect() + }); + } + queued } - fn get_slots(queued_attestations: &[QueuedAttestation]) -> Vec { - queued_attestations.iter().map(|a| a.slot.into()).collect() + fn get_queued_attestations() -> BTreeMap> { + queue_from_slots(1..4) + } + + fn get_slots(queued_attestations: &BTreeMap>) -> Vec { + queued_attestations + .values() + .flatten() + .map(|a| a.slot.into()) + .collect() } fn test_queued_attestations(current_time: Slot) -> (Vec, Vec) { @@ -1997,4 +2054,25 @@ mod tests { assert!(queued.is_empty()); assert_eq!(dequeued, vec![1, 2, 3]); } + + #[test] + fn dequeue_attestations_out_of_order() { + // A future-slot vote enqueued before a vote that becomes due sooner must not block the + // due vote from being released. + let mut queued = queue_from_slots([4, 3]); + + // At slot 4, the slot-3 vote is due (3 < 4) and must be released. + let dequeued = dequeue_attestations(Slot::new(4), &mut queued); + + assert_eq!( + get_slots(&dequeued), + vec![3], + "slot-3 vote must be dequeued at slot 4" + ); + assert_eq!( + get_slots(&queued), + vec![4], + "only the not-yet-due slot-4 vote should remain" + ); + } } diff --git a/consensus/fork_choice/src/lib.rs b/consensus/fork_choice/src/lib.rs index dcc499547b..453926fbee 100644 --- a/consensus/fork_choice/src/lib.rs +++ b/consensus/fork_choice/src/lib.rs @@ -6,7 +6,7 @@ pub use crate::fork_choice::{ AttestationFromBlock, Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, InvalidAttestation, InvalidBlock, InvalidPayloadAttestation, ParentImportStatus, PayloadVerificationStatus, PersistedForkChoice, PersistedForkChoiceV28, PersistedForkChoiceV29, - QueuedAttestation, ResetPayloadStatuses, + QueuedAttestation, ResetPayloadStatuses, dequeue_attestations, }; pub use fork_choice_store::ForkChoiceStore; pub use proto_array::{ diff --git a/consensus/fork_choice/src/metrics.rs b/consensus/fork_choice/src/metrics.rs index b5cda2f587..2ad18fc1ed 100644 --- a/consensus/fork_choice/src/metrics.rs +++ b/consensus/fork_choice/src/metrics.rs @@ -49,7 +49,11 @@ pub static FORK_CHOICE_ON_ATTESTER_SLASHING_TIMES: LazyLock> = pub fn scrape_for_metrics, E: EthSpec>(fork_choice: &ForkChoice) { set_gauge( &FORK_CHOICE_QUEUED_ATTESTATIONS, - fork_choice.queued_attestations().len() as i64, + fork_choice + .queued_attestations() + .values() + .map(Vec::len) + .sum::() as i64, ); set_gauge( &FORK_CHOICE_NODES, diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index 848834b4d8..9b3f5d8857 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -149,13 +149,17 @@ impl ForkChoiceTest { .fork_choice_write_lock() .update_time(self.harness.chain.slot().unwrap()) .unwrap(); - func( - self.harness - .chain - .canonical_head - .fork_choice_read_lock() - .queued_attestations(), - ); + let queued = self + .harness + .chain + .canonical_head + .fork_choice_read_lock() + .queued_attestations() + .values() + .flatten() + .cloned() + .collect::>(); + func(&queued); self } @@ -316,7 +320,6 @@ impl ForkChoiceTest { Duration::from_secs(0), &state, PayloadVerificationStatus::Verified, - block.message().proposer_index(), &self.harness.chain.spec, ) .unwrap(); @@ -360,7 +363,6 @@ impl ForkChoiceTest { Duration::from_secs(0), &state, PayloadVerificationStatus::Verified, - block.message().proposer_index(), &self.harness.chain.spec, ) .expect_err("on_block did not return an error"); @@ -417,6 +419,24 @@ impl ForkChoiceTest { async fn apply_attestation_to_chain( self, delay: MutationDelay, + mutation_func: F, + comparison_func: G, + ) -> Self + where + F: FnMut(&mut IndexedAttestation, &BeaconChain>), + G: FnMut(Result<(), BeaconChainError>), + { + self.apply_nth_attestation_to_chain(0, delay, mutation_func, comparison_func) + .await + } + + /// Like `apply_attestation_to_chain`, but attests with the validator at + /// `validator_index_in_committee` within the committee. Lets a test enqueue multiple distinct + /// votes for the same slot without tripping `PriorAttestationKnown`. + async fn apply_nth_attestation_to_chain( + self, + validator_index_in_committee: usize, + delay: MutationDelay, mut mutation_func: F, mut comparison_func: G, ) -> Self @@ -433,18 +453,16 @@ impl ForkChoiceTest { .produce_unaggregated_attestation(current_slot, 0) .expect("should not error while producing attestation"); - let validator_committee_index = 0; + // For these tests we always use committee index 0, which also matches the "dummy" committee + // index used post-Electra. + let committee_index = 0; + let validator_index = *head .beacon_state - .get_beacon_committee( - current_slot, - attestation - .committee_index() - .expect("should get committee index"), - ) + .get_beacon_committee(current_slot, committee_index) .expect("should get committees") .committee - .get(validator_committee_index) + .get(validator_index_in_committee) .expect("there should be an attesting validator"); let committee_count = head @@ -454,7 +472,7 @@ impl ForkChoiceTest { let subnet_id = SubnetId::compute_subnet::( current_slot, - 0, + committee_index, committee_count, &self.harness.chain.spec, ) @@ -465,7 +483,7 @@ impl ForkChoiceTest { attestation .sign( &validator_sk, - validator_committee_index, + committee_index as usize, &head.beacon_state.fork(), self.harness.chain.genesis_validators_root, &self.harness.chain.spec, @@ -474,7 +492,7 @@ impl ForkChoiceTest { let single_attestation = SingleAttestation { attester_index: validator_index as u64, - committee_index: validator_committee_index as u64, + committee_index, data: attestation.data().clone(), signature: attestation.signature().clone(), }; @@ -1041,6 +1059,100 @@ async fn invalid_attestation_delayed_slot() { .inspect_queued_attestations(|queue| assert_eq!(queue.len(), 0)); } +/// Regression test for dequeuing when votes for two different future slots are queued. +/// +/// With votes queued for consecutive slots, advancing the clock past the earlier one must release +/// only that vote and leave the later one queued until its own slot is in the past. +#[tokio::test] +async fn dequeue_attestations_consecutive_slot_divergence() { + ForkChoiceTest::new() + .apply_blocks_without_new_attestations(1) + .await + .inspect_queued_attestations(|queue| assert_eq!(queue.len(), 0)) + // Queue a vote for `slot + 2`. + .apply_nth_attestation_to_chain( + 0, + MutationDelay::NoDelay, + |attestation, _| { + let slot = attestation.data().slot; + attestation.data_mut().slot = slot + 2; + }, + |result| assert!(result.is_ok()), + ) + .await + // Queue a vote for `slot + 1`, which becomes due sooner. + // A different committee position avoids `PriorAttestationKnown`. + .apply_nth_attestation_to_chain( + 1, + MutationDelay::NoDelay, + |attestation, _| { + let slot = attestation.data().slot; + attestation.data_mut().slot = slot + 1; + }, + |result| assert!(result.is_ok()), + ) + .await + .inspect_queued_attestations(|queue| assert_eq!(queue.len(), 2)) + // Advance so the slot+1 vote is due (in the past) but the slot+2 vote is not yet. + .skip_slots(2) + .inspect_queued_attestations(|queue| { + assert_eq!( + queue.len(), + 1, + "only the due slot+1 vote should be dequeued" + ); + assert_eq!( + queue[0].slot, + Slot::new(3), + "the surviving vote must be the not-yet-due slot+2 vote" + ); + }); +} + +/// Companion to `dequeue_attestations_consecutive_slot_divergence`: votes for two different slots +/// are queued, but the clock is advanced far enough that *both* are due at dequeue time. +/// +/// When every queued vote is in the past, the whole queue drains in a single dequeue. +#[tokio::test] +async fn dequeue_attestations_conciliation() { + ForkChoiceTest::new() + .apply_blocks_without_new_attestations(1) + .await + .inspect_queued_attestations(|queue| assert_eq!(queue.len(), 0)) + // Queue a vote for `slot + 2`. + .apply_nth_attestation_to_chain( + 0, + MutationDelay::NoDelay, + |attestation, _| { + let slot = attestation.data().slot; + attestation.data_mut().slot = slot + 2; + }, + |result| assert!(result.is_ok()), + ) + .await + // Queue a vote for `slot + 1`. + .apply_nth_attestation_to_chain( + 1, + MutationDelay::NoDelay, + |attestation, _| { + let slot = attestation.data().slot; + attestation.data_mut().slot = slot + 1; + }, + |result| assert!(result.is_ok()), + ) + .await + .inspect_queued_attestations(|queue| assert_eq!(queue.len(), 2)) + // Advance past both votes (to slot + 3) so the whole queue drains. + .skip_slots(3) + .inspect_queued_attestations(|queue| { + assert_eq!( + queue.len(), + 0, + "all votes are due, so the entire queue must drain" + ); + }); +} + /// Tests that the correct target root is used when the attested-to block is in a prior epoch to /// the attestation. #[tokio::test] diff --git a/consensus/proto_array/benches/find_head.rs b/consensus/proto_array/benches/find_head.rs index 98077a7f97..07edc4d46f 100644 --- a/consensus/proto_array/benches/find_head.rs +++ b/consensus/proto_array/benches/find_head.rs @@ -68,6 +68,7 @@ fn build_chain(num_blocks: u64, gloas: bool) -> (ProtoArrayForkChoice, types::Ch }, execution_payload_block_hash: if is_gloas { Some(get_hash(i)) } else { None }, proposer_index: Some(0), + payload_received: false, }; fork_choice diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index 3dc5406212..d9acda1258 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -124,6 +124,15 @@ pub enum Operation { #[serde(default)] proposer_boost_root: Option, }, + /// Assert the result of `should_build_on_full` for the parent `block_root`, where + /// `parent_payload_status` is the status the proposer would build on and `proposal_slot` + /// is the slot being proposed. + AssertShouldBuildOnFull { + block_root: Hash256, + parent_payload_status: PayloadStatus, + proposal_slot: Slot, + expected: bool, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -321,6 +330,7 @@ impl ForkChoiceTestDefinition { execution_payload_parent_hash, execution_payload_block_hash, proposer_index: Some(0), + payload_received: false, }; fork_choice .process_block::(block, slot, &spec, Duration::ZERO) @@ -606,6 +616,30 @@ impl ForkChoiceTestDefinition { op_index ); } + Operation::AssertShouldBuildOnFull { + block_root, + parent_payload_status, + proposal_slot, + expected, + } => { + let actual = fork_choice + .should_build_on_full::( + &block_root, + parent_payload_status, + proposal_slot, + ) + .unwrap_or_else(|e| { + panic!( + "should_build_on_full op at index {} returned error: {}", + op_index, e + ) + }); + assert_eq!( + actual, expected, + "should_build_on_full mismatch at op index {}", + op_index + ); + } } } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs index bf79a0170f..07262fb0d7 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs @@ -971,6 +971,90 @@ pub fn get_gloas_proposer_boost_flips_ancestor_test_definition() -> ForkChoiceTe } } +/// Tests the slot check in `should_build_on_full`. When the parent is from an earlier slot the +/// function returns `true` and ignores PTC data-availability votes. It only checks those votes +/// when the parent is from the immediately preceding slot. +pub fn get_gloas_should_build_on_full_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1, child of genesis. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + + // PTC has voted the payload data unavailable. `is_timely` sets `payload_received` so the votes + // are consulted, and clearing the data-availability bits gives the "false" votes a majority. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(1), + is_timely: true, + is_data_available: false, + }); + + // When the parent is `Empty` `should_build_on_full` returns `false`. This check runs before + // the slot check, so the result is `false` for both the previous-slot case (block slot 1, proposal slot 2) + // and an earlier-slot case (proposal slot 3). + ops.push(Operation::AssertShouldBuildOnFull { + block_root: get_root(1), + parent_payload_status: PayloadStatus::Empty, + proposal_slot: Slot::new(2), + expected: false, + }); + ops.push(Operation::AssertShouldBuildOnFull { + block_root: get_root(1), + parent_payload_status: PayloadStatus::Empty, + proposal_slot: Slot::new(3), + expected: false, + }); + + // `Full` parent from the immediately preceding slot (block slot 1, proposal slot 2). The PTC + // votes are consulted, and since data is unavailable the proposer does not build on full. + ops.push(Operation::AssertShouldBuildOnFull { + block_root: get_root(1), + parent_payload_status: PayloadStatus::Full, + proposal_slot: Slot::new(2), + expected: false, + }); + + // `Full` parent from an *earlier* slot (block slot 1, proposal slot 3). The slot check + // short-circuits to `true` without consulting the (unavailable) PTC votes. + ops.push(Operation::AssertShouldBuildOnFull { + block_root: get_root(1), + parent_payload_status: PayloadStatus::Full, + proposal_slot: Slot::new(3), + expected: true, + }); + + // Flip the PTC view to *available* and re-check the previous-slot case. The votes now permit + // building on full. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(1), + is_timely: true, + is_data_available: true, + }); + ops.push(Operation::AssertShouldBuildOnFull { + block_root: get_root(1), + parent_payload_status: PayloadStatus::Full, + proposal_slot: Slot::new(2), + expected: true, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(get_hash(42)), + execution_payload_block_hash: Some(get_hash(0)), + spec: Some(gloas_spec()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1160,6 +1244,12 @@ mod tests { test.run(); } + #[test] + fn should_build_on_full_slot_check() { + let test = get_gloas_should_build_on_full_test_definition(); + test.run(); + } + /// Test that execution payload invalidation propagates across the V17→V29 fork /// boundary: after invalidating a V17 parent, head must not select any descendant. /// diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index bd15bb4599..04113e2c0e 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -174,6 +174,10 @@ pub struct ProtoNode { } impl ProtoNode { + pub fn is_gloas(&self) -> bool { + self.as_v29().is_ok() + } + /// Generic version of spec's `parent_payload_status` that works for pre-Gloas nodes by /// considering their parents Empty. pub fn get_parent_payload_status(&self) -> PayloadStatus { @@ -1569,10 +1573,13 @@ impl ProtoArray { /// Called by the proposer to decide whether to build on the full or empty /// parent pending node. Returns false if the PTC has voted the data as unavailable. + /// For a parent from an earlier slot the `Empty` or `Full` node has already been resolved + /// by attestation weight in `get_head`. pub fn should_build_on_full( &self, fc_node: &IndexedForkChoiceNode, proto_node: &ProtoNode, + current_slot: Slot, ) -> Result { if fc_node.payload_status == PayloadStatus::Pending { return Err(Error::InvalidPayloadStatus { @@ -1584,10 +1591,23 @@ impl ProtoArray { if fc_node.payload_status == PayloadStatus::Empty { return Ok(false); } + + if proto_node.slot().saturating_add(1u64) != current_slot { + return Ok(true); + } + // Check that false votes have not achieved an absolute majority. This allows the payload to be // considered available when either a majority have voted true or not enough votes have // been cast either way. - Ok(!proto_node.payload_data_availability::(false)?) + if proto_node.payload_data_availability::(false)? { + return Ok(false); + } + + if proto_node.payload_timeliness::(false)? { + return Ok(false); + } + + Ok(true) } pub fn should_extend_payload( diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index f0b599dbcd..c6a7829c27 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -242,6 +242,8 @@ pub struct Block { pub execution_payload_parent_hash: Option, pub execution_payload_block_hash: Option, pub proposer_index: Option, + /// Whether the block's execution payload envelope has been received. Always `false` pre-Gloas. + pub payload_received: bool, } impl Block { @@ -502,6 +504,7 @@ impl ProtoArrayForkChoice { execution_payload_parent_hash, execution_payload_block_hash, proposer_index: Some(proposer_index), + payload_received: false, }; proto_array @@ -720,15 +723,14 @@ impl ProtoArrayForkChoice { .into()); } - // Spec: `is_parent_strong`. Use payload-aware weight matching the - // payload path the head node is on from its parent. - let parent_payload_status = info.head_node.get_parent_payload_status(); - let parent_weight = info.parent_node.attestation_score(parent_payload_status); + // Spec: `is_parent_strong`. Use `PayloadStatus::Pending` to avoid weight split + // between payload statuses. https://github.com/ethereum/consensus-specs/issues/5305 + let parent_pending_weight = info.parent_node.attestation_score(PayloadStatus::Pending); let re_org_parent_weight_threshold = info.re_org_parent_weight_threshold; - let parent_strong = parent_weight > re_org_parent_weight_threshold; + let parent_strong = parent_pending_weight > re_org_parent_weight_threshold; if !parent_strong { return Err(DoNotReOrg::ParentNotStrong { - parent_weight, + parent_weight: parent_pending_weight, re_org_parent_weight_threshold, } .into()); @@ -959,6 +961,7 @@ impl ProtoArrayForkChoice { execution_payload_parent_hash: block.execution_payload_parent_hash().ok(), execution_payload_block_hash: block.execution_payload_block_hash().ok(), proposer_index: block.proposer_index().ok(), + payload_received: block.payload_received().unwrap_or(false), }) } @@ -968,6 +971,7 @@ impl ProtoArrayForkChoice { &self, block_root: &Hash256, parent_payload_status: PayloadStatus, + current_slot: Slot, ) -> Result { let block_index = self .proto_array @@ -985,7 +989,7 @@ impl ProtoArrayForkChoice { payload_status: parent_payload_status, }; self.proto_array - .should_build_on_full::(&fc_node, proto_node) + .should_build_on_full::(&fc_node, proto_node, current_slot) .map_err(|e| format!("{e:?}")) } @@ -1382,6 +1386,7 @@ mod test_compute_deltas { execution_payload_parent_hash: None, execution_payload_block_hash: None, proposer_index: Some(0), + payload_received: false, }, genesis_slot + 1, &spec, @@ -1410,6 +1415,7 @@ mod test_compute_deltas { execution_payload_parent_hash: None, execution_payload_block_hash: None, proposer_index: Some(0), + payload_received: false, }, genesis_slot + 1, &spec, @@ -1546,6 +1552,7 @@ mod test_compute_deltas { execution_payload_parent_hash: None, execution_payload_block_hash: None, proposer_index: Some(0), + payload_received: false, }, Slot::from(block.slot), &spec, diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index f88a325d4e..876e66d3af 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -880,8 +880,11 @@ pub fn process_deposit_requests_pre_gloas( spec: &ChainSpec, ) -> Result<(), BlockProcessingError> { for request in deposit_requests { - // Set deposit receipt start index - if state.deposit_requests_start_index()? == spec.unset_deposit_requests_start_index { + // Set deposit receipt start index if pre-Fulu. + // Support for the former Eth1 bridge deposit mechanism was removed in Fulu. + if !state.fork_name_unchecked().fulu_enabled() + && state.deposit_requests_start_index()? == spec.unset_deposit_requests_start_index + { *state.deposit_requests_start_index_mut()? = request.index } let slot = state.slot(); diff --git a/consensus/state_processing/src/per_epoch_processing/single_pass.rs b/consensus/state_processing/src/per_epoch_processing/single_pass.rs index 881e6bb16c..43d6606f07 100644 --- a/consensus/state_processing/src/per_epoch_processing/single_pass.rs +++ b/consensus/state_processing/src/per_epoch_processing/single_pass.rs @@ -1105,8 +1105,10 @@ impl PendingDepositsContext { let pending_deposits = state.pending_deposits()?; for deposit in pending_deposits.iter() { - // Do not process deposit requests if the Eth1 bridge deposits are not yet applied. - if deposit.slot > spec.genesis_slot + // Do not process deposit requests if pre-Fulu and the Eth1 bridge deposits are not yet applied. + // Support for the former Eth1 bridge deposit mechanism was removed in Fulu. + if !state.fork_name_unchecked().fulu_enabled() + && deposit.slot > spec.genesis_slot && state.eth1_deposit_index() < state.deposit_requests_start_index()? { break; diff --git a/consensus/types/src/builder/proposer_preferences.rs b/consensus/types/src/builder/proposer_preferences.rs index 4f27020105..97ba980d6c 100644 --- a/consensus/types/src/builder/proposer_preferences.rs +++ b/consensus/types/src/builder/proposer_preferences.rs @@ -14,8 +14,10 @@ use tree_hash_derive::TreeHash; pub struct ProposerPreferences { pub dependent_root: Hash256, pub proposal_slot: Slot, + #[serde(with = "serde_utils::quoted_u64")] pub validator_index: u64, pub fee_recipient: Address, + #[serde(with = "serde_utils::quoted_u64")] pub target_gas_limit: u64, } @@ -45,4 +47,24 @@ mod tests { use super::*; ssz_and_tree_hash_tests!(ProposerPreferences); + + /// `validator_index` and `target_gas_limit` must serialize as quoted JSON strings (Beacon API + /// convention) and round-trip back to their numeric values. + #[test] + fn quoted_u64_json_serde() { + let preferences = ProposerPreferences { + dependent_root: Hash256::ZERO, + proposal_slot: Slot::new(7), + validator_index: 42, + fee_recipient: Address::ZERO, + target_gas_limit: 30_000_000, + }; + + let value = serde_json::to_value(&preferences).unwrap(); + assert_eq!(value["validator_index"], serde_json::json!("42")); + assert_eq!(value["target_gas_limit"], serde_json::json!("30000000")); + + let decoded: ProposerPreferences = serde_json::from_value(value).unwrap(); + assert_eq!(decoded, preferences); + } } diff --git a/consensus/types/src/execution/signed_execution_payload_bid.rs b/consensus/types/src/execution/signed_execution_payload_bid.rs index 2ad6dcea1a..7dd6a88a1a 100644 --- a/consensus/types/src/execution/signed_execution_payload_bid.rs +++ b/consensus/types/src/execution/signed_execution_payload_bid.rs @@ -37,6 +37,10 @@ impl SignedExecutionPayloadBid { signature: Signature::empty(), } } + + pub fn num_blobs_expected(&self) -> usize { + self.message.blob_kzg_commitments.len() + } } #[cfg(test)] diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 027acfab7f..26f28eda45 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -1127,21 +1127,33 @@ impl BeaconState { // Post-Fulu we must never compute proposer indices using insufficient lookahead. This // would be very dangerous as it would lead to conflicts between the *true* proposer as // defined by `self.proposer_lookahead` and the output of this function. - // With MIN_SEED_LOOKAHEAD=1 (common config), this is equivalent to checking that the - // requested epoch is not the current epoch. // - // We do not run this check if this function is called from `upgrade_to_fulu`, - // which runs *after* the slot is incremented, and needs to compute the proposer - // shuffling for the epoch that was just transitioned into. - if self.fork_name_unchecked().fulu_enabled() - && epoch < current_epoch.safe_add(spec.min_seed_lookahead)? - { - return Err( - BeaconStateError::ComputeProposerIndicesInsufficientLookahead { - current_epoch, - request_epoch: epoch, - }, - ); + // Furthermore, post-Gloas, we must never compute proposers at any slot other than the + // dependent root slot itself, as slashings at subsequent slots have the ability to + // change the shuffling. + // + // For simplicity these two checks are combined into a single check on the dependent + // slot, which is safe for Fulu and Gloas. This function is always called from + // `get_beacon_proposer_indices`, which uses the cached lookahead for `current_epoch` and + // `next_epoch`. The only epoch's shuffling that should ordinarily be computed therefore + // is `next_epoch + 1`, which for Fulu and Gloas is computed during the epoch transition + // in the last slot of `current_epoch` (before the slot is incremented into + // `next_epoch`). + // + // The only case where computation of proposers in `current_epoch` and `next_epoch` is + // directly required is during the fork to Fulu itself + // (`upgrade_to_fulu`/`initialize_proposer_lookahead`), in which case the state is not + // yet the Fulu variant, and we omit the check. + if self.fork_name_unchecked().fulu_enabled() { + let dependent_slot = spec.proposer_shuffling_decision_slot::(epoch); + if self.slot() != dependent_slot { + return Err( + BeaconStateError::ComputeProposerIndicesInsufficientLookahead { + current_epoch, + request_epoch: epoch, + }, + ); + } } } else { // Pre-Fulu the situation is reversed, we *should not* compute proposer indices using @@ -1375,7 +1387,7 @@ impl BeaconState { spec: &ChainSpec, ) -> Result, BeaconStateError> { // This isn't in the spec, but we remove the footgun that is requesting the current epoch - // for a Fulu state. + // or next epoch for a Fulu state. if let Ok(proposer_lookahead) = self.proposer_lookahead() && epoch >= self.current_epoch() && epoch <= self.next_epoch()? @@ -1394,7 +1406,15 @@ impl BeaconState { } // Not using the cached validator indices since they are shuffled. - let indices = self.get_active_validator_indices(epoch, spec)?; + let mut indices = self.get_active_validator_indices(epoch, spec)?; + + // Post-Gloas, slashed validators are excluded from proposer selection + if self.fork_name_unchecked().gloas_enabled() { + let latest_block_slot = self.latest_block_header().slot; + let slashings_cache = self.slashings_cache(); + slashings_cache.check_initialized(latest_block_slot)?; + indices.retain(|&index| !slashings_cache.is_slashed(index)); + } let preimage = self.get_seed(epoch, Domain::BeaconProposer, spec)?; self.compute_proposer_indices(epoch, preimage.as_slice(), &indices, spec) diff --git a/consensus/types/src/state/slashings_cache.rs b/consensus/types/src/state/slashings_cache.rs index b6ed583df8..cdaa2c6c89 100644 --- a/consensus/types/src/state/slashings_cache.rs +++ b/consensus/types/src/state/slashings_cache.rs @@ -64,3 +64,127 @@ impl SlashingsCache { self.latest_block_slot = Some(latest_block_slot); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Epoch, Hash256}; + use bls::PublicKeyBytes; + + /// Build a minimal validator with the given `slashed` flag. The other fields are irrelevant to + /// the slashings cache. + fn validator(slashed: bool) -> Validator { + Validator { + pubkey: PublicKeyBytes::empty(), + withdrawal_credentials: Hash256::ZERO, + effective_balance: 0, + slashed, + activation_eligibility_epoch: Epoch::new(0), + activation_epoch: Epoch::new(0), + exit_epoch: Epoch::new(0), + withdrawable_epoch: Epoch::new(0), + } + } + + /// Validators 1 and 3 are slashed, the rest are not. + fn validators() -> Vec { + vec![ + validator(false), + validator(true), + validator(false), + validator(true), + validator(false), + ] + } + + #[test] + fn new_captures_slashed_indices() { + let validators = validators(); + let cache = SlashingsCache::new(Slot::new(7), validators.iter()); + + // The cache is initialized at the block slot it was built for. + assert!(cache.is_initialized(Slot::new(7))); + assert!(!cache.is_initialized(Slot::new(8))); + + // Each index reports the same `slashed` status as the source validator. + for (index, validator) in validators.iter().enumerate() { + assert_eq!( + cache.is_slashed(index), + validator.slashed, + "validator {index} slashed status mismatch" + ); + } + + // An out-of-bounds index is not slashed. + assert!(!cache.is_slashed(validators.len())); + } + + #[test] + fn default_is_uninitialized() { + let cache = SlashingsCache::default(); + + // A default cache is not initialized at any slot. + assert!(!cache.is_initialized(Slot::new(0))); + assert_eq!( + cache.check_initialized(Slot::new(0)), + Err(BeaconStateError::SlashingsCacheUninitialized { + initialized_slot: None, + latest_block_slot: Slot::new(0), + }) + ); + + // It reports nothing as slashed. This is exactly why callers must check initialization + // before trusting `is_slashed`. + assert!(!cache.is_slashed(0)); + } + + #[test] + fn check_initialized_matches_block_slot() { + let cache = SlashingsCache::new(Slot::new(3), validators().iter()); + + assert_eq!(cache.check_initialized(Slot::new(3)), Ok(())); + assert_eq!( + cache.check_initialized(Slot::new(4)), + Err(BeaconStateError::SlashingsCacheUninitialized { + initialized_slot: Some(Slot::new(3)), + latest_block_slot: Slot::new(4), + }) + ); + } + + #[test] + fn record_validator_slashing_requires_matching_slot() { + let mut cache = SlashingsCache::new(Slot::new(3), validators().iter()); + + // Index 0 starts unslashed. + assert!(!cache.is_slashed(0)); + + // Recording at the initialized slot succeeds and marks the validator slashed. + cache.record_validator_slashing(Slot::new(3), 0).unwrap(); + assert!(cache.is_slashed(0)); + + // Recording at a slot the cache is not initialized for errors and leaves the set unchanged. + assert_eq!( + cache.record_validator_slashing(Slot::new(4), 2), + Err(BeaconStateError::SlashingsCacheUninitialized { + initialized_slot: Some(Slot::new(3)), + latest_block_slot: Slot::new(4), + }) + ); + assert!(!cache.is_slashed(2)); + } + + #[test] + fn update_latest_block_slot_preserves_slashed_set() { + let mut cache = SlashingsCache::new(Slot::new(3), validators().iter()); + + cache.update_latest_block_slot(Slot::new(4)); + + // The initialized slot moves forward without clearing the recorded slashings. + assert!(!cache.is_initialized(Slot::new(3))); + assert!(cache.is_initialized(Slot::new(4))); + assert!(cache.is_slashed(1)); + assert!(cache.is_slashed(3)); + assert!(!cache.is_slashed(0)); + } +} diff --git a/deny.toml b/deny.toml index 015f2ec88b..5a8691fd5c 100644 --- a/deny.toml +++ b/deny.toml @@ -21,6 +21,7 @@ deny = [ { crate = "scrypt", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "syn", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "uuid", deny-multiple-versions = true, reason = "dependency hygiene" }, + { crate = "lru", deny-multiple-versions = true, reason = "use hashlink instead" }, ] [sources] diff --git a/lighthouse/tests/beacon_node.rs b/lighthouse/tests/beacon_node.rs index 8bdab48282..b8fd978ac5 100644 --- a/lighthouse/tests/beacon_node.rs +++ b/lighthouse/tests/beacon_node.rs @@ -2785,10 +2785,49 @@ fn invalid_block_roots_default_mainnet() { }) } +#[test] +fn enable_mplex_default() { + CommandLineTest::new() + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_mplex); + }) +} + +#[test] +fn enable_mplex_true() { + CommandLineTest::new() + .flag("enable-mplex", Some("true")) + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_mplex); + }) +} + +#[test] +fn enable_mplex_false() { + CommandLineTest::new() + .flag("enable-mplex", Some("false")) + .run_with_zero_port() + .with_config(|config| { + assert!(!config.network.enable_mplex); + }) +} + +#[test] +fn enable_mplex_no_value() { + CommandLineTest::new() + .flag("enable-mplex", None) + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_mplex); + }) +} + #[test] fn partial_columns() { CommandLineTest::new() - .flag("enable-partial-columns", None) + .flag("enable-partial-columns", Some("true")) .run_with_zero_port() .with_config(|config| { assert!(config.network.enable_partial_columns); @@ -2803,6 +2842,18 @@ fn partial_columns() { }) } +#[test] +fn partial_columns_no_value() { + // Passing the flag without a value should enable partial columns. + CommandLineTest::new() + .flag("enable-partial-columns", None) + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_partial_columns); + assert!(config.chain.enable_partial_columns); + }); +} + #[test] fn partial_columns_default_hoodi() { CommandLineTest::new() @@ -2826,10 +2877,10 @@ fn partial_columns_default_sepolia() { } #[test] -fn partial_columns_disable_overrides_hoodi_default() { +fn partial_columns_false_overrides_hoodi_default() { CommandLineTest::new() .flag("network", Some("hoodi")) - .flag("disable-partial-columns", None) + .flag("enable-partial-columns", Some("false")) .run_with_zero_port() .with_config(|config| { assert!(!config.network.enable_partial_columns); @@ -2838,24 +2889,12 @@ fn partial_columns_disable_overrides_hoodi_default() { } #[test] -fn partial_columns_disable_on_mainnet_no_op() { +fn partial_columns_false_on_mainnet() { CommandLineTest::new() - .flag("disable-partial-columns", None) + .flag("enable-partial-columns", Some("false")) .run_with_zero_port() .with_config(|config| { assert!(!config.network.enable_partial_columns); assert!(!config.chain.enable_partial_columns); }); } - -#[test] -fn partial_columns_enable_disable_conflict() { - let mut cmd = base_cmd(); - cmd.arg("--enable-partial-columns") - .arg("--disable-partial-columns"); - let output = cmd.output().expect("should run command"); - assert!( - !output.status.success(), - "expected clap to reject --enable-partial-columns and --disable-partial-columns together", - ); -} diff --git a/lighthouse/tests/validator_client.rs b/lighthouse/tests/validator_client.rs index 945e363ab5..2cbf2aaef0 100644 --- a/lighthouse/tests/validator_client.rs +++ b/lighthouse/tests/validator_client.rs @@ -130,6 +130,21 @@ fn disable_auto_discover_flag() { .with_config(|config| assert!(config.disable_auto_discover)); } +#[test] +fn disable_proposer_duties_v2_default() { + CommandLineTest::new() + .run() + .with_config(|config| assert!(!config.disable_proposer_duties_v2)); +} + +#[test] +fn disable_proposer_duties_v2_flag() { + CommandLineTest::new() + .flag("disable-proposer-duties-v2", None) + .run() + .with_config(|config| assert!(config.disable_proposer_duties_v2)); +} + #[test] fn init_slashing_protections_flag() { CommandLineTest::new() diff --git a/slasher/Cargo.toml b/slasher/Cargo.toml index a068b2e885..83cfb4861e 100644 --- a/slasher/Cargo.toml +++ b/slasher/Cargo.toml @@ -22,9 +22,9 @@ ethereum_ssz_derive = { workspace = true } filesystem = { workspace = true } fixed_bytes = { workspace = true } flate2 = { version = "1.0.14", features = ["zlib"], default-features = false } +hashlink = { workspace = true } lmdb-rkv = { git = "https://github.com/sigp/lmdb-rs", rev = "f33845c6469b94265319aac0ed5085597862c27e", optional = true } lmdb-rkv-sys = { git = "https://github.com/sigp/lmdb-rs", rev = "f33845c6469b94265319aac0ed5085597862c27e", optional = true } -lru = { workspace = true } # MDBX is pinned at the last version with Windows and macOS support. mdbx = { package = "libmdbx", git = "https://github.com/sigp/libmdbx-rs", rev = "e6ff4b9377c1619bcf0bfdf52bee5a980a432a1a", optional = true } diff --git a/slasher/src/database.rs b/slasher/src/database.rs index 80d073a81c..ed8079689b 100644 --- a/slasher/src/database.rs +++ b/slasher/src/database.rs @@ -9,8 +9,8 @@ use crate::{ }; use bls::AggregateSignature; use byteorder::{BigEndian, ByteOrder}; +use hashlink::lru_cache::LruCache; use interface::{Environment, OpenDatabases, RwTransaction}; -use lru::LruCache; use parking_lot::Mutex; use serde::de::DeserializeOwned; use ssz::{Decode, Encode}; @@ -305,7 +305,8 @@ impl SlasherDB { } } - let attestation_root_cache = Mutex::new(LruCache::new(config.attestation_root_cache_size)); + let attestation_root_cache = + Mutex::new(LruCache::new(config.attestation_root_cache_size.get())); let mut db = Self { env, @@ -559,7 +560,7 @@ impl SlasherDB { let indexed_attestation = self.get_indexed_attestation(txn, indexed_id)?; let attestation_data_root = indexed_attestation.data().tree_hash_root(); - cache.put(indexed_id, attestation_data_root); + cache.insert(indexed_id, attestation_data_root); Ok((attestation_data_root, Some(indexed_attestation))) } @@ -570,13 +571,13 @@ impl SlasherDB { attestation_data_root: Hash256, ) { let mut cache = self.attestation_root_cache.lock(); - cache.put(indexed_attestation_id, attestation_data_root); + cache.insert(indexed_attestation_id, attestation_data_root); } fn delete_attestation_data_roots(&self, ids: impl IntoIterator) { let mut cache = self.attestation_root_cache.lock(); for indexed_id in ids { - cache.pop(&indexed_id); + cache.remove(&indexed_id); } } diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index f566a89ded..a497fdaeff 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.8 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.10 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index 723c5e7e9e..c81f94d8e9 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -84,6 +84,8 @@ excluded_paths = [ "tests/.*/.*/networking/gossip_sync_committee_message/.*", "tests/.*/.*/networking/gossip_sync_committee_contribution_and_proof/.*", "tests/.*/.*/networking/gossip_blob_sidecar/.*", + "tests/.*/.*/networking/gossip_data_column_sidecar/.*", + "tests/.*/.*/networking/gossip_partial_data_column_sidecar/.*", # TODO: fast confirmation rule not merged yet "tests/.*/.*/fast_confirmation", ] diff --git a/testing/ef_tests/src/cases/epoch_processing.rs b/testing/ef_tests/src/cases/epoch_processing.rs index ec243f05cc..b89a72ca77 100644 --- a/testing/ef_tests/src/cases/epoch_processing.rs +++ b/testing/ef_tests/src/cases/epoch_processing.rs @@ -423,6 +423,9 @@ impl> Case for EpochProcessing { // Processing requires the committee caches. pre_state.build_all_committee_caches(spec).unwrap(); + // Proposer index computation (e.g. proposer lookahead) requires the slashings cache post-Gloas + pre_state.build_slashings_cache().unwrap(); + let mut state = pre_state.clone(); let mut expected = self.post.clone(); diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index cf122dbd65..9edc5b85c8 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -53,6 +53,9 @@ pub struct PowBlock { pub struct Head { slot: Slot, root: Hash256, + // Post-gloas, the head check also asserts the payload status of the head block + #[serde(default)] + payload_status: Option, } #[derive(Debug, Clone, Copy, PartialEq, Deserialize)] @@ -132,6 +135,10 @@ pub enum Step< }, Attestation { attestation: TAttestation, + // Post-Gloas `on_attestation` tests can assert that an attestation is rejected (e.g. an + // invalid payload-present index). Defaults to `true` for the pre-Gloas tests that omit it. + #[serde(default = "default_true")] + valid: bool, }, AttesterSlashing { attester_slashing: TAttesterSlashing, @@ -169,8 +176,12 @@ fn default_true() -> bool { #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Meta { - #[serde(rename(deserialize = "description"))] - _description: String, + #[serde(default, rename(deserialize = "description"))] + _description: Option, + // Some Gloas fork choice tests carry a `bls_setting` instead of a description. We accept and + // ignore it: the value is always `1` (BLS required), which matches our default behaviour. + #[serde(default, rename(deserialize = "bls_setting"))] + _bls_setting: Option, } #[derive(Debug)] @@ -240,17 +251,19 @@ impl LoadCase for ForkChoiceTest { valid, }) } - Step::Attestation { attestation } => { + Step::Attestation { attestation, valid } => { if fork_name.electra_enabled() { ssz_decode_file(&path.join(format!("{}.ssz_snappy", attestation))).map( |attestation| Step::Attestation { attestation: Attestation::Electra(attestation), + valid, }, ) } else { ssz_decode_file(&path.join(format!("{}.ssz_snappy", attestation))).map( |attestation| Step::Attestation { attestation: Attestation::Base(attestation), + valid, }, ) } @@ -389,7 +402,9 @@ impl Case for ForkChoiceTest { proofs.clone(), *valid, )?, - Step::Attestation { attestation } => tester.process_attestation(attestation)?, + Step::Attestation { attestation, valid } => { + tester.process_attestation(attestation, *valid)? + } Step::AttesterSlashing { attester_slashing } => { tester.process_attester_slashing(attester_slashing.to_ref()) } @@ -673,7 +688,7 @@ impl Tester { if success { for attestation in block.message().body().attestations() { let att = attestation.clone_as_attestation(); - let _ = self.process_attestation(&att); + let _ = self.process_attestation(&att, true); } for attester_slashing in block.message().body().attester_slashings() { self.process_attester_slashing(attester_slashing); @@ -786,7 +801,7 @@ impl Tester { if success { for attestation in block.message().body().attestations() { let att = attestation.clone_as_attestation(); - let _ = self.process_attestation(&att); + let _ = self.process_attestation(&att, true); } for attester_slashing in block.message().body().attester_slashings() { self.process_attester_slashing(attester_slashing); @@ -848,7 +863,6 @@ impl Tester { block_delay, &state, PayloadVerificationStatus::Irrelevant, - block.message().proposer_index(), &self.harness.chain.spec, ); @@ -863,22 +877,41 @@ impl Tester { Ok(()) } - pub fn process_attestation(&self, attestation: &Attestation) -> Result<(), Error> { - let (indexed_attestation, _) = obtain_indexed_attestation_and_committees_per_slot( + pub fn process_attestation( + &self, + attestation: &Attestation, + valid: bool, + ) -> Result<(), Error> { + // Post-Gloas `on_attestation` tests can assert that an attestation is rejected (e.g. an + // invalid same-slot/payload-present index). Treat any failure in either indexing or fork + // choice application as a rejection so it can be compared against the expected `valid` flag. + let result = obtain_indexed_attestation_and_committees_per_slot( &self.harness.chain, attestation.to_ref(), ) - .map_err(|e| Error::InternalError(format!("attestation indexing failed with {:?}", e)))?; - let verified_attestation: ManuallyVerifiedAttestation> = - ManuallyVerifiedAttestation { - attestation, - indexed_attestation, - }; + .map_err(|e| format!("attestation indexing failed with {:?}", e)) + .and_then(|(indexed_attestation, _)| { + let verified_attestation: ManuallyVerifiedAttestation> = + ManuallyVerifiedAttestation { + attestation, + indexed_attestation, + }; - self.harness - .chain - .apply_attestation_to_fork_choice(&verified_attestation) - .map_err(|e| Error::InternalError(format!("attestation import failed with {:?}", e))) + self.harness + .chain + .apply_attestation_to_fork_choice(&verified_attestation) + .map_err(|e| format!("attestation import failed with {:?}", e)) + }); + + if valid { + result.map_err(Error::InternalError) + } else if result.is_ok() { + Err(Error::DidntFail( + "attestation was valid but the test expects it to be rejected".to_string(), + )) + } else { + Ok(()) + } } pub fn process_attester_slashing(&self, attester_slashing: AttesterSlashingRef) { @@ -909,9 +942,17 @@ impl Tester { let chain_head = Head { slot: head.head_slot(), root: head.head_block_root(), + // Compared separately below so the slot/root equality is not affected. + payload_status: expected_head.payload_status, }; - check_equal("head", chain_head, expected_head) + check_equal("head", chain_head, expected_head)?; + + if let Some(expected_status) = expected_head.payload_status { + self.check_head_payload_status(expected_status)?; + } + + Ok(()) } pub fn check_time(&self, expected_time: u64) -> Result<(), Error> { diff --git a/testing/ef_tests/src/cases/operations.rs b/testing/ef_tests/src/cases/operations.rs index f5c999920d..d851427a95 100644 --- a/testing/ef_tests/src/cases/operations.rs +++ b/testing/ef_tests/src/cases/operations.rs @@ -204,7 +204,12 @@ impl Operation for Deposit { ssz_decode_file(path) } - fn is_enabled_for_fork(_: ForkName) -> bool { + fn is_enabled_for_fork(fork_name: ForkName) -> bool { + // The standalone `deposit` operation tests were removed in Fulu (deposits are processed + // via `deposit_request` from Electra onwards). + if fork_name.fulu_enabled() { + return false; + } // Some deposit tests require signature verification but are not marked as such. cfg!(not(feature = "fake_crypto")) } diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index df1ece49dd..b45ea3a230 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -708,13 +708,6 @@ impl Handler for ForkChoiceHandler { return false; } - // No FCU override tests prior to bellatrix, and removed in Gloas. - if self.handler_name == "should_override_forkchoice_update" - && (!fork_name.bellatrix_enabled() || fork_name.gloas_enabled()) - { - return false; - } - // Deposit tests exist only for Electra and later. if self.handler_name == "deposit_with_reorg" && !fork_name.electra_enabled() { return false; @@ -725,9 +718,10 @@ impl Handler for ForkChoiceHandler { return false; } - // on_execution_payload_envelope, get_parent_payload_status, and + // on_attestation, on_execution_payload_envelope, get_parent_payload_status, and // on_payload_attestation_message tests exist only for Gloas and later. - if (self.handler_name == "on_execution_payload_envelope" + if (self.handler_name == "on_attestation" + || self.handler_name == "on_execution_payload_envelope" || self.handler_name == "get_parent_payload_status" || self.handler_name == "on_payload_attestation_message") && !fork_name.gloas_enabled() diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 6e1c4fdc10..9af88e0201 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -708,14 +708,18 @@ mod ssz_static { #[test] fn blob_sidecar() { - SszStaticHandler::, MinimalEthSpec>::deneb_and_later().run(); - SszStaticHandler::, MainnetEthSpec>::deneb_and_later().run(); + SszStaticHandler::, MinimalEthSpec>::deneb_only().run(); + SszStaticHandler::, MainnetEthSpec>::deneb_only().run(); + SszStaticHandler::, MinimalEthSpec>::electra_only().run(); + SszStaticHandler::, MainnetEthSpec>::electra_only().run(); } #[test] fn blob_identifier() { - SszStaticHandler::::deneb_and_later().run(); - SszStaticHandler::::deneb_and_later().run(); + SszStaticHandler::::deneb_only().run(); + SszStaticHandler::::deneb_only().run(); + SszStaticHandler::::electra_only().run(); + SszStaticHandler::::electra_only().run(); } #[test] @@ -1025,6 +1029,12 @@ fn fork_choice_get_head() { ForkChoiceHandler::::new("get_head").run(); } +#[test] +fn fork_choice_on_attestation() { + ForkChoiceHandler::::new("on_attestation").run(); + ForkChoiceHandler::::new("on_attestation").run(); +} + #[test] fn fork_choice_on_block() { ForkChoiceHandler::::new("on_block").run(); @@ -1049,12 +1059,6 @@ fn fork_choice_withholding() { // There is no mainnet variant for this test. } -#[test] -fn fork_choice_should_override_forkchoice_update() { - ForkChoiceHandler::::new("should_override_forkchoice_update").run(); - ForkChoiceHandler::::new("should_override_forkchoice_update").run(); -} - #[test] fn fork_choice_get_proposer_head() { ForkChoiceHandler::::new("get_proposer_head").run(); diff --git a/testing/execution_engine_integration/src/geth.rs b/testing/execution_engine_integration/src/geth.rs index 4b62e68e94..a676cb1a4c 100644 --- a/testing/execution_engine_integration/src/geth.rs +++ b/testing/execution_engine_integration/src/geth.rs @@ -108,7 +108,6 @@ impl GenericExecutionEngine for GethEngine { .arg(http_auth_port.to_string()) .arg("--port") .arg(network_port.to_string()) - .arg("--allow-insecure-unlock") .arg("--authrpc.jwtsecret") .arg(jwt_secret_path.as_path().to_str().unwrap()) // This flag is required to help Geth perform reliably when feeding it blocks diff --git a/validator_client/src/cli.rs b/validator_client/src/cli.rs index e5fe1580da..cf21e276d7 100644 --- a/validator_client/src/cli.rs +++ b/validator_client/src/cli.rs @@ -105,6 +105,17 @@ pub struct ValidatorClient { )] pub disable_attesting: bool, + #[clap( + long, + help = "Fetch proposer duties using the v1 beacon node endpoint instead of v2. The v1 \ + endpoint reports an incorrect dependent root which causes spurious proposer duty \ + re-org warnings. Only enable this flag if your beacon node does not serve the v2 \ + proposer duties endpoint.", + display_order = 0, + help_heading = FLAG_HEADER + )] + pub disable_proposer_duties_v2: bool, + #[clap( long, help = "If present, the validator client will use longer timeouts for requests \ diff --git a/validator_client/src/config.rs b/validator_client/src/config.rs index 418cd385da..3e5abebc68 100644 --- a/validator_client/src/config.rs +++ b/validator_client/src/config.rs @@ -92,6 +92,8 @@ pub struct Config { #[serde(flatten)] pub initialized_validators: InitializedValidatorsConfig, pub disable_attesting: bool, + /// Fetch proposer duties using the v1 endpoint instead of v2. + pub disable_proposer_duties_v2: bool, } impl Default for Config { @@ -139,6 +141,7 @@ impl Default for Config { distributed: false, initialized_validators: <_>::default(), disable_attesting: false, + disable_proposer_duties_v2: false, } } } @@ -402,6 +405,7 @@ impl Config { }; config.disable_attesting = validator_client_config.disable_attesting; + config.disable_proposer_duties_v2 = validator_client_config.disable_proposer_duties_v2; Ok(config) } diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 71d9333493..9680189b1a 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -502,6 +502,7 @@ impl ProductionValidatorClient { .attestation_selection_proof_config(attestation_selection_proof_config) .sync_selection_proof_config(sync_selection_proof_config) .disable_attesting(config.disable_attesting) + .disable_proposer_duties_v2(config.disable_proposer_duties_v2) .build()?, ); diff --git a/validator_client/validator_services/src/duties_service.rs b/validator_client/validator_services/src/duties_service.rs index 2a371abf62..5fe413a216 100644 --- a/validator_client/validator_services/src/duties_service.rs +++ b/validator_client/validator_services/src/duties_service.rs @@ -305,6 +305,7 @@ pub struct DutiesServiceBuilder { /// Create sync selection proof config sync_selection_proof_config: SelectionProofConfig, disable_attesting: bool, + disable_proposer_duties_v2: bool, } impl Default for DutiesServiceBuilder { @@ -325,6 +326,7 @@ impl DutiesServiceBuilder { attestation_selection_proof_config: SelectionProofConfig::default(), sync_selection_proof_config: SelectionProofConfig::default(), disable_attesting: false, + disable_proposer_duties_v2: false, } } @@ -382,6 +384,11 @@ impl DutiesServiceBuilder { self } + pub fn disable_proposer_duties_v2(mut self, disable_proposer_duties_v2: bool) -> Self { + self.disable_proposer_duties_v2 = disable_proposer_duties_v2; + self + } + pub fn build(self) -> Result, String> { Ok(DutiesService { attesters: Default::default(), @@ -405,6 +412,7 @@ impl DutiesServiceBuilder { enable_high_validator_count_metrics: self.enable_high_validator_count_metrics, selection_proof_config: self.attestation_selection_proof_config, disable_attesting: self.disable_attesting, + disable_proposer_duties_v2: self.disable_proposer_duties_v2, }) } } @@ -437,6 +445,11 @@ pub struct DutiesService { /// Pass the config for distributed or non-distributed mode. pub selection_proof_config: SelectionProofConfig, pub disable_attesting: bool, + /// Use the v1 proposer duties endpoint instead of v2. The v1 endpoint reports an incorrect + /// dependent root, causing spurious "Proposer duties re-org" warnings. This flag exists for + /// compatibility with beacon nodes that do not yet serve the v2 endpoint and can be removed + /// after Gloas. + pub disable_proposer_duties_v2: bool, } impl DutiesService { @@ -1660,54 +1673,8 @@ async fn poll_beacon_proposers( // Only download duties and push out additional block production events if we have some // validators. if !local_pubkeys.is_empty() { - let download_result = duties_service - .beacon_nodes - .first_success(|beacon_node| async move { - let _timer = validator_metrics::start_timer_vec( - &validator_metrics::DUTIES_SERVICE_TIMES, - &[validator_metrics::PROPOSER_DUTIES_HTTP_GET], - ); - beacon_node - .get_validator_duties_proposer(current_epoch) - .await - }) - .await; - - match download_result { - Ok(response) => { - let dependent_root = response.dependent_root; - - let relevant_duties = response - .data - .into_iter() - .filter(|proposer_duty| local_pubkeys.contains(&proposer_duty.pubkey)) - .collect::>(); - - debug!( - %dependent_root, - num_relevant_duties = relevant_duties.len(), - "Downloaded proposer duties" - ); - - if let Some((prior_dependent_root, _)) = duties_service - .proposers - .write() - .insert(current_epoch, (dependent_root, relevant_duties)) - && dependent_root != prior_dependent_root - { - warn!( - %prior_dependent_root, - %dependent_root, - msg = "this may happen from time to time", - "Proposer duties re-org" - ) - } - } - // Don't return early here, we still want to try and produce blocks using the cached values. - Err(e) => error!( - err = %e, - "Failed to download proposer duties" - ), + for epoch in [current_epoch, current_epoch + 1] { + fetch_and_store_proposer_duties(duties_service, epoch, &local_pubkeys).await; } // Compute the block proposers for this slot again, now that we've received an update from @@ -1750,6 +1717,70 @@ async fn poll_beacon_proposers( Ok(()) } +async fn fetch_and_store_proposer_duties( + duties_service: &DutiesService, + epoch: Epoch, + local_pubkeys: &HashSet, +) { + let use_v2 = !duties_service.disable_proposer_duties_v2; + let download_result = duties_service + .beacon_nodes + .first_success(|beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::PROPOSER_DUTIES_HTTP_GET], + ); + // Prefer the v2 endpoint, which reports the correct dependent root. The v1 endpoint + // returns an incorrect dependent root, leading to spurious "Proposer duties re-org" + // warnings. + if use_v2 { + beacon_node.get_validator_duties_proposer_v2(epoch).await + } else { + beacon_node.get_validator_duties_proposer(epoch).await + } + }) + .await; + + match download_result { + Ok(response) => { + let dependent_root = response.dependent_root; + + let relevant_duties = response + .data + .into_iter() + .filter(|proposer_duty| local_pubkeys.contains(&proposer_duty.pubkey)) + .collect::>(); + + debug!( + %dependent_root, + %epoch, + num_relevant_duties = relevant_duties.len(), + "Downloaded proposer duties" + ); + + if let Some((prior_dependent_root, _)) = duties_service + .proposers + .write() + .insert(epoch, (dependent_root, relevant_duties)) + && dependent_root != prior_dependent_root + { + warn!( + %prior_dependent_root, + %dependent_root, + %epoch, + msg = "this may happen from time to time", + "Proposer duties re-org" + ) + } + } + Err(e) => error!( + err = %e, + %epoch, + "Failed to download proposer duties" + ), + } +} + /// Query the beacon node for ptc duties for any known validators. async fn poll_beacon_ptc_attesters( duties_service: &Arc>, diff --git a/validator_client/validator_services/src/payload_attestation_service.rs b/validator_client/validator_services/src/payload_attestation_service.rs index f4cd26552a..34b9f96f7f 100644 --- a/validator_client/validator_services/src/payload_attestation_service.rs +++ b/validator_client/validator_services/src/payload_attestation_service.rs @@ -1,5 +1,6 @@ use crate::duties_service::DutiesService; use beacon_node_fallback::BeaconNodeFallback; +use eth2::types::PtcDuty; use logging::crit; use slot_clock::SlotClock; use std::ops::Deref; @@ -7,7 +8,7 @@ use std::sync::Arc; use task_executor::TaskExecutor; use tokio::time::sleep; use tracing::{debug, error, info}; -use types::{ChainSpec, EthSpec, Slot}; +use types::{ChainSpec, EthSpec, PayloadAttestationData, Slot}; use validator_store::ValidatorStore; pub struct Inner { @@ -74,7 +75,9 @@ where let interval_fut = async move { loop { - self.run_update().await; + if let Err(e) = self.spawn_payload_attestation_tasks().await { + error!(error = e, "Failed to produce payload attestations"); + } } }; @@ -82,18 +85,32 @@ where Ok(()) } - async fn run_update(&self) { + async fn spawn_payload_attestation_tasks(&self) -> Result<(), String> { let Some(attestation_slot) = self.wait_for_attestation_slot().await else { - return; + return Ok(()); + }; + + let Some((duties, attestation_data)) = self + .produce_payload_attestation_data(attestation_slot) + .await? + else { + return Ok(()); }; let service = self.clone(); self.executor.spawn( async move { - service.produce_and_publish(attestation_slot).await; + if let Err(e) = service + .sign_and_publish(attestation_slot, duties, attestation_data) + .await + { + crit!(error = e, %attestation_slot, "Failed to publish payload attestations"); + } }, "payload_attestation_producer", ); + + Ok(()) } async fn wait_for_attestation_slot(&self) -> Option { @@ -136,11 +153,18 @@ where Some(attestation_slot) } - async fn produce_and_publish(&self, slot: types::Slot) { + /// Produce the payload attestation data for `slot`, returned alongside the duties to sign. + /// + /// Returns `Ok(None)` when there is nothing to publish (no duties, or no block for the slot) + /// and `Err` when data production failed. + async fn produce_payload_attestation_data( + &self, + slot: Slot, + ) -> Result, PayloadAttestationData)>, String> { let duties = self.duties_service.get_ptc_duties_for_slot(slot); if duties.is_empty() { - return; + return Ok(None); } debug!( @@ -167,15 +191,10 @@ where %slot, "No block received for slot, skipping payload attestation" ); - return; + return Ok(None); } Err(e) => { - error!( - error = %e, - %slot, - "Failed to produce payload attestation data" - ); - return; + return Err(e.to_string()); } }; @@ -186,6 +205,17 @@ where "Received payload attestation data" ); + Ok(Some((duties, attestation_data))) + } + + /// Sign `attestation_data` for each duty and publish the resulting messages, preferring SSZ + /// and falling back to JSON. + async fn sign_and_publish( + &self, + slot: Slot, + duties: Vec, + attestation_data: PayloadAttestationData, + ) -> Result<(), String> { let mut messages = Vec::with_capacity(duties.len()); for duty in &duties { @@ -209,7 +239,7 @@ where } if messages.is_empty() { - return; + return Ok(()); } let count = messages.len(); @@ -227,42 +257,31 @@ where }) .await; - let result = match result { - Ok(()) => Ok(()), - Err(_) => { - debug!(%slot, "SSZ publish failed, falling back to JSON"); - self.beacon_nodes - .first_success(|beacon_node| { - let messages = messages.clone(); - async move { - beacon_node - .post_beacon_pool_payload_attestations(&messages, fork_name) - .await - .map_err(|e| { - format!("Failed to publish payload attestations (JSON): {e:?}") - }) - } - }) - .await - } - }; - - match result { - Ok(()) => { - info!( - %slot, - %count, - "Successfully published payload attestations" - ); - } - Err(e) => { - crit!( - error = %e, - %slot, - "Failed to publish payload attestations" - ); - } + if result.is_err() { + debug!(%slot, "SSZ publish failed, falling back to JSON"); + self.beacon_nodes + .first_success(|beacon_node| { + let messages = messages.clone(); + async move { + beacon_node + .post_beacon_pool_payload_attestations(&messages, fork_name) + .await + .map_err(|e| { + format!("Failed to publish payload attestations (JSON): {e:?}") + }) + } + }) + .await + .map_err(|e| e.to_string())?; } + + info!( + %slot, + %count, + "Successfully published payload attestations" + ); + + Ok(()) } } @@ -529,7 +548,15 @@ mod tests { .mock_post_beacon_pool_payload_attestations(); let service = harness.service; - service.produce_and_publish(attestation_slot).await; + let (duties, attestation_data) = service + .produce_payload_attestation_data(attestation_slot) + .await + .unwrap() + .unwrap(); + service + .sign_and_publish(attestation_slot, duties, attestation_data) + .await + .unwrap(); let messages = harness .mock_beacon_node_1 @@ -591,7 +618,15 @@ mod tests { .mock_post_beacon_pool_payload_attestations(); let service = harness.service; - service.produce_and_publish(attestation_slot).await; + let (duties, attestation_data) = service + .produce_payload_attestation_data(attestation_slot) + .await + .unwrap() + .unwrap(); + service + .sign_and_publish(attestation_slot, duties, attestation_data) + .await + .unwrap(); // first_success function tries both beacon nodes for SSZ post payload attestation: // first pass: both fail (mock_ssz returns 500, mock_json does not support SSZ) @@ -625,9 +660,16 @@ mod tests { let service = harness.service; - // when there is no duty, produce_and_publish should return early + // when there is no duty, data production returns `None` so there is nothing to publish // therefore, the beacon node is not called, expected to hit 0 - service.produce_and_publish(Slot::new(1)).await; + let data = service + .produce_payload_attestation_data(Slot::new(1)) + .await + .unwrap(); + assert!( + data.is_none(), + "Expected no data to be produced without duties" + ); mock.expect(0).assert(); assert!( @@ -665,8 +707,11 @@ mod tests { .mock_post_beacon_pool_payload_attestations(); let service = harness.service; - // The produce_and_publish() should return early before reaching the POST endpoint - service.produce_and_publish(attestation_slot).await; + // Data production should error before any signing/publishing happens. + let result = service + .produce_payload_attestation_data(attestation_slot) + .await; + assert!(result.is_err()); // Both beacon nodes should not be called at all mock_ssz.expect(0).assert(); @@ -712,7 +757,15 @@ mod tests { .mock_post_beacon_pool_payload_attestations_ssz(Duration::from_secs(0)); let service = harness.service; - service.produce_and_publish(attestation_slot).await; + let (duties, attestation_data) = service + .produce_payload_attestation_data(attestation_slot) + .await + .unwrap() + .unwrap(); + service + .sign_and_publish(attestation_slot, duties, attestation_data) + .await + .unwrap(); let messages = harness .mock_beacon_node_1 diff --git a/validator_client/validator_services/src/proposer_preferences_service.rs b/validator_client/validator_services/src/proposer_preferences_service.rs index fc17a1bce6..330517482e 100644 --- a/validator_client/validator_services/src/proposer_preferences_service.rs +++ b/validator_client/validator_services/src/proposer_preferences_service.rs @@ -1,12 +1,14 @@ use crate::duties_service::DutiesService; use beacon_node_fallback::BeaconNodeFallback; +use eth2::types::ProposerData; use slot_clock::SlotClock; +use std::collections::HashMap; use std::ops::Deref; use std::sync::Arc; use task_executor::TaskExecutor; use tokio::time::sleep; use tracing::{debug, error, info, warn}; -use types::{ChainSpec, Epoch, EthSpec, ForkName, ProposerPreferences}; +use types::{ChainSpec, Epoch, EthSpec, ForkName, Hash256, ProposerPreferences}; use validator_store::ValidatorStore; pub struct Inner { @@ -66,6 +68,8 @@ impl ProposerPreferencesSer let executor = self.executor.clone(); let interval_fut = async move { + let mut published_preferences: HashMap = HashMap::new(); + loop { let Some(current_slot) = self.slot_clock.now() else { error!("Failed to read slot clock"); @@ -73,29 +77,16 @@ impl ProposerPreferencesSer continue; }; - if !self - .chain_spec - .fork_name_at_slot::(current_slot) - .gloas_enabled() - { - let duration_to_next_epoch = self - .slot_clock - .duration_to_next_epoch(S::E::slots_per_epoch()) - .unwrap_or_else(|| slot_duration * S::E::slots_per_epoch() as u32); - sleep(duration_to_next_epoch).await; - continue; - } - let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); - let fork_name = self.chain_spec.fork_name_at_slot::(current_slot); - self.publish_proposer_preferences(current_epoch, fork_name) + + self.poll_and_publish_preferences(current_epoch, &mut published_preferences) .await; - let duration_to_next_epoch = self + let duration_to_next_slot = self .slot_clock - .duration_to_next_epoch(S::E::slots_per_epoch()) - .unwrap_or_else(|| slot_duration * S::E::slots_per_epoch() as u32); - sleep(duration_to_next_epoch).await; + .duration_to_next_slot() + .unwrap_or(slot_duration); + sleep(duration_to_next_slot).await; } }; @@ -103,15 +94,57 @@ impl ProposerPreferencesSer Ok(()) } - async fn publish_proposer_preferences(&self, current_epoch: Epoch, fork_name: ForkName) { - let (dependent_root, duties) = { - let proposers = self.duties_service.proposers.read(); - match proposers.get(¤t_epoch) { - Some((root, duties)) => (*root, duties.clone()), - None => return, + /// Publish proposer preferences for `current_epoch` and `current_epoch + 1`. + /// Will only publish preferences for a given epoch once per dependent root. + async fn poll_and_publish_preferences( + &self, + current_epoch: Epoch, + published_preferences: &mut HashMap, + ) { + for (epoch, fork_name) in [ + ( + current_epoch, + self.chain_spec.fork_name_at_epoch(current_epoch), + ), + ( + current_epoch + 1, + self.chain_spec.fork_name_at_epoch(current_epoch + 1), + ), + ] { + if !fork_name.gloas_enabled() { + continue; } - }; + let (dependent_root, duties) = { + let proposers = self.duties_service.proposers.read(); + match proposers.get(&epoch) { + Some((root, duties)) => (*root, duties.clone()), + None => continue, + } + }; + + if published_preferences.get(&epoch) == Some(&dependent_root) { + continue; + } + + if self + .publish_proposer_preferences(epoch, fork_name, dependent_root, duties) + .await + { + published_preferences.insert(epoch, dependent_root); + } + } + + published_preferences.retain(|epoch, _| *epoch >= current_epoch); + } + + async fn publish_proposer_preferences( + &self, + epoch: Epoch, + fork_name: ForkName, + dependent_root: Hash256, + duties: Vec, + ) -> bool { let preferences_to_sign: Vec<_> = { let mut result = vec![]; for duty in &duties { @@ -144,11 +177,11 @@ impl ProposerPreferencesSer }; if preferences_to_sign.is_empty() { - return; + return false; } debug!( - %current_epoch, + %epoch, count = preferences_to_sign.len(), "Signing proposer preferences" ); @@ -172,7 +205,7 @@ impl ProposerPreferencesSer } if signed.is_empty() { - return; + return false; } let count = signed.len(); @@ -182,39 +215,50 @@ impl ProposerPreferencesSer .first_success(|beacon_node| { let signed = signed.clone(); async move { - match beacon_node + beacon_node .post_validator_proposer_preferences_ssz(&signed, fork_name) .await - { - Ok(()) => Ok(()), - Err(ssz_err) => { - debug!(error = ?ssz_err, "SSZ publish failed, falling back to JSON"); - beacon_node - .post_validator_proposer_preferences(&signed, fork_name) - .await - .map_err(|e| { - format!("Failed to publish proposer preferences: {e:?}") - }) - } - } + .map_err(|e| format!("Failed to publish proposer preferences (SSZ): {e:?}")) } }) .await; + let result = match result { + Ok(()) => Ok(()), + Err(ssz_err) => { + debug!(error = %ssz_err, "SSZ publish failed, falling back to JSON"); + self.beacon_nodes + .first_success(|beacon_node| { + let signed = signed.clone(); + async move { + beacon_node + .post_validator_proposer_preferences(&signed, fork_name) + .await + .map_err(|e| { + format!("Failed to publish proposer preferences (JSON): {e:?}") + }) + } + }) + .await + } + }; + match result { Ok(()) => { info!( - %current_epoch, + %epoch, %count, "Successfully published proposer preferences" ); + true } Err(e) => { error!( error = %e, - %current_epoch, + %epoch, "Failed to publish proposer preferences" ); + false } } } diff --git a/wordlist.txt b/wordlist.txt index 822e336146..f0076e6332 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -45,6 +45,7 @@ Fusaka Geth GiB Gitcoin +Gloas Gnosis Goerli Grafana