From 71f6eab51f5c5e58afc9c3f4fbfbca4dfc605025 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 4 Mar 2026 15:50:42 +1100 Subject: [PATCH 01/15] Bump deps --- Cargo.lock | 79 ++++++++++++++++++++++++++---------------------------- Cargo.toml | 9 +++++-- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40c550f4c6..1795de0bc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1909,7 +1909,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]] @@ -2442,7 +2442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 2.0.111", + "syn 1.0.109", ] [[package]] @@ -3985,11 +3985,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -4996,7 +4996,7 @@ dependencies = [ [[package]] name = "libp2p" version = "0.56.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "bytes", "either", @@ -5027,7 +5027,7 @@ dependencies = [ [[package]] name = "libp2p-allow-block-list" version = "0.6.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "libp2p-connection-limits" version = "0.6.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5047,7 +5047,7 @@ dependencies = [ [[package]] name = "libp2p-core" version = "0.43.2" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "either", "fnv", @@ -5071,7 +5071,7 @@ dependencies = [ [[package]] name = "libp2p-dns" version = "0.44.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "async-trait", "futures", @@ -5086,7 +5086,7 @@ dependencies = [ [[package]] name = "libp2p-gossipsub" version = "0.50.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "async-channel 2.5.0", "asynchronous-codec", @@ -5098,7 +5098,7 @@ dependencies = [ "futures", "futures-timer", "getrandom 0.2.16", - "hashlink 0.10.0", + "hashlink 0.11.0", "hex_fmt", "libp2p-core", "libp2p-identity", @@ -5116,7 +5116,7 @@ dependencies = [ [[package]] name = "libp2p-identify" version = "0.47.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "asynchronous-codec", "either", @@ -5156,7 +5156,7 @@ dependencies = [ [[package]] name = "libp2p-mdns" version = "0.48.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "hickory-proto", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "libp2p-metrics" version = "0.17.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "libp2p-core", @@ -5190,7 +5190,7 @@ dependencies = [ [[package]] name = "libp2p-mplex" version = "0.43.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "asynchronous-codec", "bytes", @@ -5208,7 +5208,7 @@ dependencies = [ [[package]] name = "libp2p-noise" version = "0.46.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "asynchronous-codec", "bytes", @@ -5230,7 +5230,7 @@ dependencies = [ [[package]] name = "libp2p-quic" version = "0.13.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "futures-timer", @@ -5250,14 +5250,14 @@ dependencies = [ [[package]] name = "libp2p-swarm" -version = "0.47.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +version = "0.47.1" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "either", "fnv", "futures", "futures-timer", - "hashlink 0.10.0", + "hashlink 0.11.0", "libp2p-core", "libp2p-identity", "libp2p-swarm-derive", @@ -5272,7 +5272,7 @@ dependencies = [ [[package]] name = "libp2p-swarm-derive" version = "0.35.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "heck", "quote", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "libp2p-tcp" version = "0.44.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "futures-timer", @@ -5297,7 +5297,7 @@ dependencies = [ [[package]] name = "libp2p-tls" version = "0.6.2" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "futures-rustls", @@ -5315,7 +5315,7 @@ dependencies = [ [[package]] name = "libp2p-upnp" version = "0.6.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "futures-timer", @@ -5329,7 +5329,7 @@ dependencies = [ [[package]] name = "libp2p-yamux" version = "0.47.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "either", "futures", @@ -6021,7 +6021,7 @@ dependencies = [ [[package]] name = "multistream-select" version = "0.13.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "bytes", "futures", @@ -7129,7 +7129,7 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-protobuf" version = "0.8.1" -source = "git+https://github.com/sigp/quick-protobuf.git?rev=681f413312404ab6e51f0b46f39b0075c6f4ebfd#681f413312404ab6e51f0b46f39b0075c6f4ebfd" +source = "git+https://github.com/sigp/quick-protobuf.git?rev=87c4ccb9bb2af494de375f5f6c62850badd26304#87c4ccb9bb2af494de375f5f6c62850badd26304" dependencies = [ "byteorder", ] @@ -7137,7 +7137,7 @@ dependencies = [ [[package]] name = "quick-protobuf-codec" version = "0.3.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "asynchronous-codec", "bytes", @@ -7149,8 +7149,7 @@ dependencies = [ [[package]] name = "quinn" version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +source = "git+https://github.com/sigp/quinn?rev=59af87979c8411864c1cb68613222f54ed2930a7#59af87979c8411864c1cb68613222f54ed2930a7" dependencies = [ "bytes", "cfg_aliases", @@ -7160,7 +7159,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.35", - "socket2 0.6.1", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -7170,8 +7169,7 @@ dependencies = [ [[package]] name = "quinn-proto" version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +source = "git+https://github.com/sigp/quinn?rev=59af87979c8411864c1cb68613222f54ed2930a7#59af87979c8411864c1cb68613222f54ed2930a7" dependencies = [ "bytes", "getrandom 0.3.4", @@ -7191,15 +7189,14 @@ dependencies = [ [[package]] name = "quinn-udp" version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +source = "git+https://github.com/sigp/quinn?rev=59af87979c8411864c1cb68613222f54ed2930a7#59af87979c8411864c1cb68613222f54ed2930a7" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -7822,7 +7819,7 @@ dependencies = [ [[package]] name = "rw-stream-sink" version = "0.4.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "pin-project", @@ -10132,7 +10129,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]] @@ -10607,7 +10604,7 @@ dependencies = [ [[package]] name = "yamux" version = "0.13.8" -source = "git+https://github.com/sigp/rust-yamux?rev=575b17c0f44f4253079a6bafaa2de74ca1d6dfaa#575b17c0f44f4253079a6bafaa2de74ca1d6dfaa" +source = "git+https://github.com/sigp/rust-yamux?rev=29efa6aebd4bdfcb16bfb21969ec0c785e570b74#29efa6aebd4bdfcb16bfb21969ec0c785e570b74" dependencies = [ "futures", "log", diff --git a/Cargo.toml b/Cargo.toml index 5f6f43d2f2..ab33cb6310 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -302,5 +302,10 @@ inherits = "release" debug = true [patch.crates-io] -quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "681f413312404ab6e51f0b46f39b0075c6f4ebfd" } -yamux = { git = "https://github.com/sigp/rust-yamux", rev = "575b17c0f44f4253079a6bafaa2de74ca1d6dfaa" } +quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "87c4ccb9bb2af494de375f5f6c62850badd26304" } +yamux = { git = "https://github.com/sigp/rust-yamux", rev = "29efa6aebd4bdfcb16bfb21969ec0c785e570b74" } +quinn = { git = "https://github.com/sigp/quinn", rev = "59af87979c8411864c1cb68613222f54ed2930a7" } + +[patch."https://github.com/libp2p/rust-libp2p.git"] +libp2p = { git = "https://github.com/sigp/rust-libp2p.git", rev = "f88e43de9eba00b416d0374b1a1fb2de47b65864" } +libp2p-mplex = { git = "https://github.com/sigp/rust-libp2p.git", rev = "f88e43de9eba00b416d0374b1a1fb2de47b65864" } From ac1db1d2e23f849f7937bbc38cb9c85445d837dc Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:48:30 +0800 Subject: [PATCH 02/15] update cargo-sort (#8933) Co-Authored-By: Tan Chee Keong --- Cargo.toml | 30 +++--------------------------- common/logging/Cargo.toml | 2 +- common/malloc_utils/Cargo.toml | 5 +---- 3 files changed, 5 insertions(+), 32 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ab33cb6310..82db6dbfc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,20 +166,7 @@ initialized_validators = { path = "validator_client/initialized_validators" } int_to_bytes = { path = "consensus/int_to_bytes" } itertools = "0.10" kzg = { path = "crypto/kzg" } -libp2p = { git = "https://github.com/libp2p/rust-libp2p.git", default-features = false, features = [ - "identify", - "yamux", - "noise", - "dns", - "tcp", - "tokio", - "secp256k1", - "macros", - "metrics", - "quic", - "upnp", - "gossipsub", -] } +libp2p = { git = "https://github.com/libp2p/rust-libp2p.git", default-features = false, features = ["identify", "yamux", "noise", "dns", "tcp", "tokio", "secp256k1", "macros", "metrics", "quic", "upnp", "gossipsub"] } libsecp256k1 = "0.7" lighthouse_network = { path = "beacon_node/lighthouse_network" } lighthouse_validator_store = { path = "validator_client/lighthouse_validator_store" } @@ -219,13 +206,7 @@ r2d2 = "0.8" rand = "0.9.0" rayon = "1.7" regex = "1" -reqwest = { version = "0.12", default-features = false, features = [ - "blocking", - "json", - "stream", - "rustls-tls", - "native-tls-vendored", -] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "stream", "rustls-tls", "native-tls-vendored"] } ring = "0.17" rpds = "0.11" rusqlite = { version = "0.28", features = ["bundled"] } @@ -254,12 +235,7 @@ sysinfo = "0.26" system_health = { path = "common/system_health" } task_executor = { path = "common/task_executor" } tempfile = "3" -tokio = { version = "1", features = [ - "rt-multi-thread", - "sync", - "signal", - "macros", -] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "signal", "macros"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", features = ["codec", "compat", "time"] } tracing = "0.1.40" diff --git a/common/logging/Cargo.toml b/common/logging/Cargo.toml index 41c82dbd61..cbebd1a501 100644 --- a/common/logging/Cargo.toml +++ b/common/logging/Cargo.toml @@ -13,7 +13,7 @@ logroller = { workspace = true } metrics = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tokio = { workspace = true, features = [ "time" ] } +tokio = { workspace = true, features = ["time"] } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-core = { workspace = true } diff --git a/common/malloc_utils/Cargo.toml b/common/malloc_utils/Cargo.toml index 1052128852..e90490bf09 100644 --- a/common/malloc_utils/Cargo.toml +++ b/common/malloc_utils/Cargo.toml @@ -35,7 +35,4 @@ tikv-jemallocator = { version = "0.6.0", optional = true, features = ["stats"] } # Jemalloc's background_threads feature requires Linux (pthreads). [target.'cfg(target_os = "linux")'.dependencies] -tikv-jemallocator = { version = "0.6.0", optional = true, features = [ - "stats", - "background_threads", -] } +tikv-jemallocator = { version = "0.6.0", optional = true, features = ["stats", "background_threads"] } From 5a174f2a00b33d4905bcc241749b96965541132a Mon Sep 17 00:00:00 2001 From: Mac L Date: Fri, 6 Mar 2026 09:54:43 +0200 Subject: [PATCH 03/15] Fix lints for Rust v1.94.0 (#8939) Following the release of Rust v1.94.0 there are new Clippy lints which do not pass and are blocking CI (which pulls in the latest version of Rust) This is pretty much the minimum just to get CI running again. Most of the errors involve error types being too large. For now I've added allows but later it might be worth doing a refactor to `Box` or otherwise remove the problematic error types. Co-Authored-By: Mac L --- beacon_node/beacon_chain/tests/attestation_verification.rs | 1 + beacon_node/beacon_chain/tests/payload_invalidation.rs | 1 + beacon_node/beacon_chain/tests/store_tests.rs | 1 + beacon_node/execution_layer/src/lib.rs | 2 +- beacon_node/http_api/src/lib.rs | 1 + slasher/service/src/lib.rs | 1 + 6 files changed, 6 insertions(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index 208798dfdf..9553965ae6 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -1,4 +1,5 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::attestation_verification::{ Error, batch_verify_aggregated_attestations, batch_verify_unaggregated_attestations, diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index 1204412d65..11c916e850 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1,4 +1,5 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::block_verification_types::RpcBlock; use beacon_chain::{ diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index ea5f735bde..e618873bdd 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -1,4 +1,5 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::attestation_verification::Error as AttnError; use beacon_chain::block_verification_types::RpcBlock; diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 33b83aab09..024c6805b9 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -2205,7 +2205,7 @@ fn verify_builder_bid( .cloned() .map(|withdrawals| { Withdrawals::::try_from(withdrawals) - .map_err(InvalidBuilderPayload::SszTypesError) + .map_err(|e| Box::new(InvalidBuilderPayload::SszTypesError(e))) .map(|w| w.tree_hash_root()) }) .transpose()?; diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 095c52fb29..69aa7cd91f 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::result_large_err)] //! This crate contains a HTTP server which serves the endpoints listed here: //! //! https://github.com/ethereum/beacon-APIs diff --git a/slasher/service/src/lib.rs b/slasher/service/src/lib.rs index ac15b49ee9..69ec59aa2c 100644 --- a/slasher/service/src/lib.rs +++ b/slasher/service/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::result_large_err)] mod service; pub use service::SlasherService; From 6a92761f441e5a3a9169454df11025cb1a32d751 Mon Sep 17 00:00:00 2001 From: Akihito Nakano Date: Sat, 7 Mar 2026 08:09:33 +0900 Subject: [PATCH 04/15] Fix cargo-sort errors (#8945) The `cargo-sort` job in CI is [failing](https://github.com/sigp/lighthouse/actions/runs/22781651620/job/66088700318?pr=8932) since [cargo-sort v2.1.1](https://github.com/DevinR528/cargo-sort/releases/tag/v2.1.1) has been released, which reports new errors for our Cargo.toml files. Ran `cargo-sort` formatter locally with the new version. Co-Authored-By: ackintosh --- account_manager/Cargo.toml | 5 +---- beacon_node/Cargo.toml | 13 +++++-------- beacon_node/beacon_chain/Cargo.toml | 9 ++++++--- common/logging/Cargo.toml | 3 ++- consensus/types/Cargo.toml | 5 +---- 5 files changed, 15 insertions(+), 20 deletions(-) diff --git a/account_manager/Cargo.toml b/account_manager/Cargo.toml index 8dd50cbc6e..05e6f12554 100644 --- a/account_manager/Cargo.toml +++ b/account_manager/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "account_manager" version = { workspace = true } -authors = [ - "Paul Hauner ", - "Luke Anderson ", -] +authors = ["Paul Hauner ", "Luke Anderson "] edition = { workspace = true } [dependencies] diff --git a/beacon_node/Cargo.toml b/beacon_node/Cargo.toml index 5352814dd5..ebefa6a451 100644 --- a/beacon_node/Cargo.toml +++ b/beacon_node/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "beacon_node" version = { workspace = true } -authors = [ - "Paul Hauner ", - "Age Manning ", "Age Manning "] edition = { workspace = true } [features] -test_logger = [] # Print log output to stderr when running tests instead of dropping it +# Print log output to stderr when running tests instead of dropping it. +test_logger = [] [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } diff --git a/consensus/types/Cargo.toml b/consensus/types/Cargo.toml index feea855c84..e5c5662d71 100644 --- a/consensus/types/Cargo.toml +++ b/consensus/types/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "types" version = "0.2.1" -authors = [ - "Paul Hauner ", - "Age Manning ", -] +authors = ["Paul Hauner ", "Age Manning "] edition = { workspace = true } [features] From 3deab9b0410233c1d57bddfaa9903cc6fbdaa958 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 9 Mar 2026 11:27:08 +1100 Subject: [PATCH 05/15] Release v8.1.2 --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1795de0bc1..cba93f2fd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "account_manager" -version = "8.1.1" +version = "8.1.2" dependencies = [ "account_utils", "bls", @@ -1276,7 +1276,7 @@ dependencies = [ [[package]] name = "beacon_node" -version = "8.1.1" +version = "8.1.2" dependencies = [ "account_utils", "beacon_chain", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "boot_node" -version = "8.1.1" +version = "8.1.2" dependencies = [ "beacon_node", "bytes", @@ -4897,7 +4897,7 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "lcli" -version = "8.1.1" +version = "8.1.2" dependencies = [ "account_utils", "beacon_chain", @@ -5383,7 +5383,7 @@ dependencies = [ [[package]] name = "lighthouse" -version = "8.1.1" +version = "8.1.2" dependencies = [ "account_manager", "account_utils", @@ -5515,7 +5515,7 @@ dependencies = [ [[package]] name = "lighthouse_version" -version = "8.1.1" +version = "8.1.2" dependencies = [ "regex", ] @@ -9619,7 +9619,7 @@ dependencies = [ [[package]] name = "validator_client" -version = "8.1.1" +version = "8.1.2" dependencies = [ "account_utils", "beacon_node_fallback", diff --git a/Cargo.toml b/Cargo.toml index 82db6dbfc4..f483e998c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ resolver = "2" [workspace.package] edition = "2024" -version = "8.1.1" +version = "8.1.2" [workspace.dependencies] account_utils = { path = "common/account_utils" } From 9f3873f2bf242440d1ee8e06d078ea189b73e53b Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 10 Mar 2026 16:49:35 +1100 Subject: [PATCH 06/15] Fix syn in Cargo.lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index d96021aaea..704039a175 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2461,7 +2461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.111", ] [[package]] From 081229b7488e37d472f768a3908a0c37fb320d7c Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 10 Mar 2026 18:57:51 +1100 Subject: [PATCH 07/15] Implement proposer duties v2 endpoint (#8918) Fix the issue with the `proposer_duties` endpoint using the wrong dependent root post-Fulu by implementing the new v2 endpoint: - https://github.com/ethereum/beacon-APIs/pull/563 We need to add this in time for Gloas, and then we can we can deprecate and remove v1. - Add a new API handler for the v2 endpoint - Add client code in the `eth2` crate - Update existing tests and add some new ones to confirm the different behaviour of v1 and v2 There's a bit of test duplication with v1, but this will be resolved once v1 and its tests are deleted. Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul Co-Authored-By: chonghe <44791194+chong-he@users.noreply.github.com> --- beacon_node/http_api/src/lib.rs | 3 +- beacon_node/http_api/src/proposer_duties.rs | 99 ++++++-- beacon_node/http_api/src/validator/mod.rs | 17 +- .../http_api/tests/interactive_tests.rs | 234 ++++++++++++++++++ beacon_node/http_api/tests/tests.rs | 99 ++++++++ common/eth2/src/lib.rs | 18 ++ 6 files changed, 438 insertions(+), 32 deletions(-) diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 0a0ae683ca..b5b74a3840 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -263,6 +263,7 @@ pub fn prometheus_metrics() -> warp::filters::log::Log( // GET validator/duties/proposer/{epoch} let get_validator_duties_proposer = get_validator_duties_proposer( - eth_v1.clone(), + any_version.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), diff --git a/beacon_node/http_api/src/proposer_duties.rs b/beacon_node/http_api/src/proposer_duties.rs index 1ebb174785..0b0926f955 100644 --- a/beacon_node/http_api/src/proposer_duties.rs +++ b/beacon_node/http_api/src/proposer_duties.rs @@ -13,13 +13,45 @@ use slot_clock::SlotClock; use tracing::debug; use types::{Epoch, EthSpec, Hash256, Slot}; +/// Selects which dependent root to return in the API response. +/// +/// - `Legacy`: the block root at the last slot of epoch N-1 (v1 behaviour, for backwards compat). +/// - `True`: the fork-aware proposer shuffling decision root (v2 behaviour). Pre-Fulu this equals +/// the legacy root; post-Fulu it uses epoch N-2. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DependentRootSelection { + Legacy, + True, +} + /// The struct that is returned to the requesting HTTP client. type ApiDuties = api_types::DutiesResponse>; -/// Handles a request from the HTTP API for proposer duties. +/// Handles a request from the HTTP API for v1 proposer duties. +/// +/// Returns the legacy dependent root (block root at end of epoch N-1) for backwards compatibility. pub fn proposer_duties( request_epoch: Epoch, chain: &BeaconChain, +) -> Result { + proposer_duties_internal(request_epoch, chain, DependentRootSelection::Legacy) +} + +/// Handles a request from the HTTP API for v2 proposer duties. +/// +/// Returns the true fork-aware dependent root. Pre-Fulu this equals the legacy root; post-Fulu it +/// uses epoch N-2 due to deterministic proposer lookahead with `min_seed_lookahead`. +pub fn proposer_duties_v2( + request_epoch: Epoch, + chain: &BeaconChain, +) -> Result { + proposer_duties_internal(request_epoch, chain, DependentRootSelection::True) +} + +fn proposer_duties_internal( + request_epoch: Epoch, + chain: &BeaconChain, + root_selection: DependentRootSelection, ) -> Result { let current_epoch = chain .slot_clock @@ -49,24 +81,29 @@ pub fn proposer_duties( if request_epoch == current_epoch || request_epoch == tolerant_current_epoch { // If we could consider ourselves in the `request_epoch` when allowing for clock disparity // tolerance then serve this request from the cache. - if let Some(duties) = try_proposer_duties_from_cache(request_epoch, chain)? { + if let Some(duties) = try_proposer_duties_from_cache(request_epoch, chain, root_selection)? + { Ok(duties) } else { debug!(%request_epoch, "Proposer cache miss"); - compute_and_cache_proposer_duties(request_epoch, chain) + compute_and_cache_proposer_duties(request_epoch, chain, root_selection) } } else if request_epoch == current_epoch .safe_add(1) .map_err(warp_utils::reject::arith_error)? { - let (proposers, _dependent_root, legacy_dependent_root, execution_status, _fork) = + let (proposers, dependent_root, legacy_dependent_root, execution_status, _fork) = compute_proposer_duties_from_head(request_epoch, chain) .map_err(warp_utils::reject::unhandled_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => legacy_dependent_root, + DependentRootSelection::True => dependent_root, + }; convert_to_api_response( chain, request_epoch, - legacy_dependent_root, + selected_root, execution_status.is_optimistic_or_invalid(), proposers, ) @@ -84,7 +121,7 @@ pub fn proposer_duties( // request_epoch < current_epoch // // Queries about the past are handled with a slow path. - compute_historic_proposer_duties(request_epoch, chain) + compute_historic_proposer_duties(request_epoch, chain, root_selection) } } @@ -98,6 +135,7 @@ pub fn proposer_duties( fn try_proposer_duties_from_cache( request_epoch: Epoch, chain: &BeaconChain, + root_selection: DependentRootSelection, ) -> Result, warp::reject::Rejection> { let head = chain.canonical_head.cached_head(); let head_block = &head.snapshot.beacon_block; @@ -116,11 +154,14 @@ fn try_proposer_duties_from_cache( .beacon_state .proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root, &chain.spec) .map_err(warp_utils::reject::beacon_state_error)?; - let legacy_dependent_root = head - .snapshot - .beacon_state - .legacy_proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root) - .map_err(warp_utils::reject::beacon_state_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => head + .snapshot + .beacon_state + .legacy_proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root) + .map_err(warp_utils::reject::beacon_state_error)?, + DependentRootSelection::True => head_decision_root, + }; let execution_optimistic = chain .is_optimistic_or_invalid_head_block(head_block) .map_err(warp_utils::reject::unhandled_error)?; @@ -134,7 +175,7 @@ fn try_proposer_duties_from_cache( convert_to_api_response( chain, request_epoch, - legacy_dependent_root, + selected_root, execution_optimistic, indices.to_vec(), ) @@ -155,6 +196,7 @@ fn try_proposer_duties_from_cache( fn compute_and_cache_proposer_duties( current_epoch: Epoch, chain: &BeaconChain, + root_selection: DependentRootSelection, ) -> Result { let (indices, dependent_root, legacy_dependent_root, execution_status, fork) = compute_proposer_duties_from_head(current_epoch, chain) @@ -168,10 +210,14 @@ fn compute_and_cache_proposer_duties( .map_err(BeaconChainError::from) .map_err(warp_utils::reject::unhandled_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => legacy_dependent_root, + DependentRootSelection::True => dependent_root, + }; convert_to_api_response( chain, current_epoch, - legacy_dependent_root, + selected_root, execution_status.is_optimistic_or_invalid(), indices, ) @@ -182,6 +228,7 @@ fn compute_and_cache_proposer_duties( fn compute_historic_proposer_duties( epoch: Epoch, chain: &BeaconChain, + root_selection: DependentRootSelection, ) -> Result { // If the head is quite old then it might still be relevant for a historical request. // @@ -219,9 +266,9 @@ fn compute_historic_proposer_duties( }; // Ensure the state lookup was correct. - if state.current_epoch() != epoch { + if state.current_epoch() != epoch && state.current_epoch() + 1 != epoch { return Err(warp_utils::reject::custom_server_error(format!( - "state epoch {} not equal to request epoch {}", + "state from epoch {} cannot serve request epoch {}", state.current_epoch(), epoch ))); @@ -234,18 +281,18 @@ fn compute_historic_proposer_duties( // We can supply the genesis block root as the block root since we know that the only block that // decides its own root is the genesis block. - let legacy_dependent_root = state - .legacy_proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root) - .map_err(BeaconChainError::from) - .map_err(warp_utils::reject::unhandled_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => state + .legacy_proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root) + .map_err(BeaconChainError::from) + .map_err(warp_utils::reject::unhandled_error)?, + DependentRootSelection::True => state + .proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root, &chain.spec) + .map_err(BeaconChainError::from) + .map_err(warp_utils::reject::unhandled_error)?, + }; - convert_to_api_response( - chain, - epoch, - legacy_dependent_root, - execution_optimistic, - indices, - ) + convert_to_api_response(chain, epoch, selected_root, execution_optimistic, indices) } /// Converts the internal representation of proposer duties into one that is compatible with the diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index a9082df715..3d96b85870 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -6,7 +6,7 @@ use crate::utils::{ AnyVersionFilter, ChainFilter, EthV1Filter, NetworkTxFilter, NotWhileSyncingFilter, ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message, }; -use crate::version::V3; +use crate::version::{V1, V2, V3, unsupported_version_rejection}; use crate::{StateId, attester_duties, proposer_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::validator_monitor::timestamp_now; @@ -971,12 +971,12 @@ pub fn post_validator_aggregate_and_proofs( // GET validator/duties/proposer/{epoch} pub fn get_validator_duties_proposer( - eth_v1: EthV1Filter, + any_version: AnyVersionFilter, chain_filter: ChainFilter, not_while_syncing_filter: NotWhileSyncingFilter, task_spawner_filter: TaskSpawnerFilter, ) -> ResponseFilter { - eth_v1 + any_version .and(warp::path("validator")) .and(warp::path("duties")) .and(warp::path("proposer")) @@ -990,13 +990,20 @@ pub fn get_validator_duties_proposer( .and(task_spawner_filter) .and(chain_filter) .then( - |epoch: Epoch, + |endpoint_version: EndpointVersion, + epoch: Epoch, not_synced_filter: Result<(), Rejection>, task_spawner: TaskSpawner, chain: Arc>| { task_spawner.blocking_json_task(Priority::P0, move || { not_synced_filter?; - proposer_duties::proposer_duties(epoch, &chain) + if endpoint_version == V1 { + proposer_duties::proposer_duties(epoch, &chain) + } else if endpoint_version == V2 { + proposer_duties::proposer_duties_v2(epoch, &chain) + } else { + Err(unsupported_version_rejection(endpoint_version)) + } }) }, ) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index a18dd10464..e0e4029875 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -1053,6 +1053,240 @@ async fn proposer_duties_with_gossip_tolerance() { ); } +// Test that a request for next epoch v2 proposer duties succeeds when the current slot clock is +// within gossip clock disparity (500ms) of the new epoch. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn proposer_duties_v2_with_gossip_tolerance() { + let validator_count = 24; + + let tester = InteractiveTester::::new(None, validator_count).await; + let harness = &tester.harness; + let spec = &harness.spec; + let client = &tester.client; + + let num_initial = 4 * E::slots_per_epoch() - 1; + let next_epoch_start_slot = Slot::new(num_initial + 1); + + harness.advance_slot(); + harness + .extend_chain_with_sync( + num_initial as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + SyncCommitteeStrategy::NoValidators, + LightClientStrategy::Disabled, + ) + .await; + + assert_eq!(harness.chain.slot().unwrap(), num_initial); + + // Set the clock to just before the next epoch. + harness.chain.slot_clock.advance_time( + Duration::from_secs(spec.seconds_per_slot) - spec.maximum_gossip_clock_disparity(), + ); + assert_eq!( + harness + .chain + .slot_clock + .now_with_future_tolerance(spec.maximum_gossip_clock_disparity()) + .unwrap(), + next_epoch_start_slot + ); + + let head_state = harness.get_current_state(); + let head_block_root = harness.head_block_root(); + let tolerant_current_epoch = next_epoch_start_slot.epoch(E::slots_per_epoch()); + + // Prime the proposer shuffling cache with an incorrect entry (regression test). + let wrong_decision_root = head_state + .proposer_shuffling_decision_root(head_block_root, spec) + .unwrap(); + let wrong_proposer_indices = vec![0; E::slots_per_epoch() as usize]; + harness + .chain + .beacon_proposer_cache + .lock() + .insert( + tolerant_current_epoch, + wrong_decision_root, + wrong_proposer_indices.clone(), + head_state.fork(), + ) + .unwrap(); + + // Request the v2 proposer duties. + let proposer_duties_tolerant_current_epoch = client + .get_validator_duties_proposer_v2(tolerant_current_epoch) + .await + .unwrap(); + + assert_eq!( + proposer_duties_tolerant_current_epoch.dependent_root, + head_state + .proposer_shuffling_decision_root_at_epoch( + tolerant_current_epoch, + head_block_root, + spec, + ) + .unwrap() + ); + assert_ne!( + proposer_duties_tolerant_current_epoch + .data + .iter() + .map(|data| data.validator_index as usize) + .collect::>(), + wrong_proposer_indices, + ); + + // We should get the exact same result after properly advancing into the epoch. + harness + .chain + .slot_clock + .advance_time(spec.maximum_gossip_clock_disparity()); + assert_eq!(harness.chain.slot().unwrap(), next_epoch_start_slot); + let proposer_duties_current_epoch = client + .get_validator_duties_proposer_v2(tolerant_current_epoch) + .await + .unwrap(); + + assert_eq!( + proposer_duties_tolerant_current_epoch, + proposer_duties_current_epoch + ); +} + +// Test that post-Fulu, v1 and v2 proposer duties return different dependent roots. +// Post-Fulu, the true dependent root shifts to the block root at the end of epoch N-2 (due to +// `min_seed_lookahead`), while the legacy v1 root remains at the end of epoch N-1. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn proposer_duties_v2_post_fulu_dependent_root() { + type E = MinimalEthSpec; + let spec = test_spec::(); + + if !spec.is_fulu_scheduled() { + return; + } + + let validator_count = 24; + let slots_per_epoch = E::slots_per_epoch(); + + let tester = InteractiveTester::::new(Some(spec.clone()), validator_count).await; + let harness = &tester.harness; + let client = &tester.client; + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + mock_el.server.all_payloads_valid(); + + // Build 3 full epochs of chain so we're in epoch 3. + let num_slots = 3 * slots_per_epoch; + harness.advance_slot(); + harness + .extend_chain_with_sync( + num_slots as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + SyncCommitteeStrategy::AllValidators, + LightClientStrategy::Disabled, + ) + .await; + + let current_epoch = harness.chain.epoch().unwrap(); + assert_eq!(current_epoch, Epoch::new(3)); + + // For epoch 3 with min_seed_lookahead=1: + // Post-Fulu decision slot: end of epoch N-2 = end of epoch 1 = slot 15 + // Legacy decision slot: end of epoch N-1 = end of epoch 2 = slot 23 + let true_decision_slot = Epoch::new(1).end_slot(slots_per_epoch); + let legacy_decision_slot = Epoch::new(2).end_slot(slots_per_epoch); + assert_eq!(true_decision_slot, Slot::new(15)); + assert_eq!(legacy_decision_slot, Slot::new(23)); + + // Fetch the block roots at these slots to compute expected dependent roots. + let expected_v2_root = harness + .chain + .block_root_at_slot(true_decision_slot, beacon_chain::WhenSlotSkipped::Prev) + .unwrap() + .unwrap(); + let expected_v1_root = harness + .chain + .block_root_at_slot(legacy_decision_slot, beacon_chain::WhenSlotSkipped::Prev) + .unwrap() + .unwrap(); + + // Sanity check: the two roots should be different since they refer to different blocks. + assert_ne!( + expected_v1_root, expected_v2_root, + "legacy and true decision roots should differ post-Fulu" + ); + + // Query v1 and v2 proposer duties for the current epoch. + let v1_result = client + .get_validator_duties_proposer(current_epoch) + .await + .unwrap(); + let v2_result = client + .get_validator_duties_proposer_v2(current_epoch) + .await + .unwrap(); + + // The proposer assignments (data) must be identical. + assert_eq!(v1_result.data, v2_result.data); + + // The dependent roots must differ. + assert_ne!( + v1_result.dependent_root, v2_result.dependent_root, + "v1 and v2 dependent roots should differ post-Fulu" + ); + + // Verify each root matches the expected value. + assert_eq!( + v1_result.dependent_root, expected_v1_root, + "v1 dependent root should be block root at end of epoch N-1" + ); + assert_eq!( + v2_result.dependent_root, expected_v2_root, + "v2 dependent root should be block root at end of epoch N-2" + ); + + // Also verify the next-epoch path (epoch 4). + let next_epoch = current_epoch + 1; + let v1_next = client + .get_validator_duties_proposer(next_epoch) + .await + .unwrap(); + let v2_next = client + .get_validator_duties_proposer_v2(next_epoch) + .await + .unwrap(); + + assert_eq!(v1_next.data, v2_next.data); + assert_ne!( + v1_next.dependent_root, v2_next.dependent_root, + "v1 and v2 next-epoch dependent roots should differ post-Fulu" + ); + + // For epoch 4: true decision is end of epoch 2 (slot 23), legacy is end of epoch 3 (slot 31). + let expected_v2_next_root = harness + .chain + .block_root_at_slot( + Epoch::new(2).end_slot(slots_per_epoch), + beacon_chain::WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap(); + let expected_v1_next_root = harness + .chain + .block_root_at_slot( + Epoch::new(3).end_slot(slots_per_epoch), + beacon_chain::WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap_or(harness.head_block_root()); + assert_eq!(v1_next.dependent_root, expected_v1_next_root); + assert_eq!(v2_next.dependent_root, expected_v2_next_root); + assert_ne!(expected_v2_next_root, harness.head_block_root()); +} + // Test that a request to `lighthouse/custody/backfill` succeeds by verifying that `CustodyContext` and `DataColumnCustodyInfo` // have been updated with the correct values. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 6696e109a5..739c39aa32 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -3392,6 +3392,80 @@ impl ApiTester { self } + pub async fn test_get_validator_duties_proposer_v2(self) -> Self { + let current_epoch = self.chain.epoch().unwrap(); + + for epoch in 0..=current_epoch.as_u64() + 1 { + let epoch = Epoch::from(epoch); + + // Compute the true dependent root using the spec's decision slot. + let decision_slot = self.chain.spec.proposer_shuffling_decision_slot::(epoch); + let dependent_root = self + .chain + .block_root_at_slot(decision_slot, WhenSlotSkipped::Prev) + .unwrap() + .unwrap_or(self.chain.head_beacon_block_root()); + + let result = self + .client + .get_validator_duties_proposer_v2(epoch) + .await + .unwrap(); + + let mut state = self + .chain + .state_at_slot( + epoch.start_slot(E::slots_per_epoch()), + StateSkipConfig::WithStateRoots, + ) + .unwrap(); + + state + .build_committee_cache(RelativeEpoch::Current, &self.chain.spec) + .unwrap(); + + let expected_duties = epoch + .slot_iter(E::slots_per_epoch()) + .map(|slot| { + let index = state + .get_beacon_proposer_index(slot, &self.chain.spec) + .unwrap(); + let pubkey = state.validators().get(index).unwrap().pubkey; + + ProposerData { + pubkey, + validator_index: index as u64, + slot, + } + }) + .collect::>(); + + let expected = DutiesResponse { + data: expected_duties, + execution_optimistic: Some(false), + dependent_root, + }; + + assert_eq!(result, expected); + + // v1 and v2 should return the same data. + let v1_result = self + .client + .get_validator_duties_proposer(epoch) + .await + .unwrap(); + assert_eq!(result.data, v1_result.data); + } + + // Requests to the epochs after the next epoch should fail. + self.client + .get_validator_duties_proposer_v2(current_epoch + 2) + .await + .unwrap_err(); + + self + } + pub async fn test_get_validator_duties_early(self) -> Self { let current_epoch = self.chain.epoch().unwrap(); let next_epoch = current_epoch + 1; @@ -7617,6 +7691,31 @@ async fn get_validator_duties_proposer_with_skip_slots() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_proposer_v2() { + ApiTester::new_from_config(ApiTesterConfig { + spec: test_spec::(), + retain_historic_states: true, + ..ApiTesterConfig::default() + }) + .await + .test_get_validator_duties_proposer_v2() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_proposer_v2_with_skip_slots() { + ApiTester::new_from_config(ApiTesterConfig { + spec: test_spec::(), + retain_historic_states: true, + ..ApiTesterConfig::default() + }) + .await + .skip_slots(E::slots_per_epoch() * 2) + .test_get_validator_duties_proposer_v2() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn block_production() { ApiTester::new().await.test_block_production().await; diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index ac96da6173..628c12981a 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -2144,6 +2144,24 @@ impl BeaconNodeHttpClient { .await } + /// `GET v2/validator/duties/proposer/{epoch}` + pub async fn get_validator_duties_proposer_v2( + &self, + epoch: Epoch, + ) -> Result>, Error> { + let mut path = self.eth_path(V2)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("duties") + .push("proposer") + .push(&epoch.to_string()); + + self.get_with_timeout(path, self.timeouts.proposer_duties) + .await + } + /// `GET v2/validator/blocks/{slot}` pub async fn get_validator_blocks( &self, From 906400ed3435fffebaf5a56b60b778dbe3883ec1 Mon Sep 17 00:00:00 2001 From: Romeo Date: Tue, 10 Mar 2026 14:36:58 +0100 Subject: [PATCH 08/15] Implement proposer lookahead endpoint (#8815) closes #8809 Implement GET /eth/v1/beacon/states/{state_id}/proposer_lookahead ([beacon-APIs#565](https://github.com/ethereum/beacon-APIs/pull/565)). Returns the proposer lookahead from Fulu+ states; 400 for pre-Fulu. Includes integration test. Co-Authored-By: romeoscript Co-Authored-By: Tan Chee Keong --- beacon_node/http_api/src/beacon/states.rs | 68 +++++++++++++++++- beacon_node/http_api/src/lib.rs | 5 ++ beacon_node/http_api/tests/tests.rs | 85 ++++++++++++++++++++++- common/eth2/src/lib.rs | 41 +++++++++++ 4 files changed, 196 insertions(+), 3 deletions(-) diff --git a/beacon_node/http_api/src/beacon/states.rs b/beacon_node/http_api/src/beacon/states.rs index 50be7211d8..02ac3f4da7 100644 --- a/beacon_node/http_api/src/beacon/states.rs +++ b/beacon_node/http_api/src/beacon/states.rs @@ -3,17 +3,20 @@ use crate::task_spawner::{Priority, TaskSpawner}; use crate::utils::ResponseFilter; use crate::validator::pubkey_to_validator_index; use crate::version::{ - ResponseIncludesVersion, add_consensus_version_header, + ResponseIncludesVersion, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, }; use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, WhenSlotSkipped}; use eth2::types::{ - ValidatorBalancesRequestBody, ValidatorId, ValidatorIdentitiesRequestBody, + self as api_types, ValidatorBalancesRequestBody, ValidatorId, ValidatorIdentitiesRequestBody, ValidatorsRequestBody, }; +use ssz::Encode; use std::sync::Arc; use types::{AttestationShufflingId, BeaconStateError, CommitteeCache, EthSpec, RelativeEpoch}; use warp::filters::BoxedFilter; +use warp::http::Response; +use warp::hyper::Body; use warp::{Filter, Reply}; use warp_utils::query::multi_key_query; @@ -160,6 +163,67 @@ pub fn get_beacon_state_pending_deposits( .boxed() } +// GET beacon/states/{state_id}/proposer_lookahead +pub fn get_beacon_state_proposer_lookahead( + beacon_states_path: BeaconStatesPath, +) -> ResponseFilter { + beacon_states_path + .clone() + .and(warp::path("proposer_lookahead")) + .and(warp::path::end()) + .and(warp::header::optional::("accept")) + .then( + |state_id: StateId, + task_spawner: TaskSpawner, + chain: Arc>, + accept_header: Option| { + task_spawner.blocking_response_task(Priority::P1, move || { + let (data, execution_optimistic, finalized, fork_name) = state_id + .map_state_and_execution_optimistic_and_finalized( + &chain, + |state, execution_optimistic, finalized| { + let Ok(lookahead) = state.proposer_lookahead() else { + return Err(warp_utils::reject::custom_bad_request( + "Proposer lookahead is not available for pre-Fulu states" + .to_string(), + )); + }; + + Ok(( + lookahead.to_vec(), + execution_optimistic, + finalized, + state.fork_name_unchecked(), + )) + }, + )?; + + match accept_header { + Some(api_types::Accept::Ssz) => Response::builder() + .status(200) + .body(data.as_ssz_bytes().into()) + .map(|res: Response| add_ssz_content_type_header(res)) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to create response: {}", + e + )) + }), + _ => execution_optimistic_finalized_beacon_response( + ResponseIncludesVersion::Yes(fork_name), + execution_optimistic, + finalized, + data, + ) + .map(|res| warp::reply::json(&res).into_response()), + } + .map(|resp| add_consensus_version_header(resp, fork_name)) + }) + }, + ) + .boxed() +} + // GET beacon/states/{state_id}/randao?epoch pub fn get_beacon_state_randao( beacon_states_path: BeaconStatesPath, diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index b5b74a3840..0ef8654d8d 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -650,6 +650,10 @@ pub fn serve( let get_beacon_state_pending_consolidations = states::get_beacon_state_pending_consolidations(beacon_states_path.clone()); + // GET beacon/states/{state_id}/proposer_lookahead + let get_beacon_state_proposer_lookahead = + states::get_beacon_state_proposer_lookahead(beacon_states_path.clone()); + // GET beacon/headers // // Note: this endpoint only returns information about blocks in the canonical chain. Given that @@ -3284,6 +3288,7 @@ pub fn serve( .uor(get_beacon_state_pending_deposits) .uor(get_beacon_state_pending_partial_withdrawals) .uor(get_beacon_state_pending_consolidations) + .uor(get_beacon_state_proposer_lookahead) .uor(get_beacon_headers) .uor(get_beacon_headers_block_id) .uor(get_beacon_block) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 739c39aa32..a97ce01ac1 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -37,7 +37,7 @@ use proto_array::ExecutionStatus; use reqwest::{RequestBuilder, Response, StatusCode}; use sensitive_url::SensitiveUrl; use slot_clock::SlotClock; -use ssz::BitList; +use ssz::{BitList, Decode}; use state_processing::per_block_processing::get_expected_withdrawals; use state_processing::per_slot_processing; use state_processing::state_advance::partial_state_advance; @@ -1409,6 +1409,72 @@ impl ApiTester { self } + pub async fn test_beacon_states_proposer_lookahead(self) -> Self { + for state_id in self.interesting_state_ids() { + let mut state_opt = state_id + .state(&self.chain) + .ok() + .map(|(state, _execution_optimistic, _finalized)| state); + + let result = match self + .client + .get_beacon_states_proposer_lookahead(state_id.0) + .await + { + Ok(response) => response, + Err(e) => panic!("query failed incorrectly: {e:?}"), + }; + + if result.is_none() && state_opt.is_none() { + continue; + } + + let state = state_opt.as_mut().expect("result should be none"); + let expected = state.proposer_lookahead().unwrap(); + + let response = result.unwrap(); + assert_eq!(response.data(), &expected.to_vec()); + + // Check that the version header is returned in the response + let fork_name = state.fork_name(&self.chain.spec).unwrap(); + assert_eq!(response.version(), Some(fork_name),); + } + + self + } + + pub async fn test_beacon_states_proposer_lookahead_ssz(self) -> Self { + for state_id in self.interesting_state_ids() { + let mut state_opt = state_id + .state(&self.chain) + .ok() + .map(|(state, _execution_optimistic, _finalized)| state); + + let result = match self + .client + .get_beacon_states_proposer_lookahead_ssz(state_id.0) + .await + { + Ok(response) => response, + Err(e) => panic!("query failed incorrectly: {e:?}"), + }; + + if result.is_none() && state_opt.is_none() { + continue; + } + + let state = state_opt.as_mut().expect("result should be none"); + let expected = state.proposer_lookahead().unwrap(); + + let ssz_bytes = result.unwrap(); + let decoded = Vec::::from_ssz_bytes(&ssz_bytes) + .expect("should decode SSZ proposer lookahead"); + assert_eq!(decoded, expected.to_vec()); + } + + self + } + pub async fn test_beacon_headers_all_slots(self) -> Self { for slot in 0..CHAIN_LENGTH { let slot = Slot::from(slot); @@ -7360,6 +7426,23 @@ async fn beacon_get_state_info_electra() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn beacon_get_state_info_fulu() { + let mut config = ApiTesterConfig::default(); + config.spec.altair_fork_epoch = Some(Epoch::new(0)); + config.spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + config.spec.capella_fork_epoch = Some(Epoch::new(0)); + config.spec.deneb_fork_epoch = Some(Epoch::new(0)); + config.spec.electra_fork_epoch = Some(Epoch::new(0)); + config.spec.fulu_fork_epoch = Some(Epoch::new(0)); + ApiTester::new_from_config(config) + .await + .test_beacon_states_proposer_lookahead() + .await + .test_beacon_states_proposer_lookahead_ssz() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn beacon_get_blocks() { ApiTester::new() diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 628c12981a..5547ced491 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -898,6 +898,47 @@ impl BeaconNodeHttpClient { .map(|opt| opt.map(BeaconResponse::ForkVersioned)) } + /// `GET beacon/states/{state_id}/proposer_lookahead` + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_states_proposer_lookahead( + &self, + state_id: StateId, + ) -> Result>>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("states") + .push(&state_id.to_string()) + .push("proposer_lookahead"); + + self.get_fork_contextual(path, |fork| fork) + .await + .map(|opt| opt.map(BeaconResponse::ForkVersioned)) + } + + /// `GET beacon/states/{state_id}/proposer_lookahead` + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_states_proposer_lookahead_ssz( + &self, + state_id: StateId, + ) -> Result>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("states") + .push(&state_id.to_string()) + .push("proposer_lookahead"); + + self.get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.default) + .await + } + /// `GET beacon/light_client/updates` /// /// Returns `Ok(None)` on a 404 error. From 2bb79f43aadbf6e71ab2ea67efdda4585e9184ea Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Wed, 11 Mar 2026 02:43:59 +0900 Subject: [PATCH 09/15] Update contribution guidlines regarding LLM usage (#8879) Co-Authored-By: Eitan Seri- Levi --- CONTRIBUTING.md | 9 +++++++++ wordlist.txt | 1 + 2 files changed, 10 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cad219c89..f81f75cd8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,15 @@ Requests](https://github.com/sigp/lighthouse/pulls) is where code gets reviewed. We use [discord](https://discord.gg/cyAszAh) to chat informally. +### A Note on LLM usage + +We are happy to support contributors who are genuinely engaging with the code base. Our general policy regarding LLM usage: + +- Please refrain from submissions that you haven't thoroughly understood, reviewed, and tested. +- Please disclose if a significant portion of your contribution was AI-generated. +- Descriptions and comments should be made by you. +- We reserve the right to reject any contributions we feel are violating the spirit of open source contribution. + ### General Work-Flow We recommend the following work-flow for contributors: diff --git a/wordlist.txt b/wordlist.txt index e0e1fe7d73..822e336146 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -58,6 +58,7 @@ JSON KeyManager Kurtosis LMDB +LLM LLVM LRU LTO From 815040dc3c056560c5c67a7a71a87d2bbc658fd2 Mon Sep 17 00:00:00 2001 From: Mac L Date: Wed, 11 Mar 2026 07:43:26 +0200 Subject: [PATCH 10/15] Remove `c-kzg` (#8930) #7330 Removes `c-kzg` from our `kzg` crate and rely fully on the `rust_eth_kzg` crate. This removes the old `Blob` type entirely and instead handles `rust_eth_kzg::KzgBlobRef`s directly which allows us to avoid some extra stack allocations . Similarly, we make `Bytes32` and `Bytes48` type aliases rather than structs as this fits better with the new `rust_eth_kzg` API. Co-Authored-By: Mac L --- Cargo.lock | 1 - Cargo.toml | 3 - beacon_node/beacon_chain/src/kzg_utils.rs | 50 +++-- .../test_utils/execution_block_generator.rs | 15 +- consensus/types/src/data/blob_sidecar.rs | 11 +- consensus/types/src/kzg_ext/mod.rs | 2 +- crypto/kzg/Cargo.toml | 2 - crypto/kzg/benches/benchmark.rs | 18 +- crypto/kzg/src/kzg_commitment.rs | 10 +- crypto/kzg/src/kzg_proof.rs | 8 +- crypto/kzg/src/lib.rs | 172 ++++++++---------- crypto/kzg/src/trusted_setup.rs | 18 +- deny.toml | 1 + .../cases/kzg_verify_cell_kzg_proof_batch.rs | 6 +- 14 files changed, 129 insertions(+), 188 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 704039a175..0ca12dce46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4825,7 +4825,6 @@ name = "kzg" version = "0.1.0" dependencies = [ "arbitrary", - "c-kzg", "criterion", "educe", "ethereum_hashing", diff --git a/Cargo.toml b/Cargo.toml index efedfe3b37..7572cc324d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -117,9 +117,6 @@ bitvec = "1" bls = { path = "crypto/bls" } byteorder = "1" bytes = "1.11.1" -# Turn off c-kzg's default features which include `blst/portable`. We can turn on blst's portable -# feature ourselves when desired. -c-kzg = { version = "2.1", default-features = false } cargo_metadata = "0.19" clap = { version = "4.5.4", features = ["derive", "cargo", "wrap_help"] } clap_utils = { path = "common/clap_utils" } diff --git a/beacon_node/beacon_chain/src/kzg_utils.rs b/beacon_node/beacon_chain/src/kzg_utils.rs index 33b3260361..10cb208729 100644 --- a/beacon_node/beacon_chain/src/kzg_utils.rs +++ b/beacon_node/beacon_chain/src/kzg_utils.rs @@ -1,6 +1,5 @@ use kzg::{ - Blob as KzgBlob, Bytes48, Cell as KzgCell, CellRef as KzgCellRef, CellsAndKzgProofs, - Error as KzgError, Kzg, KzgBlobRef, + Cell as KzgCell, CellRef as KzgCellRef, CellsAndKzgProofs, Error as KzgError, Kzg, KzgBlobRef, }; use rayon::prelude::*; use ssz_types::{FixedVector, VariableList}; @@ -15,18 +14,18 @@ use types::{ SignedBeaconBlock, SignedBeaconBlockHeader, SignedBlindedBeaconBlock, Slot, }; -/// Converts a blob ssz List object to an array to be used with the kzg -/// crypto library. -fn ssz_blob_to_crypto_blob(blob: &Blob) -> Result { - KzgBlob::from_bytes(blob.as_ref()).map_err(Into::into) +/// Converts a blob ssz FixedVector to a reference to a fixed-size array +/// to be used with `rust_eth_kzg`. +fn ssz_blob_to_kzg_blob_ref(blob: &Blob) -> Result, KzgError> { + blob.as_ref().try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "blob should have a guaranteed size due to FixedVector: {e:?}" + )) + }) } -fn ssz_blob_to_crypto_blob_boxed(blob: &Blob) -> Result, KzgError> { - ssz_blob_to_crypto_blob::(blob).map(Box::new) -} - -/// Converts a cell ssz List object to an array to be used with the kzg -/// crypto library. +/// Converts a cell ssz FixedVector to a reference to a fixed-size array +/// to be used with `rust_eth_kzg`. fn ssz_cell_to_crypto_cell(cell: &Cell) -> Result, KzgError> { let cell_bytes: &[u8] = cell.as_ref(); cell_bytes @@ -42,8 +41,8 @@ pub fn validate_blob( kzg_proof: KzgProof, ) -> Result<(), KzgError> { let _timer = crate::metrics::start_timer(&crate::metrics::KZG_VERIFICATION_SINGLE_TIMES); - let kzg_blob = ssz_blob_to_crypto_blob_boxed::(blob)?; - kzg.verify_blob_kzg_proof(&kzg_blob, kzg_commitment, kzg_proof) + let kzg_blob = ssz_blob_to_kzg_blob_ref::(blob)?; + kzg.verify_blob_kzg_proof(kzg_blob, kzg_commitment, kzg_proof) } /// Validate a batch of `DataColumnSidecar`. @@ -72,7 +71,7 @@ where } for &proof in data_column.kzg_proofs() { - proofs.push(Bytes48::from(proof)); + proofs.push(proof.0); } // In Gloas, commitments come from the block's ExecutionPayloadBid, not the sidecar. @@ -90,7 +89,7 @@ where }; for &commitment in kzg_commitments.iter() { - commitments.push(Bytes48::from(commitment)); + commitments.push(commitment.0); } let expected_len = column_indices.len(); @@ -120,7 +119,7 @@ pub fn validate_blobs( let _timer = crate::metrics::start_timer(&crate::metrics::KZG_VERIFICATION_BATCH_TIMES); let blobs = blobs .into_iter() - .map(|blob| ssz_blob_to_crypto_blob::(blob)) + .map(|blob| ssz_blob_to_kzg_blob_ref::(blob)) .collect::, KzgError>>()?; kzg.verify_blob_kzg_proof_batch(&blobs, expected_kzg_commitments, kzg_proofs) @@ -132,8 +131,8 @@ pub fn compute_blob_kzg_proof( blob: &Blob, kzg_commitment: KzgCommitment, ) -> Result { - let kzg_blob = ssz_blob_to_crypto_blob_boxed::(blob)?; - kzg.compute_blob_kzg_proof(&kzg_blob, kzg_commitment) + let kzg_blob = ssz_blob_to_kzg_blob_ref::(blob)?; + kzg.compute_blob_kzg_proof(kzg_blob, kzg_commitment) } /// Compute the kzg commitment for a given blob. @@ -141,8 +140,8 @@ pub fn blob_to_kzg_commitment( kzg: &Kzg, blob: &Blob, ) -> Result { - let kzg_blob = ssz_blob_to_crypto_blob_boxed::(blob)?; - kzg.blob_to_kzg_commitment(&kzg_blob) + let kzg_blob = ssz_blob_to_kzg_blob_ref::(blob)?; + kzg.blob_to_kzg_commitment(kzg_blob) } /// Compute the kzg proof for a given blob and an evaluation point z. @@ -151,10 +150,9 @@ pub fn compute_kzg_proof( blob: &Blob, z: Hash256, ) -> Result<(KzgProof, Hash256), KzgError> { - let z = z.0.into(); - let kzg_blob = ssz_blob_to_crypto_blob_boxed::(blob)?; - kzg.compute_kzg_proof(&kzg_blob, &z) - .map(|(proof, z)| (proof, Hash256::from_slice(&z.to_vec()))) + let kzg_blob = ssz_blob_to_kzg_blob_ref::(blob)?; + kzg.compute_kzg_proof(kzg_blob, &z.0) + .map(|(proof, z)| (proof, Hash256::from_slice(&z))) } /// Verify a `kzg_proof` for a `kzg_commitment` that evaluating a polynomial at `z` results in `y` @@ -165,7 +163,7 @@ pub fn verify_kzg_proof( z: Hash256, y: Hash256, ) -> Result { - kzg.verify_kzg_proof(kzg_commitment, &z.0.into(), &y.0.into(), kzg_proof) + kzg.verify_kzg_proof(kzg_commitment, &z.0, &y.0, kzg_proof) } /// Build data column sidecars from a signed beacon block and its blobs. diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index 62a46246da..e94924d8b2 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -989,7 +989,7 @@ pub fn generate_pow_block( #[cfg(test)] mod test { use super::*; - use kzg::{Bytes48, CellRef, KzgBlobRef, trusted_setup::get_trusted_setup}; + use kzg::{CellRef, KzgBlobRef, trusted_setup::get_trusted_setup}; use types::{MainnetEthSpec, MinimalEthSpec}; #[test] @@ -1015,10 +1015,11 @@ mod test { fn validate_blob_bundle_v1() -> Result<(), String> { let kzg = load_kzg()?; let (kzg_commitment, kzg_proof, blob) = load_test_blobs_bundle_v1::()?; - let kzg_blob = kzg::Blob::from_bytes(blob.as_ref()) - .map(Box::new) - .map_err(|e| format!("Error converting blob to kzg blob: {e:?}"))?; - kzg.verify_blob_kzg_proof(&kzg_blob, kzg_commitment, kzg_proof) + let kzg_blob: KzgBlobRef = blob + .as_ref() + .try_into() + .map_err(|e| format!("Error converting blob to kzg blob ref: {e:?}"))?; + kzg.verify_blob_kzg_proof(kzg_blob, kzg_commitment, kzg_proof) .map_err(|e| format!("Invalid blobs bundle: {e:?}")) } @@ -1028,8 +1029,8 @@ mod test { load_test_blobs_bundle_v2::().map(|(commitment, proofs, blob)| { let kzg_blob: KzgBlobRef = blob.as_ref().try_into().unwrap(); ( - vec![Bytes48::from(commitment); proofs.len()], - proofs.into_iter().map(|p| p.into()).collect::>(), + vec![commitment.0; proofs.len()], + proofs.into_iter().map(|p| p.0).collect::>(), kzg.compute_cells(kzg_blob).unwrap(), ) })?; diff --git a/consensus/types/src/data/blob_sidecar.rs b/consensus/types/src/data/blob_sidecar.rs index 638491d6d7..2774176190 100644 --- a/consensus/types/src/data/blob_sidecar.rs +++ b/consensus/types/src/data/blob_sidecar.rs @@ -3,7 +3,7 @@ use std::{fmt::Debug, hash::Hash, sync::Arc}; use bls::Signature; use context_deserialize::context_deserialize; use educe::Educe; -use kzg::{BYTES_PER_BLOB, BYTES_PER_FIELD_ELEMENT, Blob as KzgBlob, Kzg, KzgCommitment, KzgProof}; +use kzg::{BYTES_PER_BLOB, BYTES_PER_FIELD_ELEMENT, Kzg, KzgCommitment, KzgProof}; use merkle_proof::{MerkleTreeError, merkle_root_from_branch, verify_merkle_proof}; use rand::Rng; use safe_arith::ArithError; @@ -253,14 +253,17 @@ impl BlobSidecar { let blob = Blob::::new(blob_bytes) .map_err(|e| format!("error constructing random blob: {:?}", e))?; - let kzg_blob = KzgBlob::from_bytes(&blob).unwrap(); + let kzg_blob: &[u8; BYTES_PER_BLOB] = blob + .as_ref() + .try_into() + .map_err(|e| format!("error converting blob to kzg blob ref: {:?}", e))?; let commitment = kzg - .blob_to_kzg_commitment(&kzg_blob) + .blob_to_kzg_commitment(kzg_blob) .map_err(|e| format!("error computing kzg commitment: {:?}", e))?; let proof = kzg - .compute_blob_kzg_proof(&kzg_blob, commitment) + .compute_blob_kzg_proof(kzg_blob, commitment) .map_err(|e| format!("error computing kzg proof: {:?}", e))?; Ok(Self { diff --git a/consensus/types/src/kzg_ext/mod.rs b/consensus/types/src/kzg_ext/mod.rs index 63533ec71f..e0ec9dd956 100644 --- a/consensus/types/src/kzg_ext/mod.rs +++ b/consensus/types/src/kzg_ext/mod.rs @@ -1,6 +1,6 @@ pub mod consts; -pub use kzg::{Blob as KzgBlob, Error as KzgError, Kzg, KzgCommitment, KzgProof}; +pub use kzg::{Error as KzgError, Kzg, KzgCommitment, KzgProof}; use ssz_types::VariableList; diff --git a/crypto/kzg/Cargo.toml b/crypto/kzg/Cargo.toml index 840f8cfc9c..19f39a182b 100644 --- a/crypto/kzg/Cargo.toml +++ b/crypto/kzg/Cargo.toml @@ -12,7 +12,6 @@ fake_crypto = [] [dependencies] arbitrary = { workspace = true, optional = true } -c-kzg = { workspace = true } educe = { workspace = true } ethereum_hashing = { workspace = true } ethereum_serde_utils = { workspace = true } @@ -28,7 +27,6 @@ tree_hash = { workspace = true } [dev-dependencies] criterion = { workspace = true } -serde_json = { workspace = true } [[bench]] name = "benchmark" diff --git a/crypto/kzg/benches/benchmark.rs b/crypto/kzg/benches/benchmark.rs index 432d84654a..d5d5596211 100644 --- a/crypto/kzg/benches/benchmark.rs +++ b/crypto/kzg/benches/benchmark.rs @@ -1,6 +1,5 @@ -use c_kzg::KzgSettings; use criterion::{criterion_group, criterion_main, Criterion}; -use kzg::{trusted_setup::get_trusted_setup, TrustedSetup, NO_PRECOMPUTE}; +use kzg::trusted_setup::get_trusted_setup; use rust_eth_kzg::{DASContext, TrustedSetup as PeerDASTrustedSetup}; pub fn bench_init_context(c: &mut Criterion) { @@ -20,21 +19,6 @@ pub fn bench_init_context(c: &mut Criterion) { ) }) }); - c.bench_function("Initialize context c-kzg (4844)", |b| { - b.iter(|| { - let trusted_setup: TrustedSetup = - serde_json::from_reader(trusted_setup_bytes.as_slice()) - .map_err(|e| format!("Unable to read trusted setup file: {}", e)) - .expect("should have trusted setup"); - KzgSettings::load_trusted_setup( - &trusted_setup.g1_monomial(), - &trusted_setup.g1_lagrange(), - &trusted_setup.g2_monomial(), - NO_PRECOMPUTE, - ) - .unwrap() - }) - }); } criterion_group!(benches, bench_init_context); diff --git a/crypto/kzg/src/kzg_commitment.rs b/crypto/kzg/src/kzg_commitment.rs index bc5fc5f5aa..d8ef4b36cf 100644 --- a/crypto/kzg/src/kzg_commitment.rs +++ b/crypto/kzg/src/kzg_commitment.rs @@ -1,4 +1,4 @@ -use c_kzg::BYTES_PER_COMMITMENT; +use crate::{Bytes48, BYTES_PER_COMMITMENT}; use educe::Educe; use ethereum_hashing::hash_fixed; use serde::de::{Deserialize, Deserializer}; @@ -14,7 +14,7 @@ pub const VERSIONED_HASH_VERSION_KZG: u8 = 0x01; #[derive(Educe, Clone, Copy, Encode, Decode)] #[educe(PartialEq, Eq, Hash)] #[ssz(struct_behaviour = "transparent")] -pub struct KzgCommitment(pub [u8; c_kzg::BYTES_PER_COMMITMENT]); +pub struct KzgCommitment(pub [u8; BYTES_PER_COMMITMENT]); impl KzgCommitment { pub fn calculate_versioned_hash(&self) -> Hash256 { @@ -24,13 +24,13 @@ impl KzgCommitment { } pub fn empty_for_testing() -> Self { - KzgCommitment([0; c_kzg::BYTES_PER_COMMITMENT]) + KzgCommitment([0; BYTES_PER_COMMITMENT]) } } -impl From for c_kzg::Bytes48 { +impl From for Bytes48 { fn from(value: KzgCommitment) -> Self { - value.0.into() + value.0 } } diff --git a/crypto/kzg/src/kzg_proof.rs b/crypto/kzg/src/kzg_proof.rs index aa9ed185a0..e0867520eb 100644 --- a/crypto/kzg/src/kzg_proof.rs +++ b/crypto/kzg/src/kzg_proof.rs @@ -1,4 +1,4 @@ -use c_kzg::BYTES_PER_PROOF; +use crate::BYTES_PER_PROOF; use serde::de::{Deserialize, Deserializer}; use serde::ser::{Serialize, Serializer}; use ssz_derive::{Decode, Encode}; @@ -11,12 +11,6 @@ use tree_hash::{PackedEncoding, TreeHash}; #[ssz(struct_behaviour = "transparent")] pub struct KzgProof(pub [u8; BYTES_PER_PROOF]); -impl From for c_kzg::Bytes48 { - fn from(value: KzgProof) -> Self { - value.0.into() - } -} - impl KzgProof { /// Creates a valid proof using `G1_POINT_AT_INFINITY`. pub fn empty() -> Self { diff --git a/crypto/kzg/src/lib.rs b/crypto/kzg/src/lib.rs index 66499dad8e..6ee352b0db 100644 --- a/crypto/kzg/src/lib.rs +++ b/crypto/kzg/src/lib.rs @@ -12,11 +12,12 @@ pub use crate::{ trusted_setup::TrustedSetup, }; -pub use c_kzg::{ - Blob, Bytes32, Bytes48, KzgSettings, BYTES_PER_BLOB, BYTES_PER_COMMITMENT, - BYTES_PER_FIELD_ELEMENT, BYTES_PER_PROOF, FIELD_ELEMENTS_PER_BLOB, +pub use rust_eth_kzg::constants::{ + BYTES_PER_BLOB, BYTES_PER_COMMITMENT, BYTES_PER_FIELD_ELEMENT, FIELD_ELEMENTS_PER_BLOB, }; +pub const BYTES_PER_PROOF: usize = 48; + use crate::trusted_setup::load_trusted_setup; use rayon::prelude::*; pub use rust_eth_kzg::{ @@ -25,13 +26,6 @@ pub use rust_eth_kzg::{ }; use tracing::{instrument, Span}; -/// Disables the fixed-base multi-scalar multiplication optimization for computing -/// cell KZG proofs, because `rust-eth-kzg` already handles the precomputation. -/// -/// Details about `precompute` parameter can be found here: -/// -pub const NO_PRECOMPUTE: u64 = 0; - // Note: Both `NUMBER_OF_COLUMNS` and `CELLS_PER_EXT_BLOB` are preset values - however this // is a constant in the KZG library - be aware that overriding `NUMBER_OF_COLUMNS` will break KZG // operations. @@ -39,14 +33,15 @@ pub type CellsAndKzgProofs = ([Cell; CELLS_PER_EXT_BLOB], [KzgProof; CELLS_PER_E pub type KzgBlobRef<'a> = &'a [u8; BYTES_PER_BLOB]; +type Bytes32 = [u8; 32]; +type Bytes48 = [u8; 48]; + #[derive(Debug)] pub enum Error { /// An error from initialising the trusted setup. TrustedSetupError(String), - /// An error from the underlying kzg library. - Kzg(c_kzg::Error), - /// A prover/verifier error from the rust-eth-kzg library. - PeerDASKZG(rust_eth_kzg::Error), + /// An error from the rust-eth-kzg library. + Kzg(rust_eth_kzg::Error), /// The kzg verification failed KzgVerificationFailed, /// Misc indexing error @@ -57,38 +52,29 @@ pub enum Error { DASContextUninitialized, } -impl From for Error { - fn from(value: c_kzg::Error) -> Self { +impl From for Error { + fn from(value: rust_eth_kzg::Error) -> Self { Error::Kzg(value) } } -/// A wrapper over a kzg library that holds the trusted setup parameters. +/// A wrapper over the rust-eth-kzg library that holds the trusted setup parameters. #[derive(Debug)] pub struct Kzg { - trusted_setup: KzgSettings, context: DASContext, } impl Kzg { pub fn new_from_trusted_setup_no_precomp(trusted_setup: &[u8]) -> Result { - let (ckzg_trusted_setup, rkzg_trusted_setup) = load_trusted_setup(trusted_setup)?; + let rkzg_trusted_setup = load_trusted_setup(trusted_setup)?; let context = DASContext::new(&rkzg_trusted_setup, rust_eth_kzg::UsePrecomp::No); - Ok(Self { - trusted_setup: KzgSettings::load_trusted_setup( - &ckzg_trusted_setup.g1_monomial(), - &ckzg_trusted_setup.g1_lagrange(), - &ckzg_trusted_setup.g2_monomial(), - NO_PRECOMPUTE, - )?, - context, - }) + Ok(Self { context }) } /// Load the kzg trusted setup parameters from a vec of G1 and G2 points. pub fn new_from_trusted_setup(trusted_setup: &[u8]) -> Result { - let (ckzg_trusted_setup, rkzg_trusted_setup) = load_trusted_setup(trusted_setup)?; + let rkzg_trusted_setup = load_trusted_setup(trusted_setup)?; // It's not recommended to change the config parameter for precomputation as storage // grows exponentially, but the speedup is exponential - after a while the speedup @@ -100,15 +86,7 @@ impl Kzg { }, ); - Ok(Self { - trusted_setup: KzgSettings::load_trusted_setup( - &ckzg_trusted_setup.g1_monomial(), - &ckzg_trusted_setup.g1_lagrange(), - &ckzg_trusted_setup.g2_monomial(), - NO_PRECOMPUTE, - )?, - context, - }) + Ok(Self { context }) } fn context(&self) -> &DASContext { @@ -118,34 +96,35 @@ impl Kzg { /// Compute the kzg proof given a blob and its kzg commitment. pub fn compute_blob_kzg_proof( &self, - blob: &Blob, + blob: KzgBlobRef<'_>, kzg_commitment: KzgCommitment, ) -> Result { - self.trusted_setup - .compute_blob_kzg_proof(blob, &kzg_commitment.into()) - .map(|proof| KzgProof(proof.to_bytes().into_inner())) - .map_err(Into::into) + let proof = self + .context() + .compute_blob_kzg_proof(blob, &kzg_commitment.0) + .map_err(Error::Kzg)?; + Ok(KzgProof(proof)) } /// Verify a kzg proof given the blob, kzg commitment and kzg proof. pub fn verify_blob_kzg_proof( &self, - blob: &Blob, + blob: KzgBlobRef<'_>, kzg_commitment: KzgCommitment, kzg_proof: KzgProof, ) -> Result<(), Error> { if cfg!(feature = "fake_crypto") { return Ok(()); } - if !self.trusted_setup.verify_blob_kzg_proof( - blob, - &kzg_commitment.into(), - &kzg_proof.into(), - )? { - Err(Error::KzgVerificationFailed) - } else { - Ok(()) - } + self.context() + .verify_blob_kzg_proof(blob, &kzg_commitment.0, &kzg_proof.0) + .map_err(|e| { + if e.is_proof_invalid() { + Error::KzgVerificationFailed + } else { + Error::Kzg(e) + } + }) } /// Verify a batch of blob commitment proof triplets. @@ -154,52 +133,48 @@ impl Kzg { /// TODO(pawan): test performance against a parallelized rayon impl. pub fn verify_blob_kzg_proof_batch( &self, - blobs: &[Blob], + blobs: &[KzgBlobRef<'_>], kzg_commitments: &[KzgCommitment], kzg_proofs: &[KzgProof], ) -> Result<(), Error> { if cfg!(feature = "fake_crypto") { return Ok(()); } - let commitments_bytes = kzg_commitments - .iter() - .map(|comm| Bytes48::from(*comm)) - .collect::>(); + let blob_refs: Vec<&[u8; BYTES_PER_BLOB]> = blobs.to_vec(); + let commitment_refs: Vec<&[u8; 48]> = kzg_commitments.iter().map(|c| &c.0).collect(); + let proof_refs: Vec<&[u8; 48]> = kzg_proofs.iter().map(|p| &p.0).collect(); - let proofs_bytes = kzg_proofs - .iter() - .map(|proof| Bytes48::from(*proof)) - .collect::>(); - - if !self.trusted_setup.verify_blob_kzg_proof_batch( - blobs, - &commitments_bytes, - &proofs_bytes, - )? { - Err(Error::KzgVerificationFailed) - } else { - Ok(()) - } + self.context() + .verify_blob_kzg_proof_batch(blob_refs, commitment_refs, proof_refs) + .map_err(|e| { + if e.is_proof_invalid() { + Error::KzgVerificationFailed + } else { + Error::Kzg(e) + } + }) } /// Converts a blob to a kzg commitment. - pub fn blob_to_kzg_commitment(&self, blob: &Blob) -> Result { - self.trusted_setup + pub fn blob_to_kzg_commitment(&self, blob: KzgBlobRef<'_>) -> Result { + let commitment = self + .context() .blob_to_kzg_commitment(blob) - .map(|commitment| KzgCommitment(commitment.to_bytes().into_inner())) - .map_err(Into::into) + .map_err(Error::Kzg)?; + Ok(KzgCommitment(commitment)) } /// Computes the kzg proof for a given `blob` and an evaluation point `z` pub fn compute_kzg_proof( &self, - blob: &Blob, + blob: KzgBlobRef<'_>, z: &Bytes32, ) -> Result<(KzgProof, Bytes32), Error> { - self.trusted_setup - .compute_kzg_proof(blob, z) - .map(|(proof, y)| (KzgProof(proof.to_bytes().into_inner()), y)) - .map_err(Into::into) + let (proof, y) = self + .context() + .compute_kzg_proof(blob, *z) + .map_err(Error::Kzg)?; + Ok((KzgProof(proof), y)) } /// Verifies a `kzg_proof` for a `kzg_commitment` that evaluating a polynomial at `z` results in `y` @@ -213,9 +188,14 @@ impl Kzg { if cfg!(feature = "fake_crypto") { return Ok(true); } - self.trusted_setup - .verify_kzg_proof(&kzg_commitment.into(), z, y, &kzg_proof.into()) - .map_err(Into::into) + match self + .context() + .verify_kzg_proof(&kzg_commitment.0, *z, *y, &kzg_proof.0) + { + Ok(()) => Ok(true), + Err(e) if e.is_proof_invalid() => Ok(false), + Err(e) => Err(Error::Kzg(e)), + } } /// Computes the cells and associated proofs for a given `blob`. @@ -226,18 +206,15 @@ impl Kzg { let (cells, proofs) = self .context() .compute_cells_and_kzg_proofs(blob) - .map_err(Error::PeerDASKZG)?; + .map_err(Error::Kzg)?; - // Convert the proof type to a c-kzg proof type - let c_kzg_proof = proofs.map(KzgProof); - Ok((cells, c_kzg_proof)) + let kzg_proofs = proofs.map(KzgProof); + Ok((cells, kzg_proofs)) } /// Computes the cells for a given `blob`. pub fn compute_cells(&self, blob: KzgBlobRef<'_>) -> Result<[Cell; CELLS_PER_EXT_BLOB], Error> { - self.context() - .compute_cells(blob) - .map_err(Error::PeerDASKZG) + self.context().compute_cells(blob).map_err(Error::Kzg) } /// Verifies a batch of cell-proof-commitment triplets. @@ -291,8 +268,8 @@ impl Kzg { for (cell, proof, commitment) in &column_data { cells.push(*cell); - proofs.push(proof.as_ref()); - commitments.push(commitment.as_ref()); + proofs.push(proof); + commitments.push(commitment); } // Create per-chunk tracing span for visualizing parallel processing. @@ -319,7 +296,7 @@ impl Kzg { Err(e) if e.is_proof_invalid() => { Err((Some(column_index), Error::KzgVerificationFailed)) } - Err(e) => Err((Some(column_index), Error::PeerDASKZG(e))), + Err(e) => Err((Some(column_index), Error::Kzg(e))), } }) .collect::, (Option, Error)>>()?; @@ -335,10 +312,9 @@ impl Kzg { let (cells, proofs) = self .context() .recover_cells_and_kzg_proofs(cell_ids.to_vec(), cells.to_vec()) - .map_err(Error::PeerDASKZG)?; + .map_err(Error::Kzg)?; - // Convert the proof type to a c-kzg proof type - let c_kzg_proof = proofs.map(KzgProof); - Ok((cells, c_kzg_proof)) + let kzg_proofs = proofs.map(KzgProof); + Ok((cells, kzg_proofs)) } } diff --git a/crypto/kzg/src/trusted_setup.rs b/crypto/kzg/src/trusted_setup.rs index 75884b8199..5c285b50f2 100644 --- a/crypto/kzg/src/trusted_setup.rs +++ b/crypto/kzg/src/trusted_setup.rs @@ -24,7 +24,7 @@ struct G1Point([u8; BYTES_PER_G1_POINT]); struct G2Point([u8; BYTES_PER_G2_POINT]); /// Contains the trusted setup parameters that are required to instantiate a -/// `c_kzg::KzgSettings` object. +/// `rust_eth_kzg::TrustedSetup` object. /// /// The serialize/deserialize implementations are written according to /// the format specified in the ethereum consensus specs trusted setup files. @@ -155,19 +155,9 @@ fn strip_prefix(s: &str) -> &str { } } -/// Loads the trusted setup from JSON. -/// -/// ## Note: -/// Currently we load both c-kzg and rust-eth-kzg trusted setup structs, because c-kzg is still being -/// used for 4844. Longer term we're planning to switch all KZG operations to the rust-eth-kzg -/// crate, and we'll be able to maintain a single trusted setup struct. -pub(crate) fn load_trusted_setup( - trusted_setup: &[u8], -) -> Result<(TrustedSetup, PeerDASTrustedSetup), Error> { - let ckzg_trusted_setup: TrustedSetup = serde_json::from_slice(trusted_setup) - .map_err(|e| Error::TrustedSetupError(format!("{e:?}")))?; +/// Loads the trusted setup from JSON bytes into a `rust_eth_kzg::TrustedSetup`. +pub(crate) fn load_trusted_setup(trusted_setup: &[u8]) -> Result { let trusted_setup_json = std::str::from_utf8(trusted_setup) .map_err(|e| Error::TrustedSetupError(format!("{e:?}")))?; - let rkzg_trusted_setup = PeerDASTrustedSetup::from_json(trusted_setup_json); - Ok((ckzg_trusted_setup, rkzg_trusted_setup)) + Ok(PeerDASTrustedSetup::from_json(trusted_setup_json)) } diff --git a/deny.toml b/deny.toml index 3b230155f7..cf0cd7d3cd 100644 --- a/deny.toml +++ b/deny.toml @@ -11,6 +11,7 @@ deny = [ { crate = "derivative", reason = "use educe or derive_more instead" }, { crate = "ark-ff", reason = "present in Cargo.lock but not needed by Lighthouse" }, { crate = "openssl", reason = "non-Rust dependency, use rustls instead" }, + { crate = "c-kzg", reason = "non-Rust dependency, use rust_eth_kzg instead" }, { crate = "strum", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "reqwest", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "aes", deny-multiple-versions = true, reason = "takes a long time to compile" }, diff --git a/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs b/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs index 7973af861f..200f439c28 100644 --- a/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs +++ b/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs @@ -1,6 +1,6 @@ use super::*; use crate::case_result::compare_result; -use kzg::{Bytes48, Error as KzgError}; +use kzg::Error as KzgError; use serde::Deserialize; use std::marker::PhantomData; @@ -47,8 +47,8 @@ impl Case for KZGVerifyCellKZGProofBatch { let result = parse_input(&self.input).and_then(|(cells, proofs, cell_indices, commitments)| { - let proofs: Vec = proofs.iter().map(|&proof| proof.into()).collect(); - let commitments: Vec = commitments.iter().map(|&c| c.into()).collect(); + let proofs = proofs.iter().map(|&proof| proof.0).collect::>(); + let commitments = commitments.iter().map(|&c| c.0).collect::>(); let cells = cells.iter().map(|c| c.as_ref()).collect::>(); let kzg = get_kzg(); match kzg.verify_cell_proof_batch(&cells, &proofs, cell_indices, &commitments) { From 6350a270319267615034475613c7ff3366b941d3 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:20:02 -0500 Subject: [PATCH 11/15] Optionally check DB invariants at runtime (#8952) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/beacon_chain/src/invariants.rs | 56 ++ beacon_node/beacon_chain/src/lib.rs | 1 + beacon_node/beacon_chain/tests/store_tests.rs | 43 + beacon_node/http_api/src/database.rs | 9 + beacon_node/http_api/src/lib.rs | 14 + beacon_node/store/src/invariants.rs | 781 ++++++++++++++++++ beacon_node/store/src/lib.rs | 1 + beacon_node/store/src/state_cache.rs | 13 + 8 files changed, 918 insertions(+) create mode 100644 beacon_node/beacon_chain/src/invariants.rs create mode 100644 beacon_node/store/src/invariants.rs diff --git a/beacon_node/beacon_chain/src/invariants.rs b/beacon_node/beacon_chain/src/invariants.rs new file mode 100644 index 0000000000..7bcec7b0b4 --- /dev/null +++ b/beacon_node/beacon_chain/src/invariants.rs @@ -0,0 +1,56 @@ +//! Beacon chain database invariant checks. +//! +//! Builds the `InvariantContext` from beacon chain state and delegates all checks +//! to `HotColdDB::check_invariants`. + +use crate::BeaconChain; +use crate::beacon_chain::BeaconChainTypes; +use store::invariants::{InvariantCheckResult, InvariantContext}; + +impl BeaconChain { + /// Run all database invariant checks. + /// + /// Collects context from fork choice, state cache, custody columns, and pubkey cache, + /// then delegates to the store-level `check_invariants` method. + pub fn check_database_invariants(&self) -> Result { + let fork_choice_blocks = { + let fc = self.canonical_head.fork_choice_read_lock(); + let proto_array = fc.proto_array().core_proto_array(); + proto_array + .nodes + .iter() + .filter(|node| { + // Only check blocks that are descendants of the finalized checkpoint. + // Pruned non-canonical fork blocks may linger in the proto-array but + // are legitimately absent from the database. + fc.is_finalized_checkpoint_or_descendant(node.root) + }) + .map(|node| (node.root, node.slot)) + .collect() + }; + + let custody_context = self.data_availability_checker.custody_context(); + + 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(), + pubkey_cache_pubkeys: { + let cache = self.validator_pubkey_cache.read(); + (0..cache.len()) + .filter_map(|i| { + cache.get(i).map(|pk| { + use store::StoreItem; + crate::validator_pubkey_cache::DatabasePubkey::from_pubkey(pk) + .as_store_bytes() + }) + }) + .collect() + }, + }; + + self.store.check_invariants(&ctx) + } +} diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index 4efd90bd22..29081fd767 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -29,6 +29,7 @@ pub mod fork_choice_signal; pub mod graffiti_calculator; pub mod historical_blocks; pub mod historical_data_columns; +pub mod invariants; pub mod kzg_utils; pub mod light_client_finality_update_verification; pub mod light_client_optimistic_update_verification; diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index b6d729cc61..86f4af3efc 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -148,6 +148,22 @@ fn get_harness_generic( harness } +/// Check that all database invariants hold. +/// +/// Panics with a descriptive message if any invariant is violated. +fn check_db_invariants(harness: &TestHarness) { + let result = harness + .chain + .check_database_invariants() + .expect("invariant check should not error"); + + assert!( + result.is_ok(), + "database invariant violations found:\n{:#?}", + result.violations, + ); +} + fn get_states_descendant_of_block( store: &HotColdDB, BeaconNodeBackend>, block_root: Hash256, @@ -308,6 +324,7 @@ async fn full_participation_no_skips() { check_split_slot(&harness, store); check_chain_dump(&harness, num_blocks_produced + 1); check_iterators(&harness); + check_db_invariants(&harness); } #[tokio::test] @@ -352,6 +369,7 @@ async fn randomised_skips() { check_split_slot(&harness, store.clone()); check_chain_dump(&harness, num_blocks_produced + 1); check_iterators(&harness); + check_db_invariants(&harness); } #[tokio::test] @@ -400,6 +418,7 @@ async fn long_skip() { check_split_slot(&harness, store); check_chain_dump(&harness, initial_blocks + final_blocks + 1); check_iterators(&harness); + check_db_invariants(&harness); } /// Go forward to the point where the genesis randao value is no longer part of the vector. @@ -1769,6 +1788,8 @@ async fn prunes_abandoned_fork_between_two_finalized_checkpoints() { } assert!(!rig.knows_head(&stray_head)); + + check_db_invariants(&rig); } #[tokio::test] @@ -1897,6 +1918,8 @@ async fn pruning_does_not_touch_abandoned_block_shared_with_canonical_chain() { assert!(!rig.knows_head(&stray_head)); let chain_dump = rig.chain.chain_dump().unwrap(); assert!(get_blocks(&chain_dump).contains(&shared_head)); + + check_db_invariants(&rig); } #[tokio::test] @@ -1988,6 +2011,8 @@ async fn pruning_does_not_touch_blocks_prior_to_finalization() { } rig.assert_knows_head(stray_head.into()); + + check_db_invariants(&rig); } #[tokio::test] @@ -2127,6 +2152,8 @@ async fn prunes_fork_growing_past_youngest_finalized_checkpoint() { } assert!(!rig.knows_head(&stray_head)); + + check_db_invariants(&rig); } // This is to check if state outside of normal block processing are pruned correctly. @@ -2377,6 +2404,8 @@ async fn finalizes_non_epoch_start_slot() { state_hash ); } + + check_db_invariants(&rig); } fn check_all_blocks_exist<'a>( @@ -2643,6 +2672,8 @@ async fn pruning_test( check_all_states_exist(&harness, all_canonical_states.iter()); check_no_states_exist(&harness, stray_states.difference(&all_canonical_states)); check_no_blocks_exist(&harness, stray_blocks.values()); + + check_db_invariants(&harness); } #[tokio::test] @@ -2707,6 +2738,8 @@ async fn garbage_collect_temp_states_from_failed_block_on_finalization() { vec![(genesis_state_root, Slot::new(0))], "get_states_descendant_of_block({bad_block_parent_root:?})" ); + + check_db_invariants(&harness); } #[tokio::test] @@ -3361,6 +3394,16 @@ async fn weak_subjectivity_sync_test( store.clone().reconstruct_historic_states(None).unwrap(); assert_eq!(store.get_anchor_info().anchor_slot, wss_aligned_slot); assert_eq!(store.get_anchor_info().state_upper_limit, Slot::new(0)); + + // Check database invariants after full checkpoint sync + backfill + reconstruction. + let result = beacon_chain + .check_database_invariants() + .expect("invariant check should not error"); + assert!( + result.is_ok(), + "database invariant violations:\n{:#?}", + result.violations, + ); } // This test prunes data columns from epoch 0 and then tries to re-import them via diff --git a/beacon_node/http_api/src/database.rs b/beacon_node/http_api/src/database.rs index 8a50ec45b0..4737d92079 100644 --- a/beacon_node/http_api/src/database.rs +++ b/beacon_node/http_api/src/database.rs @@ -2,6 +2,7 @@ use beacon_chain::store::metadata::CURRENT_SCHEMA_VERSION; use beacon_chain::{BeaconChain, BeaconChainTypes}; use serde::Serialize; use std::sync::Arc; +use store::invariants::InvariantCheckResult; use store::{AnchorInfo, BlobInfo, Split, StoreConfig}; #[derive(Debug, Serialize)] @@ -30,3 +31,11 @@ pub fn info( blob_info, }) } + +pub fn check_invariants( + chain: Arc>, +) -> Result { + chain.check_database_invariants().map_err(|e| { + warp_utils::reject::custom_bad_request(format!("error checking database invariants: {e:?}")) + }) +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 0ef8654d8d..26bad809df 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -3007,6 +3007,19 @@ pub fn serve( }, ); + // GET lighthouse/database/invariants + let get_lighthouse_database_invariants = database_path + .and(warp::path("invariants")) + .and(warp::path::end()) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .then( + |task_spawner: TaskSpawner, chain: Arc>| { + task_spawner + .blocking_json_task(Priority::P1, move || database::check_invariants(chain)) + }, + ); + // POST lighthouse/database/reconstruct let post_lighthouse_database_reconstruct = database_path .and(warp::path("reconstruct")) @@ -3336,6 +3349,7 @@ pub fn serve( .uor(get_lighthouse_validator_inclusion) .uor(get_lighthouse_staking) .uor(get_lighthouse_database_info) + .uor(get_lighthouse_database_invariants) .uor(get_lighthouse_custody_info) .uor(get_lighthouse_attestation_performance) .uor(get_beacon_light_client_optimistic_update) diff --git a/beacon_node/store/src/invariants.rs b/beacon_node/store/src/invariants.rs new file mode 100644 index 0000000000..eb5232d344 --- /dev/null +++ b/beacon_node/store/src/invariants.rs @@ -0,0 +1,781 @@ +//! Database invariant checks for the hot and cold databases. +//! +//! These checks verify the consistency of data stored in the database. They are designed to be +//! called from the HTTP API and from tests to detect data corruption or bugs in the store logic. +//! +//! See the `check_invariants` and `check_database_invariants` methods for the full list. + +use crate::hdiff::StorageStrategy; +use crate::hot_cold_store::{ColdStateSummary, HotStateSummary}; +use crate::{DBColumn, Error, ItemStore}; +use crate::{HotColdDB, Split}; +use serde::Serialize; +use ssz::Decode; +use std::cmp; +use std::collections::HashSet; +use types::*; + +/// Result of running invariant checks on the database. +#[derive(Debug, Clone, Serialize)] +pub struct InvariantCheckResult { + /// List of invariant violations found. + pub violations: Vec, +} + +impl InvariantCheckResult { + pub fn new() -> Self { + Self { + violations: Vec::new(), + } + } + + pub fn is_ok(&self) -> bool { + self.violations.is_empty() + } + + pub fn add_violation(&mut self, violation: InvariantViolation) { + self.violations.push(violation); + } + + pub fn merge(&mut self, other: InvariantCheckResult) { + self.violations.extend(other.violations); + } +} + +impl Default for InvariantCheckResult { + fn default() -> Self { + Self::new() + } +} + +/// Context data from the beacon chain needed for invariant checks. +/// +/// This allows all invariant checks to live in the store crate while still checking +/// invariants that depend on fork choice, state cache, and custody context. +pub struct InvariantContext { + /// Block roots tracked by fork choice (invariant 1). + pub fork_choice_blocks: Vec<(Hash256, Slot)>, + /// State roots held in the in-memory state cache (invariant 8). + pub state_cache_roots: Vec, + /// Custody columns for the current epoch (invariant 7). + pub custody_columns: Vec, + /// Compressed pubkey bytes from the in-memory validator pubkey cache, indexed by validator index + /// (invariant 9). + pub pubkey_cache_pubkeys: Vec>, +} + +/// A single invariant violation. +#[derive(Debug, Clone, Serialize)] +pub enum InvariantViolation { + /// Invariant 1: fork choice block consistency. + /// + /// ```text + /// block in fork_choice && descends_from_finalized -> block in hot_db + /// ``` + ForkChoiceBlockMissing { block_root: Hash256, slot: Slot }, + /// Invariant 2: block and state consistency. + /// + /// ```text + /// block in hot_db && block.slot >= split.slot + /// -> state_summary for block.state_root() in hot_db + /// ``` + HotBlockMissingStateSummary { + block_root: Hash256, + slot: Slot, + state_root: Hash256, + }, + /// Invariant 3: state summary diff consistency. + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db according to hierarchy rules + /// ``` + HotStateMissingSnapshot { state_root: Hash256, slot: Slot }, + /// Invariant 3: state summary diff consistency (missing diff). + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db according to hierarchy rules + /// ``` + HotStateMissingDiff { state_root: Hash256, slot: Slot }, + /// Invariant 3: DiffFrom/ReplayFrom base slot must reference an existing summary. + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db according to hierarchy rules + /// ``` + HotStateBaseSummaryMissing { + slot: Slot, + base_state_root: Hash256, + }, + /// Invariant 4: state summary chain consistency. + /// + /// ```text + /// state_summary in hot_db && state_summary.slot > split.slot + /// -> state_summary for previous_state_root in hot_db + /// ``` + HotStateMissingPreviousSummary { + slot: Slot, + previous_state_root: Hash256, + }, + /// Invariant 5: block and execution payload consistency. + /// + /// ```text + /// block in hot_db && !prune_payloads -> payload for block.root in hot_db + /// ``` + ExecutionPayloadMissing { block_root: Hash256, slot: Slot }, + /// Invariant 6: block and blobs consistency. + /// + /// ```text + /// block in hot_db && num_blob_commitments > 0 + /// -> blob_list for block.root in hot_db + /// ``` + BlobSidecarMissing { block_root: Hash256, slot: Slot }, + /// Invariant 7: block and data columns consistency. + /// + /// ```text + /// block in hot_db && num_blob_commitments > 0 + /// && block.slot >= earliest_available_slot + /// && data_column_idx in custody_columns + /// -> (block_root, data_column_idx) in hot_db + /// ``` + DataColumnMissing { + block_root: Hash256, + slot: Slot, + column_index: ColumnIndex, + }, + /// Invariant 8: state cache and disk consistency. + /// + /// ```text + /// state in state_cache -> state_summary in hot_db + /// ``` + StateCacheMissingSummary { state_root: Hash256 }, + /// Invariant 9: pubkey cache consistency. + /// + /// ```text + /// state_summary in hot_db + /// -> all validator pubkeys from state.validators are in the hot_db + /// ``` + PubkeyCacheMissing { validator_index: usize }, + /// Invariant 9b: pubkey cache value mismatch. + /// + /// ```text + /// pubkey_cache[i] == hot_db(PubkeyCache)[i] + /// ``` + PubkeyCacheMismatch { validator_index: usize }, + /// Invariant 10: block root indices mapping. + /// + /// ```text + /// oldest_block_slot <= i < split.slot + /// -> block_root for slot i in cold_db + /// && block for block_root in hot_db + /// ``` + ColdBlockRootMissing { + slot: Slot, + oldest_block_slot: Slot, + split_slot: Slot, + }, + /// Invariant 10: block root index references a block that must exist. + /// + /// ```text + /// oldest_block_slot <= i < split.slot + /// -> block_root for slot i in cold_db + /// && block for block_root in hot_db + /// ``` + ColdBlockRootOrphan { slot: Slot, block_root: Hash256 }, + /// Invariant 11: state root indices mapping. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + ColdStateRootMissing { + slot: Slot, + state_lower_limit: Slot, + state_upper_limit: Slot, + split_slot: Slot, + }, + /// Invariant 11: state root index must have a cold state summary. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + ColdStateRootMissingSummary { slot: Slot, state_root: Hash256 }, + /// Invariant 11: cold state summary slot must match index slot. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + ColdStateRootSlotMismatch { + slot: Slot, + state_root: Hash256, + summary_slot: Slot, + }, + /// Invariant 12: cold state diff consistency. + /// + /// ```text + /// cold_state_summary in cold_db + /// -> slot |-> state diff/snapshot/nothing in cold_db according to diff hierarchy + /// ``` + ColdStateMissingSnapshot { state_root: Hash256, slot: Slot }, + /// Invariant 12: cold state diff consistency (missing diff). + /// + /// ```text + /// cold_state_summary in cold_db + /// -> slot |-> state diff/snapshot/nothing in cold_db according to diff hierarchy + /// ``` + ColdStateMissingDiff { state_root: Hash256, slot: Slot }, + /// Invariant 12: DiffFrom/ReplayFrom base slot must reference an existing summary. + /// + /// ```text + /// cold_state_summary in cold_db + /// -> slot |-> state diff/snapshot/nothing in cold_db according to diff hierarchy + /// ``` + ColdStateBaseSummaryMissing { slot: Slot, base_slot: Slot }, +} + +impl, Cold: ItemStore> HotColdDB { + /// Run all database invariant checks. + /// + /// The `ctx` parameter provides data from the beacon chain layer (fork choice, state cache, + /// custody columns, pubkey cache) so that all invariant checks can live in this single file. + pub fn check_invariants(&self, ctx: &InvariantContext) -> Result { + let mut result = InvariantCheckResult::new(); + let split = self.get_split_info(); + + result.merge(self.check_fork_choice_block_consistency(ctx)?); + result.merge(self.check_hot_block_invariants(&split, ctx)?); + result.merge(self.check_hot_state_summary_diff_consistency()?); + result.merge(self.check_hot_state_summary_chain_consistency(&split)?); + result.merge(self.check_state_cache_consistency(ctx)?); + result.merge(self.check_cold_block_root_indices(&split)?); + result.merge(self.check_cold_state_root_indices(&split)?); + result.merge(self.check_cold_state_diff_consistency()?); + result.merge(self.check_pubkey_cache_consistency(ctx)?); + + Ok(result) + } + + /// Invariant 1 (Hot DB): Fork choice block consistency. + /// + /// ```text + /// block in fork_choice && descends_from_finalized -> block in hot_db + /// ``` + /// + /// Every canonical fork choice block (descending from finalized) must exist in the hot + /// database. Pruned non-canonical fork blocks may linger in the proto-array and are + /// excluded from this check. + fn check_fork_choice_block_consistency( + &self, + ctx: &InvariantContext, + ) -> Result { + let mut result = InvariantCheckResult::new(); + + for &(block_root, slot) in &ctx.fork_choice_blocks { + let exists = self + .hot_db + .key_exists(DBColumn::BeaconBlock, block_root.as_slice())?; + if !exists { + result + .add_violation(InvariantViolation::ForkChoiceBlockMissing { block_root, slot }); + } + } + + Ok(result) + } + + /// Invariants 2, 5, 6, 7 (Hot DB): Block-related consistency checks. + /// + /// Iterates hot DB blocks once and checks: + /// - Invariant 2: block-state summary consistency + /// - Invariant 5: execution payload consistency (when prune_payloads=false) + /// - Invariant 6: blob sidecar consistency (Deneb to Fulu) + /// - Invariant 7: data column consistency (post-Fulu, when custody_columns provided) + fn check_hot_block_invariants( + &self, + split: &Split, + ctx: &InvariantContext, + ) -> Result { + let mut result = InvariantCheckResult::new(); + + let check_payloads = !self.get_config().prune_payloads; + let bellatrix_fork_slot = self + .spec + .bellatrix_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let deneb_fork_slot = self + .spec + .deneb_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let fulu_fork_slot = self + .spec + .fulu_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let oldest_blob_slot = self.get_blob_info().oldest_blob_slot; + let oldest_data_column_slot = self.get_data_column_info().oldest_data_column_slot; + + for res in self.hot_db.iter_column::(DBColumn::BeaconBlock) { + let (block_root, block_bytes) = res?; + let block = SignedBlindedBeaconBlock::::from_ssz_bytes(&block_bytes, &self.spec)?; + let slot = block.slot(); + + // Invariant 2: block-state consistency. + if slot >= split.slot { + let state_root = block.state_root(); + let has_summary = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSummary, state_root.as_slice())?; + if !has_summary { + result.add_violation(InvariantViolation::HotBlockMissingStateSummary { + block_root, + slot, + state_root, + }); + } + } + + // Invariant 5: execution payload consistency. + // TODO(gloas): reconsider this invariant + if check_payloads + && let Some(bellatrix_slot) = bellatrix_fork_slot + && slot >= bellatrix_slot + && !self.execution_payload_exists(&block_root)? + && !self.payload_envelope_exists(&block_root)? + { + result.add_violation(InvariantViolation::ExecutionPayloadMissing { + block_root, + slot, + }); + } + + // Invariant 6: blob sidecar consistency. + // Only check blocks that actually have blob KZG commitments — blocks with 0 + // commitments legitimately have no blob sidecars stored. + if let Some(deneb_slot) = deneb_fork_slot + && let Some(oldest_blob) = oldest_blob_slot + && slot >= deneb_slot + && slot >= oldest_blob + && fulu_fork_slot.is_none_or(|fulu_slot| slot < fulu_slot) + && block.num_expected_blobs() > 0 + { + let has_blob = self + .blobs_db + .key_exists(DBColumn::BeaconBlob, block_root.as_slice())?; + if !has_blob { + result + .add_violation(InvariantViolation::BlobSidecarMissing { block_root, slot }); + } + } + + // Invariant 7: data column consistency. + // Only check blocks that actually have blob KZG commitments. + // TODO(gloas): reconsider this invariant — non-canonical payloads won't have + // their data column sidecars stored. + if !ctx.custody_columns.is_empty() + && let Some(fulu_slot) = fulu_fork_slot + && let Some(oldest_dc) = oldest_data_column_slot + && slot >= fulu_slot + && slot >= oldest_dc + && block.num_expected_blobs() > 0 + { + let stored_columns = self.get_data_column_keys(block_root)?; + for col_idx in &ctx.custody_columns { + if !stored_columns.contains(col_idx) { + result.add_violation(InvariantViolation::DataColumnMissing { + block_root, + slot, + column_index: *col_idx, + }); + } + } + } + } + + Ok(result) + } + + /// Invariant 3 (Hot DB): State summary diff/snapshot consistency. + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db per HDiff hierarchy rules + /// ``` + /// + /// Each hot state summary should have the correct storage artifact (snapshot, diff, or + /// nothing) according to the HDiff hierarchy configuration. The hierarchy uses the + /// anchor_slot as its start point for the hot DB. + fn check_hot_state_summary_diff_consistency(&self) -> Result { + let mut result = InvariantCheckResult::new(); + + let anchor_slot = self.get_anchor_info().anchor_slot; + + // Collect all summary slots and their strategies in a first pass. + let mut known_state_roots = HashSet::new(); + let mut base_state_refs: Vec<(Slot, Hash256)> = Vec::new(); + + for res in self + .hot_db + .iter_column::(DBColumn::BeaconStateHotSummary) + { + let (state_root, value) = res?; + let summary = HotStateSummary::from_ssz_bytes(&value)?; + + known_state_roots.insert(state_root); + + match self.hierarchy.storage_strategy(summary.slot, anchor_slot)? { + StorageStrategy::Snapshot => { + let has_snapshot = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSnapshot, state_root.as_slice())?; + if !has_snapshot { + result.add_violation(InvariantViolation::HotStateMissingSnapshot { + state_root, + slot: summary.slot, + }); + } + } + StorageStrategy::DiffFrom(base_slot) => { + let has_diff = self + .hot_db + .key_exists(DBColumn::BeaconStateHotDiff, state_root.as_slice())?; + if !has_diff { + result.add_violation(InvariantViolation::HotStateMissingDiff { + state_root, + slot: summary.slot, + }); + } + if let Ok(base_root) = summary.diff_base_state.get_root(base_slot) { + base_state_refs.push((summary.slot, base_root)); + } + } + StorageStrategy::ReplayFrom(base_slot) => { + if let Ok(base_root) = summary.diff_base_state.get_root(base_slot) { + base_state_refs.push((summary.slot, base_root)); + } + } + } + } + + // Verify that all diff base state roots reference existing summaries. + for (slot, base_state_root) in base_state_refs { + if !known_state_roots.contains(&base_state_root) { + result.add_violation(InvariantViolation::HotStateBaseSummaryMissing { + slot, + base_state_root, + }); + } + } + + Ok(result) + } + + /// Invariant 4 (Hot DB): State summary chain consistency. + /// + /// ```text + /// state_summary in hot_db && state_summary.slot > split.slot + /// -> state_summary for previous_state_root in hot_db + /// ``` + /// + /// The chain of `previous_state_root` links must be continuous back to the split state. + /// The split state itself is the boundary and does not need a predecessor in the hot DB. + fn check_hot_state_summary_chain_consistency( + &self, + split: &Split, + ) -> Result { + let mut result = InvariantCheckResult::new(); + + for res in self + .hot_db + .iter_column::(DBColumn::BeaconStateHotSummary) + { + let (_state_root, value) = res?; + let summary = HotStateSummary::from_ssz_bytes(&value)?; + + if summary.slot > split.slot { + let prev_root = summary.previous_state_root; + let has_prev = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSummary, prev_root.as_slice())?; + if !has_prev { + result.add_violation(InvariantViolation::HotStateMissingPreviousSummary { + slot: summary.slot, + previous_state_root: prev_root, + }); + } + } + } + + Ok(result) + } + + /// Invariant 8 (Hot DB): State cache and disk consistency. + /// + /// ```text + /// state in state_cache -> state_summary in hot_db + /// ``` + /// + /// Every state held in the in-memory state cache (including the finalized state) should + /// have a corresponding hot state summary on disk. + fn check_state_cache_consistency( + &self, + ctx: &InvariantContext, + ) -> Result { + let mut result = InvariantCheckResult::new(); + + for &state_root in &ctx.state_cache_roots { + let has_summary = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSummary, state_root.as_slice())?; + if !has_summary { + result.add_violation(InvariantViolation::StateCacheMissingSummary { state_root }); + } + } + + Ok(result) + } + + /// Invariant 10 (Cold DB): Block root indices. + /// + /// ```text + /// oldest_block_slot <= i < split.slot + /// -> block_root for slot i in cold_db + /// && block for block_root in hot_db + /// ``` + /// + /// Every slot in the cold range (from `oldest_block_slot` to `split.slot`) should have a + /// block root index entry, and the referenced block should exist in the hot DB. Note that + /// skip slots store the most recent non-skipped block's root, so `block.slot()` may differ + /// from the index slot. + fn check_cold_block_root_indices(&self, split: &Split) -> Result { + let mut result = InvariantCheckResult::new(); + + let anchor_info = self.get_anchor_info(); + + if anchor_info.oldest_block_slot >= split.slot { + return Ok(result); + } + + for slot_val in anchor_info.oldest_block_slot.as_u64()..split.slot.as_u64() { + let slot = Slot::new(slot_val); + + let slot_bytes = slot_val.to_be_bytes(); + let block_root_bytes = self + .cold_db + .get_bytes(DBColumn::BeaconBlockRoots, &slot_bytes)?; + + let Some(root_bytes) = block_root_bytes else { + result.add_violation(InvariantViolation::ColdBlockRootMissing { + slot, + oldest_block_slot: anchor_info.oldest_block_slot, + split_slot: split.slot, + }); + continue; + }; + + if root_bytes.len() != 32 { + return Err(Error::InvalidKey(format!( + "cold block root at slot {slot} has invalid length {}", + root_bytes.len() + ))); + } + + let block_root = Hash256::from_slice(&root_bytes); + let block_exists = self + .hot_db + .key_exists(DBColumn::BeaconBlock, block_root.as_slice())?; + if !block_exists { + result.add_violation(InvariantViolation::ColdBlockRootOrphan { slot, block_root }); + } + } + + Ok(result) + } + + /// Invariant 11 (Cold DB): State root indices. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + fn check_cold_state_root_indices(&self, split: &Split) -> Result { + let mut result = InvariantCheckResult::new(); + + let anchor_info = self.get_anchor_info(); + + // Expected slots are: (i <= state_lower_limit || i >= effective_upper) && i < split.slot + // where effective_upper = min(split.slot, state_upper_limit). + for slot_val in 0..split.slot.as_u64() { + let slot = Slot::new(slot_val); + + if slot <= anchor_info.state_lower_limit + || slot >= cmp::min(split.slot, anchor_info.state_upper_limit) + { + let slot_bytes = slot_val.to_be_bytes(); + let Some(root_bytes) = self + .cold_db + .get_bytes(DBColumn::BeaconStateRoots, &slot_bytes)? + else { + result.add_violation(InvariantViolation::ColdStateRootMissing { + slot, + state_lower_limit: anchor_info.state_lower_limit, + state_upper_limit: anchor_info.state_upper_limit, + split_slot: split.slot, + }); + continue; + }; + + if root_bytes.len() != 32 { + return Err(Error::InvalidKey(format!( + "cold state root at slot {slot} has invalid length {}", + root_bytes.len() + ))); + } + + let state_root = Hash256::from_slice(&root_bytes); + + match self + .cold_db + .get_bytes(DBColumn::BeaconColdStateSummary, state_root.as_slice())? + { + None => { + result.add_violation(InvariantViolation::ColdStateRootMissingSummary { + slot, + state_root, + }); + } + Some(summary_bytes) => { + let summary = ColdStateSummary::from_ssz_bytes(&summary_bytes)?; + if summary.slot != slot { + result.add_violation(InvariantViolation::ColdStateRootSlotMismatch { + slot, + state_root, + summary_slot: summary.slot, + }); + } + } + } + } + } + + Ok(result) + } + + /// Invariant 12 (Cold DB): Cold state diff/snapshot consistency. + /// + /// ```text + /// cold_state_summary in cold_db + /// -> state diff/snapshot/nothing in cold_db per HDiff hierarchy rules + /// ``` + /// + /// Each cold state summary should have the correct storage artifact according to the + /// HDiff hierarchy. Cold states always use genesis (slot 0) as the hierarchy start since + /// they are finalized and have no anchor_slot dependency. + fn check_cold_state_diff_consistency(&self) -> Result { + let mut result = InvariantCheckResult::new(); + + let mut summary_slots = HashSet::new(); + let mut base_slot_refs = Vec::new(); + + for res in self + .cold_db + .iter_column::(DBColumn::BeaconColdStateSummary) + { + let (state_root, value) = res?; + let summary = ColdStateSummary::from_ssz_bytes(&value)?; + + summary_slots.insert(summary.slot); + + let slot_bytes = summary.slot.as_u64().to_be_bytes(); + + match self + .hierarchy + .storage_strategy(summary.slot, Slot::new(0))? + { + StorageStrategy::Snapshot => { + let has_snapshot = self + .cold_db + .key_exists(DBColumn::BeaconStateSnapshot, &slot_bytes)?; + if !has_snapshot { + result.add_violation(InvariantViolation::ColdStateMissingSnapshot { + state_root, + slot: summary.slot, + }); + } + } + StorageStrategy::DiffFrom(base_slot) => { + let has_diff = self + .cold_db + .key_exists(DBColumn::BeaconStateDiff, &slot_bytes)?; + if !has_diff { + result.add_violation(InvariantViolation::ColdStateMissingDiff { + state_root, + slot: summary.slot, + }); + } + base_slot_refs.push((summary.slot, base_slot)); + } + StorageStrategy::ReplayFrom(base_slot) => { + base_slot_refs.push((summary.slot, base_slot)); + } + } + } + + // Verify that all DiffFrom/ReplayFrom base slots reference existing summaries. + for (slot, base_slot) in base_slot_refs { + if !summary_slots.contains(&base_slot) { + result.add_violation(InvariantViolation::ColdStateBaseSummaryMissing { + slot, + base_slot, + }); + } + } + + Ok(result) + } + + /// Invariant 9 (Hot DB): Pubkey cache consistency. + /// + /// ```text + /// all validator pubkeys from states are in hot_db(PubkeyCache) + /// ``` + /// + /// Checks that the in-memory pubkey cache and the on-disk PubkeyCache column have the same + /// number of entries AND that each pubkey matches at every validator index. + fn check_pubkey_cache_consistency( + &self, + ctx: &InvariantContext, + ) -> Result { + let mut result = InvariantCheckResult::new(); + + // Read on-disk pubkeys by sequential validator index (matching how they are stored + // with Hash256::from_low_u64_be(index) as key). + // Iterate in-memory pubkeys and verify each matches on disk. + for (validator_index, in_memory_bytes) in ctx.pubkey_cache_pubkeys.iter().enumerate() { + let mut key = [0u8; 32]; + key[24..].copy_from_slice(&(validator_index as u64).to_be_bytes()); + match self.hot_db.get_bytes(DBColumn::PubkeyCache, &key)? { + Some(on_disk_bytes) if in_memory_bytes != &on_disk_bytes => { + result + .add_violation(InvariantViolation::PubkeyCacheMismatch { validator_index }); + } + None => { + result + .add_violation(InvariantViolation::PubkeyCacheMissing { validator_index }); + } + _ => {} + } + } + + Ok(result) + } +} diff --git a/beacon_node/store/src/lib.rs b/beacon_node/store/src/lib.rs index 3363eb800c..bfa1200602 100644 --- a/beacon_node/store/src/lib.rs +++ b/beacon_node/store/src/lib.rs @@ -15,6 +15,7 @@ pub mod hdiff; pub mod historic_state_cache; pub mod hot_cold_store; mod impls; +pub mod invariants; mod memory_store; pub mod metadata; pub mod metrics; diff --git a/beacon_node/store/src/state_cache.rs b/beacon_node/store/src/state_cache.rs index 4b0d1ee016..6d159c9361 100644 --- a/beacon_node/store/src/state_cache.rs +++ b/beacon_node/store/src/state_cache.rs @@ -111,6 +111,19 @@ impl StateCache { self.hdiff_buffers.mem_usage() } + /// Return all state roots currently held in the cache, including the finalized state. + pub fn state_roots(&self) -> Vec { + let mut roots: Vec = self + .states + .iter() + .map(|(&state_root, _)| state_root) + .collect(); + if let Some(ref finalized) = self.finalized_state { + roots.push(finalized.state_root); + } + roots + } + pub fn update_finalized_state( &mut self, state_root: Hash256, From bff72a920da50a2abefa44b75c98b9597200ee8a Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 12 Mar 2026 10:06:25 +1100 Subject: [PATCH 12/15] Update database and block replayer to handle payload envelopes (#8886) Closes: - https://github.com/sigp/lighthouse/issues/8869 - Update `BlockReplayer` to support replay of execution payload envelopes. - Update `HotColdDB` to load payload envelopes and feed them to the `BlockReplayer` for both hot + cold states. However the cold DB code is not fully working yet (see: https://github.com/sigp/lighthouse/issues/8958). - Add `StatePayloadStatus` to allow callers to specify whether they want a state with a payload applied, or not. - Fix the state cache to key by `StatePayloadStatus`. - Lots of fixes to block production and block processing regarding state management. - Initial test harness support for producing+processing Gloas blocks+envelopes - A few new tests to cover Gloas DB operations Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul Co-Authored-By: Jimmy Chen --- beacon_node/beacon_chain/src/beacon_chain.rs | 27 +- .../beacon_chain/src/blob_verification.rs | 10 +- .../src/block_production/gloas.rs | 15 +- .../beacon_chain/src/block_production/mod.rs | 41 +- .../beacon_chain/src/block_verification.rs | 37 +- beacon_node/beacon_chain/src/builder.rs | 12 +- .../beacon_chain/src/canonical_head.rs | 13 +- .../src/data_column_verification.rs | 11 +- .../src/schema_change/migration_schema_v24.rs | 2 + .../beacon_chain/src/state_advance_timer.rs | 14 +- beacon_node/beacon_chain/src/test_utils.rs | 164 ++++++- beacon_node/beacon_chain/tests/rewards.rs | 3 +- beacon_node/beacon_chain/tests/store_tests.rs | 443 +++++++++++++++++- .../test_utils/execution_block_generator.rs | 10 +- .../http_api/src/attestation_performance.rs | 3 +- .../http_api/src/block_packing_efficiency.rs | 3 +- beacon_node/http_api/src/produce_block.rs | 2 +- .../http_api/src/sync_committee_rewards.rs | 3 +- beacon_node/store/src/hdiff.rs | 6 + beacon_node/store/src/hot_cold_store.rs | 252 ++++++++-- beacon_node/store/src/reconstruct.rs | 1 + beacon_node/store/src/state_cache.rs | 43 +- .../state_processing/src/block_replayer.rs | 136 +++++- .../src/envelope_processing.rs | 2 - .../src/per_block_processing/tests.rs | 2 +- .../state_processing/src/state_advance.rs | 5 + .../types/src/block/signed_beacon_block.rs | 27 ++ consensus/types/src/execution/mod.rs | 2 + .../src/execution/state_payload_status.rs | 18 + consensus/types/src/state/beacon_state.rs | 20 +- 30 files changed, 1243 insertions(+), 84 deletions(-) create mode 100644 consensus/types/src/execution/state_payload_status.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 07f3bb01fa..ab2097e001 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -2031,9 +2031,16 @@ impl BeaconChain { // required information. (justified_checkpoint, committee_len) } else { + // We assume that the `Pending` state has the same shufflings as a `Full` state + // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root let (advanced_state_root, mut state) = self .store - .get_advanced_hot_state(beacon_block_root, request_slot, beacon_state_root)? + .get_advanced_hot_state( + beacon_block_root, + StatePayloadStatus::Pending, + request_slot, + beacon_state_root, + )? .ok_or(Error::MissingBeaconState(beacon_state_root))?; if state.current_epoch() < request_epoch { partial_state_advance( @@ -4662,12 +4669,19 @@ impl BeaconChain { if cached_head.head_block_root() == parent_block_root { (Cow::Borrowed(head_state), cached_head.head_state_root()) } else { + // TODO(gloas): this function needs updating to be envelope-aware + // See: https://github.com/sigp/lighthouse/issues/8957 let block = self .get_blinded_block(&parent_block_root)? .ok_or(Error::MissingBeaconBlock(parent_block_root))?; let (state_root, state) = self .store - .get_advanced_hot_state(parent_block_root, proposal_slot, block.state_root())? + .get_advanced_hot_state( + parent_block_root, + StatePayloadStatus::Pending, + proposal_slot, + block.state_root(), + )? .ok_or(Error::MissingBeaconState(block.state_root()))?; (Cow::Owned(state), state_root) }; @@ -6599,9 +6613,16 @@ impl BeaconChain { let (mut state, state_root) = if let Some((state, state_root)) = head_state_opt { (state, state_root) } else { + // We assume that the `Pending` state has the same shufflings as a `Full` state + // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root let (state_root, state) = self .store - .get_advanced_hot_state(head_block_root, target_slot, head_block.state_root)? + .get_advanced_hot_state( + head_block_root, + StatePayloadStatus::Pending, + target_slot, + head_block.state_root, + )? .ok_or(Error::MissingBeaconState(head_block.state_root))?; (state, state_root) }; diff --git a/beacon_node/beacon_chain/src/blob_verification.rs b/beacon_node/beacon_chain/src/blob_verification.rs index fe111628db..86b385d818 100644 --- a/beacon_node/beacon_chain/src/blob_verification.rs +++ b/beacon_node/beacon_chain/src/blob_verification.rs @@ -20,6 +20,7 @@ use tree_hash::TreeHash; use types::data::BlobIdentifier; use types::{ BeaconStateError, BlobSidecar, Epoch, EthSpec, Hash256, SignedBeaconBlockHeader, Slot, + StatePayloadStatus, }; /// An error occurred while validating a gossip blob. @@ -508,9 +509,16 @@ pub fn validate_blob_sidecar_for_gossip = (BeaconBlock>, ConsensusBlockValue); +type BlockProductionResult = (BeaconBlock, BeaconState, ConsensusBlockValue); pub type PreparePayloadResult = Result, BlockProductionError>; pub type PreparePayloadHandle = JoinHandle>>; @@ -425,6 +425,12 @@ impl BeaconChain { )) } + /// Complete a block by computing its state root, and + /// + /// Return `(block, pending_state, block_value)` where: + /// + /// - `pending_state` is the state post block application (prior to payload application) + /// - `block_value` is the consensus-layer rewards for `block` #[allow(clippy::type_complexity)] fn complete_partial_beacon_block_gloas( &self, @@ -433,7 +439,7 @@ impl BeaconChain { payload_data: Option>, mut state: BeaconState, verification: ProduceBlockVerification, - ) -> Result<(BeaconBlock>, u64), BlockProductionError> { + ) -> Result, BlockProductionError> { let PartialBeaconBlock { slot, proposer_index, @@ -545,6 +551,9 @@ impl BeaconChain { drop(state_root_timer); + // Clone the Pending state (post-block, pre-envelope) for callers that need it. + let pending_state = state.clone(); + let (mut block, _) = signed_beacon_block.deconstruct(); *block.state_root_mut() = state_root; @@ -605,7 +614,7 @@ impl BeaconChain { "Produced beacon block" ); - Ok((block, consensus_block_value)) + Ok((block, pending_state, consensus_block_value)) } // TODO(gloas) introduce `ProposerPreferences` so we can build out trustless diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 76c8b77e93..b33323f527 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use proto_array::ProposerHeadError; use slot_clock::SlotClock; use tracing::{debug, error, info, instrument, warn}; -use types::{BeaconState, Hash256, Slot}; +use types::{BeaconState, Hash256, Slot, StatePayloadStatus}; use crate::{ BeaconChain, BeaconChainTypes, BlockProductionError, StateSkipConfig, @@ -37,8 +37,14 @@ impl BeaconChain { }; let (state, state_root_opt) = if head_slot < slot { // Attempt an aggressive re-org if configured and the conditions are right. - if let Some((re_org_state, re_org_state_root)) = - self.get_state_for_re_org(slot, head_slot, head_block_root) + // 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) { info!( %slot, @@ -49,9 +55,30 @@ impl BeaconChain { } else { // Fetch the head state advanced through to `slot`, which should be present in the // state cache thanks to the state advance timer. + // TODO(gloas): need to fix this once fork choice understands payloads + // for now we just use the existence of the head's payload envelope to determine + // whether we should build atop it + let (payload_status, parent_state_root) = if gloas_enabled + && let Ok(Some(envelope)) = self.store.get_payload_envelope(&head_block_root) + { + debug!( + %slot, + parent_state_root = ?envelope.message.state_root, + parent_block_root = ?head_block_root, + "Building Gloas block on full state" + ); + (StatePayloadStatus::Full, envelope.message.state_root) + } else { + (StatePayloadStatus::Pending, head_state_root) + }; let (state_root, state) = self .store - .get_advanced_hot_state(head_block_root, slot, head_state_root) + .get_advanced_hot_state( + head_block_root, + payload_status, + slot, + parent_state_root, + ) .map_err(BlockProductionError::FailedToLoadState)? .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; (state, Some(state_root)) @@ -204,7 +231,11 @@ impl BeaconChain { let (state_root, state) = self .store - .get_advanced_hot_state_from_cache(re_org_parent_block, slot) + .get_advanced_hot_state_from_cache( + re_org_parent_block, + StatePayloadStatus::Pending, + slot, + ) .or_else(|| { warn!(reason = "no state in cache", "Not attempting re-org"); None diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index b748bf5c6c..1be9bd4181 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -99,7 +99,8 @@ use tracing::{Instrument, Span, debug, debug_span, error, info_span, instrument} use types::{ BeaconBlockRef, BeaconState, BeaconStateError, BlobsList, ChainSpec, DataColumnSidecarList, Epoch, EthSpec, FullPayload, Hash256, InconsistentFork, KzgProofs, RelativeEpoch, - SignedBeaconBlock, SignedBeaconBlockHeader, Slot, data::DataColumnSidecarError, + SignedBeaconBlock, SignedBeaconBlockHeader, Slot, StatePayloadStatus, + data::DataColumnSidecarError, }; /// Maximum block slot number. Block with slots bigger than this constant will NOT be processed. @@ -1491,7 +1492,11 @@ impl ExecutionPendingBlock { let distance = block.slot().as_u64().saturating_sub(state.slot().as_u64()); for _ in 0..distance { - let state_root = if parent.beacon_block.slot() == state.slot() { + // TODO(gloas): could do a similar optimisation here for Full blocks if we have access + // to the parent envelope and its `state_root`. + let state_root = if parent.beacon_block.slot() == state.slot() + && state.payload_status() == StatePayloadStatus::Pending + { // If it happens that `pre_state` has *not* already been advanced forward a single // slot, then there is no need to compute the state root for this // `per_slot_processing` call since that state root is already stored in the parent @@ -1908,9 +1913,31 @@ fn load_parent>( // Retrieve any state that is advanced through to at most `block.slot()`: this is // particularly important if `block` descends from the finalized/split block, but at a slot // prior to the finalized slot (which is invalid and inaccessible in our DB schema). + // + // Post-Gloas we must also fetch a state with the correct payload status. If the current + // block builds upon the payload of its parent block, then we know the parent block is FULL + // and we need to load the full state. + let (payload_status, parent_state_root) = + if block.as_block().fork_name_unchecked().gloas_enabled() + && let Ok(parent_bid_block_hash) = parent_block.payload_bid_block_hash() + { + if block.as_block().is_parent_block_full(parent_bid_block_hash) { + // TODO(gloas): loading the envelope here is not very efficient + let envelope = chain.store.get_payload_envelope(&root)?.ok_or_else(|| { + BeaconChainError::DBInconsistent(format!( + "Missing envelope for parent block {root:?}", + )) + })?; + (StatePayloadStatus::Full, envelope.message.state_root) + } else { + (StatePayloadStatus::Pending, parent_block.state_root()) + } + } else { + (StatePayloadStatus::Pending, parent_block.state_root()) + }; let (parent_state_root, state) = chain .store - .get_advanced_hot_state(root, block.slot(), parent_block.state_root())? + .get_advanced_hot_state(root, payload_status, block.slot(), parent_state_root)? .ok_or_else(|| { BeaconChainError::DBInconsistent( format!("Missing state for parent block {root:?}",), @@ -1933,7 +1960,9 @@ fn load_parent>( ); } - let beacon_state_root = if state.slot() == parent_block.slot() { + let beacon_state_root = if state.slot() == parent_block.slot() + && let StatePayloadStatus::Pending = payload_status + { // Sanity check. if parent_state_root != parent_block.state_root() { return Err(BeaconChainError::DBInconsistent(format!( diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index d5935b492a..59fa5ec9ec 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -45,7 +45,7 @@ use tree_hash::TreeHash; use types::data::CustodyIndex; use types::{ BeaconBlock, BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, - Epoch, EthSpec, Hash256, SignedBeaconBlock, Slot, + Epoch, EthSpec, Hash256, SignedBeaconBlock, Slot, StatePayloadStatus, }; /// An empty struct used to "witness" all the `BeaconChainTypes` traits. It has no user-facing @@ -783,8 +783,16 @@ where .map_err(|e| descriptive_db_error("head block", &e))? .ok_or("Head block not found in store")?; + // TODO(gloas): update head loading to load Full block once fork choice works + let payload_status = StatePayloadStatus::Pending; + let (_head_state_root, head_state) = store - .get_advanced_hot_state(head_block_root, current_slot, head_block.state_root()) + .get_advanced_hot_state( + head_block_root, + payload_status, + current_slot, + head_block.state_root(), + ) .map_err(|e| descriptive_db_error("head state", &e))? .ok_or("Head state not found in store")?; diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 1a08ac3f88..fd060e2b59 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -305,8 +305,16 @@ impl CanonicalHead { .get_full_block(&beacon_block_root)? .ok_or(Error::MissingBeaconBlock(beacon_block_root))?; let current_slot = fork_choice.fc_store().get_current_slot(); + + // TODO(gloas): pass a better payload status once fork choice is implemented + let payload_status = StatePayloadStatus::Pending; let (_, beacon_state) = store - .get_advanced_hot_state(beacon_block_root, current_slot, beacon_block.state_root())? + .get_advanced_hot_state( + beacon_block_root, + payload_status, + current_slot, + beacon_block.state_root(), + )? .ok_or(Error::MissingBeaconState(beacon_block.state_root()))?; let snapshot = BeaconSnapshot { @@ -673,10 +681,13 @@ impl BeaconChain { .get_full_block(&new_view.head_block_root)? .ok_or(Error::MissingBeaconBlock(new_view.head_block_root))?; + // TODO(gloas): update once we have fork choice + let payload_status = StatePayloadStatus::Pending; let (_, beacon_state) = self .store .get_advanced_hot_state( new_view.head_block_root, + payload_status, current_slot, beacon_block.state_root(), )? diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index 08acfdffa4..dde9fad342 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -20,7 +20,7 @@ use tracing::{debug, instrument}; use types::data::ColumnIndex; use types::{ BeaconStateError, ChainSpec, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSubnetId, - EthSpec, Hash256, Slot, + EthSpec, Hash256, Slot, StatePayloadStatus, }; /// An error occurred while validating a gossip data column. @@ -706,9 +706,16 @@ fn verify_proposer_and_signature( index = %column_index, "Proposer shuffling cache miss for column verification" ); + // We assume that the `Pending` state has the same shufflings as a `Full` state + // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root chain .store - .get_advanced_hot_state(block_parent_root, column_slot, parent_block.state_root) + .get_advanced_hot_state( + block_parent_root, + StatePayloadStatus::Pending, + column_slot, + parent_block.state_root, + ) .map_err(|e| GossipDataColumnError::BeaconChainError(Box::new(e.into())))? .ok_or_else(|| { GossipDataColumnError::BeaconChainError(Box::new( diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs index 1e1823a836..c8dfe1ac9b 100644 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs +++ b/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs @@ -16,6 +16,7 @@ use store::{ use tracing::{debug, info, warn}; use types::{ BeaconState, CACHED_EPOCHS, ChainSpec, Checkpoint, CommitteeCache, EthSpec, Hash256, Slot, + execution::StatePayloadStatus, }; /// We stopped using the pruning checkpoint in schema v23 but never explicitly deleted it. @@ -58,6 +59,7 @@ pub fn get_state_v22( base_state, summary.slot, summary.latest_block_root, + StatePayloadStatus::Pending, update_cache, ) .map(Some) diff --git a/beacon_node/beacon_chain/src/state_advance_timer.rs b/beacon_node/beacon_chain/src/state_advance_timer.rs index cb916cb514..4c070e7ecc 100644 --- a/beacon_node/beacon_chain/src/state_advance_timer.rs +++ b/beacon_node/beacon_chain/src/state_advance_timer.rs @@ -26,7 +26,10 @@ use std::sync::{ use task_executor::TaskExecutor; use tokio::time::{Instant, sleep, sleep_until}; use tracing::{Instrument, debug, debug_span, error, instrument, warn}; -use types::{AttestationShufflingId, BeaconStateError, EthSpec, Hash256, RelativeEpoch, Slot}; +use types::{ + AttestationShufflingId, BeaconStateError, EthSpec, Hash256, RelativeEpoch, Slot, + StatePayloadStatus, +}; /// If the head slot is more than `MAX_ADVANCE_DISTANCE` from the current slot, then don't perform /// the state advancement. @@ -277,9 +280,16 @@ fn advance_head(beacon_chain: &Arc>) -> Resu (snapshot.beacon_block_root, snapshot.beacon_state_root()) }; + // TODO(gloas): do better once we have fork choice + let payload_status = StatePayloadStatus::Pending; let (head_state_root, mut state) = beacon_chain .store - .get_advanced_hot_state(head_block_root, current_slot, head_block_state_root)? + .get_advanced_hot_state( + head_block_root, + payload_status, + current_slot, + head_block_state_root, + )? .ok_or(Error::HeadMissingFromSnapshotCache(head_block_root))?; let initial_slot = state.slot(); diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index eefb5d48b7..4bc5bb21d3 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -27,7 +27,7 @@ use bls::{ use eth2::types::{GraffitiPolicy, SignedBlockContentsTuple}; use execution_layer::test_utils::generate_genesis_header; use execution_layer::{ - ExecutionLayer, + ExecutionLayer, NewPayloadRequest, NewPayloadRequestGloas, auth::JwtKey, test_utils::{DEFAULT_JWT_SECRET, ExecutionBlockGenerator, MockBuilder, MockExecutionLayer}, }; @@ -52,7 +52,8 @@ use ssz_types::{RuntimeVariableList, VariableList}; use state_processing::ConsensusContext; use state_processing::per_block_processing::compute_timestamp_at_slot; use state_processing::per_block_processing::{ - BlockSignatureStrategy, VerifyBlockRoot, per_block_processing, + BlockSignatureStrategy, VerifyBlockRoot, deneb::kzg_commitment_to_versioned_hash, + per_block_processing, }; use state_processing::state_advance::complete_state_advance; use std::borrow::Cow; @@ -66,6 +67,7 @@ use store::database::interface::BeaconNodeBackend; use store::{HotColdDB, ItemStore, MemoryStore, config::StoreConfig}; use task_executor::TaskExecutor; use task_executor::{ShutdownReason, test_utils::TestRuntime}; +use tracing::debug; use tree_hash::TreeHash; use typenum::U4294967296; use types::attestation::IndexedAttestationBase; @@ -1092,6 +1094,86 @@ where (block_contents, block_response.state) } + /// Returns a newly created block, signed by the proposer for the given slot, + /// along with the execution payload envelope (for Gloas) and the pending state. + /// + /// For pre-Gloas forks, the envelope is `None` and this behaves like `make_block`. + pub async fn make_block_with_envelope( + &self, + mut state: BeaconState, + slot: Slot, + ) -> ( + SignedBlockContentsTuple, + Option>, + BeaconState, + ) { + assert_ne!(slot, 0, "can't produce a block at slot 0"); + assert!(slot >= state.slot()); + + if state.fork_name_unchecked().gloas_enabled() + || self.spec.fork_name_at_slot::(slot).gloas_enabled() + { + complete_state_advance(&mut state, None, slot, &self.spec) + .expect("should be able to advance state to slot"); + state.build_caches(&self.spec).expect("should build caches"); + + let proposer_index = state.get_beacon_proposer_index(slot, &self.spec).unwrap(); + + let graffiti = Graffiti::from(self.rng.lock().random::<[u8; 32]>()); + let graffiti_settings = + GraffitiSettings::new(Some(graffiti), Some(GraffitiPolicy::PreserveUserGraffiti)); + let randao_reveal = self.sign_randao_reveal(&state, proposer_index, slot); + + let (block, pending_state, _consensus_block_value) = self + .chain + .produce_block_on_state_gloas( + state, + None, + slot, + randao_reveal, + graffiti_settings, + ProduceBlockVerification::VerifyRandao, + ) + .await + .unwrap(); + + let signed_block = Arc::new(block.sign( + &self.validator_keypairs[proposer_index].sk, + &pending_state.fork(), + pending_state.genesis_validators_root(), + &self.spec, + )); + + // Retrieve the cached envelope produced during block production and sign it. + let signed_envelope = self + .chain + .pending_payload_envelopes + .write() + .remove(slot) + .map(|envelope| { + let epoch = slot.epoch(E::slots_per_epoch()); + let domain = self.spec.get_domain( + epoch, + Domain::BeaconBuilder, + &pending_state.fork(), + pending_state.genesis_validators_root(), + ); + let message = envelope.signing_root(domain); + let signature = self.validator_keypairs[proposer_index].sk.sign(message); + SignedExecutionPayloadEnvelope { + message: envelope, + signature, + } + }); + + let block_contents: SignedBlockContentsTuple = (signed_block, None); + (block_contents, signed_envelope, pending_state) + } else { + let (block_contents, state) = self.make_block(state, slot).await; + (block_contents, None, state) + } + } + /// Useful for the `per_block_processing` tests. Creates a block, and returns the state after /// caches are built but before the generated block is processed. pub async fn make_block_return_pre_state( @@ -2575,6 +2657,84 @@ where Ok(block_hash) } + /// Process an execution payload envelope for a Gloas block. + pub async fn process_envelope( + &self, + block_root: Hash256, + signed_envelope: SignedExecutionPayloadEnvelope, + pending_state: &mut BeaconState, + ) -> Hash256 { + let state_root = signed_envelope.message.state_root; + debug!( + slot = %signed_envelope.message.slot, + ?state_root, + "Processing execution payload envelope" + ); + let block_state_root = pending_state + .update_tree_hash_cache() + .expect("should compute pending state root"); + + state_processing::envelope_processing::process_execution_payload_envelope( + pending_state, + Some(block_state_root), + &signed_envelope, + state_processing::VerifySignatures::True, + state_processing::envelope_processing::VerifyStateRoot::True, + &self.spec, + ) + .expect("should process envelope"); + + // Notify the EL of the new payload so forkchoiceUpdated can reference it. + let block = self + .chain + .store + .get_blinded_block(&block_root) + .expect("should read block from store") + .expect("block should exist in store"); + + let bid = &block + .message() + .body() + .signed_execution_payload_bid() + .expect("Gloas block should have a payload bid") + .message; + + let versioned_hashes = bid + .blob_kzg_commitments + .iter() + .map(kzg_commitment_to_versioned_hash) + .collect(); + + let request = NewPayloadRequest::Gloas(NewPayloadRequestGloas { + execution_payload: &signed_envelope.message.payload, + versioned_hashes, + parent_beacon_block_root: block.message().parent_root(), + execution_requests: &signed_envelope.message.execution_requests, + }); + + self.chain + .execution_layer + .as_ref() + .expect("harness should have execution layer") + .notify_new_payload(request) + .await + .expect("newPayload should succeed"); + + // Store the envelope. + self.chain + .store + .put_payload_envelope(&block_root, signed_envelope) + .expect("should store envelope"); + + // Store the Full state. + self.chain + .store + .put_state(&state_root, pending_state) + .expect("should store full state"); + + state_root + } + /// Builds an `Rpc` block from a `SignedBeaconBlock` and blobs or data columns retrieved from /// the database. pub fn build_rpc_block_from_store_blobs( diff --git a/beacon_node/beacon_chain/tests/rewards.rs b/beacon_node/beacon_chain/tests/rewards.rs index bc7c98041f..1889c1f625 100644 --- a/beacon_node/beacon_chain/tests/rewards.rs +++ b/beacon_node/beacon_chain/tests/rewards.rs @@ -845,13 +845,14 @@ async fn check_all_base_rewards_for_subset( .state_at_slot(Slot::new(slot - 1), StateSkipConfig::WithoutStateRoots) .unwrap(); + // TODO(gloas): handle payloads? let mut pre_state = BlockReplayer::>::new( parent_state, &harness.spec, ) .no_signature_verification() .minimal_block_root_verification() - .apply_blocks(vec![], Some(block.slot())) + .apply_blocks(vec![], vec![], Some(block.slot())) .unwrap() .into_state(); diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 86f4af3efc..a70ad89ca9 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -708,8 +708,13 @@ async fn block_replayer_hooks() { .add_attested_blocks_at_slots(state.clone(), state_root, &block_slots, &all_validators) .await; - let blocks = store - .load_blocks_to_replay(Slot::new(0), max_slot, end_block_root.into()) + let (blocks, envelopes) = store + .load_blocks_to_replay( + Slot::new(0), + max_slot, + end_block_root.into(), + StatePayloadStatus::Pending, + ) .unwrap(); let mut pre_slots = vec![]; @@ -744,7 +749,7 @@ async fn block_replayer_hooks() { post_block_slots.push(block.slot()); Ok(()) })) - .apply_blocks(blocks, None) + .apply_blocks(blocks, envelopes, None) .unwrap() .into_state(); @@ -3842,7 +3847,12 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { let (split_state_root, mut advanced_split_state) = harness .chain .store - .get_advanced_hot_state(split.block_root, split.slot, split.state_root) + .get_advanced_hot_state( + split.block_root, + StatePayloadStatus::Pending, + split.slot, + split.state_root, + ) .unwrap() .unwrap(); complete_state_advance( @@ -5470,6 +5480,427 @@ fn check_finalization(harness: &TestHarness, expected_slot: u64) { ); } +// ===================== Gloas Store Tests ===================== + +/// Test basic Gloas block + envelope storage and retrieval. +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_no_skips() { + test_gloas_block_and_envelope_storage_generic(32, vec![], false).await +} + +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_some_skips() { + test_gloas_block_and_envelope_storage_generic(32, vec![2, 4, 5, 16, 23, 24, 25], false).await +} + +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_no_skips_w_cache() { + test_gloas_block_and_envelope_storage_generic(32, vec![], true).await +} + +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_some_skips_w_cache() { + test_gloas_block_and_envelope_storage_generic(32, vec![2, 4, 5, 16, 23, 24, 25], true).await +} + +async fn test_gloas_block_and_envelope_storage_generic( + num_slots: u64, + skipped_slots: Vec, + use_state_cache: bool, +) { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store_config = if !use_state_cache { + StoreConfig { + state_cache_size: new_non_zero_usize(1), + ..StoreConfig::default() + } + } else { + StoreConfig::default() + }; + let spec = test_spec::(); + let store = get_store_generic(&db_path, store_config, spec); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + let spec = &harness.chain.spec; + + let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); + let mut state = genesis_state; + + let mut block_roots = vec![]; + let mut stored_states = vec![(Slot::new(0), StatePayloadStatus::Full, genesis_state_root)]; + + for i in 1..=num_slots { + let slot = Slot::new(i); + harness.advance_slot(); + + if skipped_slots.contains(&i) { + complete_state_advance(&mut state, None, slot, spec) + .expect("should be able to advance state to slot"); + + let state_root = state.canonical_root().unwrap(); + store.put_state(&state_root, &state).unwrap(); + stored_states.push((slot, state.payload_status(), state_root)); + } + + let (block_contents, envelope, mut pending_state) = + harness.make_block_with_envelope(state, slot).await; + let block_root = block_contents.0.canonical_root(); + + // Process the block. + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + let pending_state_root = pending_state.update_tree_hash_cache().unwrap(); + stored_states.push((slot, StatePayloadStatus::Pending, pending_state_root)); + + // Process the envelope. + let envelope = envelope.expect("Gloas block should have envelope"); + let mut full_state = pending_state.clone(); + let envelope_state_root = envelope.message.state_root; + let full_state_root = harness + .process_envelope(block_root, envelope, &mut full_state) + .await; + assert_eq!(full_state_root, envelope_state_root); + stored_states.push((slot, StatePayloadStatus::Full, full_state_root)); + + block_roots.push(block_root); + state = full_state; + } + + // Verify block storage. + for (i, block_root) in block_roots.iter().enumerate() { + // Block can be loaded. + assert!( + store.get_blinded_block(block_root).unwrap().is_some(), + "block at slot {} should be in DB", + i + 1 + ); + + // Envelope can be loaded. + let loaded_envelope = store.get_payload_envelope(block_root).unwrap(); + assert!( + loaded_envelope.is_some(), + "envelope at slot {} should be in DB", + i + 1 + ); + } + + // Verify state storage. + // Iterate in reverse order to frustrate the cache. + for (slot, payload_status, state_root) in stored_states.into_iter().rev() { + println!("{slot}: {state_root:?}"); + let Some(mut loaded_state) = store + .get_state(&state_root, Some(slot), CACHE_STATE_IN_TESTS) + .unwrap() + else { + panic!("missing {payload_status:?} state at slot {slot} with root {state_root:?}"); + }; + assert_eq!(loaded_state.slot(), slot); + assert_eq!( + loaded_state.payload_status(), + payload_status, + "slot = {slot}" + ); + assert_eq!( + loaded_state.canonical_root().unwrap(), + state_root, + "slot = {slot}" + ); + } +} + +/// Test that Pending and Full states have the correct payload status through round-trip +/// storage and retrieval. +#[tokio::test] +async fn test_gloas_state_payload_status() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + let num_blocks = 6u64; + let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); + let mut state = genesis_state; + + for i in 1..=num_blocks { + let slot = Slot::new(i); + harness.advance_slot(); + + let (block_contents, envelope, pending_state) = + harness.make_block_with_envelope(state, slot).await; + let block_root = block_contents.0.canonical_root(); + + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + // Verify the pending state has correct payload status. + assert_eq!( + pending_state.payload_status(), + StatePayloadStatus::Pending, + "pending state at slot {} should be Pending", + i + ); + + // Process the envelope and verify the full state has correct payload status. + let envelope = envelope.expect("Gloas block should have envelope"); + let mut full_state = pending_state; + let full_state_root = harness + .process_envelope(block_root, envelope, &mut full_state) + .await; + + assert_eq!( + full_state.payload_status(), + StatePayloadStatus::Full, + "full state at slot {} should be Full", + i + ); + + // Round-trip: load the full state from DB and check status. + let loaded_full = store + .get_state(&full_state_root, None, CACHE_STATE_IN_TESTS) + .unwrap() + .expect("full state should exist in DB"); + assert_eq!( + loaded_full.payload_status(), + StatePayloadStatus::Full, + "loaded full state at slot {} should be Full after round-trip", + i + ); + + state = full_state; + } +} + +/// Test block replay with and without envelopes. +#[tokio::test] +async fn test_gloas_block_replay_with_envelopes() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + let num_blocks = 16u64; + let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); + let mut state = genesis_state.clone(); + + let mut last_block_root = Hash256::zero(); + let mut pending_states = HashMap::new(); + let mut full_states = HashMap::new(); + + for i in 1..=num_blocks { + let slot = Slot::new(i); + harness.advance_slot(); + + let (block_contents, envelope, pending_state) = + harness.make_block_with_envelope(state, slot).await; + let block_root = block_contents.0.canonical_root(); + + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + let pending_state_root = pending_state.clone().update_tree_hash_cache().unwrap(); + pending_states.insert(slot, (pending_state_root, pending_state.clone())); + + let envelope = envelope.expect("Gloas block should have envelope"); + let mut full_state = pending_state; + let full_state_root = harness + .process_envelope(block_root, envelope, &mut full_state) + .await; + full_states.insert(slot, (full_state_root, full_state.clone())); + + last_block_root = block_root; + state = full_state; + } + + let end_slot = Slot::new(num_blocks); + + // Load blocks for Pending replay (no envelopes for the last block). + let (blocks_pending, envelopes_pending) = store + .load_blocks_to_replay( + Slot::new(0), + end_slot, + last_block_root, + StatePayloadStatus::Pending, + ) + .unwrap(); + assert!( + !blocks_pending.is_empty(), + "should have blocks for pending replay" + ); + // For Pending, no envelope for the first block (slot 0) or last block; envelopes for + // intermediate blocks whose payloads are canonical. + let expected_pending_envelopes = blocks_pending.len().saturating_sub(2); + assert_eq!( + envelopes_pending.len(), + expected_pending_envelopes, + "pending replay should have envelopes for all blocks except the last" + ); + assert!( + blocks_pending + .iter() + .skip(1) + .take(envelopes_pending.len()) + .map(|block| block.slot()) + .eq(envelopes_pending + .iter() + .map(|envelope| envelope.message.slot)), + "block and envelope slots should match" + ); + + // Load blocks for Full replay (envelopes for all blocks including the last). + let (blocks_full, envelopes_full) = store + .load_blocks_to_replay( + Slot::new(0), + end_slot, + last_block_root, + StatePayloadStatus::Full, + ) + .unwrap(); + assert_eq!( + envelopes_full.len(), + expected_pending_envelopes + 1, + "full replay should have one more envelope than pending replay" + ); + + // Replay to Pending state and verify. + let mut replayed_pending = + BlockReplayer::::new(genesis_state.clone(), store.get_chain_spec()) + .no_signature_verification() + .minimal_block_root_verification() + .desired_state_payload_status(StatePayloadStatus::Pending) + .apply_blocks(blocks_pending, envelopes_pending, None) + .expect("should replay blocks to pending state") + .into_state(); + replayed_pending.apply_pending_mutations().unwrap(); + + let (_, mut expected_pending) = pending_states.get(&end_slot).unwrap().clone(); + expected_pending.apply_pending_mutations().unwrap(); + + replayed_pending.drop_all_caches().unwrap(); + expected_pending.drop_all_caches().unwrap(); + assert_eq!( + replayed_pending, expected_pending, + "replayed pending state should match stored pending state" + ); + + // Replay to Full state and verify. + let mut replayed_full = + BlockReplayer::::new(genesis_state, store.get_chain_spec()) + .no_signature_verification() + .minimal_block_root_verification() + .desired_state_payload_status(StatePayloadStatus::Full) + .apply_blocks(blocks_full, envelopes_full, None) + .expect("should replay blocks to full state") + .into_state(); + replayed_full.apply_pending_mutations().unwrap(); + + let (_, mut expected_full) = full_states.get(&end_slot).unwrap().clone(); + expected_full.apply_pending_mutations().unwrap(); + + replayed_full.drop_all_caches().unwrap(); + expected_full.drop_all_caches().unwrap(); + assert_eq!( + replayed_full, expected_full, + "replayed full state should match stored full state" + ); +} + +/// Test the hot state hierarchy with Full states stored as ReplayFrom. +#[tokio::test] +async fn test_gloas_hot_state_hierarchy() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + // Build enough blocks to span multiple epochs. With MinimalEthSpec (8 slots/epoch), + // 40 slots covers 5 epochs. + let num_blocks = E::slots_per_epoch() * 5; + // TODO(gloas): enable finalisation by increasing this threshold + let some_validators = (0..LOW_VALIDATOR_COUNT / 2).collect::>(); + + let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); + + // Use manual block building with envelopes for the first few blocks, + // then use the standard attested-blocks path once we've verified envelope handling. + let mut state = genesis_state; + let mut last_block_root = Hash256::zero(); + + for i in 1..=num_blocks { + let slot = Slot::new(i); + harness.advance_slot(); + + let (block_contents, envelope, pending_state) = + harness.make_block_with_envelope(state.clone(), slot).await; + let block_root = block_contents.0.canonical_root(); + + // Attest to previous block before processing next. + if i > 1 { + let state_root = state.update_tree_hash_cache().unwrap(); + harness.attest_block( + &state, + state_root, + last_block_root.into(), + &block_contents.0, + &some_validators, + ); + } + + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + let envelope = envelope.expect("Gloas block should have envelope"); + let mut full_state = pending_state; + harness + .process_envelope(block_root, envelope, &mut full_state) + .await; + + last_block_root = block_root; + state = full_state; + } + + // Verify states can be loaded and have correct payload status. + let _head_state = harness.get_current_state(); + let _head_slot = harness.head_slot(); + + // States at all slots on the canonical chain should be retrievable. + for slot_num in 1..=num_blocks { + let slot = Slot::new(slot_num); + // Get the state root from the block at this slot via the state root iterator. + let state_root = harness.chain.state_root_at_slot(slot).unwrap().unwrap(); + + let mut loaded_state = store + .get_state(&state_root, Some(slot), CACHE_STATE_IN_TESTS) + .unwrap() + .unwrap(); + assert_eq!(loaded_state.canonical_root().unwrap(), state_root); + } + + // Verify chain dump and iterators work with Gloas states. + check_chain_dump(&harness, num_blocks + 1); + check_iterators(&harness); +} + /// Check that the HotColdDB's split_slot is equal to the start slot of the last finalized epoch. fn check_split_slot( harness: &TestHarness, @@ -5521,7 +5952,9 @@ fn check_chain_dump_from_slot(harness: &TestHarness, from_slot: Slot, expected_l ); // Check presence of execution payload on disk. - if harness.chain.spec.bellatrix_fork_epoch.is_some() { + if harness.chain.spec.bellatrix_fork_epoch.is_some() + && !harness.chain.spec.is_gloas_scheduled() + { assert!( harness .chain diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index e94924d8b2..a66f7a9b55 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -932,8 +932,14 @@ pub fn generate_genesis_header(spec: &ChainSpec) -> Option None, + ForkName::Gloas => { + // TODO(gloas): we are using a Fulu header for now, but this gets fixed up by the + // genesis builder anyway which translates it to bid/latest_block_hash. + let mut header = ExecutionPayloadHeader::Fulu(<_>::default()); + *header.block_hash_mut() = genesis_block_hash.unwrap_or_default(); + *header.transactions_root_mut() = empty_transactions_root; + Some(header) + } } } diff --git a/beacon_node/http_api/src/attestation_performance.rs b/beacon_node/http_api/src/attestation_performance.rs index 6e285829d2..05ed36e68b 100644 --- a/beacon_node/http_api/src/attestation_performance.rs +++ b/beacon_node/http_api/src/attestation_performance.rs @@ -205,8 +205,9 @@ pub fn get_attestation_performance( }) .collect::, _>>()?; + // TODO(gloas): add payloads replayer = replayer - .apply_blocks(blocks, None) + .apply_blocks(blocks, vec![], None) .map_err(|e| custom_server_error(format!("{:?}", e)))?; } diff --git a/beacon_node/http_api/src/block_packing_efficiency.rs b/beacon_node/http_api/src/block_packing_efficiency.rs index 3772470b28..725a0648a5 100644 --- a/beacon_node/http_api/src/block_packing_efficiency.rs +++ b/beacon_node/http_api/src/block_packing_efficiency.rs @@ -398,8 +398,9 @@ pub fn get_block_packing_efficiency( }) .collect::, _>>()?; + // TODO(gloas): add payloads replayer = replayer - .apply_blocks(blocks, None) + .apply_blocks(blocks, vec![], None) .map_err(|e: PackingEfficiencyError| custom_server_error(format!("{:?}", e)))?; } diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 607221686f..70475de130 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -70,7 +70,7 @@ pub async fn produce_block_v4( let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); - let (block, consensus_block_value) = chain + let (block, _pending_state, consensus_block_value) = chain .produce_block_with_verification_gloas( randao_reveal, slot, diff --git a/beacon_node/http_api/src/sync_committee_rewards.rs b/beacon_node/http_api/src/sync_committee_rewards.rs index 9bc1f6ead4..8715fc2b1e 100644 --- a/beacon_node/http_api/src/sync_committee_rewards.rs +++ b/beacon_node/http_api/src/sync_committee_rewards.rs @@ -66,11 +66,12 @@ pub fn get_state_before_applying_block( }) .map_err(|e| custom_not_found(format!("Parent state is not available! {:?}", e)))?; + // TODO(gloas): handle payloads? let replayer = BlockReplayer::new(parent_state, &chain.spec) .no_signature_verification() .state_root_iter([Ok((parent_block.state_root(), parent_block.slot()))].into_iter()) .minimal_block_root_verification() - .apply_blocks(vec![], Some(block.slot())) + .apply_blocks(vec![], vec![], Some(block.slot())) .map_err(unhandled_error::)?; Ok(replayer.into_state()) diff --git a/beacon_node/store/src/hdiff.rs b/beacon_node/store/src/hdiff.rs index 3777c83b60..85ac56454c 100644 --- a/beacon_node/store/src/hdiff.rs +++ b/beacon_node/store/src/hdiff.rs @@ -654,6 +654,12 @@ impl HierarchyModuli { /// layer 2 diff will point to the start snapshot instead of the layer 1 diff at /// 2998272. pub fn storage_strategy(&self, slot: Slot, start_slot: Slot) -> Result { + // Initially had the idea of using different storage strategies for full and pending states, + // but it was very complex. However without this concept we end up storing two diffs/two + // snapshots at full slots. The complexity of managing skipped slots was the main impetus + // for reverting the payload-status sensitive design: a Full skipped slot has no same-slot + // Pending state to replay from, so has to be handled differently from Full non-skipped + // slots. match slot.cmp(&start_slot) { Ordering::Less => return Err(Error::LessThanStart(slot, start_slot)), Ordering::Equal => return Ok(StorageStrategy::Snapshot), diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index fe3477dbfe..428086c464 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -186,6 +186,7 @@ pub enum HotColdDBError { MissingHotHDiff(Hash256), MissingHDiff(Slot), MissingExecutionPayload(Hash256), + MissingExecutionPayloadEnvelope(Hash256), MissingFullBlockExecutionPayloadPruned(Hash256, Slot), MissingAnchorInfo, MissingFrozenBlockSlot(Hash256), @@ -1132,10 +1133,13 @@ impl, Cold: ItemStore> HotColdDB pub fn get_advanced_hot_state( &self, block_root: Hash256, + payload_status: StatePayloadStatus, max_slot: Slot, state_root: Hash256, ) -> Result)>, Error> { - if let Some(cached) = self.get_advanced_hot_state_from_cache(block_root, max_slot) { + if let Some(cached) = + self.get_advanced_hot_state_from_cache(block_root, payload_status, max_slot) + { return Ok(Some(cached)); } @@ -1157,7 +1161,11 @@ impl, Cold: ItemStore> HotColdDB .into()); } - let state_root = if block_root == split.block_root && split.slot <= max_slot { + // Split state should always be `Pending`. + let state_root = if block_root == split.block_root + && let StatePayloadStatus::Pending = payload_status + && split.slot <= max_slot + { split.state_root } else { state_root @@ -1204,11 +1212,12 @@ impl, Cold: ItemStore> HotColdDB pub fn get_advanced_hot_state_from_cache( &self, block_root: Hash256, + payload_status: StatePayloadStatus, max_slot: Slot, ) -> Option<(Hash256, BeaconState)> { self.state_cache .lock() - .get_by_block_root(block_root, max_slot) + .get_by_block_root(block_root, payload_status, max_slot) } /// Delete a state, ensuring it is removed from the LRU cache, as well as from on-disk. @@ -1379,6 +1388,8 @@ impl, Cold: ItemStore> HotColdDB // NOTE: `hot_storage_strategy` can error if there are states in the database // prior to the `anchor_slot`. This can happen if checkpoint sync has been // botched and left some states in the database prior to completing. + // Use `Pending` status here because snapshots and diffs are only stored for + // `Pending` states. if let Some(slot) = slot && let Ok(strategy) = self.hot_storage_strategy(slot) { @@ -1846,6 +1857,55 @@ impl, Cold: ItemStore> HotColdDB } } + /// Compute the `StatePayloadStatus` for a stored state based on its summary. + /// + /// In future this might become a field of the summary, but this would require a whole DB + /// migration. For now we use an extra read from the DB to determine it. + fn get_hot_state_summary_payload_status( + &self, + summary: &HotStateSummary, + ) -> Result { + // Treat pre-Gloas states as `Pending`. + if !self + .spec + .fork_name_at_slot::(summary.slot) + .gloas_enabled() + { + return Ok(StatePayloadStatus::Pending); + } + + // Treat genesis state as `Pending` (`BeaconBlock` state). + let previous_state_root = summary.previous_state_root; + if previous_state_root.is_zero() { + return Ok(StatePayloadStatus::Pending); + } + + // Load the hot state summary for the previous state. + // + // If it has the same slot as this summary then we know this summary is for a `Full` state + // (payload state), because they are always diffed against their same-slot `Pending` state. + // + // If the previous summary has a different slot AND the latest block is from `summary.slot`, + // then this state *must* be `Pending` (it is the summary for latest block itself). + // + // Otherwise, we are at a skipped slot and must traverse the graph of state summaries + // backwards until we reach a summary for the latest block. This recursion could be quite + // far in the case of a long skip. We could optimise this in future using the + // `diff_base_state` (like in `get_ancestor_state_root`), or by doing a proper DB + // migration. + let previous_state_summary = self + .load_hot_state_summary(&previous_state_root)? + .ok_or(Error::MissingHotStateSummary(previous_state_root))?; + + if previous_state_summary.slot == summary.slot { + Ok(StatePayloadStatus::Full) + } else if summary.slot == summary.latest_block_slot { + Ok(StatePayloadStatus::Pending) + } else { + self.get_hot_state_summary_payload_status(&previous_state_summary) + } + } + fn load_hot_hdiff_buffer(&self, state_root: Hash256) -> Result { if let Some(buffer) = self .state_cache @@ -1941,13 +2001,22 @@ impl, Cold: ItemStore> HotColdDB ) -> Result, Hash256)>, Error> { metrics::inc_counter(&metrics::BEACON_STATE_HOT_GET_COUNT); - if let Some(HotStateSummary { - slot, - latest_block_root, - diff_base_state, - .. - }) = self.load_hot_state_summary(state_root)? + if let Some( + summary @ HotStateSummary { + slot, + latest_block_root, + diff_base_state, + .. + }, + ) = self.load_hot_state_summary(state_root)? { + let payload_status = self.get_hot_state_summary_payload_status(&summary)?; + debug!( + %slot, + ?state_root, + ?payload_status, + "Loading hot state" + ); let mut state = match self.hot_storage_strategy(slot)? { strat @ StorageStrategy::Snapshot | strat @ StorageStrategy::DiffFrom(_) => { let buffer_timer = metrics::start_timer_vec( @@ -1999,6 +2068,7 @@ impl, Cold: ItemStore> HotColdDB base_state, slot, latest_block_root, + payload_status, update_cache, )? } @@ -2016,19 +2086,26 @@ impl, Cold: ItemStore> HotColdDB base_state: BeaconState, slot: Slot, latest_block_root: Hash256, + desired_payload_status: StatePayloadStatus, update_cache: bool, ) -> Result, Error> { - if base_state.slot() == slot { + if base_state.slot() == slot && base_state.payload_status() == desired_payload_status { return Ok(base_state); } - let blocks = self.load_blocks_to_replay(base_state.slot(), slot, latest_block_root)?; + let (blocks, envelopes) = self.load_blocks_to_replay( + base_state.slot(), + slot, + latest_block_root, + desired_payload_status, + )?; let _t = metrics::start_timer(&metrics::STORE_BEACON_REPLAY_HOT_BLOCKS_TIME); // If replaying blocks, and `update_cache` is true, also cache the epoch boundary // state that this state is based on. It may be useful as the basis of more states // in the same epoch. let state_cache_hook = |state_root, state: &mut BeaconState| { + // TODO(gloas): prevent caching of the payload_status=Full state? if !update_cache || state.slot() % E::slots_per_epoch() != 0 { return Ok(()); } @@ -2052,9 +2129,19 @@ impl, Cold: ItemStore> HotColdDB Ok(()) }; + debug!( + %slot, + blocks = ?blocks.iter().map(|block| block.slot()).collect::>(), + envelopes = ?envelopes.iter().map(|e| e.message.slot).collect::>(), + payload_status = ?desired_payload_status, + "Replaying blocks and envelopes" + ); + self.replay_blocks( base_state, blocks, + envelopes, + desired_payload_status, slot, no_state_root_iter(), Some(Box::new(state_cache_hook)), @@ -2358,7 +2445,7 @@ impl, Cold: ItemStore> HotColdDB return Ok(base_state); } - let blocks = self.load_cold_blocks(base_state.slot() + 1, slot)?; + let (blocks, envelopes) = self.load_cold_blocks(base_state.slot() + 1, slot)?; // Include state root for base state as it is required by block processing to not // have to hash the state. @@ -2367,7 +2454,17 @@ impl, Cold: ItemStore> HotColdDB self.forwards_state_roots_iterator_until(base_state.slot(), slot, || { Err(Error::StateShouldNotBeRequired(slot)) })?; - let state = self.replay_blocks(base_state, blocks, slot, Some(state_root_iter), None)?; + // TODO(gloas): calculate correct payload status for cold states + let payload_status = StatePayloadStatus::Pending; + let state = self.replay_blocks( + base_state, + blocks, + envelopes, + payload_status, + slot, + Some(state_root_iter), + None, + )?; debug!( target_slot = %slot, replay_time_ms = metrics::stop_timer_with_duration(replay_timer).as_millis(), @@ -2460,40 +2557,77 @@ impl, Cold: ItemStore> HotColdDB } } - /// Load cold blocks between `start_slot` and `end_slot` inclusive. + /// Load cold blocks and payload envelopes between `start_slot` and `end_slot` inclusive. + #[allow(clippy::type_complexity)] pub fn load_cold_blocks( &self, start_slot: Slot, end_slot: Slot, - ) -> Result>, Error> { + ) -> Result< + ( + Vec>, + Vec>, + ), + Error, + > { let _t = metrics::start_timer(&metrics::STORE_BEACON_LOAD_COLD_BLOCKS_TIME); let block_root_iter = self.forwards_block_roots_iterator_until(start_slot, end_slot, || { Err(Error::StateShouldNotBeRequired(end_slot)) })?; - process_results(block_root_iter, |iter| { + let blocks = process_results(block_root_iter, |iter| { iter.map(|(block_root, _slot)| block_root) .dedup() .map(|block_root| { self.get_blinded_block(&block_root)? .ok_or(Error::MissingBlock(block_root)) }) - .collect() - })? + .collect::, Error>>() + })??; + + // If Gloas is not enabled for any slots in the range, just return `blocks`. + if !self.spec.fork_name_at_slot::(start_slot).gloas_enabled() + && !self.spec.fork_name_at_slot::(end_slot).gloas_enabled() + { + return Ok((blocks, vec![])); + } + // TODO(gloas): wire this up + let end_block_root = Hash256::ZERO; + let desired_payload_status = StatePayloadStatus::Pending; + let envelopes = self.load_payload_envelopes_for_blocks( + &blocks, + end_block_root, + desired_payload_status, + )?; + + Ok((blocks, envelopes)) } - /// Load the blocks between `start_slot` and `end_slot` by backtracking from `end_block_hash`. + /// Load the blocks & envelopes between `start_slot` and `end_slot` by backtracking from + /// `end_block_root`. /// /// Blocks are returned in slot-ascending order, suitable for replaying on a state with slot /// equal to `start_slot`, to reach a state with slot equal to `end_slot`. + /// + /// Payloads are also returned in slot-ascending order, but only payloads forming part of + /// the chain are loaded (payloads for EMPTY slots are omitted). Prior to Gloas, an empty + /// vec of payloads will be returned. + #[allow(clippy::type_complexity)] pub fn load_blocks_to_replay( &self, start_slot: Slot, end_slot: Slot, - end_block_hash: Hash256, - ) -> Result>>, Error> { + end_block_root: Hash256, + desired_payload_status: StatePayloadStatus, + ) -> Result< + ( + Vec>, + Vec>, + ), + Error, + > { let _t = metrics::start_timer(&metrics::STORE_BEACON_LOAD_HOT_BLOCKS_TIME); - let mut blocks = ParentRootBlockIterator::new(self, end_block_hash) + let mut blocks = ParentRootBlockIterator::new(self, end_block_root) .map(|result| result.map(|(_, block)| block)) // Include the block at the end slot (if any), it needs to be // replayed in order to construct the canonical state at `end_slot`. @@ -2520,17 +2654,70 @@ impl, Cold: ItemStore> HotColdDB }) .collect::, _>>()?; blocks.reverse(); - Ok(blocks) + + // If Gloas is not enabled for any slots in the range, just return `blocks`. + if !self.spec.fork_name_at_slot::(start_slot).gloas_enabled() + && !self.spec.fork_name_at_slot::(end_slot).gloas_enabled() + { + return Ok((blocks, vec![])); + } + + let envelopes = self.load_payload_envelopes_for_blocks( + &blocks, + end_block_root, + desired_payload_status, + )?; + + Ok((blocks, envelopes)) + } + + pub fn load_payload_envelopes_for_blocks( + &self, + blocks: &[SignedBlindedBeaconBlock], + end_block_root: Hash256, + desired_payload_status: StatePayloadStatus, + ) -> Result>, Error> { + let mut envelopes = vec![]; + + for (block, next_block) in blocks.iter().tuple_windows() { + if block.fork_name_unchecked().gloas_enabled() { + // Check next block to see if this block's payload is canonical on this chain. + let block_hash = block.payload_bid_block_hash()?; + if !next_block.is_parent_block_full(block_hash) { + // No payload at this slot (empty), nothing to load. + continue; + } + // Using `parent_root` avoids computation. + let block_root = next_block.parent_root(); + let envelope = self + .get_payload_envelope(&block_root)? + .ok_or(HotColdDBError::MissingExecutionPayloadEnvelope(block_root))?; + envelopes.push(envelope); + } + } + + // Load the payload for the last block if desired. + if let StatePayloadStatus::Full = desired_payload_status { + let envelope = self.get_payload_envelope(&end_block_root)?.ok_or( + HotColdDBError::MissingExecutionPayloadEnvelope(end_block_root), + )?; + envelopes.push(envelope); + } + + Ok(envelopes) } /// Replay `blocks` on top of `state` until `target_slot` is reached. /// /// Will skip slots as necessary. The returned state is not guaranteed /// to have any caches built, beyond those immediately required by block processing. + #[allow(clippy::too_many_arguments)] pub fn replay_blocks( &self, state: BeaconState, - blocks: Vec>>, + blocks: Vec>, + envelopes: Vec>, + desired_payload_status: StatePayloadStatus, target_slot: Slot, state_root_iter: Option>>, pre_slot_hook: Option>, @@ -2539,7 +2726,8 @@ impl, Cold: ItemStore> HotColdDB let mut block_replayer = BlockReplayer::new(state, &self.spec) .no_signature_verification() - .minimal_block_root_verification(); + .minimal_block_root_verification() + .desired_state_payload_status(desired_payload_status); let have_state_root_iterator = state_root_iter.is_some(); if let Some(state_root_iter) = state_root_iter { @@ -2551,7 +2739,7 @@ impl, Cold: ItemStore> HotColdDB } block_replayer - .apply_blocks(blocks, Some(target_slot)) + .apply_blocks(blocks, envelopes, Some(target_slot)) .map(|block_replayer| { if have_state_root_iterator && block_replayer.state_root_miss() { warn!( @@ -4006,11 +4194,15 @@ impl HotStateSummary { // slots where there isn't a skip). let latest_block_root = state.get_latest_block_root(state_root); + // Payload status of the state determines a lot about how it is stored. + let payload_status = state.payload_status(); + let get_state_root = |slot| { if slot == state.slot() { + // TODO(gloas): I think we can remove this case Ok::<_, Error>(state_root) } else { - Ok(get_ancestor_state_root(store, state, slot).map_err(|e| { + Ok::<_, Error>(get_ancestor_state_root(store, state, slot).map_err(|e| { Error::StateSummaryIteratorError { error: e, from_state_root: state_root, @@ -4030,6 +4222,12 @@ impl HotStateSummary { let previous_state_root = if state.slot() == 0 { // Set to 0x0 for genesis state to prevent any sort of circular reference. Hash256::zero() + } else if let StatePayloadStatus::Full = payload_status + && state.slot() == state.latest_block_header().slot + { + // A Full state at a non-skipped slot builds off the Pending state of the same slot, + // i.e. the state with the same `state_root` as its `BeaconBlock` + state.latest_block_header().state_root } else { get_state_root(state.slot().safe_sub(1_u64)?)? }; diff --git a/beacon_node/store/src/reconstruct.rs b/beacon_node/store/src/reconstruct.rs index 7aca692ef9..e51543c3a2 100644 --- a/beacon_node/store/src/reconstruct.rs +++ b/beacon_node/store/src/reconstruct.rs @@ -67,6 +67,7 @@ where state.build_caches(&self.spec)?; + // TODO(gloas): handle payload envelope replay process_results(block_root_iter, |iter| -> Result<(), Error> { let mut io_batch = vec![]; diff --git a/beacon_node/store/src/state_cache.rs b/beacon_node/store/src/state_cache.rs index 6d159c9361..d016922ade 100644 --- a/beacon_node/store/src/state_cache.rs +++ b/beacon_node/store/src/state_cache.rs @@ -7,7 +7,7 @@ use lru::LruCache; use std::collections::{BTreeMap, HashMap, HashSet}; use std::num::NonZeroUsize; use tracing::instrument; -use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256, Slot}; +use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256, Slot, execution::StatePayloadStatus}; /// Fraction of the LRU cache to leave intact during culling. const CULL_EXEMPT_NUMERATOR: usize = 1; @@ -23,10 +23,10 @@ pub struct FinalizedState { state: BeaconState, } -/// Map from block_root -> slot -> state_root. +/// Map from (block_root, payload_status) -> slot -> state_root. #[derive(Debug, Default)] pub struct BlockMap { - blocks: HashMap, + blocks: HashMap<(Hash256, StatePayloadStatus), SlotMap>, } /// Map from slot -> state_root. @@ -143,8 +143,11 @@ impl StateCache { return Err(Error::FinalizedStateDecreasingSlot); } + let payload_status = state.payload_status(); + // Add to block map. - self.block_map.insert(block_root, state.slot(), state_root); + self.block_map + .insert(block_root, payload_status, state.slot(), state_root); // Prune block map. let state_roots_to_prune = self.block_map.prune(state.slot()); @@ -267,7 +270,9 @@ impl StateCache { // Record the connection from block root and slot to this state. let slot = state.slot(); - self.block_map.insert(block_root, slot, state_root); + let payload_status = state.payload_status(); + self.block_map + .insert(block_root, payload_status, slot, state_root); Ok(PutStateOutcome::New(deleted_states)) } @@ -316,9 +321,10 @@ impl StateCache { pub fn get_by_block_root( &mut self, block_root: Hash256, + payload_status: StatePayloadStatus, slot: Slot, ) -> Option<(Hash256, BeaconState)> { - let slot_map = self.block_map.blocks.get(&block_root)?; + let slot_map = self.block_map.blocks.get(&(block_root, payload_status))?; // Find the state at `slot`, or failing that the most recent ancestor. let state_root = slot_map @@ -339,7 +345,12 @@ impl StateCache { } pub fn delete_block_states(&mut self, block_root: &Hash256) { - if let Some(slot_map) = self.block_map.delete_block_states(block_root) { + let (pending_state_roots, full_state_roots) = + self.block_map.delete_block_states(block_root); + for slot_map in [pending_state_roots, full_state_roots] + .into_iter() + .flatten() + { for state_root in slot_map.slots.values() { self.states.pop(state_root); } @@ -412,8 +423,14 @@ impl StateCache { } impl BlockMap { - fn insert(&mut self, block_root: Hash256, slot: Slot, state_root: Hash256) { - let slot_map = self.blocks.entry(block_root).or_default(); + fn insert( + &mut self, + block_root: Hash256, + payload_status: StatePayloadStatus, + slot: Slot, + state_root: Hash256, + ) { + let slot_map = self.blocks.entry((block_root, payload_status)).or_default(); slot_map.slots.insert(slot, state_root); } @@ -444,8 +461,12 @@ impl BlockMap { }); } - fn delete_block_states(&mut self, block_root: &Hash256) -> Option { - self.blocks.remove(block_root) + fn delete_block_states(&mut self, block_root: &Hash256) -> (Option, Option) { + let pending_state_roots = self + .blocks + .remove(&(*block_root, StatePayloadStatus::Pending)); + let full_state_roots = self.blocks.remove(&(*block_root, StatePayloadStatus::Full)); + (pending_state_roots, full_state_roots) } } diff --git a/consensus/state_processing/src/block_replayer.rs b/consensus/state_processing/src/block_replayer.rs index 56e667cdd3..a10d6179fe 100644 --- a/consensus/state_processing/src/block_replayer.rs +++ b/consensus/state_processing/src/block_replayer.rs @@ -1,6 +1,11 @@ use crate::{ BlockProcessingError, BlockSignatureStrategy, ConsensusContext, SlotProcessingError, - VerifyBlockRoot, per_block_processing, per_epoch_processing::EpochProcessingSummary, + VerifyBlockRoot, VerifySignatures, + envelope_processing::{ + EnvelopeProcessingError, VerifyStateRoot, process_execution_payload_envelope, + }, + per_block_processing, + per_epoch_processing::EpochProcessingSummary, per_slot_processing, }; use itertools::Itertools; @@ -8,7 +13,7 @@ use std::iter::Peekable; use std::marker::PhantomData; use types::{ BeaconState, BeaconStateError, BlindedPayload, ChainSpec, EthSpec, Hash256, SignedBeaconBlock, - Slot, + SignedExecutionPayloadEnvelope, Slot, execution::StatePayloadStatus, }; pub type PreBlockHook<'a, E, Error> = Box< @@ -24,7 +29,7 @@ pub type PostSlotHook<'a, E, Error> = Box< >; pub type StateRootIterDefault = std::iter::Empty>; -/// Efficiently apply blocks to a state while configuring various parameters. +/// Efficiently apply blocks and payloads to a state while configuring various parameters. /// /// Usage follows a builder pattern. pub struct BlockReplayer< @@ -41,8 +46,21 @@ pub struct BlockReplayer< post_block_hook: Option>, pre_slot_hook: Option>, post_slot_hook: Option>, + /// Iterator over state roots for all *block* states. + /// + /// Pre-Gloas, this is all states. Post-Gloas, this is *just* the states corresponding to beacon + /// blocks. For states corresponding to payloads, we read the state root from the payload + /// envelope. + // TODO(gloas): this concept might need adjusting when we implement the cold DB. pub(crate) state_root_iter: Option>, state_root_miss: bool, + /// The payload status of the state desired as the end result of block replay. + /// + /// This dictates whether a payload should be applied after applying the last block. + /// + /// Prior to Gloas, this should always be set to `StatePayloadStatus::Pending` to indicate + /// that no envelope needs to be applied. + desired_state_payload_status: StatePayloadStatus, _phantom: PhantomData, } @@ -50,7 +68,12 @@ pub struct BlockReplayer< pub enum BlockReplayError { SlotProcessing(SlotProcessingError), BlockProcessing(BlockProcessingError), + EnvelopeProcessing(EnvelopeProcessingError), BeaconState(BeaconStateError), + /// A payload envelope for this `slot` was required but not provided. + MissingPayloadEnvelope { + slot: Slot, + }, } impl From for BlockReplayError { @@ -65,6 +88,12 @@ impl From for BlockReplayError { } } +impl From for BlockReplayError { + fn from(e: EnvelopeProcessingError) -> Self { + Self::EnvelopeProcessing(e) + } +} + impl From for BlockReplayError { fn from(e: BeaconStateError) -> Self { Self::BeaconState(e) @@ -96,6 +125,7 @@ where post_slot_hook: None, state_root_iter: None, state_root_miss: false, + desired_state_payload_status: StatePayloadStatus::Pending, _phantom: PhantomData, } } @@ -161,6 +191,14 @@ where self } + /// Set the desired payload status of the state reached by replay. + /// + /// This determines whether to apply a payload after applying the last block. + pub fn desired_state_payload_status(mut self, payload_status: StatePayloadStatus) -> Self { + self.desired_state_payload_status = payload_status; + self + } + /// Compute the state root for `self.state` as efficiently as possible. /// /// This function MUST only be called when `self.state` is a post-state, i.e. it MUST not be @@ -208,6 +246,38 @@ where Ok(state_root) } + /// Apply an execution payload envelope to `self.state`. + /// + /// The `block_state_root` MUST be the `state_root` of the most recently applied block. + /// + /// Returns the `state_root` of `self.state` after payload application. + fn apply_payload_envelope( + &mut self, + envelope: &SignedExecutionPayloadEnvelope, + block_state_root: Hash256, + ) -> Result { + // TODO(gloas): bulk signature verification could be relevant here? + let verify_payload_signatures = + if let BlockSignatureStrategy::NoVerification = self.block_sig_strategy { + VerifySignatures::False + } else { + VerifySignatures::True + }; + // TODO(gloas): state root verif enabled during initial prototyping + let verify_state_root = VerifyStateRoot::True; + process_execution_payload_envelope( + &mut self.state, + Some(block_state_root), + envelope, + verify_payload_signatures, + verify_state_root, + self.spec, + ) + .map_err(BlockReplayError::from)?; + + Ok(envelope.message.state_root) + } + /// Apply `blocks` atop `self.state`, taking care of slot processing. /// /// If `target_slot` is provided then the state will be advanced through to `target_slot` @@ -215,8 +285,21 @@ where pub fn apply_blocks( mut self, blocks: Vec>>, + payload_envelopes: Vec>, target_slot: Option, ) -> Result { + let mut envelopes_iter = payload_envelopes.into_iter(); + + let mut next_envelope_at_slot = |slot| { + if let Some(envelope) = envelopes_iter.next() + && envelope.message.slot == slot + { + Ok(envelope) + } else { + Err(BlockReplayError::MissingPayloadEnvelope { slot }) + } + }; + for (i, block) in blocks.iter().enumerate() { // Allow one additional block at the start which is only used for its state root. if i == 0 && block.slot() <= self.state.slot() { @@ -224,7 +307,35 @@ where } while self.state.slot() < block.slot() { - let state_root = self.get_state_root(&blocks, i)?; + let mut state_root = self.get_state_root(&blocks, i)?; + + // Apply the payload for the *previous* block if the bid in the current block + // indicates that the parent is full (and it hasn't already been applied). + state_root = if block.fork_name_unchecked().gloas_enabled() + && self.state.slot() == self.state.latest_block_header().slot + { + let latest_bid_block_hash = self + .state + .latest_execution_payload_bid() + .map_err(BlockReplayError::from)? + .block_hash; + + // Similar to `is_parent_block_full`, but reading the block hash from the + // not-yet-applied `block`. The slot 0 case covers genesis (no block replay reqd). + if self.state.slot() != 0 && block.is_parent_block_full(latest_bid_block_hash) { + let envelope = next_envelope_at_slot(self.state.slot())?; + // State root for the next slot processing is now the envelope's state root. + self.apply_payload_envelope(&envelope, state_root)? + } else { + // Empty payload at this slot, the state root is unchanged from when the + // beacon block was applied. + state_root + } + } else { + // Pre-Gloas or at skipped slots post-Gloas, the state root of the parent state + // is always the output from `self.get_state_root`. + state_root + }; if let Some(ref mut pre_slot_hook) = self.pre_slot_hook { pre_slot_hook(state_root, &mut self.state)?; @@ -268,9 +379,24 @@ where } } + // Apply the last payload if desired. + let mut opt_state_root = if let StatePayloadStatus::Full = self.desired_state_payload_status + && let Some(last_block) = blocks.last() + { + let envelope = next_envelope_at_slot(self.state.slot())?; + Some(self.apply_payload_envelope(&envelope, last_block.state_root())?) + } else { + None + }; + if let Some(target_slot) = target_slot { while self.state.slot() < target_slot { - let state_root = self.get_state_root(&blocks, blocks.len())?; + // Read state root from `opt_state_root` if a payload was just applied. + let state_root = if let Some(root) = opt_state_root.take() { + root + } else { + self.get_state_root(&blocks, blocks.len())? + }; if let Some(ref mut pre_slot_hook) = self.pre_slot_hook { pre_slot_hook(state_root, &mut self.state)?; diff --git a/consensus/state_processing/src/envelope_processing.rs b/consensus/state_processing/src/envelope_processing.rs index be6b7c1b29..97953b835f 100644 --- a/consensus/state_processing/src/envelope_processing.rs +++ b/consensus/state_processing/src/envelope_processing.rs @@ -241,8 +241,6 @@ pub fn process_execution_payload_envelope( // TODO(gloas): newPayload happens here in the spec, ensure we wire that up correctly process_deposit_requests_post_gloas(state, &execution_requests.deposits, spec)?; - - // TODO(gloas): gotta update these process_withdrawal_requests(state, &execution_requests.withdrawals, spec)?; process_consolidation_requests(state, &execution_requests.consolidations, spec)?; diff --git a/consensus/state_processing/src/per_block_processing/tests.rs b/consensus/state_processing/src/per_block_processing/tests.rs index 96610c2010..0203b33e61 100644 --- a/consensus/state_processing/src/per_block_processing/tests.rs +++ b/consensus/state_processing/src/per_block_processing/tests.rs @@ -1014,7 +1014,7 @@ async fn block_replayer_peeking_state_roots() { let block_replayer = BlockReplayer::new(parent_state, &harness.chain.spec) .state_root_iter(state_root_iter.into_iter()) .no_signature_verification() - .apply_blocks(vec![target_block], None) + .apply_blocks(vec![target_block], vec![], None) .unwrap(); assert_eq!( diff --git a/consensus/state_processing/src/state_advance.rs b/consensus/state_processing/src/state_advance.rs index 11a956bc2a..1114562155 100644 --- a/consensus/state_processing/src/state_advance.rs +++ b/consensus/state_processing/src/state_advance.rs @@ -77,6 +77,11 @@ pub fn partial_state_advance( // (all-zeros) state root. let mut initial_state_root = Some(if state.slot() > state.latest_block_header().slot { state_root_opt.unwrap_or_else(Hash256::zero) + } else if state.slot() == state.latest_block_header().slot + && !state.latest_block_header().state_root.is_zero() + { + // Post-Gloas Full state case. + state.latest_block_header().state_root } else { state_root_opt.ok_or(Error::StateRootNotProvided)? }); diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index aeb3c18d95..b6218ba64d 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -14,6 +14,7 @@ use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ + ExecutionBlockHash, block::{ BLOB_KZG_COMMITMENTS_INDEX, BeaconBlock, BeaconBlockAltair, BeaconBlockBase, BeaconBlockBellatrix, BeaconBlockBodyBellatrix, BeaconBlockBodyCapella, @@ -365,6 +366,32 @@ impl> SignedBeaconBlock format_kzg_commitments(commitments.as_ref()) } + + /// Convenience accessor for the block's bid's `block_hash`. + /// + /// This method returns an error prior to Gloas. + pub fn payload_bid_block_hash(&self) -> Result { + self.message() + .body() + .signed_execution_payload_bid() + .map(|bid| bid.message.block_hash) + } + + /// Check if the `parent_hash` in this block's `signed_payload_bid` matches `parent_block_hash`. + /// + /// This function is useful post-Gloas for determining if the parent block is full, *without* + /// necessarily needing access to a beacon state. The passed in `parent_block_hash` MUST be the + /// `block_hash` from the parent beacon block's bid. If the parent beacon state is available + /// this can alternatively be fetched from `state.latest_payload_bid`. + /// + /// This function returns `false` for all blocks prior to Gloas. + pub fn is_parent_block_full(&self, parent_block_hash: ExecutionBlockHash) -> bool { + let Ok(signed_payload_bid) = self.message().body().signed_execution_payload_bid() else { + // Prior to Gloas. + return false; + }; + signed_payload_bid.message.parent_block_hash == parent_block_hash + } } // We can convert pre-Bellatrix blocks without payloads into blocks with payloads. diff --git a/consensus/types/src/execution/mod.rs b/consensus/types/src/execution/mod.rs index a3d4ed8730..591be32b24 100644 --- a/consensus/types/src/execution/mod.rs +++ b/consensus/types/src/execution/mod.rs @@ -12,6 +12,7 @@ mod payload; mod signed_bls_to_execution_change; mod signed_execution_payload_bid; mod signed_execution_payload_envelope; +mod state_payload_status; pub use bls_to_execution_change::BlsToExecutionChange; pub use eth1_data::Eth1Data; @@ -41,3 +42,4 @@ pub use payload::{ pub use signed_bls_to_execution_change::SignedBlsToExecutionChange; pub use signed_execution_payload_bid::SignedExecutionPayloadBid; pub use signed_execution_payload_envelope::SignedExecutionPayloadEnvelope; +pub use state_payload_status::StatePayloadStatus; diff --git a/consensus/types/src/execution/state_payload_status.rs b/consensus/types/src/execution/state_payload_status.rs new file mode 100644 index 0000000000..1661be6060 --- /dev/null +++ b/consensus/types/src/execution/state_payload_status.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +/// Payload status as it applies to a `BeaconState` post-Gloas. +/// +/// A state can either be a post-state for a block (in which case we call it `Pending`) or a +/// payload envelope (`Full`). When handling states it is often necessary to know which of these +/// two variants is required. +/// +/// Note that states at skipped slots could be either `Pending` or `Full`, depending on whether +/// the payload for the most-recently applied block was also applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StatePayloadStatus { + /// For states produced by `process_block` executed on a `BeaconBlock`. + Pending, + /// For states produced by `process_execution_payload` on a `ExecutionPayloadEnvelope`. + Full, +} diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index bd67f469d2..34cfd0ca1c 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -36,7 +36,7 @@ use crate::{ execution::{ Eth1Data, ExecutionPayloadHeaderBellatrix, ExecutionPayloadHeaderCapella, ExecutionPayloadHeaderDeneb, ExecutionPayloadHeaderElectra, ExecutionPayloadHeaderFulu, - ExecutionPayloadHeaderRef, ExecutionPayloadHeaderRefMut, + ExecutionPayloadHeaderRef, ExecutionPayloadHeaderRefMut, StatePayloadStatus, }, fork::{Fork, ForkName, ForkVersionDecode, InconsistentFork, map_fork_name}, light_client::consts::{ @@ -1266,6 +1266,24 @@ impl BeaconState { } } + /// Determine the payload status of this state. + /// + /// Prior to Gloas this is always `Pending`. + /// + /// Post-Gloas, the definition of the `StatePayloadStatus` is: + /// + /// - `Full` if this state is the result of envelope processing. + /// - `Pending` if this state is the result of block processing. + pub fn payload_status(&self) -> StatePayloadStatus { + if !self.fork_name_unchecked().gloas_enabled() { + StatePayloadStatus::Pending + } else if self.is_parent_block_full() { + StatePayloadStatus::Full + } else { + StatePayloadStatus::Pending + } + } + /// Return `true` if the validator who produced `slot_signature` is eligible to aggregate. /// /// Spec v0.12.1 From a36b7f3ddbf4bd8456403ef1f945403c7a23affa Mon Sep 17 00:00:00 2001 From: lystopad Date: Thu, 12 Mar 2026 00:03:05 +0000 Subject: [PATCH 13/15] Schedule Fulu fork for Chiado testnet (#8954) Co-Authored-By: Oleksandr Lystopad --- .../built_in_network_configs/chiado/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/eth2_network_config/built_in_network_configs/chiado/config.yaml b/common/eth2_network_config/built_in_network_configs/chiado/config.yaml index f0c04d891a..e1eb022cc9 100644 --- a/common/eth2_network_config/built_in_network_configs/chiado/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/chiado/config.yaml @@ -49,7 +49,7 @@ ELECTRA_FORK_VERSION: 0x0500006f ELECTRA_FORK_EPOCH: 948224 # Thu Mar 6 2025 09:43:40 GMT+0000 # Fulu FULU_FORK_VERSION: 0x0600006f -FULU_FORK_EPOCH: 18446744073709551615 +FULU_FORK_EPOCH: 1353216 # Mon Mar 16 2026 09:33:00 UTC # Gloas GLOAS_FORK_VERSION: 0x0700006f GLOAS_FORK_EPOCH: 18446744073709551615 From e1e97e6df069a67bb687fd02829ac53b6950d378 Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:11:37 +0800 Subject: [PATCH 14/15] Fix proposer lookahead endpoint JSON return type (#8970) Co-Authored-By: Tan Chee Keong --- beacon_node/http_api/src/beacon/states.rs | 4 ++-- beacon_node/http_api/tests/tests.rs | 5 +++-- common/eth2/src/lib.rs | 2 +- common/eth2/src/types.rs | 9 +++++++++ 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/beacon_node/http_api/src/beacon/states.rs b/beacon_node/http_api/src/beacon/states.rs index 02ac3f4da7..84ef3c1f26 100644 --- a/beacon_node/http_api/src/beacon/states.rs +++ b/beacon_node/http_api/src/beacon/states.rs @@ -9,7 +9,7 @@ use crate::version::{ use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, WhenSlotSkipped}; use eth2::types::{ self as api_types, ValidatorBalancesRequestBody, ValidatorId, ValidatorIdentitiesRequestBody, - ValidatorsRequestBody, + ValidatorIndexData, ValidatorsRequestBody, }; use ssz::Encode; use std::sync::Arc; @@ -213,7 +213,7 @@ pub fn get_beacon_state_proposer_lookahead( ResponseIncludesVersion::Yes(fork_name), execution_optimistic, finalized, - data, + ValidatorIndexData(data), ) .map(|res| warp::reply::json(&res).into_response()), } diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index a97ce01ac1..aed7a6b200 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -1430,10 +1430,11 @@ impl ApiTester { } let state = state_opt.as_mut().expect("result should be none"); - let expected = state.proposer_lookahead().unwrap(); + let expected = state.proposer_lookahead().unwrap().to_vec(); let response = result.unwrap(); - assert_eq!(response.data(), &expected.to_vec()); + // Compare Vec directly, not Vec + assert_eq!(response.data().0, expected); // Check that the version header is returned in the response let fork_name = state.fork_name(&self.chain.spec).unwrap(); diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 5547ced491..af87af14ba 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -904,7 +904,7 @@ impl BeaconNodeHttpClient { pub async fn get_beacon_states_proposer_lookahead( &self, state_id: StateId, - ) -> Result>>, Error> { + ) -> Result>, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 2f86170812..94dff95bc6 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -708,6 +708,15 @@ pub struct DataColumnIndicesQuery { #[serde(transparent)] pub struct ValidatorIndexData(#[serde(with = "serde_utils::quoted_u64_vec")] pub Vec); +impl<'de, T> ContextDeserialize<'de, T> for ValidatorIndexData { + fn context_deserialize(deserializer: D, _context: T) -> Result + where + D: Deserializer<'de>, + { + Self::deserialize(deserializer) + } +} + /// Borrowed variant of `ValidatorIndexData`, for serializing/sending. #[derive(Clone, Copy, Serialize)] #[serde(transparent)] From 4b3a9d3d10a6181a1a1588880de133457eb90816 Mon Sep 17 00:00:00 2001 From: Shane K Moore <41407272+shane-moore@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:53:32 -0700 Subject: [PATCH 15/15] Refactor/stream vc vote publishing (#8880) Changes four `ValidatorStore` batch signing methods to return `impl Stream` instead of `Future`. Services consume the stream and publish each batch as it arrives. No behavioral change for lh since `LighthouseValidatorStore` wraps everything in `stream::once` Also replaces anonymous tuples in method signatures with named structs Co-Authored-By: shane-moore Co-Authored-By: Michael Sproul Co-Authored-By: Mac L --- Cargo.lock | 1 + testing/web3signer_tests/src/lib.rs | 66 ++- .../http_api/src/tests/keystores.rs | 43 +- .../lighthouse_validator_store/src/lib.rs | 496 ++++++++++++------ .../src/attestation_service.rs | 348 ++++++------ .../src/sync_committee_service.rs | 239 ++++----- validator_client/validator_store/Cargo.toml | 1 + validator_client/validator_store/src/lib.rs | 89 ++-- 8 files changed, 740 insertions(+), 543 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ca12dce46..1d187d1c68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9710,6 +9710,7 @@ version = "0.1.0" dependencies = [ "bls", "eth2", + "futures", "slashing_protection", "types", ] diff --git a/testing/web3signer_tests/src/lib.rs b/testing/web3signer_tests/src/lib.rs index 4b9432b67b..1f36f8d4ce 100644 --- a/testing/web3signer_tests/src/lib.rs +++ b/testing/web3signer_tests/src/lib.rs @@ -25,6 +25,7 @@ mod tests { use eth2_keystore::KeystoreBuilder; use eth2_network_config::Eth2NetworkConfig; use fixed_bytes::FixedBytesExtended; + use futures::StreamExt; use initialized_validators::{ InitializedValidators, load_pem_certificate, load_pkcs12_identity, }; @@ -50,7 +51,7 @@ mod tests { use types::{attestation::AttestationBase, *}; use url::Url; use validator_store::{ - Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore, + AttestationToSign, Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore, }; /// If the we are unable to reach the Web3Signer HTTP API within this time out then we will @@ -654,13 +655,14 @@ mod tests { .await .assert_signatures_match("attestation", |pubkey, validator_store| async move { let attestation = get_attestation(); - validator_store - .sign_attestations(vec![(0, pubkey, 0, attestation)]) - .await - .unwrap() - .pop() - .unwrap() - .1 + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap().unwrap().pop().unwrap().1 }) .await .assert_signatures_match("signed_aggregate", |pubkey, validator_store| async move { @@ -879,22 +881,28 @@ mod tests { .await .assert_signatures_match("first_attestation", |pubkey, validator_store| async move { let attestation = first_attestation(); - validator_store - .sign_attestations(vec![(0, pubkey, 0, attestation)]) - .await - .unwrap() - .pop() - .unwrap() - .1 + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap().unwrap().pop().unwrap().1 }) .await .assert_slashable_attestation_should_sign( "double_vote_attestation", move |pubkey, validator_store| async move { let attestation = double_vote_attestation(); - validator_store - .sign_attestations(vec![(0, pubkey, 0, attestation)]) - .await + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap() }, slashable_message_should_sign, ) @@ -903,9 +911,14 @@ mod tests { "surrounding_attestation", move |pubkey, validator_store| async move { let attestation = surrounding_attestation(); - validator_store - .sign_attestations(vec![(0, pubkey, 0, attestation)]) - .await + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap() }, slashable_message_should_sign, ) @@ -914,9 +927,14 @@ mod tests { "surrounded_attestation", move |pubkey, validator_store| async move { let attestation = surrounded_attestation(); - validator_store - .sign_attestations(vec![(0, pubkey, 0, attestation)]) - .await + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap() }, slashable_message_should_sign, ) diff --git a/validator_client/http_api/src/tests/keystores.rs b/validator_client/http_api/src/tests/keystores.rs index 601b2f1666..eb35075526 100644 --- a/validator_client/http_api/src/tests/keystores.rs +++ b/validator_client/http_api/src/tests/keystores.rs @@ -9,6 +9,7 @@ use eth2::lighthouse_vc::{ types::Web3SignerValidatorRequest, }; use fixed_bytes::FixedBytesExtended; +use futures::StreamExt; use itertools::Itertools; use lighthouse_validator_store::DEFAULT_GAS_LIMIT; use rand::rngs::StdRng; @@ -19,6 +20,7 @@ use std::{collections::HashMap, path::Path}; use tokio::runtime::Handle; use typenum::Unsigned; use types::{Address, attestation::AttestationBase}; +use validator_store::AttestationToSign; use validator_store::ValidatorStore; use zeroize::Zeroizing; @@ -1101,11 +1103,16 @@ async fn generic_migration_test( // Sign attestations on VC1. for (validator_index, attestation) in first_vc_attestations { let public_key = keystore_pubkey(&keystores[validator_index]); - let safe_attestations = tester1 + let stream = tester1 .validator_store - .sign_attestations(vec![(0, public_key, 0, attestation.clone())]) - .await - .unwrap(); + .sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey: public_key, + validator_committee_index: 0, + attestation: attestation.clone(), + }]); + tokio::pin!(stream); + let safe_attestations = stream.next().await.unwrap().unwrap(); assert_eq!(safe_attestations.len(), 1); // Compare data only, ignoring signatures which are added during signing. assert_eq!(safe_attestations[0].1.data(), attestation.data()); @@ -1184,10 +1191,16 @@ async fn generic_migration_test( // Sign attestations on the second VC. for (validator_index, attestation, should_succeed) in second_vc_attestations { let public_key = keystore_pubkey(&keystores[validator_index]); - let result = tester2 + let stream = tester2 .validator_store - .sign_attestations(vec![(0, public_key, 0, attestation.clone())]) - .await; + .sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey: public_key, + validator_committee_index: 0, + attestation: attestation.clone(), + }]); + tokio::pin!(stream); + let result = stream.next().await.unwrap(); match result { Ok(safe_attestations) => { if should_succeed { @@ -1331,14 +1344,14 @@ async fn delete_concurrent_with_signing() { for j in 0..num_attestations { let att = make_attestation(j, j + 1); for (validator_index, public_key) in thread_pubkeys.iter().enumerate() { - let _ = validator_store - .sign_attestations(vec![( - validator_index as u64, - *public_key, - 0, - att.clone(), - )]) - .await; + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: validator_index as u64, + pubkey: *public_key, + validator_committee_index: 0, + attestation: att.clone(), + }]); + tokio::pin!(stream); + let _ = stream.next().await; } } }); diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index 7806482ffb..e8c1cfbc43 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -2,7 +2,7 @@ use account_utils::validator_definitions::{PasswordStorage, ValidatorDefinition} use bls::{PublicKeyBytes, Signature}; use doppelganger_service::DoppelgangerService; use eth2::types::PublishBlockRequest; -use futures::future::join_all; +use futures::{Stream, future::join_all, stream}; use initialized_validators::InitializedValidators; use logging::crit; use parking_lot::{Mutex, RwLock}; @@ -17,7 +17,7 @@ use std::marker::PhantomData; use std::path::Path; use std::sync::Arc; use task_executor::TaskExecutor; -use tracing::{error, info, instrument, warn}; +use tracing::{Instrument, debug, error, info, info_span, instrument, warn}; use types::{ AbstractExecPayload, Address, AggregateAndProof, Attestation, BeaconBlock, BlindedPayload, ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, ExecutionPayloadEnvelope, Fork, @@ -28,7 +28,8 @@ use types::{ ValidatorRegistrationData, VoluntaryExit, graffiti::GraffitiString, }; use validator_store::{ - DoppelgangerStatus, Error as ValidatorStoreError, ProposalData, SignedBlock, UnsignedBlock, + AggregateToSign, AttestationToSign, ContributionToSign, DoppelgangerStatus, + Error as ValidatorStoreError, ProposalData, SignedBlock, SyncMessageToSign, UnsignedBlock, ValidatorStore, }; @@ -691,6 +692,119 @@ impl LighthouseValidatorStore { Ok(safe_attestations) } + + /// Signs an `AggregateAndProof` for a given validator. + /// + /// The resulting `SignedAggregateAndProof` is sent on the aggregation channel and cannot be + /// modified by actors other than the signing validator. + pub async fn produce_signed_aggregate_and_proof( + &self, + validator_pubkey: PublicKeyBytes, + aggregator_index: u64, + aggregate: Attestation, + selection_proof: SelectionProof, + ) -> Result, Error> { + let signing_epoch = aggregate.data().target.epoch; + let signing_context = self.signing_context(Domain::AggregateAndProof, signing_epoch); + + let message = + AggregateAndProof::from_attestation(aggregator_index, aggregate, selection_proof); + + let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?; + let signature = signing_method + .get_signature::>( + SignableMessage::SignedAggregateAndProof(message.to_ref()), + signing_context, + &self.spec, + &self.task_executor, + ) + .await?; + + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_AGGREGATES_TOTAL, + &[validator_metrics::SUCCESS], + ); + + Ok(SignedAggregateAndProof::from_aggregate_and_proof( + message, signature, + )) + } + + pub async fn produce_sync_committee_signature( + &self, + slot: Slot, + beacon_block_root: Hash256, + validator_index: u64, + validator_pubkey: &PublicKeyBytes, + ) -> Result { + let signing_epoch = slot.epoch(E::slots_per_epoch()); + let signing_context = self.signing_context(Domain::SyncCommittee, signing_epoch); + + // Bypass `with_validator_signing_method`: sync committee messages are not slashable. + let signing_method = self.doppelganger_bypassed_signing_method(*validator_pubkey)?; + + let signature = signing_method + .get_signature::>( + SignableMessage::SyncCommitteeSignature { + beacon_block_root, + slot, + }, + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_SYNC_COMMITTEE_MESSAGES_TOTAL, + &[validator_metrics::SUCCESS], + ); + + Ok(SyncCommitteeMessage { + slot, + beacon_block_root, + validator_index, + signature, + }) + } + + pub async fn produce_signed_contribution_and_proof( + &self, + aggregator_index: u64, + aggregator_pubkey: PublicKeyBytes, + contribution: SyncCommitteeContribution, + selection_proof: SyncSelectionProof, + ) -> Result, Error> { + let signing_epoch = contribution.slot.epoch(E::slots_per_epoch()); + let signing_context = self.signing_context(Domain::ContributionAndProof, signing_epoch); + + // Bypass `with_validator_signing_method`: sync committee messages are not slashable. + let signing_method = self.doppelganger_bypassed_signing_method(aggregator_pubkey)?; + + let message = ContributionAndProof { + aggregator_index, + contribution, + selection_proof: selection_proof.into(), + }; + + let signature = signing_method + .get_signature::>( + SignableMessage::SignedContributionAndProof(&message), + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_SYNC_COMMITTEE_CONTRIBUTIONS_TOTAL, + &[validator_metrics::SUCCESS], + ); + + Ok(SignedContributionAndProof { message, signature }) + } } impl ValidatorStore for LighthouseValidatorStore { @@ -882,72 +996,83 @@ impl ValidatorStore for LighthouseValidatorS } } - async fn sign_attestations( + fn sign_attestations( self: &Arc, - mut attestations: Vec<(u64, PublicKeyBytes, usize, Attestation)>, - ) -> Result)>, Error> { - // Sign all attestations concurrently. - let signing_futures = - attestations - .iter_mut() - .map(|(_, pubkey, validator_committee_index, attestation)| { + mut attestations: Vec>, + ) -> impl Stream)>, Error>> + Send { + let store = self.clone(); + stream::once(async move { + // Sign all attestations concurrently. + let signing_futures = attestations.iter_mut().map( + |AttestationToSign { + pubkey, + validator_committee_index, + attestation, + .. + }| { let pubkey = *pubkey; let validator_committee_index = *validator_committee_index; + let store = store.clone(); async move { - self.sign_attestation_no_slashing_protection( - pubkey, - validator_committee_index, - attestation, - ) - .await + store + .sign_attestation_no_slashing_protection( + pubkey, + validator_committee_index, + attestation, + ) + .await } - }); + }, + ); - // Execute all signing in parallel. - let results: Vec<_> = join_all(signing_futures).await; + // Execute all signing in parallel. + let results: Vec<_> = join_all(signing_futures).await; - // Collect successfully signed attestations and log errors. - let mut signed_attestations = Vec::with_capacity(attestations.len()); - for (result, (validator_index, pubkey, _, attestation)) in - results.into_iter().zip(attestations.into_iter()) - { - match result { - Ok(()) => { - signed_attestations.push((validator_index, attestation, pubkey)); - } - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - warn!( - info = "a validator may have recently been removed from this VC", - ?pubkey, - "Missing pubkey for attestation" - ); - } - Err(e) => { - crit!( - error = ?e, - "Failed to sign attestation" - ); + // Collect successfully signed attestations and log errors. + let mut signed_attestations = Vec::with_capacity(attestations.len()); + for (result, att) in results.into_iter().zip(attestations.into_iter()) { + match result { + Ok(()) => { + signed_attestations.push(( + att.validator_index, + att.attestation, + att.pubkey, + )); + } + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + warn!( + info = "a validator may have recently been removed from this VC", + ?pubkey, + "Missing pubkey for attestation" + ); + } + Err(e) => { + crit!( + error = ?e, + "Failed to sign attestation" + ); + } } } - } - if signed_attestations.is_empty() { - return Ok(vec![]); - } + if signed_attestations.is_empty() { + return Ok(vec![]); + } - // Check slashing protection and insert into database. Use a dedicated blocking thread - // to avoid clogging the async executor with blocking database I/O. - let validator_store = self.clone(); - let safe_attestations = self - .task_executor - .spawn_blocking_handle( - move || validator_store.slashing_protect_attestations(signed_attestations), - "slashing_protect_attestations", - ) - .ok_or(Error::ExecutorError)? - .await - .map_err(|_| Error::ExecutorError)??; - Ok(safe_attestations) + // Check slashing protection and insert into database. Use a dedicated blocking + // thread to avoid clogging the async executor with blocking database I/O. + let validator_store = store.clone(); + let safe_attestations = store + .task_executor + .spawn_blocking_handle( + move || validator_store.slashing_protect_attestations(signed_attestations), + "slashing_protect_attestations", + ) + .ok_or(Error::ExecutorError)? + .await + .map_err(|_| Error::ExecutorError)??; + Ok(safe_attestations) + }) } async fn sign_validator_registration_data( @@ -979,43 +1104,6 @@ impl ValidatorStore for LighthouseValidatorS }) } - /// Signs an `AggregateAndProof` for a given validator. - /// - /// The resulting `SignedAggregateAndProof` is sent on the aggregation channel and cannot be - /// modified by actors other than the signing validator. - async fn produce_signed_aggregate_and_proof( - &self, - validator_pubkey: PublicKeyBytes, - aggregator_index: u64, - aggregate: Attestation, - selection_proof: SelectionProof, - ) -> Result, Error> { - let signing_epoch = aggregate.data().target.epoch; - let signing_context = self.signing_context(Domain::AggregateAndProof, signing_epoch); - - let message = - AggregateAndProof::from_attestation(aggregator_index, aggregate, selection_proof); - - let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?; - let signature = signing_method - .get_signature::>( - SignableMessage::SignedAggregateAndProof(message.to_ref()), - signing_context, - &self.spec, - &self.task_executor, - ) - .await?; - - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_AGGREGATES_TOTAL, - &[validator_metrics::SUCCESS], - ); - - Ok(SignedAggregateAndProof::from_aggregate_and_proof( - message, signature, - )) - } - /// Produces a `SelectionProof` for the `slot`, signed by with corresponding secret key to /// `validator_pubkey`. async fn produce_selection_proof( @@ -1090,80 +1178,172 @@ impl ValidatorStore for LighthouseValidatorS Ok(signature.into()) } - async fn produce_sync_committee_signature( - &self, - slot: Slot, - beacon_block_root: Hash256, - validator_index: u64, - validator_pubkey: &PublicKeyBytes, - ) -> Result { - let signing_epoch = slot.epoch(E::slots_per_epoch()); - let signing_context = self.signing_context(Domain::SyncCommittee, signing_epoch); - - // Bypass `with_validator_signing_method`: sync committee messages are not slashable. - let signing_method = self.doppelganger_bypassed_signing_method(*validator_pubkey)?; - - let signature = signing_method - .get_signature::>( - SignableMessage::SyncCommitteeSignature { - beacon_block_root, - slot, + fn sign_aggregate_and_proofs( + self: &Arc, + aggregates: Vec>, + ) -> impl Stream>, Error>> + Send { + let store = self.clone(); + let count = aggregates.len(); + stream::once(async move { + let signing_futures = aggregates.into_iter().map( + |AggregateToSign { + pubkey, + aggregator_index, + aggregate, + selection_proof, + }| { + let store = store.clone(); + async move { + let result = store + .produce_signed_aggregate_and_proof( + pubkey, + aggregator_index, + aggregate, + selection_proof, + ) + .await; + (pubkey, result) + } }, - signing_context, - &self.spec, - &self.task_executor, - ) - .await - .map_err(Error::SpecificError)?; + ); - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_SYNC_COMMITTEE_MESSAGES_TOTAL, - &[validator_metrics::SUCCESS], - ); + let results = join_all(signing_futures) + .instrument(info_span!("sign_aggregates", count)) + .await; - Ok(SyncCommitteeMessage { - slot, - beacon_block_root, - validator_index, - signature, + let mut signed = Vec::with_capacity(results.len()); + for (pubkey, result) in results { + match result { + Ok(agg) => signed.push(agg), + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently + // removed via the API. + debug!(?pubkey, "Missing pubkey for aggregate"); + } + Err(e) => { + crit!(error = ?e, pubkey = ?pubkey, "Failed to sign aggregate"); + } + } + } + Ok(signed) }) } - async fn produce_signed_contribution_and_proof( - &self, - aggregator_index: u64, - aggregator_pubkey: PublicKeyBytes, - contribution: SyncCommitteeContribution, - selection_proof: SyncSelectionProof, - ) -> Result, Error> { - let signing_epoch = contribution.slot.epoch(E::slots_per_epoch()); - let signing_context = self.signing_context(Domain::ContributionAndProof, signing_epoch); + fn sign_sync_committee_signatures( + self: &Arc, + messages: Vec, + ) -> impl Stream, Error>> + Send { + let store = self.clone(); + let count = messages.len(); + stream::once(async move { + let signing_futures = messages.into_iter().map( + |SyncMessageToSign { + slot, + beacon_block_root, + validator_index, + pubkey, + }| { + let store = store.clone(); + async move { + let result = store + .produce_sync_committee_signature( + slot, + beacon_block_root, + validator_index, + &pubkey, + ) + .await; + (pubkey, validator_index, slot, result) + } + }, + ); - // Bypass `with_validator_signing_method`: sync committee messages are not slashable. - let signing_method = self.doppelganger_bypassed_signing_method(aggregator_pubkey)?; + let results = join_all(signing_futures) + .instrument(info_span!("sign_sync_signatures", count)) + .await; - let message = ContributionAndProof { - aggregator_index, - contribution, - selection_proof: selection_proof.into(), - }; + let mut signed = Vec::with_capacity(results.len()); + for (_pubkey, validator_index, slot, result) in results { + match result { + Ok(sig) => signed.push(sig), + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently + // removed via the API. + debug!( + ?pubkey, + validator_index, + %slot, + "Missing pubkey for sync committee signature" + ); + } + Err(e) => { + crit!( + validator_index, + %slot, + error = ?e, + "Failed to sign sync committee signature" + ); + } + } + } + Ok(signed) + }) + } - let signature = signing_method - .get_signature::>( - SignableMessage::SignedContributionAndProof(&message), - signing_context, - &self.spec, - &self.task_executor, - ) - .await - .map_err(Error::SpecificError)?; + fn sign_sync_committee_contributions( + self: &Arc, + contributions: Vec>, + ) -> impl Stream>, Error>> + Send { + let store = self.clone(); + let count = contributions.len(); + stream::once(async move { + let signing_futures = contributions.into_iter().map( + |ContributionToSign { + aggregator_index, + aggregator_pubkey, + contribution, + selection_proof, + }| { + let store = store.clone(); + let slot = contribution.slot; + async move { + let result = store + .produce_signed_contribution_and_proof( + aggregator_index, + aggregator_pubkey, + contribution, + selection_proof, + ) + .await; + (slot, result) + } + }, + ); - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_SYNC_COMMITTEE_CONTRIBUTIONS_TOTAL, - &[validator_metrics::SUCCESS], - ); + let results = join_all(signing_futures) + .instrument(info_span!("sign_sync_contributions", count)) + .await; - Ok(SignedContributionAndProof { message, signature }) + let mut signed = Vec::with_capacity(results.len()); + for (slot, result) in results { + match result { + Ok(contribution) => signed.push(contribution), + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently + // removed via the API. + debug!(?pubkey, %slot, "Missing pubkey for sync contribution"); + } + Err(e) => { + crit!( + %slot, + error = ?e, + "Unable to sign sync committee contribution" + ); + } + } + } + Ok(signed) + }) } /// Prune the slashing protection database so that it remains performant. diff --git a/validator_client/validator_services/src/attestation_service.rs b/validator_client/validator_services/src/attestation_service.rs index a9d5283312..fe808efd88 100644 --- a/validator_client/validator_services/src/attestation_service.rs +++ b/validator_client/validator_services/src/attestation_service.rs @@ -1,6 +1,6 @@ use crate::duties_service::{DutiesService, DutyAndProof}; use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, beacon_head_monitor::HeadEvent}; -use futures::future::join_all; +use futures::StreamExt; use logging::crit; use slot_clock::SlotClock; use std::collections::HashMap; @@ -13,7 +13,7 @@ use tokio::time::{Duration, Instant, sleep, sleep_until}; use tracing::{Instrument, debug, error, info, info_span, instrument, warn}; use tree_hash::TreeHash; use types::{Attestation, AttestationData, ChainSpec, CommitteeIndex, EthSpec, Hash256, Slot}; -use validator_store::{Error as ValidatorStoreError, ValidatorStore}; +use validator_store::{AggregateToSign, AttestationToSign, ValidatorStore}; /// Builds an `AttestationService`. #[derive(Default)] @@ -560,12 +560,12 @@ impl AttestationService AttestationService(attestation_data.slot); - let single_attestations = safe_attestations - .iter() - .filter_map(|(i, a)| { - match a.to_single_attestation_with_attester_index(*i) { - Ok(a) => Some(a), - Err(e) => { - // This shouldn't happen unless BN and VC are out of sync with - // respect to the Electra fork. - error!( - error = ?e, + // Publish each batch as it arrives from the stream. + let mut received_non_empty_batch = false; + while let Some(result) = attestation_stream.next().await { + match result { + Ok(batch) if !batch.is_empty() => { + received_non_empty_batch = true; + + let single_attestations = batch + .iter() + .filter_map(|(attester_index, attestation)| { + match attestation + .to_single_attestation_with_attester_index(*attester_index) + { + Ok(single_attestation) => Some(single_attestation), + Err(e) => { + // This shouldn't happen unless BN and VC are out of sync with + // respect to the Electra fork. + error!( + error = ?e, + committee_index = attestation_data.index, + slot = slot.as_u64(), + "type" = "unaggregated", + "Unable to convert to SingleAttestation" + ); + None + } + } + }) + .collect::>(); + let single_attestations = &single_attestations; + let validator_indices = single_attestations + .iter() + .map(|att| att.attester_index) + .collect::>(); + let published_count = single_attestations.len(); + + // Post the attestations to the BN. + match self + .beacon_nodes + .request(ApiTopic::Attestations, |beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::ATTESTATION_SERVICE_TIMES, + &[validator_metrics::ATTESTATIONS_HTTP_POST], + ); + + beacon_node + .post_beacon_pool_attestations_v2::( + single_attestations.clone(), + fork_name, + ) + .await + }) + .instrument(info_span!("publish_attestations", count = published_count)) + .await + { + Ok(()) => info!( + count = published_count, + validator_indices = ?validator_indices, + head_block = ?attestation_data.beacon_block_root, + committee_index = attestation_data.index, + slot = attestation_data.slot.as_u64(), + "type" = "unaggregated", + "Successfully published attestations" + ), + Err(e) => error!( + error = %e, committee_index = attestation_data.index, slot = slot.as_u64(), "type" = "unaggregated", - "Unable to convert to SingleAttestation" - ); - None + "Unable to publish attestations" + ), } } - }) - .collect::>(); - let single_attestations = &single_attestations; - let validator_indices = single_attestations - .iter() - .map(|att| att.attester_index) - .collect::>(); - let published_count = single_attestations.len(); + Err(e) => { + crit!(error = ?e, "Failed to sign attestations"); + } + _ => {} + } + } - // Post the attestations to the BN. - match self - .beacon_nodes - .request(ApiTopic::Attestations, |beacon_node| async move { - let _timer = validator_metrics::start_timer_vec( - &validator_metrics::ATTESTATION_SERVICE_TIMES, - &[validator_metrics::ATTESTATIONS_HTTP_POST], - ); - - beacon_node - .post_beacon_pool_attestations_v2::( - single_attestations.clone(), - fork_name, - ) - .await - }) - .instrument(info_span!("publish_attestations", count = published_count)) - .await - { - Ok(()) => info!( - count = published_count, - validator_indices = ?validator_indices, - head_block = ?attestation_data.beacon_block_root, - committee_index = attestation_data.index, - slot = attestation_data.slot.as_u64(), - "type" = "unaggregated", - "Successfully published attestations" - ), - Err(e) => error!( - error = %e, - committee_index = attestation_data.index, - slot = slot.as_u64(), - "type" = "unaggregated", - "Unable to publish attestations" - ), + if !received_non_empty_batch { + warn!("No attestations were published"); } Ok(()) @@ -725,113 +737,103 @@ impl AttestationService(attestation_data, &self.chain_spec) { - crit!("Inconsistent validator duties during signing"); - return None; - } - - match self - .validator_store - .produce_signed_aggregate_and_proof( - duty.pubkey, - duty.validator_index, - aggregated_attestation.clone(), - selection_proof.clone(), - ) - .await - { - Ok(aggregate) => Some(aggregate), - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - // A pubkey can be missing when a validator was recently - // removed via the API. - debug!(?pubkey, "Missing pubkey for aggregate"); - None - } - Err(e) => { - crit!( - error = ?e, - pubkey = ?duty.pubkey, - "Failed to sign aggregate" - ); - None - } - } - }); - - // Execute all the futures in parallel, collecting any successful results. - let aggregator_count = validator_duties + // Build the batch of aggregates to sign. + let aggregates_to_sign: Vec<_> = validator_duties .iter() - .filter(|d| d.selection_proof.is_some()) - .count(); - let signed_aggregate_and_proofs = join_all(signing_futures) - .instrument(info_span!("sign_aggregates", count = aggregator_count)) - .await - .into_iter() - .flatten() - .collect::>(); + .filter_map(|duty_and_proof| { + let duty = &duty_and_proof.duty; + let selection_proof = duty_and_proof.selection_proof.as_ref()?; - if !signed_aggregate_and_proofs.is_empty() { - let signed_aggregate_and_proofs_slice = signed_aggregate_and_proofs.as_slice(); - match self - .beacon_nodes - .first_success(|beacon_node| async move { - let _timer = validator_metrics::start_timer_vec( - &validator_metrics::ATTESTATION_SERVICE_TIMES, - &[validator_metrics::AGGREGATES_HTTP_POST], - ); - if fork_name.electra_enabled() { - beacon_node - .post_validator_aggregate_and_proof_v2( - signed_aggregate_and_proofs_slice, - fork_name, - ) - .await - } else { - beacon_node - .post_validator_aggregate_and_proof_v1( - signed_aggregate_and_proofs_slice, - ) - .await - } + if !duty.match_attestation_data::(attestation_data, &self.chain_spec) { + crit!("Inconsistent validator duties during signing"); + return None; + } + + Some(AggregateToSign { + pubkey: duty.pubkey, + aggregator_index: duty.validator_index, + aggregate: aggregated_attestation.clone(), + selection_proof: selection_proof.clone(), }) - .instrument(info_span!( - "publish_aggregates", - count = signed_aggregate_and_proofs.len() - )) - .await - { - Ok(()) => { - for signed_aggregate_and_proof in signed_aggregate_and_proofs { - let attestation = signed_aggregate_and_proof.message().aggregate(); - info!( - aggregator = signed_aggregate_and_proof.message().aggregator_index(), - signatures = attestation.num_set_aggregation_bits(), - head_block = format!("{:?}", attestation.data().beacon_block_root), - committee_index = attestation.committee_index(), - slot = attestation.data().slot.as_u64(), - "type" = "aggregated", - "Successfully published attestation" - ); + }) + .collect(); + + // Sign aggregates. Returns a stream of batches. + let aggregate_stream = self + .validator_store + .sign_aggregate_and_proofs(aggregates_to_sign); + tokio::pin!(aggregate_stream); + + // Publish each batch as it arrives from the stream. + while let Some(result) = aggregate_stream.next().await { + match result { + Ok(batch) if !batch.is_empty() => { + let signed_aggregate_and_proofs = batch.as_slice(); + match self + .beacon_nodes + .first_success(|beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::ATTESTATION_SERVICE_TIMES, + &[validator_metrics::AGGREGATES_HTTP_POST], + ); + if fork_name.electra_enabled() { + beacon_node + .post_validator_aggregate_and_proof_v2( + signed_aggregate_and_proofs, + fork_name, + ) + .await + } else { + beacon_node + .post_validator_aggregate_and_proof_v1( + signed_aggregate_and_proofs, + ) + .await + } + }) + .instrument(info_span!( + "publish_aggregates", + count = signed_aggregate_and_proofs.len() + )) + .await + { + Ok(()) => { + for signed_aggregate_and_proof in signed_aggregate_and_proofs { + let attestation = signed_aggregate_and_proof.message().aggregate(); + info!( + aggregator = + signed_aggregate_and_proof.message().aggregator_index(), + signatures = attestation.num_set_aggregation_bits(), + head_block = + format!("{:?}", attestation.data().beacon_block_root), + committee_index = attestation.committee_index(), + slot = attestation.data().slot.as_u64(), + "type" = "aggregated", + "Successfully published attestation" + ); + } + } + Err(e) => { + for signed_aggregate_and_proof in signed_aggregate_and_proofs { + let attestation = &signed_aggregate_and_proof.message().aggregate(); + crit!( + error = %e, + aggregator = signed_aggregate_and_proof + .message() + .aggregator_index(), + committee_index = attestation.committee_index(), + slot = attestation.data().slot.as_u64(), + "type" = "aggregated", + "Failed to publish attestation" + ); + } + } } } Err(e) => { - for signed_aggregate_and_proof in signed_aggregate_and_proofs { - let attestation = &signed_aggregate_and_proof.message().aggregate(); - crit!( - error = %e, - aggregator = signed_aggregate_and_proof.message().aggregator_index(), - committee_index = attestation.committee_index(), - slot = attestation.data().slot.as_u64(), - "type" = "aggregated", - "Failed to publish attestation" - ); - } + crit!(error = ?e, "Failed to sign aggregates"); } + _ => {} } } diff --git a/validator_client/validator_services/src/sync_committee_service.rs b/validator_client/validator_services/src/sync_committee_service.rs index 59e8524a1a..26ce052ea0 100644 --- a/validator_client/validator_services/src/sync_committee_service.rs +++ b/validator_client/validator_services/src/sync_committee_service.rs @@ -2,8 +2,8 @@ use crate::duties_service::DutiesService; use beacon_node_fallback::{ApiTopic, BeaconNodeFallback}; use bls::PublicKeyBytes; use eth2::types::BlockId; +use futures::StreamExt; use futures::future::FutureExt; -use futures::future::join_all; use logging::crit; use slot_clock::SlotClock; use std::collections::HashMap; @@ -17,7 +17,7 @@ use types::{ ChainSpec, EthSpec, Hash256, Slot, SyncCommitteeSubscription, SyncContributionData, SyncDuty, SyncSelectionProof, SyncSubnetId, }; -use validator_store::{Error as ValidatorStoreError, ValidatorStore}; +use validator_store::{ContributionToSign, SyncMessageToSign, ValidatorStore}; pub const SUBSCRIPTION_LOOKAHEAD_EPOCHS: u64 = 4; @@ -247,78 +247,57 @@ impl SyncCommitteeService, ) -> Result<(), ()> { - // Create futures to produce sync committee signatures. - let signature_futures = validator_duties.iter().map(|duty| async move { - match self - .validator_store - .produce_sync_committee_signature( - slot, - beacon_block_root, - duty.validator_index, - &duty.pubkey, - ) - .await - { - Ok(signature) => Some(signature), - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - // A pubkey can be missing when a validator was recently - // removed via the API. - debug!( - ?pubkey, - validator_index = duty.validator_index, - %slot, - "Missing pubkey for sync committee signature" - ); - None + let messages_to_sign: Vec<_> = validator_duties + .iter() + .map(|duty| SyncMessageToSign { + slot, + beacon_block_root, + validator_index: duty.validator_index, + pubkey: duty.pubkey, + }) + .collect(); + + let signature_stream = self + .validator_store + .sign_sync_committee_signatures(messages_to_sign); + tokio::pin!(signature_stream); + + while let Some(result) = signature_stream.next().await { + match result { + Ok(committee_signatures) if !committee_signatures.is_empty() => { + let committee_signatures = &committee_signatures; + match self + .beacon_nodes + .request(ApiTopic::SyncCommittee, |beacon_node| async move { + beacon_node + .post_beacon_pool_sync_committee_signatures(committee_signatures) + .await + }) + .instrument(info_span!( + "publish_sync_signatures", + count = committee_signatures.len() + )) + .await + { + Ok(()) => info!( + count = committee_signatures.len(), + head_block = ?beacon_block_root, + %slot, + "Successfully published sync committee messages" + ), + Err(e) => error!( + %slot, + error = %e, + "Unable to publish sync committee messages" + ), + } } Err(e) => { - crit!( - validator_index = duty.validator_index, - %slot, - error = ?e, - "Failed to sign sync committee signature" - ); - None + crit!(%slot, error = ?e, "Failed to sign sync committee signatures"); } + _ => {} } - }); - - // Execute all the futures in parallel, collecting any successful results. - let committee_signatures = &join_all(signature_futures) - .instrument(info_span!( - "sign_sync_signatures", - count = validator_duties.len() - )) - .await - .into_iter() - .flatten() - .collect::>(); - - self.beacon_nodes - .request(ApiTopic::SyncCommittee, |beacon_node| async move { - beacon_node - .post_beacon_pool_sync_committee_signatures(committee_signatures) - .await - }) - .instrument(info_span!( - "publish_sync_signatures", - count = committee_signatures.len() - )) - .await - .map_err(|e| { - error!( - %slot, - error = %e, - "Unable to publish sync committee messages" - ); - })?; - - info!( - count = committee_signatures.len(), - head_block = ?beacon_block_root, - %slot, - "Successfully published sync committee messages" - ); + } Ok(()) } @@ -389,77 +368,61 @@ impl SyncCommitteeService Some(signed_contribution), - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - // A pubkey can be missing when a validator was recently - // removed via the API. - debug!(?pubkey, %slot, "Missing pubkey for sync contribution"); - None - } - Err(e) => { - crit!( + let contributions_to_sign: Vec<_> = subnet_aggregators + .into_iter() + .map( + |(aggregator_index, aggregator_pk, selection_proof)| ContributionToSign { + aggregator_index, + aggregator_pubkey: aggregator_pk, + contribution: contribution.clone(), + selection_proof, + }, + ) + .collect(); + + let contribution_stream = self + .validator_store + .sign_sync_committee_contributions(contributions_to_sign); + tokio::pin!(contribution_stream); + + while let Some(result) = contribution_stream.next().await { + match result { + Ok(signed_contributions) if !signed_contributions.is_empty() => { + let signed_contributions = &signed_contributions; + // Publish to the beacon node. + match self + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_contribution_and_proofs(signed_contributions) + .await + }) + .instrument(info_span!( + "publish_sync_contributions", + count = signed_contributions.len() + )) + .await + { + Ok(()) => info!( + subnet = %subnet_id, + beacon_block_root = %beacon_block_root, + num_signers = contribution.aggregation_bits.num_set_bits(), %slot, - error = ?e, - "Unable to sign sync committee contribution" - ); - None + "Successfully published sync contributions" + ), + Err(e) => error!( + %slot, + error = %e, + "Unable to publish signed contributions and proofs" + ), } } - }, - ); - - // Execute all the futures in parallel, collecting any successful results. - let signed_contributions = &join_all(signature_futures) - .instrument(info_span!( - "sign_sync_contributions", - count = aggregator_count - )) - .await - .into_iter() - .flatten() - .collect::>(); - - // Publish to the beacon node. - self.beacon_nodes - .first_success(|beacon_node| async move { - beacon_node - .post_validator_contribution_and_proofs(signed_contributions) - .await - }) - .instrument(info_span!( - "publish_sync_contributions", - count = signed_contributions.len() - )) - .await - .map_err(|e| { - error!( - %slot, - error = %e, - "Unable to publish signed contributions and proofs" - ); - })?; - - info!( - subnet = %subnet_id, - beacon_block_root = %beacon_block_root, - num_signers = contribution.aggregation_bits.num_set_bits(), - %slot, - "Successfully published sync contributions" - ); + Err(e) => { + crit!(%slot, error = ?e, "Failed to sign sync committee contributions"); + } + _ => {} + } + } Ok(()) } diff --git a/validator_client/validator_store/Cargo.toml b/validator_client/validator_store/Cargo.toml index 8b1879c837..2c6a68d494 100644 --- a/validator_client/validator_store/Cargo.toml +++ b/validator_client/validator_store/Cargo.toml @@ -7,5 +7,6 @@ authors = ["Sigma Prime "] [dependencies] bls = { workspace = true } eth2 = { workspace = true } +futures = { workspace = true } slashing_protection = { workspace = true } types = { workspace = true } diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index 87ab669e8d..da0b33de18 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -1,5 +1,6 @@ use bls::{PublicKeyBytes, Signature}; use eth2::types::{FullBlockContents, PublishBlockRequest}; +use futures::Stream; use slashing_protection::NotSafe; use std::fmt::Debug; use std::future::Future; @@ -32,6 +33,38 @@ impl From for Error { } } +/// Input for batch attestation signing +pub struct AttestationToSign { + pub validator_index: u64, + pub pubkey: PublicKeyBytes, + pub validator_committee_index: usize, + pub attestation: Attestation, +} + +/// Input for batch aggregate signing +pub struct AggregateToSign { + pub pubkey: PublicKeyBytes, + pub aggregator_index: u64, + pub aggregate: Attestation, + pub selection_proof: SelectionProof, +} + +/// Input for batch sync committee message signing +pub struct SyncMessageToSign { + pub slot: Slot, + pub beacon_block_root: Hash256, + pub validator_index: u64, + pub pubkey: PublicKeyBytes, +} + +/// Input for batch sync committee contribution signing +pub struct ContributionToSign { + pub aggregator_index: u64, + pub aggregator_pubkey: PublicKeyBytes, + pub contribution: SyncCommitteeContribution, + pub selection_proof: SyncSelectionProof, +} + /// A helper struct, used for passing data from the validator store to services. pub struct ProposalData { pub validator_index: Option, @@ -106,13 +139,9 @@ pub trait ValidatorStore: Send + Sync { /// Sign a batch of `attestations` and apply slashing protection to them. /// - /// Only successfully signed attestations that pass slashing protection are returned, along with - /// the validator index of the signer. Eventually this will be replaced by `SingleAttestation` - /// use. - /// - /// Input: - /// - /// * Vec of (validator_index, pubkey, validator_committee_index, attestation). + /// Returns a stream of batches of successfully signed attestations. Each batch contains + /// attestations that passed slashing protection, along with the validator index of the signer. + /// Eventually this will be replaced by `SingleAttestation` use. /// /// Output: /// @@ -120,26 +149,14 @@ pub trait ValidatorStore: Send + Sync { #[allow(clippy::type_complexity)] fn sign_attestations( self: &Arc, - attestations: Vec<(u64, PublicKeyBytes, usize, Attestation)>, - ) -> impl Future)>, Error>> + Send; + attestations: Vec>, + ) -> impl Stream)>, Error>> + Send; fn sign_validator_registration_data( &self, validator_registration_data: ValidatorRegistrationData, ) -> impl Future>> + Send; - /// Signs an `AggregateAndProof` for a given validator. - /// - /// The resulting `SignedAggregateAndProof` is sent on the aggregation channel and cannot be - /// modified by actors other than the signing validator. - fn produce_signed_aggregate_and_proof( - &self, - validator_pubkey: PublicKeyBytes, - aggregator_index: u64, - aggregate: Attestation, - selection_proof: SelectionProof, - ) -> impl Future, Error>> + Send; - /// Produces a `SelectionProof` for the `slot`, signed by with corresponding secret key to /// `validator_pubkey`. fn produce_selection_proof( @@ -156,21 +173,23 @@ pub trait ValidatorStore: Send + Sync { subnet_id: SyncSubnetId, ) -> impl Future>> + Send; - fn produce_sync_committee_signature( - &self, - slot: Slot, - beacon_block_root: Hash256, - validator_index: u64, - validator_pubkey: &PublicKeyBytes, - ) -> impl Future>> + Send; + /// Sign a batch of aggregate and proofs and return results as a stream of batches. + fn sign_aggregate_and_proofs( + self: &Arc, + aggregates: Vec>, + ) -> impl Stream>, Error>> + Send; - fn produce_signed_contribution_and_proof( - &self, - aggregator_index: u64, - aggregator_pubkey: PublicKeyBytes, - contribution: SyncCommitteeContribution, - selection_proof: SyncSelectionProof, - ) -> impl Future, Error>> + Send; + /// Sign a batch of sync committee messages and return results as a stream of batches. + fn sign_sync_committee_signatures( + self: &Arc, + messages: Vec, + ) -> impl Stream, Error>> + Send; + + /// Sign a batch of sync committee contributions and return results as a stream of batches. + fn sign_sync_committee_contributions( + self: &Arc, + contributions: Vec>, + ) -> impl Stream>, Error>> + Send; /// Prune the slashing protection database so that it remains performant. ///