diff --git a/CONTEXT.md b/CONTEXT.md index ccca718d..7da5e644 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,3 +27,12 @@ payloads. A transport-free library living inside a tile that owns the loop. Hosted crates are hardcoded into their tile, not plugins. _Avoid_: plugin, sub-tile, service. + +**Beacon API**: +The standard Ethereum REST API a beacon node serves; validator clients are +the primary consumers. Served by the `beacon_api` hosted crate. + +**Engine API**: +The standard JSON-RPC protocol between a beacon node and its execution +client. Called by the `engine_api` hosted crate. +_Avoid_: bare "engine" (ambiguous with the execution client itself). diff --git a/Cargo.lock b/Cargo.lock index f080389e..b3641c08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4427,6 +4427,7 @@ dependencies = [ "mimalloc", "quinn-proto", "rand 0.8.6", + "silver_application_boundary", "silver_beacon_state", "silver_beacon_state_data", "silver_columns", @@ -4434,14 +4435,47 @@ dependencies = [ "silver_config", "silver_control", "silver_discovery", - "silver_engine", "silver_gossip", + "silver_httpcore", "silver_network", "silver_peer", "silver_storage", "tracing", ] +[[package]] +name = "silver_application_boundary" +version = "0.0.1" +dependencies = [ + "flux", + "hex", + "serde_json", + "silver_beacon_api", + "silver_beacon_state_data", + "silver_common", + "silver_config", + "silver_engine_api", + "silver_httpcore", + "silver_peer", + "tempfile", +] + +[[package]] +name = "silver_beacon_api" +version = "0.0.1" +dependencies = [ + "hex", + "mio", + "serde", + "serde_json", + "silver_beacon_state_data", + "silver_common", + "silver_httpcore", + "tempfile", + "toml", + "tracing", +] + [[package]] name = "silver_beacon_state" version = "0.0.1" @@ -4491,6 +4525,7 @@ version = "0.0.1" dependencies = [ "hex", "serde", + "toml", ] [[package]] @@ -4559,6 +4594,7 @@ dependencies = [ "silver_chain_spec", "silver_common", "toml", + "tracing", ] [[package]] @@ -4631,7 +4667,7 @@ dependencies = [ ] [[package]] -name = "silver_engine" +name = "silver_engine_api" version = "0.0.1" dependencies = [ "base64 0.22.1", @@ -4645,7 +4681,10 @@ dependencies = [ "sha2", "silver_common", "silver_config", + "silver_engine_api", + "silver_httpcore", "simd-json", + "tempfile", "thiserror 1.0.69", "tracing", "tracing-subscriber", @@ -4671,6 +4710,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "silver_httpcore" +version = "0.0.1" +dependencies = [ + "httparse", + "mio", + "tempfile", + "tracing", +] + [[package]] name = "silver_metrics" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index a1de50e5..55cd7603 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,7 @@ [workspace] members = [ + "crates/application_boundary", + "crates/beacon_api", "crates/beacon_state/data", "crates/beacon_state/tile", "crates/bin", @@ -11,7 +13,8 @@ members = [ "crates/discovery", "crates/e2e", "crates/gossip", - "crates/engine", + "crates/httpcore", + "crates/engine_api", "crates/metrics", "crates/network", "crates/peer", @@ -66,6 +69,8 @@ inherits = "dev" opt-level = 3 [workspace.dependencies] +silver_application_boundary = { path = "crates/application_boundary" } +silver_beacon_api = { path = "crates/beacon_api" } silver_beacon_state = { path = "crates/beacon_state/tile" } silver_beacon_state_data = { path = "crates/beacon_state/data" } silver_chain_spec = { path = "crates/config/chain_spec" } @@ -77,10 +82,11 @@ silver_ssz = { path = "crates/ssz" } silver_control = { path = "crates/control" } silver_discovery = {path = "crates/discovery" } silver_gossip = {path = "crates/gossip" } +silver_httpcore = { path = "crates/httpcore" } silver_network = {path = "crates/network" } silver_peer = {path = "crates/peer" } silver_storage = { path = "crates/storage" } -silver_engine = { path = "crates/engine"} +silver_engine_api = { path = "crates/engine_api" } flux = { git = "https://github.com/gattaca-com/flux", rev = "b5fbdf6f3e52feb785a527d5c3bba90fee2a56e4"} flux-utils = { git = "https://github.com/gattaca-com/flux", rev = "b5fbdf6f3e52feb785a527d5c3bba90fee2a56e4", features = ["bytes"]} flux-profiler = { git = "https://github.com/gattaca-com/flux", rev = "b5fbdf6f3e52feb785a527d5c3bba90fee2a56e4"} diff --git a/crates/application_boundary/Cargo.toml b/crates/application_boundary/Cargo.toml new file mode 100644 index 00000000..eaf168d9 --- /dev/null +++ b/crates/application_boundary/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "silver_application_boundary" +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +flux.workspace = true +silver_beacon_api.workspace = true +silver_beacon_state_data.workspace = true +silver_common.workspace = true +silver_config.workspace = true +silver_engine_api.workspace = true +silver_httpcore.workspace = true +silver_peer.workspace = true + +[dev-dependencies] +hex.workspace = true +serde_json.workspace = true +silver_engine_api = { workspace = true, features = ["test-el"] } +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/application_boundary/src/lib.rs b/crates/application_boundary/src/lib.rs new file mode 100644 index 00000000..86ffb3d5 --- /dev/null +++ b/crates/application_boundary/src/lib.rs @@ -0,0 +1,121 @@ +use std::time::Duration; + +use flux::{spine::SpineAdapter, tile::Tile}; +use silver_beacon_api::{BeaconApi, ChainHead, PeerCounts, SlotStatus}; +use silver_beacon_state_data::{BeaconStateReader, SpecConfig}; +use silver_common::{ + BeaconStateEvent, Enr, Identify, Keypair, SilverSpine, SyncUpdate, TProducer, TRandomAccess, + ssz_view::StatusView, +}; +use silver_config::EngineConfig; +use silver_engine_api::EngineApi; +use silver_httpcore::{Bind, Readiness, TokenRange}; +use silver_peer::PeerCounters; + +/// A tenant added here takes the next share of a raised `TENANTS`, which keeps +/// every share disjoint without a base to compute. +const TENANTS: usize = 2; +const BEACON_TOKENS: TokenRange = TokenRange::share(0, TENANTS); +const ENGINE_TOKENS: TokenRange = TokenRange::share(1, TENANTS); + +pub struct ApplicationBoundaryTile { + readiness: Readiness, + pub beacon: BeaconApi, + engine: EngineApi, +} + +impl Tile for ApplicationBoundaryTile { + fn loop_body(&mut self, adapter: &mut SpineAdapter) { + self.engine.intake(adapter); + self.readiness.wait(Duration::ZERO); + self.engine.spin(adapter, self.readiness.events()); + self.refresh_node_status(adapter); + if self.beacon.pump(self.readiness.events()) { + adapter.mark_work(); + } + } +} + +impl ApplicationBoundaryTile { + #[allow(clippy::too_many_arguments)] + pub fn new( + binds: &[Bind], + max_connections: usize, + idle_timeout: Duration, + keypair: &Keypair, + local_enr: Enr, + identify: &Identify, + spec: &SpecConfig, + state: BeaconStateReader, + engine_config: EngineConfig, + gossip_consumer: TRandomAccess, + rpc_consumer: TRandomAccess, + resp_producer: TProducer, + ) -> Self { + // A batch too small for every socket the tile can register leaves the + // rest of a busy iteration's readiness for the next one. + let sockets = + binds.len() + max_connections + EngineApi::max_sockets(engine_config.max_connections); + + let readiness = Readiness::new(sockets); + let beacon = BeaconApi::new( + readiness.registry(), + BEACON_TOKENS, + binds, + max_connections, + idle_timeout, + keypair, + local_enr, + identify, + spec, + state, + ); + let engine = EngineApi::new( + readiness.registry(), + ENGINE_TOKENS, + engine_config, + gossip_consumer, + rpc_consumer, + resp_producer, + ); + Self { readiness, beacon, engine } + } + + fn refresh_node_status(&mut self, adapter: &mut SpineAdapter) { + let status = self.beacon.node_status_mut(); + + // Consumed every iteration, and never behind the engine's capacity + // gate: a consumer's first `consume` jumps its cursor to the + // producer's write head, so a queue left unread while the pool is + // saturated loses everything published in the meantime. + adapter.consume(|event: BeaconStateEvent, _| { + if let BeaconStateEvent::Status { + ssz, + latest_block_slot, + wall_slot, + head_optimistic, + .. + } = event + { + status.slots = Some(SlotStatus { + latest_block_slot, + head: ChainHead { + root: *StatusView::head_root(&ssz), + slot: StatusView::head_slot(&ssz), + }, + wall_slot, + head_optimistic, + }); + } + }); + adapter.consume(|update: SyncUpdate, _| { + status.syncing = !matches!(update, SyncUpdate::Following); + }); + + status.el = self.engine.sync_status(); + status.peers = PeerCounts { + connected: PeerCounters::PeersConnected.get(), + connecting: PeerCounters::PeersConnecting.get(), + }; + } +} diff --git a/crates/application_boundary/tests/tile.rs b/crates/application_boundary/tests/tile.rs new file mode 100644 index 00000000..09bfa63c --- /dev/null +++ b/crates/application_boundary/tests/tile.rs @@ -0,0 +1,732 @@ +use std::{ + io::{Read, Write}, + net::{SocketAddr, TcpStream}, + os::unix::net::UnixStream, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +use flux::{spine::SpineAdapter, tile::Tile}; +use silver_application_boundary::ApplicationBoundaryTile; +use silver_beacon_api::{ChainHead, PeerCounts, SlotStatus}; +use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; +use silver_common::{ + BeaconStateEvent, ELSyncStatus, EngineFcuReq, EngineReq, EngineResp, Enr, Identify, Keypair, + PayloadValidationStatus, SilverSpine, SyncUpdate, TCache, TCacheProducer, + ssz_view::{STATUS_V2_SIZE, StatusView}, +}; +use silver_config::EngineConfig; +use silver_engine_api::test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}; +use silver_httpcore::Bind; +use silver_peer::PeerCounters; +use tempfile::TempDir; + +struct Injector; +impl Tile for Injector { + fn loop_body(&mut self, _: &mut SpineAdapter) {} +} + +fn boundary_tile( + bind: &Bind, + engine_config: EngineConfig, + tcache_names: [&'static str; 3], +) -> ApplicationBoundaryTile { + // Every `loop_body` below samples the peer gauges; left at the default + // base that is the counter file a node running on this machine serves. + PeerCounters::init_with_base( + std::env::temp_dir() + .join(format!("silver_application_boundary_test_{}", std::process::id())), + "silver", + ) + .unwrap(); + + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + let gossip_p = TCache::producer(tcache_names[0], 1 << 12); + let rpc_p = TCache::producer(tcache_names[1], 1 << 12); + let resp_p = TCache::producer(tcache_names[2], 1 << 12); + ApplicationBoundaryTile::new( + std::slice::from_ref(bind), + 64, + Duration::from_secs(75), + &keypair, + local_enr, + &Identify::default(), + &SpecConfig::mainnet(), + BeaconStateOwner::empty_test(0).reader(), + engine_config, + gossip_p.cache_ref().random_access("t", true).unwrap(), + rpc_p.cache_ref().random_access("t", true).unwrap(), + resp_p, + ) +} + +fn identity_client(addr: SocketAddr) -> JoinHandle { + std::thread::spawn(move || { + let stream = TcpStream::connect(addr).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + http_get(stream, "/eth/v1/node/identity") + }) +} + +/// A keep-alive client that hangs up the moment it has its answer, leaving a +/// half-closed peer on a connection the server still has registered. +fn identity_client_that_hangs_up(addr: SocketAddr) -> JoinHandle { + std::thread::spawn(move || { + let mut stream = TcpStream::connect(addr).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + write!(stream, "GET /eth/v1/node/identity HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap(); + let mut answer = Vec::new(); + let mut chunk = [0u8; 4096]; + while !whole_response(&answer) { + let read = stream.read(&mut chunk).unwrap(); + assert!(read > 0, "server closed a keep-alive connection before answering"); + answer.extend_from_slice(&chunk[..read]); + } + String::from_utf8(answer).unwrap() + }) +} + +fn whole_response(received: &[u8]) -> bool { + let text = String::from_utf8_lossy(received); + let Some(headers_end) = text.find("\r\n\r\n") else { return false }; + let declared: usize = text[..headers_end] + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .expect("beacon api frames every answer with its length") + .parse() + .unwrap(); + received.len() >= headers_end + "\r\n\r\n".len() + declared +} + +fn no_el() -> EngineConfig { + EngineConfig { unsafe_no_el: true, ..EngineConfig::default() } +} + +fn http_get(mut stream: impl Read + Write, path: &str) -> String { + write!(stream, "GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").unwrap(); + stream.flush().unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + String::from_utf8(response).unwrap() +} + +fn assert_identity_ok(response: &str) { + assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "unexpected response: {response}"); + let body = &response[response.find("\r\n\r\n").unwrap() + 4..]; + let json: serde_json::Value = serde_json::from_str(body).unwrap(); + assert!(json["data"]["peer_id"].as_str().is_some_and(|id| !id.is_empty())); + assert!(json["data"]["enr"].as_str().unwrap().starts_with("enr:")); + assert!(json["data"]["metadata"]["seq_number"].is_string()); +} + +fn fcu_req(byte: u8) -> EngineReq { + EngineReq::Fcu(EngineFcuReq { + block_root: [byte; 32], + head_block_hash: [byte; 32], + safe_block_hash: [0u8; 32], + finalized_block_hash: [0u8; 32], + }) +} + +fn head_block_hash_json(byte: u8) -> String { + format!("\"headBlockHash\":\"0x{}\"", hex::encode([byte; 32])) +} + +fn drain_fcu_completions( + inj: &mut SpineAdapter, + out: &mut Vec<([u8; 32], PayloadValidationStatus)>, +) { + inj.consume(|resp: EngineResp, _| { + if let EngineResp::Fcu(r) = resp { + out.push((r.block_root, r.status)); + } + }); +} + +/// Builds the event the beacon-state tile publishes for a given status. Fork +/// choice's head rides the Status payload that the p2p tiles send verbatim, +/// at the offsets `StatusView` reads. The other fields are the event's own. +fn status_event(status: SlotStatus) -> BeaconStateEvent { + let mut ssz = [0u8; STATUS_V2_SIZE]; + ssz[44..76].copy_from_slice(&status.head.root); + ssz[76..84].copy_from_slice(&status.head.slot.to_le_bytes()); + assert_eq!(StatusView::head_root(&ssz), &status.head.root); + assert_eq!(StatusView::head_slot(&ssz), status.head.slot); + BeaconStateEvent::Status { + ssz, + head_optimistic: status.head_optimistic, + latest_block_slot: status.latest_block_slot, + wall_slot: status.wall_slot, + enr_fork_id: [0u8; 16], + } +} + +#[test] +fn serves_identity_over_tcp() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ + "cs_tcp_gossip", + "cs_tcp_rpc", + "cs_tcp_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; + assert_ne!(addr.port(), 0, "port-0 bind must resolve to an ephemeral port"); + + let client = identity_client(addr); + + let deadline = Instant::now() + Duration::from_secs(10); + while !client.is_finished() { + assert!(Instant::now() < deadline, "timeout: identity over tcp"); + tile.loop_body(&mut adapter); + std::thread::sleep(Duration::from_millis(1)); + } + assert_identity_ok(&client.join().unwrap()); +} + +#[test] +fn serves_identity_over_uds() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let socket = base.path().join("beacon_api.sock"); + let mut tile = boundary_tile(&Bind::Unix(socket.clone()), no_el(), [ + "cs_uds_gossip", + "cs_uds_rpc", + "cs_uds_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + + assert_eq!(tile.beacon.local_addrs(), [Bind::Unix(socket.clone())]); + + let client = std::thread::spawn(move || { + let stream = UnixStream::connect(&socket).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + http_get(stream, "/eth/v1/node/identity") + }); + + let deadline = Instant::now() + Duration::from_secs(10); + while !client.is_finished() { + assert!(Instant::now() < deadline, "timeout: identity over uds"); + tile.loop_body(&mut adapter); + std::thread::sleep(Duration::from_millis(1)); + } + assert_identity_ok(&client.join().unwrap()); +} + +/// ADR 0004's core claim: all pumps are non-blocking, so an unanswered EL +/// call never stalls beacon-api serving, and the EL completion still lands +/// once the response arrives. +#[test] +fn serves_beacon_api_while_engine_call_in_flight() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let (mut el, endpoint) = FakeEl::tcp(); + let jwt_path = write_jwt(base.path()); + + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + ..EngineConfig::default() + }; + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), config, [ + "cs_flight_gossip", + "cs_flight_rpc", + "cs_flight_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + inj.consume(|_: EngineResp, _| {}); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ApplicationBoundaryTile, el: &mut FakeEl, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + el.pump(); + std::thread::sleep(Duration::from_millis(1)); + }; + + // Crank until the startup healthcheck trio is on the wire: the tile's + // EngineReq cursor initializes on its first consume, so injecting before + // the first loop_body would be skipped. The trio stays unanswered — three + // more in-flight EL calls. + while el.requests.len() < 3 { + crank(&mut tile, &mut el, "startup healthcheck trio"); + } + + inj.produce(fcu_req(42)); + let fcu_on_wire = + |el: &FakeEl| el.requests.iter().position(|r| r.method == "engine_forkchoiceUpdatedV3"); + while fcu_on_wire(&el).is_none() { + crank(&mut tile, &mut el, "fcu on the wire"); + } + + // The FCU (and the startup healthcheck trio) sit unanswered on the EL; + // the API request must be served anyway. + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; + let client = identity_client(addr); + while !client.is_finished() { + crank(&mut tile, &mut el, "identity served while fcu in flight"); + } + assert_identity_ok(&client.join().unwrap()); + + let mut completed = Vec::new(); + inj.consume(|resp: EngineResp, _| { + if let EngineResp::Fcu(r) = resp { + completed.push(r.block_root); + } + }); + assert!(completed.is_empty(), "engine call must still be in flight after the API response"); + + el.respond(fcu_on_wire(&el).unwrap(), FCU_VALID_RESULT); + while completed.is_empty() { + crank(&mut tile, &mut el, "fcu completion on the spine"); + inj.consume(|resp: EngineResp, _| { + if let EngineResp::Fcu(r) = resp { + completed.push(r.block_root); + } + }); + } + assert_eq!(completed, vec![[42u8; 32]]); +} + +/// (cap+1) concurrent spine requests with `max_connections = cap`: the +/// last one must stay queued on the spine until a completion frees a +/// connection, and completions must correlate out of order. +#[test] +fn pool_cap_gates_spine_intake() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let (mut el, endpoint) = FakeEl::tcp(); + let jwt_path = write_jwt(base.path()); + + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + max_connections: 3, + ..EngineConfig::default() + }; + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), config, [ + "cs_cap_gossip", + "cs_cap_rpc", + "cs_cap_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + inj.consume(|_: EngineResp, _| {}); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ApplicationBoundaryTile, el: &mut FakeEl, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + el.pump(); + std::thread::sleep(Duration::from_millis(1)); + }; + + // First loop_body fires the startup healthcheck trio; answer it so all + // three pooled connections are free before the capped scenario. + while el.requests.len() < 3 { + crank(&mut tile, &mut el, "startup healthcheck trio"); + } + for i in 0..3 { + el.respond(i, "false"); + } + + for byte in [11u8, 12, 13, 14] { + inj.produce(fcu_req(byte)); + } + + let fcu_count = |el: &FakeEl| { + el.requests.iter().filter(|r| r.method == "engine_forkchoiceUpdatedV3").count() + }; + while fcu_count(&el) < 3 { + crank(&mut tile, &mut el, "first three FCUs sent"); + } + for _ in 0..50 { + crank(&mut tile, &mut el, "cap holds"); + assert_eq!(fcu_count(&el), 3, "4th request must wait while pool is at cap"); + } + + // Free one connection by answering the SECOND fcu; the gated request + // must then be sent, and the completion must carry the responded + // request's block root. + let second = el + .requests + .iter() + .position(|r| r.body.contains(&head_block_hash_json(12))) + .expect("fcu for root 12 on the wire"); + el.respond(second, FCU_VALID_RESULT); + + while fcu_count(&el) < 4 { + crank(&mut tile, &mut el, "gated FCU sent after a connection freed"); + } + + let mut completed = Vec::new(); + inj.consume(|resp: EngineResp, _| { + if let EngineResp::Fcu(r) = resp { + completed.push(r.block_root); + } + }); + assert_eq!(completed, vec![[12u8; 32]], "out-of-order completion correlated"); +} + +/// Taking a request off the spine flips its pooled connection's readiness +/// interest to WRITABLE, so the wait feeding the engine's dispatch has to run +/// after that intake: a request reaches the EL in the iteration that took it, +/// not the one after. +#[test] +fn an_engine_request_reaches_the_el_in_the_iteration_that_takes_it() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let (mut el, endpoint) = FakeEl::tcp(); + let jwt_path = write_jwt(base.path()); + + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + ..EngineConfig::default() + }; + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), config, [ + "cs_same_iter_gossip", + "cs_same_iter_rpc", + "cs_same_iter_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + inj.consume(|_: EngineResp, _| {}); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ApplicationBoundaryTile, el: &mut FakeEl, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + el.pump(); + std::thread::sleep(Duration::from_millis(1)); + }; + + // Answering the startup trio leaves the pooled connections connected and + // free, so the requests below wait on nothing but the interest change. + while el.requests.len() < 3 { + crank(&mut tile, &mut el, "startup healthcheck trio"); + } + for i in 0..3 { + el.respond(i, "false"); + } + while tile.beacon.node_status_mut().el != ELSyncStatus::Synced { + crank(&mut tile, &mut el, "startup healthcheck answered"); + } + for _ in 0..20 { + crank(&mut tile, &mut el, "pooled connections idle again"); + } + + let fcu_count = |el: &FakeEl| { + el.requests.iter().filter(|r| r.method == "engine_forkchoiceUpdatedV3").count() + }; + let mut produce_to_wire = Vec::new(); + for byte in [51u8, 52, 53, 54, 55] { + let already_sent = fcu_count(&el); + inj.produce(fcu_req(byte)); + + let mut iterations = 0; + while fcu_count(&el) == already_sent { + crank(&mut tile, &mut el, "fcu on the wire"); + iterations += 1; + } + produce_to_wire.push(iterations); + + let on_wire = el + .requests + .iter() + .position(|r| r.body.contains(&head_block_hash_json(byte))) + .expect("fcu on the wire"); + el.respond(on_wire, FCU_VALID_RESULT); + let mut completed = Vec::new(); + while completed.is_empty() { + crank(&mut tile, &mut el, "fcu completion frees its connection"); + drain_fcu_completions(&mut inj, &mut completed); + } + } + assert_eq!(produce_to_wire, [1; 5], "iterations from produce to wire, per request"); +} + +/// A broadcast consumer's cursor jumps to the producer's write head on its +/// first read, so anything published before the tile's first `loop_body` is +/// gone — which is why the tile reads these queues unconditionally from that +/// first iteration on. +#[test] +fn node_status_tracks_the_spine_once_the_cursor_snaps() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ + "cs_status_gossip", + "cs_status_rpc", + "cs_status_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + + inj.produce(status_event(SlotStatus { + latest_block_slot: 1, + head: ChainHead { root: [1u8; 32], slot: 1 }, + wall_slot: 1, + head_optimistic: true, + })); + tile.loop_body(&mut adapter); + assert!( + tile.beacon.node_status_mut().slots.is_none(), + "a status published before the first consume is skipped, not delivered" + ); + + // The import at slot 7 landed on a branch fork choice passed over. That + // block is not the head. The head stays the slot-6 block. + let passed_over = SlotStatus { + latest_block_slot: 7, + head: ChainHead { root: [6u8; 32], slot: 6 }, + wall_slot: 9, + head_optimistic: true, + }; + inj.produce(status_event(passed_over)); + inj.produce(SyncUpdate::SyncingHead { head_root: [3u8; 32], head_slot: 9 }); + tile.loop_body(&mut adapter); + + let status = *tile.beacon.node_status_mut(); + assert_eq!(status.slots, Some(passed_over)); + assert_eq!(status.slots.unwrap().sync_distance(), 2); + assert!(status.syncing); + + let caught_up = SlotStatus { + latest_block_slot: 9, + head: ChainHead { root: [9u8; 32], slot: 9 }, + wall_slot: 9, + head_optimistic: false, + }; + inj.produce(status_event(caught_up)); + inj.produce(SyncUpdate::Following); + tile.loop_body(&mut adapter); + let status = *tile.beacon.node_status_mut(); + assert_eq!( + status.slots, + Some(caught_up), + "each status replaces the last, head and execution status included" + ); + assert!(!status.syncing, "reaching the target clears the syncing flag"); +} + +/// The engine's spine intake is gated on free pool connections; node status +/// must not be. A queue left unread for a few iterations does not stall — it +/// loses its whole backlog. +#[test] +fn node_status_updates_while_the_engine_pool_is_at_cap() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let (mut el, endpoint) = FakeEl::tcp(); + let jwt_path = write_jwt(base.path()); + + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + max_connections: 3, + ..EngineConfig::default() + }; + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), config, [ + "cs_sat_gossip", + "cs_sat_rpc", + "cs_sat_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + inj.consume(|_: EngineResp, _| {}); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ApplicationBoundaryTile, el: &mut FakeEl, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + el.pump(); + std::thread::sleep(Duration::from_millis(1)); + }; + + while el.requests.len() < 3 { + crank(&mut tile, &mut el, "startup healthcheck trio"); + } + // `eth_syncing: false` is the EL reporting itself synced; the trio also + // frees all three pooled connections. + for i in 0..3 { + el.respond(i, "false"); + } + while tile.beacon.node_status_mut().el != ELSyncStatus::Synced { + crank(&mut tile, &mut el, "EL sync status reaches the api"); + } + + for byte in [11u8, 12, 13, 14] { + inj.produce(fcu_req(byte)); + } + let fcu_count = |el: &FakeEl| { + el.requests.iter().filter(|r| r.method == "engine_forkchoiceUpdatedV3").count() + }; + while fcu_count(&el) < 3 { + crank(&mut tile, &mut el, "pool saturated with unanswered FCUs"); + } + + let published = SlotStatus { + latest_block_slot: 7, + head: ChainHead { root: [7u8; 32], slot: 7 }, + wall_slot: 9, + head_optimistic: false, + }; + inj.produce(status_event(published)); + inj.produce(SyncUpdate::Following); + while tile.beacon.node_status_mut().slots.is_none() { + crank(&mut tile, &mut el, "status consumed while the pool is at cap"); + assert_eq!(fcu_count(&el), 3, "the 4th request must stay gated on the spine"); + } + + let status = *tile.beacon.node_status_mut(); + assert_eq!(status.slots, Some(published)); + assert!(!status.syncing); + assert_eq!(status.el, ELSyncStatus::Synced); +} + +/// Peer counts reach the api through shared memory, not the spine: the peer +/// manager sets its gauges on its own tick, in another tile. +#[test] +fn node_status_tracks_the_peer_gauges() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ + "cs_peers_gossip", + "cs_peers_rpc", + "cs_peers_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + + PeerCounters::PeersConnected.set(56); + PeerCounters::PeersConnecting.set(3); + tile.loop_body(&mut adapter); + assert_eq!(tile.beacon.node_status_mut().peers, PeerCounts { connected: 56, connecting: 3 }); + + PeerCounters::PeersConnected.set(55); + tile.loop_body(&mut adapter); + assert_eq!(tile.beacon.node_status_mut().peers.connected, 55, "refreshed every iteration"); +} + +/// Both tenants register into one readiness loop, where a token either could +/// allocate would deliver one's socket to the other's dispatch. Every socket +/// here is well past its tenant's first token, and every one of them is live +/// at the same time. +#[test] +fn concurrent_clients_and_engine_calls_keep_their_own_sockets() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let (mut el, endpoint) = FakeEl::tcp(); + let jwt_path = write_jwt(base.path()); + + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + max_connections: 4, + ..EngineConfig::default() + }; + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), config, [ + "cs_alias_gossip", + "cs_alias_rpc", + "cs_alias_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + inj.consume(|_: EngineResp, _| {}); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ApplicationBoundaryTile, el: &mut FakeEl, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + el.pump(); + std::thread::sleep(Duration::from_millis(1)); + }; + + // The startup healthcheck trio takes three pooled connections; answering + // it leaves all three registered and free for the FCUs below. + while el.requests.len() < 3 { + crank(&mut tile, &mut el, "startup healthcheck trio"); + } + for i in 0..3 { + el.respond(i, "false"); + } + + let roots = [21u8, 22, 23, 24]; + for byte in roots { + inj.produce(fcu_req(byte)); + } + let fcu_count = |el: &FakeEl| { + el.requests.iter().filter(|r| r.method == "engine_forkchoiceUpdatedV3").count() + }; + while fcu_count(&el) < roots.len() { + crank(&mut tile, &mut el, "four engine calls on the wire"); + } + + // Each client hangs up on its own connection while the engine calls are + // still in flight: a shared token would deliver that hangup to the engine + // pool, which would fail the call it is waiting on. + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; + let clients = roots.map(|_| identity_client_that_hangs_up(addr)); + while !clients.iter().all(JoinHandle::is_finished) { + crank(&mut tile, &mut el, "four api clients served while the engine calls wait"); + } + for client in clients { + assert_identity_ok(&client.join().unwrap()); + } + for _ in 0..10 { + crank(&mut tile, &mut el, "hangups delivered"); + } + + let mut completed = Vec::new(); + drain_fcu_completions(&mut inj, &mut completed); + assert!(completed.is_empty(), "a client hanging up must not complete an engine call"); + + for byte in roots { + let on_wire = el + .requests + .iter() + .position(|r| r.body.contains(&head_block_hash_json(byte))) + .expect("fcu on the wire"); + el.respond(on_wire, FCU_VALID_RESULT); + } + while completed.len() < roots.len() { + crank(&mut tile, &mut el, "every engine completion on the spine"); + drain_fcu_completions(&mut inj, &mut completed); + } + completed.sort_by_key(|(root, _)| *root); + assert_eq!( + completed, + roots.map(|byte| ([byte; 32], PayloadValidationStatus::Valid)), + "each call must carry its own EL answer, not a transport failure" + ); +} + +/// In unsafe no-EL mode the engine has no client and registers nothing, so the +/// beacon-api server is the only tenant of the loop and must serve as if it +/// had one to itself. +#[test] +fn serves_concurrent_clients_with_no_engine_registered() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ + "cs_noel_gossip", + "cs_noel_rpc", + "cs_noel_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; + let clients = [(); 3].map(|()| identity_client(addr)); + + let deadline = Instant::now() + Duration::from_secs(10); + while !clients.iter().all(JoinHandle::is_finished) { + assert!(Instant::now() < deadline, "timeout: three clients served with no engine"); + tile.loop_body(&mut adapter); + std::thread::sleep(Duration::from_millis(1)); + } + for client in clients { + assert_identity_ok(&client.join().unwrap()); + } +} diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml new file mode 100644 index 00000000..a26a32c7 --- /dev/null +++ b/crates/beacon_api/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "silver_beacon_api" +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +hex.workspace = true +mio.workspace = true +silver_beacon_state_data.workspace = true +silver_common.workspace = true +silver_httpcore.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile = "3" +toml.workspace = true + +[lints] +workspace = true diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs new file mode 100644 index 00000000..d2f96a00 --- /dev/null +++ b/crates/beacon_api/examples/srv.rs @@ -0,0 +1,35 @@ +use std::time::Duration; + +use silver_beacon_api::BeaconApi; +use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; +use silver_common::{Enr, Identify, Keypair}; +use silver_httpcore::{Bind, Readiness, TokenRange}; + +fn main() { + let arg = std::env::args().nth(1).unwrap_or_else(|| "0.0.0.0:5051".into()); + let binds = arg.split(',').map(Bind::parse).collect::>(); + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + // Never-published reader: state endpoints answer 503, as pre-bootstrap. + let state = BeaconStateOwner::empty_test(0).reader(); + + let mut readiness = Readiness::new(1024); + let mut api = BeaconApi::new( + readiness.registry(), + TokenRange::whole(), + &binds, + 64, + Duration::from_secs(75), + &keypair, + local_enr, + &Identify::default(), + &SpecConfig::mainnet(), + state, + ); + println!("serving on {:?}", api.local_addrs()); + loop { + readiness.wait(Duration::ZERO); + api.pump(readiness.events()); + std::thread::sleep(Duration::from_millis(1)); + } +} diff --git a/crates/beacon_api/src/blocks.rs b/crates/beacon_api/src/blocks.rs new file mode 100644 index 00000000..03315eba --- /dev/null +++ b/crates/beacon_api/src/blocks.rs @@ -0,0 +1,673 @@ +use silver_beacon_state_data::{B256, BLSSignature, BeaconBlockHeader, Slot, StateReadView}; + +use crate::{ + ChainHead, + ids::{parse_root, parse_uint64}, + json::ReadFlags, + response::Response, + router::Request, + routes::ApiCtx, +}; + +/// `Block not found` in `apis/beacon/blocks/root.yaml` — the only answer +/// either schema offers for a block silver cannot name, whether the chain has +/// none there or this crate has no read path to it. +const NOT_FOUND: &str = "block not found"; + +const ZERO_ROOT: B256 = [0u8; 32]; + +/// The state carries `latest_block_header`, which the spec strips of the +/// signature the block arrived with, and the beacon-API tile has no channel to +/// the block store to read the original from. `SignedBeaconBlockHeader` +/// requires the field. +const UNAVAILABLE_SIGNATURE: BLSSignature = [0u8; 96]; + +pub(crate) fn get_block_root(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(block) = read_block(req, ctx, resp) else { + return; + }; + let flags = block.flags(ctx.node_status.execution_optimistic()); + resp.json_body(|json| json.flagged_envelope(flags, |json| json.block_root(&block.root))); +} + +pub(crate) fn get_block_header(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(block) = read_block(req, ctx, resp) else { + return; + }; + let Some(header) = block.header else { + resp.error(404, NOT_FOUND); + return; + }; + let flags = block.flags(ctx.node_status.execution_optimistic()); + resp.json_body(|json| { + json.flagged_envelope(flags, |json| { + json.block_header_data(&block.root, block.canonical, &header, &UNAVAILABLE_SIGNATURE) + }) + }); +} + +fn read_block(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) -> Option { + let named = req.params.get("block_id").expect("{block_id} in the route pattern"); + let Some(block_id) = BlockId::parse(named) else { + resp.error(400, "invalid block_id"); + return None; + }; + let head = ctx.node_status.head(); + let block = ctx.read_state_or(resp, 404, NOT_FOUND, |view| block_id.resolve(&view, head))?; + if block.is_none() { + resp.error(404, NOT_FOUND); + } + block +} + +struct Block { + root: B256, + /// The header of the block the state came from, present only once the + /// state carries that header whole. This crate names every other block by + /// root alone. + header: Option, + canonical: bool, + finalized: bool, +} + +impl Block { + fn flags(&self, execution_optimistic: bool) -> ReadFlags { + ReadFlags { execution_optimistic, finalized: self.finalized } + } +} + +/// `block_id` (`params/index.yaml#/BlockId`): a keyword, a slot, or a block +/// root. Not `state_id`'s vocabulary — `justified` names a state, never a +/// block. `genesis` is the first slot's block by another name. +#[derive(Clone, Copy)] +enum BlockId { + Head, + Finalized, + Slot(Slot), + Root(B256), +} + +impl BlockId { + fn parse(text: &str) -> Option { + Some(match text { + "head" => Self::Head, + "genesis" => Self::Slot(0), + "finalized" => Self::Finalized, + _ => match parse_uint64(text) { + Some(slot) => Self::Slot(slot), + None => Self::Root(parse_root(text)?), + }, + }) + } + + /// One `process_slot` fills `latest_block_header.state_root` and writes + /// the `block_roots` entry naming that block. A filled `state_root` + /// therefore proves the ring can name the state's own block. The header + /// becomes that block's only once `state_root` is in. Only that root + /// carries the header. + /// + /// `head` is fork choice's head, which the state cannot name at all. It + /// is not always the block the state came from. An import can land on a + /// branch that fork choice passes over. Before the first status names a + /// head, the state's own block is the only head this node has. + fn resolve(self, view: &StateReadView<'_>, head: Option) -> Option { + let own_header = view.slot.state().latest_block_header; + let state_slot = view.slot.slot_number(); + let own_block_root = (own_header.state_root != ZERO_ROOT) + .then(|| view.block_roots.proposed_at(own_header.slot, state_slot)) + .flatten() + .filter(|root| *root != ZERO_ROOT); + let head = + head.or_else(|| own_block_root.map(|root| ChainHead { root, slot: own_header.slot })); + + let (root, finalized) = match self { + Self::Head => { + let head = head?; + (head.root, view.epoch.finalizes_slot(head.slot)) + } + Self::Slot(slot) => { + let root = match view.block_roots.proposed_at(slot, state_slot) { + Some(root) => root, + // The ring cannot name one block: the head, between its + // arrival and the `process_slot` that records it. The + // head stands in only at its own slot. A slot that + // carried no block falls through to `None`. + None => head.filter(|head| head.slot == slot)?.root, + }; + (root, view.epoch.finalizes_slot(slot)) + } + Self::Finalized => (view.epoch.finalized_block_root()?, true), + Self::Root(root) => { + // Every other root is a block this crate has no read path to. + let slot = head + .filter(|head| head.root == root) + .map(|head| head.slot) + .or_else(|| (Some(root) == own_block_root).then_some(own_header.slot))?; + (root, view.epoch.finalizes_slot(slot)) + } + }; + Some(Block { + root, + header: (Some(root) == own_block_root).then_some(own_header), + // Only a header response carries this flag. Only the state's own + // block gets a header. That block is canonical when fork choice + // heads on it. A block canonical without being the head answers + // `false`. That happens for an ancestor of a head this state has + // not caught up to. + canonical: Some(root) == head.map(|head| head.root), + finalized, + }) + } +} + +#[cfg(test)] +mod tests { + use silver_beacon_state_data::{ + BeaconState, BeaconStateOwner, Checkpoint, EpochState, EpochStateFinalized, + SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT, SpecConfig, ValSeed, + }; + use silver_httpcore::ParsedRequest; + + use super::*; + use crate::{ + NodeStatus, SlotStatus, + router::Router, + routes::{ROUTES, preboot_ctx, synced_status, test_ctx}, + }; + + const FINALIZED_EPOCH: u64 = 280; + const FINALIZED_SLOT: Slot = FINALIZED_EPOCH * SLOTS_PER_EPOCH; + + const HEAD_BLOCK_SLOT: Slot = 9_000; + const STATE_SLOT: Slot = HEAD_BLOCK_SLOT + 1; + const EMPTY_SLOT: Slot = HEAD_BLOCK_SLOT - 1; + + /// How far back the fixture chains are recorded: the ring holds an entry + /// per slot from there up, and zeros below. A real ring holds all + /// `SLOTS_PER_HISTORICAL_ROOT` slots below the state's own, whose wrap + /// arithmetic belongs to — and is tested with — `RootsView::proposed_at`. + const RECORDED_SLOTS: Slot = 64; + + /// A chain young enough that its first slot is still recorded, and too + /// young to have finalized anything. + const YOUNG_STATE_SLOT: Slot = 11; + + /// Roots keyed by the slot they belong to and by what they are, so a body + /// that mixes two of them up says which. + fn block_root_of(slot: Slot) -> B256 { + tagged(0xb0, slot) + } + + fn state_root_of(slot: Slot) -> B256 { + tagged(0x57, slot) + } + + fn body_root_of(slot: Slot) -> B256 { + tagged(0xd0, slot) + } + + /// A block on another branch, at the head block's own slot. Fork choice + /// heads on one of these after a late or equivocating proposal. + fn sibling_root_of(slot: Slot) -> B256 { + tagged(0xb1, slot) + } + + fn tagged(kind: u8, slot: Slot) -> B256 { + let mut root = [kind; 32]; + root[24..].copy_from_slice(&slot.to_be_bytes()); + root + } + + /// Distinct from the slot it proposed at, so a body that reports one for + /// the other says which. + fn proposer_of(slot: Slot) -> u64 { + slot + 11 + } + + fn root_text(root: B256) -> String { + format!("0x{}", hex::encode(root)) + } + + fn epoch_base(finalized: Checkpoint) -> EpochStateFinalized { + EpochStateFinalized::from_state(EpochState { + finalized_checkpoint: finalized, + ..Default::default() + }) + } + + /// A published head state, grown a slot at a time the way the tile grows + /// one. Every slot carries a block except those in `empty_slots`. The + /// walk stops with the state at `state_slot`. No slot has followed a + /// block at that slot. The node's cached status names the newest block, + /// because fork choice heads on it. + fn published_chain(finalized: Checkpoint, empty_slots: &[Slot], state_slot: Slot) -> ApiCtx { + let base_slot = state_slot.saturating_sub(RECORDED_SLOTS); + let mut owner = BeaconStateOwner::new(BeaconState::for_test( + epoch_base(finalized), + &[ValSeed::default()], + base_slot, + )); + let anchor = owner.roll_fresh(); + let (mut writer, _, _) = owner.apply_block_view(anchor); + let mut head = ChainHead { root: ZERO_ROOT, slot: base_slot }; + for slot in base_slot..=state_slot { + if !empty_slots.contains(&slot) { + writer.slot.state_mut().latest_block_header = BeaconBlockHeader { + slot, + proposer_index: proposer_of(slot), + parent_root: head.root, + state_root: ZERO_ROOT, + body_root: body_root_of(slot), + }; + head = ChainHead { root: block_root_of(slot), slot }; + } + if slot == state_slot { + break; + } + writer.slot.fill_latest_block_header_state_root(state_root_of(slot)); + let latest_block = writer.slot.state().latest_block_header.slot; + let bucket = (slot % SLOTS_PER_HISTORICAL_ROOT as u64) as u32; + writer.block_roots.set(bucket, block_root_of(latest_block)); + writer.slot.advance_slot(); + } + let published = writer.commit(None, None); + owner.publish_state_id(published); + + let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); + ctx.node_status = synced_status(head.slot, head.root); + ctx + } + + /// The same published state, with fork choice heading on another block. + fn heading_elsewhere(mut ctx: ApiCtx, head: ChainHead) -> ApiCtx { + ctx.node_status.slots = Some(SlotStatus { head, ..ctx.node_status.slots.unwrap() }); + ctx + } + + fn finalized_checkpoint() -> Checkpoint { + Checkpoint { epoch: FINALIZED_EPOCH, root: block_root_of(FINALIZED_SLOT) } + } + + /// The ordinary state: the last block is a slot behind, so its own + /// `state_root` is in and the state carries its whole header. + fn head_ctx() -> ApiCtx { + published_chain(finalized_checkpoint(), &[EMPTY_SLOT, STATE_SLOT], STATE_SLOT) + } + + /// The state between a block's arrival and the next slot: the head block + /// is the state's own, at [`STATE_SLOT`], with `state_root` still zero. + fn just_applied_ctx() -> ApiCtx { + published_chain(finalized_checkpoint(), &[EMPTY_SLOT], STATE_SLOT) + } + + fn young_ctx() -> ApiCtx { + published_chain(Checkpoint::default(), &[YOUNG_STATE_SLOT], YOUNG_STATE_SLOT) + } + + fn root_path(block_id: &str) -> String { + format!("/eth/v1/beacon/blocks/{block_id}/root") + } + + fn header_path(block_id: &str) -> String { + format!("/eth/v1/beacon/headers/{block_id}") + } + + fn paths(block_id: &str) -> [String; 2] { + [root_path(block_id), header_path(block_id)] + } + + fn get(ctx: &ApiCtx, path: &str) -> Vec { + let req = ParsedRequest { + method: "GET", + path, + query: "", + body: b"", + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + }; + let mut out = Vec::new(); + Router::new(ROUTES).dispatch(&req, ctx, &mut out); + out + } + + fn body(response: &[u8]) -> &[u8] { + let text = std::str::from_utf8(response).unwrap(); + &response[text.find("\r\n\r\n").unwrap() + 4..] + } + + fn ok_body(response: &[u8]) -> String { + assert!( + response.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{}", + String::from_utf8_lossy(response) + ); + String::from_utf8(body(response).to_vec()).unwrap() + } + + fn parsed(ctx: &ApiCtx, path: &str) -> serde_json::Value { + serde_json::from_str(&ok_body(&get(ctx, path))).expect("valid JSON") + } + + fn served_root(ctx: &ApiCtx, block_id: &str) -> String { + parsed(ctx, &root_path(block_id))["data"]["root"].as_str().unwrap().to_owned() + } + + fn assert_error(response: &[u8], status: &str, code: u16, message: &str) { + assert!( + response.starts_with(format!("HTTP/1.1 {status}\r\n").as_bytes()), + "{}", + String::from_utf8_lossy(response) + ); + assert_eq!( + std::str::from_utf8(body(response)).unwrap(), + format!("{{\"code\":{code},\"message\":\"{message}\"}}") + ); + } + + fn assert_not_found(ctx: &ApiCtx, block_id: &str) { + for path in paths(block_id) { + assert_error(&get(ctx, &path), "404 Not Found", 404, NOT_FOUND); + } + } + + fn assert_no_header(ctx: &ApiCtx, block_id: &str) { + assert_error(&get(ctx, &header_path(block_id)), "404 Not Found", 404, NOT_FOUND); + } + + /// Body shape: `GetBlockRootResponse` of `apis/beacon/blocks/root.yaml` — + /// both envelope flags, then a `data` object of the one root. + #[test] + fn block_root_body_is_the_envelope_around_one_root() { + assert_eq!( + ok_body(&get(&head_ctx(), &root_path("head"))), + format!( + "{{\"execution_optimistic\":false,\"finalized\":false,\ + \"data\":{{\"root\":\"{root}\"}}}}", + root = root_text(block_root_of(HEAD_BLOCK_SLOT)), + ) + ); + } + + /// Body shape: `GetBlockHeaderResponse` of `apis/beacon/blocks/header.yaml` + /// — root, canonical and a `Phase0.SignedBeaconBlockHeader`, whose five + /// message fields are quoted decimals and lowercase `0x`-hex. + #[test] + fn block_header_body_is_the_envelope_around_the_signed_header() { + let expected = format!( + "{{\"execution_optimistic\":false,\"finalized\":false,\"data\":{{\ + \"root\":\"{root}\",\"canonical\":true,\"header\":{{\"message\":{{\ + \"slot\":\"{HEAD_BLOCK_SLOT}\",\"proposer_index\":\"{proposer}\",\ + \"parent_root\":\"{parent}\",\"state_root\":\"{state}\",\"body_root\":\"{body}\"}},\ + \"signature\":\"{signature}\"}}}}}}", + root = root_text(block_root_of(HEAD_BLOCK_SLOT)), + proposer = proposer_of(HEAD_BLOCK_SLOT), + parent = root_text(block_root_of(EMPTY_SLOT - 1)), + state = root_text(state_root_of(HEAD_BLOCK_SLOT)), + body = root_text(body_root_of(HEAD_BLOCK_SLOT)), + signature = format!("0x{}", hex::encode(UNAVAILABLE_SIGNATURE)), + ); + assert_eq!(ok_body(&get(&head_ctx(), &header_path("head"))), expected); + serde_json::from_str::(&expected).expect("valid JSON"); + } + + /// The head block answers to all three of its names, and to the same root + /// under each — the header endpoint serves whichever of them resolves to + /// the block whose header the state carries. + #[test] + fn the_head_block_answers_to_head_to_its_slot_and_to_its_root() { + let ctx = head_ctx(); + let root = root_text(block_root_of(HEAD_BLOCK_SLOT)); + for block_id in ["head", &HEAD_BLOCK_SLOT.to_string(), &root] { + assert_eq!(served_root(&ctx, block_id), root, "{block_id}"); + assert_eq!(parsed(&ctx, &header_path(block_id))["data"]["root"], root, "{block_id}"); + } + } + + /// A slot older than the head names its own block — the ring holds the + /// roots, and only the roots: the header endpoint has no header for it. + #[test] + fn an_older_slot_names_its_block_by_root_alone() { + let ctx = head_ctx(); + let older = EMPTY_SLOT - 1; + assert_eq!(served_root(&ctx, &older.to_string()), root_text(block_root_of(older))); + assert_no_header(&ctx, &older.to_string()); + } + + /// `process_slot` repeats the last block's root through a slot that + /// carried none, so the ring holding a root for a slot is not the same as + /// a block being there. + #[test] + fn a_slot_that_carried_no_block_is_not_found() { + assert_not_found(&head_ctx(), &EMPTY_SLOT.to_string()); + } + + /// Past the head block there is nothing to name: the state's own slot is + /// where the next block will go, and the ring stops below it. + #[test] + fn a_slot_past_the_head_block_is_not_found() { + let ctx = head_ctx(); + for slot in [STATE_SLOT, STATE_SLOT + 1, u64::MAX] { + assert_not_found(&ctx, &slot.to_string()); + } + } + + /// `genesis` is the first slot's block under another name, so the two + /// forms cannot drift: both answer while the chain's first slot is still + /// recorded, and neither does once it has fallen out. + #[test] + fn genesis_answers_exactly_as_the_first_slot_does() { + let young = young_ctx(); + assert_eq!(served_root(&young, "genesis"), root_text(block_root_of(0))); + assert_eq!(served_root(&young, "0"), served_root(&young, "genesis")); + assert_eq!(parsed(&young, &root_path("genesis"))["finalized"], true); + assert_not_found(&head_ctx(), "genesis"); + assert_not_found(&head_ctx(), "0"); + } + + /// `finalized` is the checkpoint's own root, and the block it names + /// answers to its slot as well. + #[test] + fn finalized_names_the_block_the_checkpoint_points_at() { + let ctx = head_ctx(); + let root = root_text(block_root_of(FINALIZED_SLOT)); + assert_eq!(served_root(&ctx, "finalized"), root); + assert_eq!(served_root(&ctx, &FINALIZED_SLOT.to_string()), root); + assert_eq!(parsed(&ctx, &root_path("finalized"))["finalized"], true); + assert_no_header(&ctx, "finalized"); + } + + /// The zero root a chain carries until its first finalization names no + /// block, and answering with it would name the wrong one. + #[test] + fn finalized_before_the_first_finalization_is_not_found() { + assert_not_found(&young_ctx(), "finalized"); + } + + /// This crate can confirm two roots: fork choice's head, and the block + /// the state came from. They are the same block here. This crate has no + /// read path to any other block. + #[test] + fn a_root_is_answered_only_for_the_head_block() { + let ctx = head_ctx(); + assert_eq!( + served_root(&ctx, &root_text(block_root_of(HEAD_BLOCK_SLOT))), + root_text(block_root_of(HEAD_BLOCK_SLOT)) + ); + for block_id in [ + root_text(block_root_of(EMPTY_SLOT - 1)), + root_text(block_root_of(FINALIZED_SLOT)), + root_text([0xab; 32]), + ] { + assert_not_found(&ctx, &block_id); + } + } + + /// This pair of endpoints exists for one window. Between a block's + /// arrival and the next slot, the state cannot name that block. + /// `latest_block_header` still carries the zero `state_root`. No ring + /// entry names the block either. The node's cached status names it + /// anyway, under all three of its names. The header is what waits. It + /// becomes the block's only when the next `process_slot` fills it. + #[test] + fn the_head_is_named_before_its_own_state_can_name_it() { + let ctx = just_applied_ctx(); + let root = root_text(block_root_of(STATE_SLOT)); + for block_id in ["head", &STATE_SLOT.to_string(), &root] { + assert_eq!(served_root(&ctx, block_id), root, "{block_id}"); + assert_no_header(&ctx, block_id); + } + assert_eq!( + served_root(&ctx, &HEAD_BLOCK_SLOT.to_string()), + root_text(block_root_of(HEAD_BLOCK_SLOT)), + "the block before it is still recorded" + ); + } + + /// A served header is always a complete one. The `state_root` it carries + /// is its own block's post-state. Filling that field is what lets the ring + /// name that block. + #[test] + fn a_served_header_carries_the_state_root_of_its_own_post_state() { + let header = &parsed(&head_ctx(), &header_path("head"))["data"]["header"]["message"]; + assert_eq!(header["state_root"], root_text(state_root_of(HEAD_BLOCK_SLOT))); + assert_ne!(header["state_root"], root_text(ZERO_ROOT)); + } + + /// Each header goes out under the root of the block it belongs to, and no + /// other. Fork choice's head is not always the block the state came from. + /// The state carries that one block's header alone. A head naming another + /// block answers by root, with no header beside it. The state's own block + /// answers under its own root, header and all. + #[test] + fn a_header_is_never_served_beside_another_block_s_root() { + let elsewhere = ChainHead { root: sibling_root_of(HEAD_BLOCK_SLOT), slot: HEAD_BLOCK_SLOT }; + let ctx = heading_elsewhere(head_ctx(), elsewhere); + + assert_eq!(served_root(&ctx, "head"), root_text(elsewhere.root)); + assert_no_header(&ctx, "head"); + assert_no_header(&ctx, &root_text(elsewhere.root)); + + let own = root_text(block_root_of(HEAD_BLOCK_SLOT)); + assert_eq!(served_root(&ctx, &own), own); + let served = &parsed(&ctx, &header_path(&own))["data"]; + assert_eq!(served["root"], own); + assert_eq!(served["canonical"], false, "fork choice has passed this branch over"); + assert_eq!( + served_root(&ctx, &HEAD_BLOCK_SLOT.to_string()), + own, + "the ring names the state's own block, whatever fork choice heads on" + ); + } + + /// `finalized` describes the block served, not the state read: the same + /// state answers both ways either side of the finalized checkpoint's slot. + #[test] + fn the_finalized_flag_follows_the_block_not_the_state() { + let ctx = head_ctx(); + for (slot, want) in + [(FINALIZED_SLOT - 1, true), (FINALIZED_SLOT, true), (FINALIZED_SLOT + 1, false)] + { + assert_eq!(parsed(&ctx, &root_path(&slot.to_string()))["finalized"], want, "{slot}"); + } + assert_eq!(parsed(&ctx, &root_path("head"))["finalized"], false); + assert_eq!(parsed(&ctx, &header_path("head"))["finalized"], false); + } + + /// The same flag the state reads carry: the head's own execution status. + #[test] + fn execution_optimistic_is_the_head_s_own_status() { + let mut ctx = head_ctx(); + for optimistic in [true, false] { + ctx.node_status.slots = + Some(SlotStatus { head_optimistic: optimistic, ..ctx.node_status.slots.unwrap() }); + for path in paths("head") { + assert_eq!(parsed(&ctx, &path)["execution_optimistic"], optimistic, "{path}"); + } + } + } + + /// `Invalid block ID` in both schemas: a value that identifies no block at + /// all is a 400, where a block silver cannot name is a 404. `justified` is + /// a `state_id` and no part of this vocabulary. + #[test] + fn a_block_id_naming_no_block_at_all_is_400() { + let ctx = head_ctx(); + let short_root = format!("0x{}", "ab".repeat(31)); + let unhex_root = format!("0x{}", "zz".repeat(32)); + for block_id in [ + "justified", + "current", + "banana", + "", + "-1", + "+5", + "0x", + "1.5", + &short_root, + &unhex_root, + "HEAD", + ] { + for path in paths(block_id) { + assert_error(&get(&ctx, &path), "400 Bad Request", 400, "invalid block_id"); + } + } + } + + /// A node can publish a state and still hear no status. That window runs + /// from bootstrap to the first imported block. A node that finds no peer + /// never leaves it. Such a node still has a head to name: the block its + /// own state came from. Fork choice can only have headed on that block. + /// Nimbus treats a 404 here as an incompatible beacon node, and deselects + /// it. + #[test] + fn the_state_s_own_block_is_the_head_until_the_first_status() { + let mut ctx = head_ctx(); + ctx.node_status = NodeStatus::default(); + let root = root_text(block_root_of(HEAD_BLOCK_SLOT)); + + for block_id in ["head", &HEAD_BLOCK_SLOT.to_string(), &root] { + assert_eq!(served_root(&ctx, block_id), root, "{block_id}"); + let served = &parsed(&ctx, &header_path(block_id))["data"]; + assert_eq!(served["root"], root, "{block_id}"); + assert_eq!(served["canonical"], true, "{block_id}"); + } + } + + /// A request naming a slot gets a block of that slot, or nothing. The + /// head stands in for the one block the ring cannot name. It stands in + /// only at the slot the head sits at. So `head` and the head's own slot + /// never disagree. + #[test] + fn a_slot_is_never_answered_with_a_block_of_another_slot() { + let behind = ChainHead { root: block_root_of(HEAD_BLOCK_SLOT), slot: HEAD_BLOCK_SLOT }; + let ctx = heading_elsewhere(just_applied_ctx(), behind); + assert_eq!(served_root(&ctx, &HEAD_BLOCK_SLOT.to_string()), root_text(behind.root)); + assert_not_found(&ctx, &STATE_SLOT.to_string()); + + let ahead = ChainHead { root: sibling_root_of(STATE_SLOT), slot: STATE_SLOT }; + let ctx = heading_elsewhere(head_ctx(), ahead); + assert_eq!(served_root(&ctx, "head"), root_text(ahead.root)); + assert_eq!( + served_root(&ctx, &STATE_SLOT.to_string()), + root_text(ahead.root), + "a head the published state has not caught up to answers under its own slot too" + ); + } + + /// Neither schema declares a 503, so a node with no state published + /// answers the only way it can — and the `block_id` verdict does not wait + /// on a state to read. + #[test] + fn reads_are_404_before_bootstrap_and_400_stays_400() { + let ctx = preboot_ctx(); + for block_id in ["head", "genesis", "finalized", "9000", &root_text([0xab; 32])] { + assert_not_found(&ctx, block_id); + } + for path in paths("banana") { + assert_error(&get(&ctx, &path), "400 Bad Request", 400, "invalid block_id"); + } + } +} diff --git a/crates/beacon_api/src/config.rs b/crates/beacon_api/src/config.rs new file mode 100644 index 00000000..36f9d3d6 --- /dev/null +++ b/crates/beacon_api/src/config.rs @@ -0,0 +1,734 @@ +//! Every value the spec calls a preset is a constant here, because silver +//! runs the mainnet preset only; every config-file key a network can vary +//! comes from [`SpecConfig`] — including the genesis, merge and eth1 +//! parameters silver itself never reads. What is left are the fork-choice and +//! networking parameters silver fixes in its own tiles. + +use silver_beacon_state_data::{ + BYTES_PER_LOGS_BLOOM, EFFECTIVE_BALANCE_INCREMENT, EPOCHS_PER_HISTORICAL_VECTOR, + EPOCHS_PER_SLASHINGS_VECTOR, EPOCHS_PER_SYNC_COMMITTEE_PERIOD, FAR_FUTURE_EPOCH, Fork, + ForkName, HISTORICAL_ROOTS_LIMIT, MAX_EXTRA_DATA_BYTES, MIN_SEED_LOOKAHEAD, + PENDING_CONSOLIDATIONS_LIMIT, PENDING_DEPOSITS_LIMIT, PENDING_PARTIAL_WITHDRAWALS_LIMIT, + SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT, SYNC_COMMITTEE_SIZE, SpecConfig, + VALIDATOR_REGISTRY_LIMIT, +}; +use silver_common::{ + EPOCHS_PER_SUBNET_SUBSCRIPTION, NUMBER_OF_CUSTODY_GROUPS, SAMPLES_PER_SLOT, SUBNETS_PER_NODE, + ssz_view::{ + MAX_BLOB_COMMITMENTS_PER_BLOCK, MAX_COMMITTEES_PER_SLOT, MAX_PAYLOAD_SIZE, + MAX_REQUEST_BLOCKS_DENEB, MAX_VALIDATORS_PER_COMMITTEE, NUMBER_OF_COLUMNS, + }, +}; + +use crate::json::Json; + +const PRESET_BASE: &str = "mainnet"; + +/// `presets/mainnet/*.yaml` (consensus-specs v1.6.0), in fork order. Values +/// silver itself computes with are imported rather than respelled. +const PRESET: &[(&str, u64)] = &[ + // phase0.yaml + ("MAX_COMMITTEES_PER_SLOT", MAX_COMMITTEES_PER_SLOT as u64), + ("TARGET_COMMITTEE_SIZE", 128), + ("MAX_VALIDATORS_PER_COMMITTEE", MAX_VALIDATORS_PER_COMMITTEE as u64), + ("SHUFFLE_ROUND_COUNT", 90), + ("HYSTERESIS_QUOTIENT", 4), + ("HYSTERESIS_DOWNWARD_MULTIPLIER", 1), + ("HYSTERESIS_UPWARD_MULTIPLIER", 5), + ("MIN_DEPOSIT_AMOUNT", 1_000_000_000), + ("MAX_EFFECTIVE_BALANCE", 32_000_000_000), + ("EFFECTIVE_BALANCE_INCREMENT", EFFECTIVE_BALANCE_INCREMENT), + ("MIN_ATTESTATION_INCLUSION_DELAY", 1), + ("SLOTS_PER_EPOCH", SLOTS_PER_EPOCH), + ("MIN_SEED_LOOKAHEAD", MIN_SEED_LOOKAHEAD), + ("EPOCHS_PER_ETH1_VOTING_PERIOD", 64), + ("SLOTS_PER_HISTORICAL_ROOT", SLOTS_PER_HISTORICAL_ROOT as u64), + ("EPOCHS_PER_HISTORICAL_VECTOR", EPOCHS_PER_HISTORICAL_VECTOR as u64), + ("EPOCHS_PER_SLASHINGS_VECTOR", EPOCHS_PER_SLASHINGS_VECTOR as u64), + ("HISTORICAL_ROOTS_LIMIT", HISTORICAL_ROOTS_LIMIT as u64), + ("VALIDATOR_REGISTRY_LIMIT", VALIDATOR_REGISTRY_LIMIT as u64), + ("BASE_REWARD_FACTOR", 64), + ("WHISTLEBLOWER_REWARD_QUOTIENT", 512), + ("PROPOSER_REWARD_QUOTIENT", 8), + ("INACTIVITY_PENALTY_QUOTIENT", 67_108_864), + ("MIN_SLASHING_PENALTY_QUOTIENT", 128), + ("PROPORTIONAL_SLASHING_MULTIPLIER", 1), + ("MAX_PROPOSER_SLASHINGS", 16), + ("MAX_ATTESTER_SLASHINGS", 2), + ("MAX_ATTESTATIONS", 128), + ("MAX_DEPOSITS", 16), + ("MAX_VOLUNTARY_EXITS", 16), + // altair.yaml + ("INACTIVITY_PENALTY_QUOTIENT_ALTAIR", 50_331_648), + ("MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR", 64), + ("PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR", 2), + ("SYNC_COMMITTEE_SIZE", SYNC_COMMITTEE_SIZE as u64), + ("EPOCHS_PER_SYNC_COMMITTEE_PERIOD", EPOCHS_PER_SYNC_COMMITTEE_PERIOD), + ("MIN_SYNC_COMMITTEE_PARTICIPANTS", 1), + ("UPDATE_TIMEOUT", 8192), + // bellatrix.yaml + ("MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX", 32), + ("MAX_BYTES_PER_TRANSACTION", 1_073_741_824), + ("MAX_TRANSACTIONS_PER_PAYLOAD", 1_048_576), + ("BYTES_PER_LOGS_BLOOM", BYTES_PER_LOGS_BLOOM as u64), + ("MAX_EXTRA_DATA_BYTES", MAX_EXTRA_DATA_BYTES as u64), + // capella.yaml + ("MAX_BLS_TO_EXECUTION_CHANGES", 16), + ("MAX_WITHDRAWALS_PER_PAYLOAD", 16), + ("MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP", 16_384), + // deneb.yaml + ("FIELD_ELEMENTS_PER_BLOB", 4096), + ("MAX_BLOB_COMMITMENTS_PER_BLOCK", MAX_BLOB_COMMITMENTS_PER_BLOCK as u64), + ("KZG_COMMITMENT_INCLUSION_PROOF_DEPTH", 17), + // electra.yaml + ("MIN_ACTIVATION_BALANCE", 32_000_000_000), + ("MAX_EFFECTIVE_BALANCE_ELECTRA", 2_048_000_000_000), + ("WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA", 4096), + ("PENDING_DEPOSITS_LIMIT", PENDING_DEPOSITS_LIMIT as u64), + ("PENDING_PARTIAL_WITHDRAWALS_LIMIT", PENDING_PARTIAL_WITHDRAWALS_LIMIT as u64), + ("PENDING_CONSOLIDATIONS_LIMIT", PENDING_CONSOLIDATIONS_LIMIT as u64), + ("MAX_ATTESTER_SLASHINGS_ELECTRA", 1), + ("MAX_ATTESTATIONS_ELECTRA", 8), + ("MAX_DEPOSIT_REQUESTS_PER_PAYLOAD", 8192), + ("MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD", 16), + ("MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD", 2), + ("MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP", 8), + ("MAX_PENDING_DEPOSITS_PER_EPOCH", 16), + // fulu.yaml + ("FIELD_ELEMENTS_PER_CELL", 64), + ("FIELD_ELEMENTS_PER_EXT_BLOB", 8192), + ("KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH", 4), + ("CELLS_PER_EXT_BLOB", 128), + ("NUMBER_OF_COLUMNS", NUMBER_OF_COLUMNS as u64), +]; + +/// Spec constants — the values no config file carries because no network may +/// change them. A validator client reads its aggregator thresholds and +/// subnet counts from here. +const CONSTANTS: &[(&str, u64)] = &[ + ("GENESIS_SLOT", 0), + ("FAR_FUTURE_EPOCH", FAR_FUTURE_EPOCH), + ("BASE_REWARDS_PER_EPOCH", 4), + ("DEPOSIT_CONTRACT_TREE_DEPTH", 32), + ("JUSTIFICATION_BITS_LENGTH", 4), + ("TARGET_AGGREGATORS_PER_COMMITTEE", 16), + ("TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE", 16), + ("SYNC_COMMITTEE_SUBNET_COUNT", 4), + ("TIMELY_SOURCE_FLAG_INDEX", 0), + ("TIMELY_TARGET_FLAG_INDEX", 1), + ("TIMELY_HEAD_FLAG_INDEX", 2), + ("TIMELY_SOURCE_WEIGHT", 14), + ("TIMELY_TARGET_WEIGHT", 26), + ("TIMELY_HEAD_WEIGHT", 14), + ("SYNC_REWARD_WEIGHT", 2), + ("PROPOSER_WEIGHT", 8), + ("WEIGHT_DENOMINATOR", 64), + ("UNSET_DEPOSIT_REQUESTS_START_INDEX", FAR_FUTURE_EPOCH), + ("FULL_EXIT_REQUEST_AMOUNT", 0), +]; + +/// Four-byte constants: the signing-domain types a validator client mixes +/// into its own domains, and the two gossip message-id domains. +const BYTES4_CONSTANTS: &[(&str, [u8; 4])] = &[ + ("DOMAIN_BEACON_PROPOSER", [0x00, 0x00, 0x00, 0x00]), + ("DOMAIN_BEACON_ATTESTER", [0x01, 0x00, 0x00, 0x00]), + ("DOMAIN_RANDAO", [0x02, 0x00, 0x00, 0x00]), + ("DOMAIN_DEPOSIT", [0x03, 0x00, 0x00, 0x00]), + ("DOMAIN_VOLUNTARY_EXIT", [0x04, 0x00, 0x00, 0x00]), + ("DOMAIN_SELECTION_PROOF", [0x05, 0x00, 0x00, 0x00]), + ("DOMAIN_AGGREGATE_AND_PROOF", [0x06, 0x00, 0x00, 0x00]), + ("DOMAIN_SYNC_COMMITTEE", [0x07, 0x00, 0x00, 0x00]), + ("DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF", [0x08, 0x00, 0x00, 0x00]), + ("DOMAIN_CONTRIBUTION_AND_PROOF", [0x09, 0x00, 0x00, 0x00]), + ("DOMAIN_BLS_TO_EXECUTION_CHANGE", [0x0a, 0x00, 0x00, 0x00]), + ("DOMAIN_APPLICATION_BUILDER", [0x00, 0x00, 0x00, 0x01]), + ("DOMAIN_PTC_ATTESTER", [0x0c, 0x00, 0x00, 0x00]), + ("MESSAGE_DOMAIN_INVALID_SNAPPY", [0x00, 0x00, 0x00, 0x00]), + ("MESSAGE_DOMAIN_VALID_SNAPPY", [0x01, 0x00, 0x00, 0x00]), +]; + +/// One-byte withdrawal-credential prefixes. +const BYTE_CONSTANTS: &[(&str, [u8; 1])] = &[ + ("BLS_WITHDRAWAL_PREFIX", [0x00]), + ("ETH1_ADDRESS_WITHDRAWAL_PREFIX", [0x01]), + ("COMPOUNDING_WITHDRAWAL_PREFIX", [0x02]), +]; + +/// Config keys a fork retired. Silver keeps one scalar per quantity and +/// serves it under the successor's name from [`configured`]; the retired +/// spelling is frozen at the value it had, since no network silver can join +/// is on the wrong side of the fork that replaced it. +const SUPERSEDED_CONFIG: &[(&str, u64)] = &[ + ("MIN_PER_EPOCH_CHURN_LIMIT", 4), + ("MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT", 8), + ("MAX_BLOBS_PER_BLOCK", 6), +]; + +/// Fork-choice, slot-timing and networking parameters. No config file silver +/// has seen varies them, so the ones silver acts on live as constants of the +/// tile that acts on them (`SlotTicker`'s attesting-interval divisors are the +/// `*_DUE_BPS` deadlines) and the rest are published for clients only. +const NETWORK_CONFIG: &[(&str, u64)] = &[ + ("PROPOSER_SCORE_BOOST", 40), + ("REORG_HEAD_WEIGHT_THRESHOLD", 20), + ("REORG_PARENT_WEIGHT_THRESHOLD", 160), + ("REORG_MAX_EPOCHS_SINCE_FINALIZATION", 2), + ("PROPOSER_REORG_CUTOFF_BPS", 1667), + ("ATTESTATION_DUE_BPS", 3333), + ("AGGREGATE_DUE_BPS", 6667), + ("SYNC_MESSAGE_DUE_BPS", 3333), + ("CONTRIBUTION_DUE_BPS", 6667), + ("ATTESTATION_DUE_BPS_GLOAS", 2500), + ("AGGREGATE_DUE_BPS_GLOAS", 5000), + ("SYNC_MESSAGE_DUE_BPS_GLOAS", 2500), + ("CONTRIBUTION_DUE_BPS_GLOAS", 5000), + ("PAYLOAD_ATTESTATION_DUE_BPS", 7500), + ("VIEW_FREEZE_CUTOFF_BPS", 7500), + ("INCLUSION_LIST_SUBMISSION_DUE_BPS", 6667), + ("PROPOSER_INCLUSION_LIST_CUTOFF_BPS", 9167), + ("MAX_PAYLOAD_SIZE", MAX_PAYLOAD_SIZE as u64), + ("MAX_REQUEST_BLOCKS", 1024), + ("MAX_REQUEST_BLOCKS_DENEB", MAX_REQUEST_BLOCKS_DENEB as u64), + ("EPOCHS_PER_SUBNET_SUBSCRIPTION", EPOCHS_PER_SUBNET_SUBSCRIPTION), + ("MIN_EPOCHS_FOR_BLOCK_REQUESTS", 33_024), + ("ATTESTATION_PROPAGATION_SLOT_RANGE", 32), + ("MAXIMUM_GOSSIP_CLOCK_DISPARITY", 500), + ("SUBNETS_PER_NODE", SUBNETS_PER_NODE as u64), + ("ATTESTATION_SUBNET_COUNT", 64), + ("ATTESTATION_SUBNET_EXTRA_BITS", 0), + ("ATTESTATION_SUBNET_PREFIX_BITS", 6), + ("NUMBER_OF_CUSTODY_GROUPS", NUMBER_OF_CUSTODY_GROUPS as u64), + ("DATA_COLUMN_SIDECAR_SUBNET_COUNT", 128), + ("MAX_REQUEST_DATA_COLUMN_SIDECARS", 16_384), + ("SAMPLES_PER_SLOT", SAMPLES_PER_SLOT as u64), + ("CUSTODY_REQUIREMENT", 4), + ("VALIDATOR_CUSTODY_REQUIREMENT", 8), + ("BALANCE_PER_ADDITIONAL_CUSTODY_GROUP", 32_000_000_000), + ("MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS", 4096), + // gloas.yaml + ("MAX_REQUEST_PAYLOADS", 128), + // EIP7441 + ("EPOCHS_PER_SHUFFLING_PHASE", 256), + ("PROPOSER_SELECTION_GAP", 2), + // EIP7805 + ("MAX_REQUEST_INCLUSION_LIST", 16), + ("MAX_BYTES_PER_INCLUSION_LIST", 8192), +]; + +/// `*_FORK_VERSION` stubs the spec mints for EIPs no fork has scheduled. +/// Literals rather than [`SpecConfig`] fields: a network cannot schedule a +/// fork that does not exist, so every config file carries the same stub +/// version and a `FAR_FUTURE_EPOCH` activation. +const EIP_FORK_STUB_VERSIONS: &[(&str, [u8; 4])] = &[ + ("EIP7441_FORK_VERSION", [0x08, 0x00, 0x00, 0x00]), + ("EIP7805_FORK_VERSION", [0x0a, 0x00, 0x00, 0x00]), + ("EIP7928_FORK_VERSION", [0x0b, 0x00, 0x00, 0x00]), +]; + +const EIP_FORK_STUB_EPOCHS: &[(&str, u64)] = &[ + ("EIP7441_FORK_EPOCH", FAR_FUTURE_EPOCH), + ("EIP7805_FORK_EPOCH", FAR_FUTURE_EPOCH), + ("EIP7928_FORK_EPOCH", FAR_FUTURE_EPOCH), +]; + +/// `GET /eth/v1/config/spec`. +pub(crate) fn spec_body(spec: &SpecConfig) -> Vec { + let mut out = Vec::new(); + let mut json = Json::new(&mut out); + json.begin_object(); + json.key("data"); + json.begin_object(); + + json.key("PRESET_BASE"); + json.string(PRESET_BASE); + json.key("CONFIG_NAME"); + json.string(&spec.network_name()); + + for fork in ForkName::ALL { + json.key(fork_version_key(fork)); + json.hex(&spec.fork_version(fork)); + json.key(fork_epoch_key(fork)); + json.quoted_u64(spec.fork_epoch(fork)); + } + + for (name, value) in configured(spec) { + json.key(name); + json.quoted_u64(value); + } + json.key("DEPOSIT_CONTRACT_ADDRESS"); + json.hex(&spec.deposit_contract_address); + json.key("TERMINAL_TOTAL_DIFFICULTY"); + json.string(&spec.terminal_total_difficulty.to_string()); + json.key("TERMINAL_BLOCK_HASH"); + json.hex(&spec.terminal_block_hash); + + for (name, value) in SUPERSEDED_CONFIG + .iter() + .chain(EIP_FORK_STUB_EPOCHS) + .chain(NETWORK_CONFIG) + .chain(PRESET) + .chain(CONSTANTS) + { + json.key(name); + json.quoted_u64(*value); + } + for (name, bytes) in BYTES4_CONSTANTS.iter().chain(EIP_FORK_STUB_VERSIONS) { + json.key(name); + json.hex(bytes); + } + for (name, bytes) in BYTE_CONSTANTS { + json.key(name); + json.hex(bytes); + } + + json.key("BLOB_SCHEDULE"); + json.begin_array(); + for entry in &spec.blob_schedule { + json.begin_object(); + json.key("EPOCH"); + json.quoted_u64(entry.epoch); + json.key("MAX_BLOBS_PER_BLOCK"); + json.quoted_u64(entry.max_blobs_per_block); + json.end_object(); + } + json.end_array(); + + json.end_object(); + json.end_object(); + out +} + +/// `GET /eth/v1/config/fork_schedule`. Unscheduled forks are omitted: the +/// list is what this node is aware of *scheduling*, and a client that +/// derives a signing domain from the last entry must not land on a fork +/// that will never activate. +pub(crate) fn fork_schedule_body(spec: &SpecConfig) -> Vec { + let mut out = Vec::new(); + let mut json = Json::new(&mut out); + json.begin_object(); + json.key("data"); + json.begin_array(); + let mut previous_version = spec.fork_version(ForkName::Phase0); + for fork in ForkName::ALL { + let epoch = spec.fork_epoch(fork); + if epoch == FAR_FUTURE_EPOCH { + continue; + } + let current_version = spec.fork_version(fork); + json.fork(&Fork { previous_version, current_version, epoch }); + previous_version = current_version; + } + json.end_array(); + json.end_object(); + out +} + +/// `GET /eth/v1/config/deposit_contract`. +pub(crate) fn deposit_contract_body(spec: &SpecConfig) -> Vec { + let mut out = Vec::new(); + let mut json = Json::new(&mut out); + json.begin_object(); + json.key("data"); + json.begin_object(); + json.key("chain_id"); + json.quoted_u64(spec.deposit_chain_id); + json.key("address"); + json.hex(&spec.deposit_contract_address); + json.end_object(); + json.end_object(); + out +} + +/// The `SpecConfig` fields under their spec names. Several carry a fork suffix +/// silver's own scalar does not, because it runs only the latest fork's +/// variant of that quantity; the retired spellings are in +/// [`SUPERSEDED_CONFIG`] and [`PRESET`]. +fn configured(spec: &SpecConfig) -> impl IntoIterator { + [ + ("MIN_GENESIS_ACTIVE_VALIDATOR_COUNT", spec.min_genesis_active_validator_count), + ("MIN_GENESIS_TIME", spec.min_genesis_time), + ("GENESIS_DELAY", spec.genesis_delay), + ("TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH", spec.terminal_block_hash_activation_epoch), + ("SECONDS_PER_SLOT", spec.seconds_per_slot), + // Teku and Nimbus reject a body whose two spellings of the slot length + // disagree, so this is derived rather than a mainnet literal. + ("SLOT_DURATION_MS", spec.seconds_per_slot * 1000), + ("SECONDS_PER_ETH1_BLOCK", spec.seconds_per_eth1_block), + ("ETH1_FOLLOW_DISTANCE", spec.eth1_follow_distance), + ("SHARD_COMMITTEE_PERIOD", spec.shard_committee_period), + ("MIN_VALIDATOR_WITHDRAWABILITY_DELAY", spec.min_validator_withdrawability_delay), + ("MAX_SEED_LOOKAHEAD", spec.max_seed_lookahead), + ("MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA", spec.min_per_epoch_churn_limit), + ( + "MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT", + spec.max_per_epoch_activation_exit_churn_limit, + ), + ("CHURN_LIMIT_QUOTIENT", spec.churn_limit_quotient), + ("CHURN_LIMIT_QUOTIENT_GLOAS", spec.churn_limit_quotient_gloas), + ("CONSOLIDATION_CHURN_LIMIT_QUOTIENT", spec.consolidation_churn_limit_quotient), + ( + "MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS", + spec.max_per_epoch_activation_churn_limit_gloas, + ), + ("INACTIVITY_SCORE_BIAS", spec.inactivity_score_bias), + ("INACTIVITY_SCORE_RECOVERY_RATE", spec.inactivity_score_recovery_rate), + ("INACTIVITY_PENALTY_QUOTIENT_BELLATRIX", spec.inactivity_penalty_quotient), + ("MIN_EPOCHS_TO_INACTIVITY_PENALTY", spec.min_epochs_to_inactivity_penalty), + ("PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX", spec.proportional_slashing_multiplier), + ("MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA", spec.min_slashing_penalty_quotient), + ("EJECTION_BALANCE", spec.ejection_balance), + ("MAX_BLOBS_PER_BLOCK_ELECTRA", spec.max_blobs_per_block_electra), + ("BLOB_SIDECAR_SUBNET_COUNT", spec.blob_sidecar_subnet_count), + ("BLOB_SIDECAR_SUBNET_COUNT_ELECTRA", spec.blob_sidecar_subnet_count_electra), + ("MAX_REQUEST_BLOB_SIDECARS", spec.max_request_blob_sidecars), + ("MAX_REQUEST_BLOB_SIDECARS_ELECTRA", spec.max_request_blob_sidecars_electra), + ("MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS", spec.min_epochs_for_blob_sidecars_requests), + ("DEPOSIT_CHAIN_ID", spec.deposit_chain_id), + ("DEPOSIT_NETWORK_ID", spec.deposit_network_id), + ] +} + +fn fork_version_key(fork: ForkName) -> &'static str { + match fork { + ForkName::Phase0 => "GENESIS_FORK_VERSION", + ForkName::Altair => "ALTAIR_FORK_VERSION", + ForkName::Bellatrix => "BELLATRIX_FORK_VERSION", + ForkName::Capella => "CAPELLA_FORK_VERSION", + ForkName::Deneb => "DENEB_FORK_VERSION", + ForkName::Electra => "ELECTRA_FORK_VERSION", + ForkName::Fulu => "FULU_FORK_VERSION", + ForkName::Gloas => "GLOAS_FORK_VERSION", + } +} + +fn fork_epoch_key(fork: ForkName) -> &'static str { + match fork { + ForkName::Phase0 => "GENESIS_EPOCH", + ForkName::Altair => "ALTAIR_FORK_EPOCH", + ForkName::Bellatrix => "BELLATRIX_FORK_EPOCH", + ForkName::Capella => "CAPELLA_FORK_EPOCH", + ForkName::Deneb => "DENEB_FORK_EPOCH", + ForkName::Electra => "ELECTRA_FORK_EPOCH", + ForkName::Fulu => "FULU_FORK_EPOCH", + ForkName::Gloas => "GLOAS_FORK_EPOCH", + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Map, Value}; + + use super::*; + + fn data(body: &[u8]) -> Value { + serde_json::from_slice::(body).expect("valid JSON")["data"].clone() + } + + fn spec_map(spec: &SpecConfig) -> Map { + data(&spec_body(spec)).as_object().unwrap().clone() + } + + /// The two rules the endpoint's description states: every numeric value + /// is a quoted decimal, every `0x` value a hex string. Hex is lowercase + /// because a client comparing an address against its own config compares + /// strings. + #[test] + fn every_spec_value_is_a_quoted_decimal_or_lowercase_hex_string() { + for (name, value) in spec_map(&SpecConfig::mainnet()) { + if name == "BLOB_SCHEDULE" { + continue; + } + let text = value.as_str().unwrap_or_else(|| panic!("{name} is not a string")); + match text.strip_prefix("0x") { + Some(digits) => { + assert!(!digits.is_empty(), "{name}"); + assert!( + digits.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "{name} = {text}" + ); + assert!(digits.len().is_multiple_of(2), "{name} = {text}"); + } + None if name == "PRESET_BASE" || name == "CONFIG_NAME" => { + assert_eq!(text, "mainnet") + } + None => assert!(text.bytes().all(|b| b.is_ascii_digit()), "{name} = {text}"), + } + } + } + + /// A repeated key is well-formed JSON that silently drops one of the two + /// values, so a parsed body cannot catch it — count the raw text. Only + /// the flat part is searched: `BLOB_SCHEDULE`, written last, repeats + /// `MAX_BLOBS_PER_BLOCK` inside every entry. + #[test] + fn no_key_is_written_twice() { + let body = String::from_utf8(spec_body(&SpecConfig::mainnet())).unwrap(); + let flat = &body[..body.find("\"BLOB_SCHEDULE\":").unwrap()]; + for name in spec_map(&SpecConfig::mainnet()).keys() { + if name == "BLOB_SCHEDULE" { + continue; + } + assert_eq!(flat.matches(&format!("\"{name}\":")).count(), 1, "{name}"); + } + } + + /// Vouch derives signing domains, aggregator thresholds and the sync + /// committee period from this body; Teku and Lighthouse compare the fork + /// versions and deposit contract against their own config. + #[test] + fn the_keys_validator_clients_read_are_all_present() { + let spec = spec_map(&SpecConfig::mainnet()); + for name in [ + "PRESET_BASE", + "SECONDS_PER_SLOT", + "SLOTS_PER_EPOCH", + "SYNC_COMMITTEE_SIZE", + "EPOCHS_PER_SYNC_COMMITTEE_PERIOD", + "SYNC_COMMITTEE_SUBNET_COUNT", + "TARGET_AGGREGATORS_PER_COMMITTEE", + "TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE", + "TARGET_COMMITTEE_SIZE", + "MAX_COMMITTEES_PER_SLOT", + "MAX_VALIDATORS_PER_COMMITTEE", + "MIN_ATTESTATION_INCLUSION_DELAY", + "MAX_EFFECTIVE_BALANCE", + "MIN_ACTIVATION_BALANCE", + "GENESIS_FORK_VERSION", + "ALTAIR_FORK_VERSION", + "FULU_FORK_EPOCH", + "GLOAS_FORK_EPOCH", + "DEPOSIT_CHAIN_ID", + "DEPOSIT_NETWORK_ID", + "DEPOSIT_CONTRACT_ADDRESS", + "DOMAIN_BEACON_PROPOSER", + "DOMAIN_BEACON_ATTESTER", + "DOMAIN_RANDAO", + "DOMAIN_SELECTION_PROOF", + "DOMAIN_AGGREGATE_AND_PROOF", + "DOMAIN_SYNC_COMMITTEE", + "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF", + "DOMAIN_CONTRIBUTION_AND_PROOF", + "DOMAIN_APPLICATION_BUILDER", + "BLS_WITHDRAWAL_PREFIX", + "FAR_FUTURE_EPOCH", + "BLOB_SCHEDULE", + ] { + assert!(spec.contains_key(name), "missing {name}"); + } + } + + /// Values transcribed from `consensus-specs` v1.6.0 + /// `configs/mainnet.yaml`. Hoodi carries no key of its own for any of + /// them, so they are network-invariant literals — except + /// `SLOT_DURATION_MS`, which is derived, because a client that also reads + /// `SECONDS_PER_SLOT` rejects a body where the two disagree. + #[test] + fn the_config_file_keys_silver_serves_as_literals_match_v1_6_0_mainnet() { + let spec = spec_map(&SpecConfig::mainnet()); + for (name, value) in [ + ("SLOT_DURATION_MS", "12000"), + ("PROPOSER_REORG_CUTOFF_BPS", "1667"), + ("ATTESTATION_DUE_BPS", "3333"), + ("AGGREGATE_DUE_BPS", "6667"), + ("SYNC_MESSAGE_DUE_BPS", "3333"), + ("CONTRIBUTION_DUE_BPS", "6667"), + ("ATTESTATION_DUE_BPS_GLOAS", "2500"), + ("AGGREGATE_DUE_BPS_GLOAS", "5000"), + ("SYNC_MESSAGE_DUE_BPS_GLOAS", "2500"), + ("CONTRIBUTION_DUE_BPS_GLOAS", "5000"), + ("PAYLOAD_ATTESTATION_DUE_BPS", "7500"), + ("VIEW_FREEZE_CUTOFF_BPS", "7500"), + ("INCLUSION_LIST_SUBMISSION_DUE_BPS", "6667"), + ("PROPOSER_INCLUSION_LIST_CUTOFF_BPS", "9167"), + ("MAX_REQUEST_PAYLOADS", "128"), + ("EPOCHS_PER_SHUFFLING_PHASE", "256"), + ("PROPOSER_SELECTION_GAP", "2"), + ("MAX_REQUEST_INCLUSION_LIST", "16"), + ("MAX_BYTES_PER_INCLUSION_LIST", "8192"), + ("EIP7441_FORK_VERSION", "0x08000000"), + ("EIP7441_FORK_EPOCH", "18446744073709551615"), + ("EIP7805_FORK_VERSION", "0x0a000000"), + ("EIP7805_FORK_EPOCH", "18446744073709551615"), + ("EIP7928_FORK_VERSION", "0x0b000000"), + ("EIP7928_FORK_EPOCH", "18446744073709551615"), + ("MIN_PER_EPOCH_CHURN_LIMIT", "4"), + ("MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT", "8"), + ("MAX_BLOBS_PER_BLOCK", "6"), + ] { + assert_eq!(spec.get(name).map(Value::as_str), Some(Some(value)), "{name}"); + } + } + + /// `SlotTicker` splits the slot at 1/3 pre-Gloas and 1/4 from Gloas, which + /// is what `ATTESTATION_DUE_BPS` and its Gloas variant name; a client that + /// times its attestations off the served body must not disagree with the + /// node it is attesting through. + #[test] + fn the_attestation_deadlines_served_match_the_ones_silver_ticks_on() { + let spec = spec_map(&SpecConfig::mainnet()); + assert_eq!(spec["ATTESTATION_DUE_BPS"], (10_000 / 3).to_string()); + assert_eq!(spec["ATTESTATION_DUE_BPS_GLOAS"], (10_000 / 4).to_string()); + } + + /// Two spellings of the slot length in one body: a client that reads both + /// aborts unless they agree, and `SECONDS_PER_SLOT` is overridable. + #[test] + fn slot_duration_ms_follows_an_overridden_seconds_per_slot() { + let spec = spec_map(&SpecConfig { seconds_per_slot: 4, ..SpecConfig::mainnet() }); + assert_eq!(spec["SECONDS_PER_SLOT"], "4"); + assert_eq!(spec["SLOT_DURATION_MS"], "4000"); + } + + /// Teku's `--network auto` preloads a builtin base config by + /// `CONFIG_NAME` while signing domains come from the fork versions, so a + /// body contradicting itself hands the client two different networks. + #[test] + fn config_name_is_the_network_the_fork_version_names() { + let sepolia = + SpecConfig { genesis_fork_version: [0x90, 0x00, 0x00, 0x69], ..SpecConfig::mainnet() }; + assert_eq!(spec_map(&sepolia)["CONFIG_NAME"], "sepolia"); + + let devnet = + SpecConfig { genesis_fork_version: [0x10, 0x00, 0x00, 0x38], ..SpecConfig::mainnet() }; + assert_eq!(spec_map(&devnet)["CONFIG_NAME"], devnet.network_name()); + assert_eq!(spec_map(&devnet)["CONFIG_NAME"], "devnet-10000038"); + + let named = SpecConfig { config_name: Some("my-devnet".to_owned()), ..devnet }; + assert_eq!(spec_map(&named)["CONFIG_NAME"], "my-devnet"); + } + + #[test] + fn spec_values_track_the_config_this_node_runs() { + let hoodi = spec_map(&SpecConfig::hoodi()); + assert_eq!(hoodi["CONFIG_NAME"], "hoodi"); + assert_eq!(hoodi["GENESIS_FORK_VERSION"], "0x10000910"); + assert_eq!(hoodi["FULU_FORK_VERSION"], "0x70000910"); + assert_eq!(hoodi["FULU_FORK_EPOCH"], "50688"); + assert_eq!(hoodi["ELECTRA_FORK_EPOCH"], "2048"); + assert_eq!(hoodi["DEPOSIT_CHAIN_ID"], "560048"); + assert_eq!(hoodi["DEPOSIT_NETWORK_ID"], "560048"); + assert_eq!(hoodi["MIN_GENESIS_TIME"], "1742212800"); + assert_eq!(hoodi["GENESIS_DELAY"], "600"); + assert_eq!(hoodi["SECONDS_PER_ETH1_BLOCK"], "12"); + assert_eq!(hoodi["TERMINAL_TOTAL_DIFFICULTY"], "0", "Hoodi merged at genesis"); + assert_eq!(hoodi["BLOB_SCHEDULE"].as_array().unwrap().len(), 2); + assert_eq!(hoodi["BLOB_SCHEDULE"][0]["EPOCH"], "52480"); + assert_eq!(hoodi["BLOB_SCHEDULE"][0]["MAX_BLOBS_PER_BLOCK"], "15"); + assert_eq!(hoodi["BLOB_SCHEDULE"][1]["EPOCH"], "54016"); + assert_eq!(hoodi["BLOB_SCHEDULE"][1]["MAX_BLOBS_PER_BLOCK"], "21"); + + let mainnet = spec_map(&SpecConfig::mainnet()); + assert_eq!(mainnet["CONFIG_NAME"], "mainnet"); + assert_eq!( + mainnet["DEPOSIT_CONTRACT_ADDRESS"], + "0x00000000219ab540356cbb839cbe05303d7705fa" + ); + assert_eq!(mainnet["GLOAS_FORK_EPOCH"], "18446744073709551615", "unscheduled"); + assert_eq!(mainnet["MAX_BLOBS_PER_BLOCK_ELECTRA"], "9"); + assert_eq!(mainnet["MIN_GENESIS_TIME"], "1606824000"); + assert_eq!(mainnet["GENESIS_DELAY"], "604800"); + assert_eq!(mainnet["SECONDS_PER_ETH1_BLOCK"], "14"); + assert_eq!(mainnet["TERMINAL_TOTAL_DIFFICULTY"], "58750000000000000000000"); + assert_eq!(mainnet["TERMINAL_BLOCK_HASH"], format!("0x{}", "00".repeat(32))); + assert_eq!(mainnet["BLOB_SCHEDULE"][0]["EPOCH"], "412672"); + assert_eq!(mainnet["BLOB_SCHEDULE"][0]["MAX_BLOBS_PER_BLOCK"], "15"); + assert_eq!(mainnet["BLOB_SCHEDULE"][1]["EPOCH"], "419072"); + } + + /// The fork-suffixed keys carry the scalars silver runs on, so a config + /// file that overrides one has to move the served value with it. Their + /// pre-fork spellings are frozen historical values and must not follow. + #[test] + fn churn_and_penalty_scalars_are_served_under_their_fork_suffixed_names() { + let spec: SpecConfig = toml::from_str( + r#" + MIN_PER_EPOCH_CHURN_LIMIT = 7 + MIN_SLASHING_PENALTY_QUOTIENT = 64 + INACTIVITY_PENALTY_QUOTIENT = 128 + PROPORTIONAL_SLASHING_MULTIPLIER = 5 + "#, + ) + .unwrap(); + let served = spec_map(&spec); + assert_eq!(served["MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA"], "7"); + assert_eq!(served["MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA"], "64"); + assert_eq!(served["INACTIVITY_PENALTY_QUOTIENT_BELLATRIX"], "128"); + assert_eq!(served["PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX"], "5"); + + assert_eq!(served["MIN_PER_EPOCH_CHURN_LIMIT"], "4"); + assert_eq!(served["MIN_SLASHING_PENALTY_QUOTIENT"], "128"); + assert_eq!(served["MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX"], "32"); + assert_eq!(served["INACTIVITY_PENALTY_QUOTIENT"], "67108864"); + assert_eq!(served["INACTIVITY_PENALTY_QUOTIENT_ALTAIR"], "50331648"); + assert_eq!(served["PROPORTIONAL_SLASHING_MULTIPLIER"], "1"); + assert_eq!(served["PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR"], "2"); + } + + #[test] + fn fork_schedule_starts_at_phase0_and_chains_versions() { + let spec = SpecConfig::mainnet(); + let forks = data(&fork_schedule_body(&spec)); + let forks = forks.as_array().unwrap(); + + assert_eq!( + forks[0], + serde_json::json!({ + "previous_version": "0x00000000", + "current_version": "0x00000000", + "epoch": "0", + }) + ); + for pair in forks.windows(2) { + assert_eq!(pair[1]["previous_version"], pair[0]["current_version"]); + } + assert_eq!(forks.last().unwrap()["current_version"], "0x06000000", "fulu is last"); + assert_eq!(forks.last().unwrap()["epoch"], "411392"); + } + + /// Unscheduled forks are omitted: Nimbus polls this list every epoch and + /// Vouch derives signing domains from it, and an entry at + /// `FAR_FUTURE_EPOCH` describes a fork that may never happen. + #[test] + fn fork_schedule_omits_unscheduled_forks_and_lists_scheduled_ones() { + let mainnet = data(&fork_schedule_body(&SpecConfig::mainnet())); + assert_eq!(mainnet.as_array().unwrap().len(), 7, "phase0 through fulu, no gloas"); + assert!( + !mainnet.as_array().unwrap().iter().any(|f| f["epoch"] == "18446744073709551615"), + "no FAR_FUTURE_EPOCH entry" + ); + + let scheduled = SpecConfig { gloas_fork_epoch: 500_000, ..SpecConfig::mainnet() }; + let with_gloas = data(&fork_schedule_body(&scheduled)); + let with_gloas = with_gloas.as_array().unwrap(); + assert_eq!(with_gloas.len(), 8); + assert_eq!( + with_gloas[7], + serde_json::json!({ + "previous_version": "0x06000000", + "current_version": "0x07000000", + "epoch": "500000", + }) + ); + } + + /// Hoodi activates altair through deneb at epoch 0, so five entries + /// share an epoch — the list is by fork, not by epoch. + #[test] + fn fork_schedule_keeps_one_entry_per_fork_when_several_share_an_epoch() { + let forks = data(&fork_schedule_body(&SpecConfig::hoodi())); + let forks = forks.as_array().unwrap(); + assert_eq!(forks.len(), 7); + assert_eq!(forks.iter().filter(|f| f["epoch"] == "0").count(), 5); + assert_eq!(forks[5]["epoch"], "2048"); + assert_eq!(forks[6]["current_version"], "0x70000910"); + } + + #[test] + fn deposit_contract_body_golden() { + assert_eq!( + String::from_utf8(deposit_contract_body(&SpecConfig::mainnet())).unwrap(), + "{\"data\":{\"chain_id\":\"1\",\"address\":\"0x00000000219ab540356cbb839cbe05303d7705fa\"}}" + ); + assert_eq!(data(&deposit_contract_body(&SpecConfig::hoodi()))["chain_id"], "560048"); + } +} diff --git a/crates/beacon_api/src/duties/mod.rs b/crates/beacon_api/src/duties/mod.rs new file mode 100644 index 00000000..11d4527e --- /dev/null +++ b/crates/beacon_api/src/duties/mod.rs @@ -0,0 +1,127 @@ +mod proposer; +mod sync; +#[cfg(test)] +mod tests; + +use silver_beacon_state_data::{ + EPOCHS_PER_SYNC_COMMITTEE_PERIOD, Epoch, PROPOSER_LOOKAHEAD_SIZE, SLOTS_PER_EPOCH, +}; + +pub(crate) use self::{ + proposer::{ProposerDuty, get_proposer_duties, get_proposer_duties_v2}, + sync::{SyncDuty, post_sync_duties}, +}; +use crate::{ids::parse_uint64, response::Response, router::Request, routes::ApiCtx}; + +/// The phrase `CurrentlySyncing` carries in `types/http.yaml`; every duties +/// schema declares that response and none declares a 404. +const CURRENTLY_SYNCING: &str = + "Beacon node is currently syncing and not serving request on that endpoint"; + +/// `proposer_lookahead` is anchored to the state's own epoch and holds this +/// many epochs of slots from that epoch's first. +const LOOKAHEAD_EPOCHS: u64 = PROPOSER_LOOKAHEAD_SIZE as u64 / SLOTS_PER_EPOCH; + +/// `LongtailState` seats the committee serving the head's own period and the +/// one after it. +const SEATED_PERIODS: u64 = 2; + +/// The epoch a request named, beside the epoch the wall clock is in. Duties +/// are read from the head state's window, and an epoch outside it is either +/// one this node will answer once its head arrives or one no node would +/// answer at all — which the wall clock alone separates. +#[derive(Clone, Copy)] +struct RequestedEpoch { + epoch: Epoch, + /// `None` until the first status: nothing to judge an early request by. + wall_epoch: Option, +} + +impl RequestedEpoch { + /// Every duties schema answers 400 "Invalid epoch" for a path parameter + /// that names no epoch at all. + fn parse(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) -> Option { + let named = req.params.get("epoch").expect("{epoch} in the route pattern"); + let Some(epoch) = parse_uint64(named) else { + resp.error(400, "invalid epoch"); + return None; + }; + Some(Self { epoch, wall_epoch: ctx.node_status.wall_epoch() }) + } +} + +/// The unit a duties endpoint schedules by, and the span of them one state +/// answers for. +#[derive(Clone, Copy)] +enum DutyWindow { + ProposerLookahead, + SeatedSyncCommittees, +} + +/// Why the epoch is outside the window the head state answers for. +enum OutOfWindow { + /// The wall clock has not reached it either, so this node answers it once + /// its head does — which is what `CurrentlySyncing` says, where a 400 + /// would blame a request the schemas call valid. + NotYet, + /// No node at the head would answer it: below the window silver keeps no + /// state the duties could come from, and above the wall clock's own + /// window no chain has scheduled them. + Never, +} + +impl DutyWindow { + /// How many units past the head's own the request falls, while the head + /// state answers for it. + fn units_ahead(self, requested: RequestedEpoch, head_epoch: Epoch) -> Result { + let Some(ahead) = self.unit_of(requested.epoch).checked_sub(self.unit_of(head_epoch)) + else { + return Err(OutOfWindow::Never); + }; + if ahead < self.width() { + return Ok(ahead); + } + if requested.wall_epoch.is_none_or(|wall| self.within_reach(requested.epoch, wall)) { + Err(OutOfWindow::NotYet) + } else { + Err(OutOfWindow::Never) + } + } + + fn respond(self, out: OutOfWindow, resp: &mut Response<'_>) { + match out { + OutOfWindow::NotYet => resp.error(503, CURRENTLY_SYNCING), + OutOfWindow::Never => resp.error(400, self.out_of_range()), + } + } + + fn unit_of(self, epoch: Epoch) -> u64 { + match self { + Self::ProposerLookahead => epoch, + Self::SeatedSyncCommittees => epoch / EPOCHS_PER_SYNC_COMMITTEE_PERIOD, + } + } + + fn width(self) -> u64 { + match self { + Self::ProposerLookahead => LOOKAHEAD_EPOCHS, + Self::SeatedSyncCommittees => SEATED_PERIODS, + } + } + + /// Whether the wall clock leaves the epoch within reach: it names a unit + /// this node's head has still to pass, or the one after it — the window a + /// head caught up to the wall clock would answer for. + fn within_reach(self, epoch: Epoch, wall_epoch: Epoch) -> bool { + self.unit_of(epoch) < self.unit_of(wall_epoch).saturating_add(self.width()) + } + + const fn out_of_range(self) -> &'static str { + match self { + Self::ProposerLookahead => "epoch is outside the proposer lookahead", + Self::SeatedSyncCommittees => { + "epoch is outside the current and next sync committee periods" + } + } + } +} diff --git a/crates/beacon_api/src/duties/proposer.rs b/crates/beacon_api/src/duties/proposer.rs new file mode 100644 index 00000000..5d8152c3 --- /dev/null +++ b/crates/beacon_api/src/duties/proposer.rs @@ -0,0 +1,159 @@ +use silver_beacon_state_data::{B256, BLSPubkey, Epoch, SLOTS_PER_EPOCH, Slot, StateReadView}; + +use crate::{ + ChainHead, + duties::{CURRENTLY_SYNCING, DutyWindow, OutOfWindow, RequestedEpoch}, + response::Response, + router::Request, + routes::ApiCtx, +}; + +/// One entry of `GetProposerDutiesResponse.data` (`ProposerDuty` in +/// `types/duty.yaml`), which v1 and v2 share. +pub(crate) struct ProposerDuty { + pub(crate) pubkey: BLSPubkey, + pub(crate) validator_index: u64, + pub(crate) slot: Slot, +} + +pub(crate) fn get_proposer_duties(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + respond_with_proposers(req, ctx, resp, DependentEpoch::Requested); +} + +pub(crate) fn get_proposer_duties_v2(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + respond_with_proposers(req, ctx, resp, DependentEpoch::Preceding); +} + +fn respond_with_proposers( + req: &Request<'_>, + ctx: &ApiCtx, + resp: &mut Response<'_>, + dependent: DependentEpoch, +) { + let Some(requested) = RequestedEpoch::parse(req, ctx, resp) else { + return; + }; + let head = ctx.node_status.head(); + let read = |view: StateReadView<'_>| EpochProposers::read(&view, head, requested, dependent); + let Some(answer) = ctx.read_state_or(resp, 503, CURRENTLY_SYNCING, read) else { + return; + }; + match answer { + Ok(proposers) => { + let execution_optimistic = ctx.node_status.execution_optimistic(); + resp.json_body(|json| { + json.dependent_envelope(&proposers.dependent_root, execution_optimistic, |json| { + json.proposer_duties(&proposers.duties) + }) + }); + } + Err(error) => error.respond(resp), + } +} + +/// Every slot of one epoch and who proposes it, with the root the answer +/// depends on. +struct EpochProposers { + dependent_root: B256, + duties: Vec, +} + +impl EpochProposers { + fn read( + view: &StateReadView<'_>, + head: Option, + requested: RequestedEpoch, + dependent: DependentEpoch, + ) -> Result { + let epochs_ahead = + DutyWindow::ProposerLookahead.units_ahead(requested, view.slot.current_epoch())?; + let dependent_root = + dependent.root(view, head, requested.epoch).ok_or(ProposerError::NoDependentRoot)?; + + let first_slot = requested.epoch * SLOTS_PER_EPOCH; + let first_entry = epochs_ahead * SLOTS_PER_EPOCH; + let duties = (0..SLOTS_PER_EPOCH) + .map(|offset| { + let validator_index = view + .epoch + .proposer_at((first_entry + offset) as usize) + .expect("the window bounds the lookahead index"); + let ix = usize::try_from(validator_index) + .ok() + .filter(|&ix| ix < view.validators.count()) + .ok_or(ProposerError::UnknownProposer)?; + Ok(ProposerDuty { + pubkey: *view.validators.pubkey(ix), + validator_index, + slot: first_slot + offset, + }) + }) + .collect::>()?; + Ok(Self { dependent_root, duties }) + } +} + +/// Whose first slot the answer's `dependent_root` is the entry before. +#[derive(Clone, Copy)] +enum DependentEpoch { + /// The epoch asked about (`apis/validator/duties/proposer.yaml`). + Requested, + /// The epoch before it (`proposer.v2.yaml`), which is where EIP-7917's + /// deterministic lookahead seeded that epoch's proposers — and the reason + /// a v2 exists at all. Silver holds Fulu states and later ones only, so no + /// epoch it answers for had its proposers seeded at a different boundary. + Preceding, +} + +impl DependentEpoch { + /// `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch) - 1)`, + /// or the genesis block root on underflow — the entry slot zero records. + /// A [`Self::Requested`] epoch above the head state's own names a slot + /// that has not happened. The endpoint's head-event rule applies instead. + /// A client matches the answer against `event.block`, unless the head is + /// in the epoch it asked for. So the answer is fork choice's head, the + /// block root on the canonical chain that the rule compares against. + fn root(self, view: &StateReadView<'_>, head: Option, epoch: Epoch) -> Option { + let dependent = match self { + Self::Requested => epoch, + Self::Preceding => epoch.saturating_sub(1), + }; + if dependent > view.slot.current_epoch() { + return head.map(|head| head.root); + } + view.block_roots + .recorded_at((dependent * SLOTS_PER_EPOCH).saturating_sub(1), view.slot.slot_number()) + } +} + +enum ProposerError { + OutOfWindow(OutOfWindow), + /// `block_roots` holds no entry for the slot the dependent root names, + /// which past genesis it always does — only a state still on slot zero + /// depends on a block root the state itself does not carry. An epoch that + /// answers from the head also lands here, until the first status names a + /// head. + NoDependentRoot, + /// The lookahead names a validator this state's own registry does not + /// hold. Nothing a request can send causes it, and leaving the slot out of + /// the answer would hide a proposal duty. + UnknownProposer, +} + +impl From for ProposerError { + fn from(out: OutOfWindow) -> Self { + Self::OutOfWindow(out) + } +} + +impl ProposerError { + fn respond(self, resp: &mut Response<'_>) { + match self { + Self::OutOfWindow(out) => DutyWindow::ProposerLookahead.respond(out, resp), + Self::NoDependentRoot => resp.error(503, CURRENTLY_SYNCING), + Self::UnknownProposer => { + resp.error(500, "proposer lookahead names a validator this state does not hold") + } + } + } +} diff --git a/crates/beacon_api/src/duties/sync.rs b/crates/beacon_api/src/duties/sync.rs new file mode 100644 index 00000000..3e514bcd --- /dev/null +++ b/crates/beacon_api/src/duties/sync.rs @@ -0,0 +1,105 @@ +use silver_beacon_state_data::{ + BLSPubkey, SYNC_COMMITTEE_SIZE, StateReadView, SyncCommittee, ValidatorsView, +}; + +use crate::{ + duties::{CURRENTLY_SYNCING, DutyWindow, OutOfWindow, RequestedEpoch}, + ids::submitted_indices, + response::Response, + router::Request, + routes::ApiCtx, +}; + +/// One entry of `GetSyncCommitteeDutiesResponse.data` (`Altair.SyncDuty`); +/// `committee_positions` is its `validator_sync_committee_indices`, and is +/// never empty — see [`CommitteeSeats::duty_of`]. +pub(crate) struct SyncDuty { + pub(crate) pubkey: BLSPubkey, + pub(crate) validator_index: u64, + pub(crate) committee_positions: Vec, +} + +pub(crate) fn post_sync_duties(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(requested) = RequestedEpoch::parse(req, ctx, resp) else { + return; + }; + let indices = match submitted_indices(req.body) { + Ok(indices) => indices, + Err(message) => { + resp.error(400, message); + return; + } + }; + let read = |view: StateReadView<'_>| { + CommitteeSeats::serving(&view, requested) + .map(|seats| seats.duties(&view.validators, &indices)) + }; + let Some(answer) = ctx.read_state_or(resp, 503, CURRENTLY_SYNCING, read) else { + return; + }; + match answer { + Ok(duties) => { + let execution_optimistic = ctx.node_status.execution_optimistic(); + resp.json_body(|json| { + json.optimistic_envelope(execution_optimistic, |json| json.sync_duties(&duties)) + }); + } + Err(out) => DutyWindow::SeatedSyncCommittees.respond(out, resp), + } +} + +/// A sync committee read the direction a duty request needs it: the state +/// holds a position's pubkey, a request names an index, and the registry +/// answers what pubkey that index carries — so `by_pubkey` orders the +/// positions by the pubkey seated there and one validator's seats are a +/// contiguous run of it. Resolving the request's own index rather than the +/// committee's 512 pubkeys also makes the `(validator_index, pubkey)` pair +/// each duty carries agree by construction. +struct CommitteeSeats<'a> { + committee: &'a SyncCommittee, + by_pubkey: Vec, +} + +impl<'a> CommitteeSeats<'a> { + fn serving(view: &StateReadView<'a>, requested: RequestedEpoch) -> Result { + let periods_ahead = + DutyWindow::SeatedSyncCommittees.units_ahead(requested, view.slot.current_epoch())?; + let committees = view.longtail.sync_committees(); + let committee = if periods_ahead == 0 { committees.current() } else { committees.next() }; + + let pubkeys = &committee.pubkeys; + let mut by_pubkey: Vec = (0..SYNC_COMMITTEE_SIZE as u32).collect(); + by_pubkey.sort_unstable_by_key(|&position| (&pubkeys[position as usize], position)); + Ok(Self { committee, by_pubkey }) + } + + fn duties(&self, validators: &ValidatorsView<'_>, requested: &[u64]) -> Vec { + requested.iter().filter_map(|&index| self.duty_of(validators, index)).collect() + } + + /// `None` for an index the registry does not hold and for a validator this + /// committee does not seat: the schema puts `minItems: 1` on + /// `validator_sync_committee_indices`, so a non-member is left out of the + /// response rather than carried with an empty list. + fn duty_of(&self, validators: &ValidatorsView<'_>, validator_index: u64) -> Option { + let ix = usize::try_from(validator_index).ok().filter(|&ix| ix < validators.count())?; + let pubkey = validators.pubkey(ix); + let committee_positions = self.positions_of(pubkey); + (!committee_positions.is_empty()).then_some(SyncDuty { + pubkey: *pubkey, + validator_index, + committee_positions, + }) + } + + /// Every seat `pubkey` holds, ascending. + fn positions_of(&self, pubkey: &BLSPubkey) -> Vec { + let seated = |&position: &u32| &self.committee.pubkeys[position as usize]; + let from = self.by_pubkey.partition_point(|position| seated(position) < pubkey); + self.by_pubkey[from..] + .iter() + .take_while(|position| seated(position) == pubkey) + .map(|&position| position as u64) + .collect() + } +} diff --git a/crates/beacon_api/src/duties/tests.rs b/crates/beacon_api/src/duties/tests.rs new file mode 100644 index 00000000..fdbf8905 --- /dev/null +++ b/crates/beacon_api/src/duties/tests.rs @@ -0,0 +1,801 @@ +use silver_beacon_state_data::{ + B256, BLSPubkey, BeaconBlockHeader, BeaconState, BeaconStateOwner, EpochState, + EpochStateFinalized, LongtailGroup, LongtailState, SLOTS_PER_HISTORICAL_ROOT, + SYNC_COMMITTEE_SIZE, Slot, SpecConfig, SyncCommittee, ValSeed, +}; +use silver_httpcore::ParsedRequest; + +use super::*; +use crate::{ + ChainHead, NodeStatus, SlotStatus, + ids::MAX_BODY_IDS, + router::Router, + routes::{ROUTES, preboot_ctx, synced_status, test_ctx}, +}; + +/// Mid-epoch and mid-period: the head sits in epoch 300, which is in sync +/// committee period 1 (epochs 256..=511), so no window edge is the head's +/// own slot or epoch. +const HEAD_EPOCH: Epoch = 300; +const HEAD_SLOT: Slot = HEAD_EPOCH * SLOTS_PER_EPOCH + 5; +const NEXT_EPOCH: Epoch = HEAD_EPOCH + 1; + +/// One validator per lookahead entry, so a proposer's index doubles as +/// the entry that named it: a body built from the wrong half of the +/// window says so. +const VALIDATOR_COUNT: usize = PROPOSER_LOOKAHEAD_SIZE; + +/// How far back the fixture chain records block roots. The base ring +/// below it is zeroed, as `BeaconState::for_test` leaves it. +const RECORDED_SLOTS: Slot = 96; + +/// The last slot of the epoch before the head's, whose entry is what v1 names +/// for the head epoch and v2 for the one after it. +const DEPENDENT_SLOT: Slot = HEAD_EPOCH * SLOTS_PER_EPOCH - 1; + +/// The entry v2 names for the head epoch: one epoch further back than v1's. +const V2_DEPENDENT_SLOT: Slot = (HEAD_EPOCH - 1) * SLOTS_PER_EPOCH - 1; + +/// Who sits where. Validator 7 sits at one position of each committee, +/// validator 11 at two of the current one, and validator 20 only in the +/// next; every other position is filler no test asks about. +const CURRENT_SEATS: &[(usize, u64)] = &[(3, 7), (0, 11), (SYNC_COMMITTEE_SIZE - 1, 11)]; +const NEXT_SEATS: &[(usize, u64)] = &[(1, 7), (5, 20)]; +const CURRENT_FILLER: u64 = 63; +const NEXT_FILLER: u64 = 62; +const SEATED_ONCE: u64 = CURRENT_SEATS[0].1; +const SEATED_TWICE: u64 = CURRENT_SEATS[1].1; +const SEATED_NEXT_PERIOD_ONLY: u64 = NEXT_SEATS[1].1; + +const PROPOSER_OUT_OF_RANGE: &str = DutyWindow::ProposerLookahead.out_of_range(); +const SYNC_OUT_OF_RANGE: &str = DutyWindow::SeatedSyncCommittees.out_of_range(); + +fn period_of(epoch: Epoch) -> u64 { + DutyWindow::SeatedSyncCommittees.unit_of(epoch) +} + +fn pubkey_of(index: usize) -> BLSPubkey { + let mut pubkey = [0xd0u8; 48]; + pubkey[40..].copy_from_slice(&(index as u64).to_be_bytes()); + pubkey +} + +fn pubkey_text(index: usize) -> String { + format!("0x{}", hex::encode(pubkey_of(index))) +} + +fn block_root_of(slot: Slot) -> B256 { + let mut root = [0xb0u8; 32]; + root[24..].copy_from_slice(&slot.to_be_bytes()); + root +} + +fn state_root_of(slot: Slot) -> B256 { + let mut root = [0x57u8; 32]; + root[24..].copy_from_slice(&slot.to_be_bytes()); + root +} + +/// A distinct index per list entry, so a capped request is not +/// deduplicated down to one lookup. +fn unquoted_index(position: usize) -> String { + format!("\"{position}\"") +} + +fn root_text(root: B256) -> String { + format!("0x{}", hex::encode(root)) +} + +/// The lookahead entry for the slot `head_epoch * SLOTS_PER_EPOCH + i` is +/// `i`, so the entries of the head epoch name validators 0..32 and those +/// of the next name 32..64. +fn epoch_base() -> EpochStateFinalized { + EpochStateFinalized::from_state(EpochState { + proposer_lookahead: std::array::from_fn(|i| i as u64), + ..Default::default() + }) +} + +fn committee(seats: &[(usize, u64)], filler: u64) -> SyncCommittee { + let mut committee = SyncCommittee { + pubkeys: [pubkey_of(filler as usize); SYNC_COMMITTEE_SIZE], + aggregate_pubkey: [0u8; 48], + }; + for &(position, validator) in seats { + committee.pubkeys[position] = pubkey_of(validator as usize); + } + committee +} + +/// The current committee's pubkeys resolved to validator indices, the way the +/// rotation that installed the committee leaves them. +fn seated_indices() -> [u32; SYNC_COMMITTEE_SIZE] { + let mut indices = [CURRENT_FILLER as u32; SYNC_COMMITTEE_SIZE]; + for &(position, validator) in CURRENT_SEATS { + indices[position] = validator as u32; + } + indices +} + +/// Both committees seated the way two period rotations seat them: the second +/// promotes the current committee out of `next`, carrying `indices` with it. +fn seated_longtail(indices: [u32; SYNC_COMMITTEE_SIZE]) -> LongtailGroup { + let mut group = LongtailGroup::new(LongtailState::default()); + let seated = { + let mut wv = group.roll_fresh(); + wv.rotate_sync_committees(&committee(CURRENT_SEATS, CURRENT_FILLER), indices); + wv.rotate_sync_committees(&committee(NEXT_SEATS, NEXT_FILLER), indices); + wv.commit() + }; + group.finalize(seated, &[seated]); + group +} + +struct Fixture { + state_slot: Slot, + empty_slots: Vec, + validator_count: usize, + sync_committee_indices: [u32; SYNC_COMMITTEE_SIZE], +} + +impl Default for Fixture { + fn default() -> Self { + Self { + state_slot: HEAD_SLOT, + empty_slots: Vec::new(), + validator_count: VALIDATOR_COUNT, + sync_committee_indices: seated_indices(), + } + } +} + +impl Fixture { + /// A published head state, grown a slot at a time the way the tile + /// grows one. Every slot carries a block except those in `empty_slots`, + /// whose ring entry repeats its predecessor's. The node's cached status + /// names the newest block, because fork choice heads on it. + fn published(self) -> ApiCtx { + let seeds: Vec = (0..self.validator_count) + .map(|index| ValSeed { pubkey: pubkey_of(index), ..Default::default() }) + .collect(); + let base_slot = self.state_slot.saturating_sub(RECORDED_SLOTS); + let mut state = BeaconState::for_test(epoch_base(), &seeds, base_slot); + state.longtail = seated_longtail(self.sync_committee_indices); + + let mut owner = BeaconStateOwner::new(state); + let anchor = owner.roll_fresh(); + let (mut writer, _, _) = owner.apply_block_view(anchor); + let mut head = ChainHead { root: [0u8; 32], slot: base_slot }; + for slot in base_slot..=self.state_slot { + if !self.empty_slots.contains(&slot) { + writer.slot.state_mut().latest_block_header = + BeaconBlockHeader { slot, parent_root: head.root, ..Default::default() }; + head = ChainHead { root: block_root_of(slot), slot }; + } + if slot == self.state_slot { + break; + } + writer.slot.fill_latest_block_header_state_root(state_root_of(slot)); + let latest_block = writer.slot.state().latest_block_header.slot; + let bucket = (slot % SLOTS_PER_HISTORICAL_ROOT as u64) as u32; + writer.block_roots.set(bucket, block_root_of(latest_block)); + writer.slot.advance_slot(); + } + let published = writer.commit(None, None); + owner.publish_state_id(published); + + let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); + ctx.node_status = synced_status(head.slot, head.root); + ctx + } +} + +fn head_ctx() -> ApiCtx { + Fixture::default().published() +} + +/// The same head state and chain, with the node reporting a wall clock that +/// has moved past it. +fn head_behind_wall(head_epoch: Epoch, wall_epoch: Epoch) -> ApiCtx { + let state_slot = head_epoch * SLOTS_PER_EPOCH + 1; + let mut ctx = Fixture { state_slot, ..Default::default() }.published(); + ctx.node_status.slots = Some(SlotStatus { + wall_slot: wall_epoch * SLOTS_PER_EPOCH, + ..ctx.node_status.slots.unwrap() + }); + ctx +} + +fn proposer_path(version: u8, epoch: &str) -> String { + format!("/eth/v{version}/validator/duties/proposer/{epoch}") +} + +fn sync_path(epoch: &str) -> String { + format!("/eth/v1/validator/duties/sync/{epoch}") +} + +fn request<'a>(method: &'a str, path: &'a str, body: &'a [u8]) -> ParsedRequest<'a> { + ParsedRequest { + method, + path, + query: "", + body, + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + } +} + +fn dispatch(ctx: &ApiCtx, req: &ParsedRequest<'_>) -> Vec { + let mut out = Vec::new(); + Router::new(ROUTES).dispatch(req, ctx, &mut out); + out +} + +fn get_proposers_v(ctx: &ApiCtx, version: u8, epoch: &str) -> Vec { + dispatch(ctx, &request("GET", &proposer_path(version, epoch), b"")) +} + +fn get_proposers(ctx: &ApiCtx, epoch: &str) -> Vec { + get_proposers_v(ctx, 1, epoch) +} + +fn post_sync(ctx: &ApiCtx, epoch: &str, body: &str) -> Vec { + dispatch(ctx, &request("POST", &sync_path(epoch), body.as_bytes())) +} + +fn body(response: &[u8]) -> &[u8] { + let text = std::str::from_utf8(response).unwrap(); + &response[text.find("\r\n\r\n").unwrap() + 4..] +} + +fn ok_body(response: &[u8]) -> String { + assert!( + response.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{}", + String::from_utf8_lossy(response) + ); + String::from_utf8(body(response).to_vec()).unwrap() +} + +fn assert_error(response: &[u8], status: &str, code: u16, message: &str) { + assert!( + response.starts_with(format!("HTTP/1.1 {status}\r\n").as_bytes()), + "{}", + String::from_utf8_lossy(response) + ); + assert_eq!( + std::str::from_utf8(body(response)).unwrap(), + format!("{{\"code\":{code},\"message\":\"{message}\"}}") + ); +} + +fn assert_syncing(response: &[u8]) { + assert_error(response, "503 Service Unavailable", 503, CURRENTLY_SYNCING); +} + +fn assert_bad_request(response: &[u8], message: &str) { + assert_error(response, "400 Bad Request", 400, message); +} + +/// The body the proposer schema declares for `epoch`, spelled out: the +/// dependent root and the optimistic flag beside one entry per slot, each +/// naming the validator the slot's lookahead entry does. +fn expected_proposers(epoch: Epoch, dependent_root: B256) -> String { + let first_entry = (epoch - HEAD_EPOCH) * SLOTS_PER_EPOCH; + let duties: Vec = (0..SLOTS_PER_EPOCH) + .map(|i| { + format!( + "{{\"pubkey\":\"{}\",\"validator_index\":\"{}\",\"slot\":\"{}\"}}", + pubkey_text((first_entry + i) as usize), + first_entry + i, + epoch * SLOTS_PER_EPOCH + i + ) + }) + .collect(); + format!( + "{{\"dependent_root\":\"{}\",\"execution_optimistic\":false,\"data\":[{}]}}", + root_text(dependent_root), + duties.join(",") + ) +} + +/// Body shape: `apis/validator/duties/proposer.yaml` — every slot of the +/// epoch, and the block root the epoch's duties depend on. +#[test] +fn proposer_duties_body_is_the_epoch_under_its_dependent_root() { + let ctx = head_ctx(); + assert_eq!( + ok_body(&get_proposers(&ctx, &HEAD_EPOCH.to_string())), + expected_proposers(HEAD_EPOCH, block_root_of(DEPENDENT_SLOT)) + ); +} + +/// The lookahead is anchored to the head state's epoch, so the next +/// epoch's proposers are its second half — and the dependent slot has not +/// happened, which the endpoint's head-event rule answers with the head +/// block's root. +#[test] +fn the_next_epoch_reads_the_far_half_of_the_lookahead() { + let ctx = head_ctx(); + assert_eq!( + ok_body(&get_proposers(&ctx, &NEXT_EPOCH.to_string())), + expected_proposers(NEXT_EPOCH, block_root_of(HEAD_SLOT)) + ); +} + +/// A dependent slot that has not happened answers with fork choice's head, +/// not with the block the published state came from. The spec's dependent +/// root is a block root on the canonical chain. A client matches the answer +/// against the head event. +#[test] +fn a_dependent_root_past_the_state_is_fork_choice_s_head() { + let mut ctx = head_ctx(); + let elsewhere = ChainHead { root: block_root_of(HEAD_SLOT + 1), slot: HEAD_SLOT + 1 }; + ctx.node_status.slots = Some(SlotStatus { head: elsewhere, ..ctx.node_status.slots.unwrap() }); + assert_eq!( + ok_body(&get_proposers(&ctx, &NEXT_EPOCH.to_string())), + expected_proposers(NEXT_EPOCH, elsewhere.root) + ); +} + +/// An epoch whose dependent slot has not happened stays unanswerable until +/// the first status names a head. An epoch the state's own `block_roots` +/// covers answers throughout. +#[test] +fn a_dependent_root_that_needs_the_head_waits_for_the_first_status() { + let mut ctx = head_ctx(); + ctx.node_status = NodeStatus::default(); + assert_syncing(&get_proposers(&ctx, &NEXT_EPOCH.to_string())); + let answered = ok_body(&get_proposers(&ctx, &HEAD_EPOCH.to_string())); + let dependent = + format!("{{\"dependent_root\":\"{}\"", root_text(block_root_of(DEPENDENT_SLOT))); + assert!(answered.starts_with(&dependent), "{answered}"); +} + +/// `proposer.v2.yaml` names `compute_start_slot_at_epoch(epoch - 1) - 1` for +/// every epoch, one further back than v1: the boundary the lookahead an +/// epoch's proposers came from was seeded at. Both epochs the window serves +/// depend on a slot `block_roots` already records, so v2 never falls back to +/// the head block's root the way v1 does for the next epoch — and the duties +/// themselves are the same list. +#[test] +fn v2_depends_on_the_epoch_before_the_one_asked_for() { + let ctx = head_ctx(); + for (epoch, dependent_slot) in [(HEAD_EPOCH, V2_DEPENDENT_SLOT), (NEXT_EPOCH, DEPENDENT_SLOT)] { + let answered = ok_body(&get_proposers_v(&ctx, 2, &epoch.to_string())); + assert_eq!(answered, expected_proposers(epoch, block_root_of(dependent_slot))); + assert!(!answered.contains(&root_text(block_root_of(HEAD_SLOT))), "{answered}"); + } +} + +/// An empty slot records the last block's root, which is what the spec's +/// own accessor answers there — the dependent root of an epoch whose +/// predecessor's last slot carried no block. +#[test] +fn a_dependent_slot_that_carried_no_block_names_the_block_before_it() { + let ctx = + Fixture { empty_slots: vec![DEPENDENT_SLOT, V2_DEPENDENT_SLOT], ..Default::default() } + .published(); + assert_eq!( + ok_body(&get_proposers(&ctx, &HEAD_EPOCH.to_string())), + expected_proposers(HEAD_EPOCH, block_root_of(DEPENDENT_SLOT - 1)) + ); + assert_eq!( + ok_body(&get_proposers_v(&ctx, 2, &HEAD_EPOCH.to_string())), + expected_proposers(HEAD_EPOCH, block_root_of(V2_DEPENDENT_SLOT - 1)) + ); +} + +/// The window is the head state's epoch and the next: an epoch below it is +/// nothing this node keeps, and one the wall clock has not scheduled is +/// nothing any state's lookahead covers. +#[test] +fn the_epochs_outside_the_lookahead_are_400() { + let ctx = head_ctx(); + for epoch in [HEAD_EPOCH - 1, 0, HEAD_EPOCH + 2, u64::MAX] { + for version in [1, 2] { + assert_bad_request( + &get_proposers_v(&ctx, version, &epoch.to_string()), + PROPOSER_OUT_OF_RANGE, + ); + } + } +} + +/// The epoch the wall clock is in, not the head state's, decides whether an +/// epoch above the window is early or wrong: for the slot between the clock +/// entering an epoch and the tile advancing the head into it the head state +/// is a whole epoch behind, and the epoch a validator client asks about +/// every slot is one this node answers as soon as it catches up. +#[test] +fn an_epoch_the_wall_clock_has_scheduled_is_503_while_the_head_is_behind() { + let wall_epoch = HEAD_EPOCH; + let ctx = head_behind_wall(wall_epoch - 1, wall_epoch); + for version in [1, 2] { + assert_syncing(&get_proposers_v(&ctx, version, &(wall_epoch + 1).to_string())); + assert_bad_request( + &get_proposers_v(&ctx, version, &(wall_epoch + 2).to_string()), + PROPOSER_OUT_OF_RANGE, + ); + } + + let last_of_period = EPOCHS_PER_SYNC_COMMITTEE_PERIOD - 1; + let ctx = head_behind_wall(last_of_period, last_of_period + 1); + let reachable = (period_of(last_of_period) + 2) * EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + assert_syncing(&post_sync(&ctx, &reachable.to_string(), "[\"7\"]")); + assert_bad_request( + &post_sync(&ctx, &(reachable + EPOCHS_PER_SYNC_COMMITTEE_PERIOD).to_string(), "[\"7\"]"), + SYNC_OUT_OF_RANGE, + ); +} + +/// The converse: an epoch no chain has scheduled duties for is the request's +/// own error however far behind the node reports itself, and an epoch below +/// the head's window is one no waiting brings back. +#[test] +fn an_unscheduled_epoch_is_400_even_while_syncing() { + let mut ctx = head_ctx(); + ctx.node_status.syncing = true; + for version in [1, 2] { + assert_bad_request( + &get_proposers_v(&ctx, version, &(HEAD_EPOCH + 2).to_string()), + PROPOSER_OUT_OF_RANGE, + ); + assert_bad_request( + &get_proposers_v(&ctx, version, &(HEAD_EPOCH - 1).to_string()), + PROPOSER_OUT_OF_RANGE, + ); + } + assert_bad_request( + &post_sync(&ctx, &(HEAD_EPOCH + 512).to_string(), "[\"7\"]"), + SYNC_OUT_OF_RANGE, + ); + + let ctx = head_behind_wall(HEAD_EPOCH, HEAD_EPOCH + 1_000); + for version in [1, 2] { + assert_bad_request( + &get_proposers_v(&ctx, version, &(HEAD_EPOCH - 1).to_string()), + PROPOSER_OUT_OF_RANGE, + ); + } +} + +/// The same window, counted from the head state and from the wall clock: a +/// unit either holds is answered or waited for, and one neither holds is the +/// request's own error. Both endpoints, driven through the units they +/// schedule by. +#[test] +fn the_wall_clock_only_decides_the_epochs_the_head_cannot_answer() { + let period = EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + for (window, unit) in + [(DutyWindow::ProposerLookahead, 1), (DutyWindow::SeatedSyncCommittees, period)] + { + let head = 8 * unit; + let ahead = |epoch, wall| { + window.units_ahead(RequestedEpoch { epoch, wall_epoch: Some(wall) }, head) + }; + assert!(matches!(ahead(head, head), Ok(0))); + assert!(matches!(ahead(head + unit, head), Ok(1))); + assert!(matches!(ahead(head - 1, head + 99 * unit), Err(OutOfWindow::Never))); + assert!(matches!(ahead(head + 2 * unit, head), Err(OutOfWindow::Never))); + assert!(matches!(ahead(head + 2 * unit, head + unit), Err(OutOfWindow::NotYet))); + assert!(matches!(ahead(head + 99 * unit, head + 98 * unit), Err(OutOfWindow::NotYet))); + assert!(matches!(ahead(head + 99 * unit, head + 97 * unit), Err(OutOfWindow::Never))); + assert!(matches!(ahead(u64::MAX, head), Err(OutOfWindow::Never))); + assert!(matches!(ahead(u64::MAX - 1, u64::MAX), Err(OutOfWindow::NotYet))); + assert!(matches!( + window.units_ahead(RequestedEpoch { epoch: head + 9 * unit, wall_epoch: None }, head), + Err(OutOfWindow::NotYet) + )); + } +} + +/// `Invalid epoch` in every duties schema: a path parameter that names no +/// epoch is answered before any state is read. +#[test] +fn an_epoch_that_is_no_number_is_400() { + let ctx = head_ctx(); + for epoch in ["abc", "-1", "+1", "1.5", "", "0x12c", "300 ", "١٢"] { + for version in [1, 2] { + assert_bad_request(&get_proposers_v(&ctx, version, epoch), "invalid epoch"); + assert_bad_request(&get_proposers_v(&preboot_ctx(), version, epoch), "invalid epoch"); + } + assert_bad_request(&post_sync(&ctx, epoch, "[\"7\"]"), "invalid epoch"); + } +} + +/// No duties schema declares a 404, and a node with no state published is +/// the case their 503 describes. +#[test] +fn duties_are_503_before_bootstrap() { + let ctx = preboot_ctx(); + for version in [1, 2] { + assert_syncing(&get_proposers_v(&ctx, version, &HEAD_EPOCH.to_string())); + } + assert_syncing(&post_sync(&ctx, &HEAD_EPOCH.to_string(), "[\"7\"]")); +} + +/// The one state whose own dependent root neither version can answer with: +/// at slot zero the genesis block's root is not in `block_roots` yet, and +/// both schemas require the field. +#[test] +fn an_epoch_whose_dependent_root_is_not_recorded_yet_is_503() { + let ctx = Fixture { state_slot: 0, ..Default::default() }.published(); + for version in [1, 2] { + assert_syncing(&get_proposers_v(&ctx, version, "0")); + } +} + +/// The epochs whose dependent slot underflows; the spec resolves them to the +/// genesis block root, which is the entry slot zero records once the chain +/// has left it. v1 underflows for epoch zero alone, v2 for the first two. +#[test] +fn an_underflowing_dependent_slot_reads_the_root_recorded_at_slot_zero() { + let ctx = Fixture { state_slot: 20, ..Default::default() }.published(); + let genesis_root = format!("{{\"dependent_root\":\"{}\"", root_text(block_root_of(0))); + for (version, epoch) in [(1, "0"), (2, "0"), (2, "1")] { + let body = ok_body(&get_proposers_v(&ctx, version, epoch)); + assert!(body.starts_with(&genesis_root), "v{version} epoch {epoch}: {body}"); + } +} + +/// A lookahead entry no validator answers to is the state contradicting +/// itself; leaving the slot out of the answer would hide a proposal duty. +#[test] +fn a_proposer_the_registry_does_not_hold_is_500() { + let ctx = Fixture { validator_count: 8, ..Default::default() }.published(); + for version in [1, 2] { + assert_error( + &get_proposers_v(&ctx, version, &HEAD_EPOCH.to_string()), + "500 Internal Server Error", + 500, + "proposer lookahead names a validator this state does not hold", + ); + } +} + +/// The flag is the head's own execution status, the same source every +/// state read answers from. +#[test] +fn execution_optimistic_follows_the_head() { + let mut ctx = head_ctx(); + ctx.node_status.slots = + Some(SlotStatus { head_optimistic: true, ..ctx.node_status.slots.unwrap() }); + for version in [1, 2] { + assert!( + ok_body(&get_proposers_v(&ctx, version, &HEAD_EPOCH.to_string())) + .contains("\"execution_optimistic\":true,") + ); + } + assert!( + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), "[\"7\"]")) + .starts_with("{\"execution_optimistic\":true,\"data\":[") + ); +} + +/// Each response carries the flags its own schema declares beside `data` +/// and no others: `finalized` belongs to none of them, and `dependent_root` +/// to the proposer responses alone — the sync committee's dependent root is +/// defined against a period, which its schema does not carry. +#[test] +fn each_response_carries_only_the_wrapper_fields_its_schema_declares() { + let ctx = head_ctx(); + for version in [1, 2] { + let proposers: serde_json::Value = serde_json::from_str(&ok_body(&get_proposers_v( + &ctx, + version, + &HEAD_EPOCH.to_string(), + ))) + .unwrap(); + let proposer_fields = proposers.as_object().unwrap(); + assert!(proposer_fields.contains_key("dependent_root")); + assert!(proposer_fields.contains_key("execution_optimistic")); + assert!(proposer_fields.contains_key("data")); + assert_eq!(proposer_fields.len(), 3, "{proposer_fields:?}"); + } + + let duties: serde_json::Value = + serde_json::from_str(&ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), "[\"7\"]"))) + .unwrap(); + let duty_fields = duties.as_object().unwrap(); + assert!(duty_fields.contains_key("execution_optimistic")); + assert!(duty_fields.contains_key("data")); + assert_eq!(duty_fields.len(), 2, "{duty_fields:?}"); +} + +/// Body shape: `apis/validator/duties/sync.yaml` — one entry per +/// validator the committee holds, each carrying every position it sits +/// at, and nothing for one it does not. +#[test] +fn sync_duties_body_lists_a_seat_per_position_and_omits_non_members() { + let ctx = head_ctx(); + let requested = format!("[\"{SEATED_ONCE}\",\"{SEATED_TWICE}\",\"{SEATED_NEXT_PERIOD_ONLY}\"]"); + assert_eq!( + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), &requested)), + format!( + "{{\"execution_optimistic\":false,\"data\":[\ + {{\"pubkey\":\"{}\",\"validator_index\":\"7\",\ + \"validator_sync_committee_indices\":[\"3\"]}},\ + {{\"pubkey\":\"{}\",\"validator_index\":\"11\",\ + \"validator_sync_committee_indices\":[\"0\",\"511\"]}}]}}", + pubkey_text(SEATED_ONCE as usize), + pubkey_text(SEATED_TWICE as usize), + ) + ); +} + +/// `minItems: 1` on `validator_sync_committee_indices`: a validator the +/// committee does not hold is left out of the array, not carried in it +/// with an empty one. An index past the registry names no pubkey to match +/// against and is left out the same way. +#[test] +fn a_validator_in_no_seat_is_omitted_rather_than_carried_empty() { + let ctx = head_ctx(); + for requested in [ + format!("[\"{SEATED_NEXT_PERIOD_ONLY}\"]"), + format!("[\"{}\"]", VALIDATOR_COUNT + 1_000), + format!("[\"{}\"]", u32::MAX), + format!("[\"{}\"]", u64::MAX), + ] { + assert_eq!( + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), &requested)), + "{\"execution_optimistic\":false,\"data\":[]}", + "{requested}" + ); + } +} + +/// A validator seated in the next period's committee and in no current seat +/// has duties there and none now. +#[test] +fn the_next_period_reads_the_committee_seated_after_this_one() { + let ctx = head_ctx(); + let next_period = (period_of(HEAD_EPOCH) + 1) * EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + let requested = format!("[\"{SEATED_ONCE}\",\"{SEATED_TWICE}\",\"{SEATED_NEXT_PERIOD_ONLY}\"]"); + assert_eq!( + ok_body(&post_sync(&ctx, &next_period.to_string(), &requested)), + format!( + "{{\"execution_optimistic\":false,\"data\":[\ + {{\"pubkey\":\"{}\",\"validator_index\":\"7\",\ + \"validator_sync_committee_indices\":[\"1\"]}},\ + {{\"pubkey\":\"{}\",\"validator_index\":\"20\",\ + \"validator_sync_committee_indices\":[\"5\"]}}]}}", + pubkey_text(SEATED_ONCE as usize), + pubkey_text(SEATED_NEXT_PERIOD_ONLY as usize), + ) + ); +} + +/// A committee serves its whole period, so every epoch of the head's own +/// answers from the current committee — including epochs the head has +/// passed and the period's last — and every epoch of the next from the +/// next committee. +#[test] +fn the_period_and_not_the_epoch_picks_the_committee() { + let ctx = head_ctx(); + let period = period_of(HEAD_EPOCH); + let first_of_period = period * EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + let last_of_period = first_of_period + EPOCHS_PER_SYNC_COMMITTEE_PERIOD - 1; + let current_seat = format!( + "{{\"execution_optimistic\":false,\"data\":[{{\"pubkey\":\"{}\",\ + \"validator_index\":\"7\",\"validator_sync_committee_indices\":[\"3\"]}}]}}", + pubkey_text(SEATED_ONCE as usize) + ); + let next_seat = format!( + "{{\"execution_optimistic\":false,\"data\":[{{\"pubkey\":\"{}\",\ + \"validator_index\":\"7\",\"validator_sync_committee_indices\":[\"1\"]}}]}}", + pubkey_text(SEATED_ONCE as usize) + ); + for epoch in [first_of_period, HEAD_EPOCH, last_of_period] { + assert_eq!( + ok_body(&post_sync(&ctx, &epoch.to_string(), "[\"7\"]")), + current_seat, + "epoch {epoch}" + ); + } + for epoch in [last_of_period + 1, last_of_period + EPOCHS_PER_SYNC_COMMITTEE_PERIOD] { + assert_eq!( + ok_body(&post_sync(&ctx, &epoch.to_string(), "[\"7\"]")), + next_seat, + "epoch {epoch}" + ); + } + for epoch in + [first_of_period - 1, 0, last_of_period + EPOCHS_PER_SYNC_COMMITTEE_PERIOD + 1, u64::MAX] + { + assert_bad_request(&post_sync(&ctx, &epoch.to_string(), "[\"7\"]"), SYNC_OUT_OF_RANGE); + } +} + +/// Duties are matched on the pubkey the registry holds for the index asked +/// about, so a seat the rotation could not resolve to a finalized index — it +/// leaves `u32::MAX` behind — is still served to the validator sitting in it. +#[test] +fn a_member_the_finalized_registry_did_not_hold_still_gets_its_duty() { + let mut sync_committee_indices = seated_indices(); + sync_committee_indices[CURRENT_SEATS[0].0] = u32::MAX; + let ctx = Fixture { sync_committee_indices, ..Default::default() }.published(); + assert_eq!( + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), &format!("[\"{SEATED_ONCE}\"]"))), + format!( + "{{\"execution_optimistic\":false,\"data\":[{{\"pubkey\":\"{}\",\ + \"validator_index\":\"7\",\"validator_sync_committee_indices\":[\"3\"]}}]}}", + pubkey_text(SEATED_ONCE as usize) + ) + ); +} + +/// A validator named twice is one validator, answered once. +#[test] +fn a_repeated_index_is_answered_once() { + let ctx = head_ctx(); + assert_eq!( + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), "[\"7\",\"7\",\"7\"]")), + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), "[\"7\"]")) + ); +} + +/// The body is an array of quoted integers with `minItems: 1`; anything +/// else is the 400 the schema declares for it, with no state read. +#[test] +fn a_body_that_is_no_index_array_is_400() { + let ctx = head_ctx(); + let epoch = HEAD_EPOCH.to_string(); + for body in ["", "[]", "{}", "[1,2]", "\"7\"", "[\"7\"", "null", "[[\"7\"]]", "[null]"] { + let expected = if body == "[]" { + "no validator index in request body" + } else { + "invalid request body" + }; + assert_bad_request(&post_sync(&ctx, &epoch, body), expected); + } + for body in ["[\"abc\"]", "[\"-1\"]", "[\"+1\"]", "[\"7\",\"1.5\"]", "[\"\"]"] { + assert_bad_request(&post_sync(&ctx, &epoch, body), "invalid validator index"); + } +} + +/// The schema caps the list at nothing, so this API does: a body naming +/// more validators than any client runs is refused rather than resolved +/// inside the seqlock read. A list at the cap is answered in full — the +/// seated validators it names come back whether it names two of them or a +/// quarter of a million. +#[test] +fn a_body_at_the_index_cap_is_answered_and_one_past_it_is_400() { + let ctx = head_ctx(); + let epoch = HEAD_EPOCH.to_string(); + let at_cap = + format!("[{}]", (0..MAX_BODY_IDS).map(unquoted_index).collect::>().join(",")); + let past_cap = + format!("[{}]", (0..=MAX_BODY_IDS).map(unquoted_index).collect::>().join(",")); + + assert_eq!( + ok_body(&post_sync(&ctx, &epoch, &at_cap)), + ok_body(&post_sync( + &ctx, + &epoch, + &format!("[\"{SEATED_ONCE}\",\"{SEATED_TWICE}\",\"{CURRENT_FILLER}\"]"), + )) + ); + assert_bad_request( + &post_sync(&ctx, &epoch, &past_cap), + "too many validator indices in request body", + ); +} + +/// Each route answers its own method and nothing else — the proposer +/// duties are a GET, the sync duties a POST. +#[test] +fn the_wrong_method_on_either_route_is_405() { + let ctx = head_ctx(); + let epoch = HEAD_EPOCH.to_string(); + for req in [ + request("POST", &proposer_path(1, &epoch), b"[\"7\"]"), + request("POST", &proposer_path(2, &epoch), b"[\"7\"]"), + request("GET", &sync_path(&epoch), b""), + ] { + assert_error(&dispatch(&ctx, &req), "405 Method Not Allowed", 405, "method not allowed"); + } +} diff --git a/crates/beacon_api/src/identity.rs b/crates/beacon_api/src/identity.rs new file mode 100644 index 00000000..10f4da35 --- /dev/null +++ b/crates/beacon_api/src/identity.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; +use silver_common::{Enr, Eth2Addr, Identify, Keypair}; + +#[derive(Debug, Serialize)] +struct IdentityResponse<'a> { + data: &'a Identity, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Identity { + peer_id: String, + enr: String, + p2p_addresses: Vec, + discovery_addresses: Vec, + metadata: Metadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Metadata { + seq_number: String, + attnets: String, + syncnets: String, + custody_group_count: String, +} + +pub(crate) fn build_identity_json( + keypair: &Keypair, + local_enr: &Enr, + identify: &Identify, +) -> Vec { + let pid_multiaddr = Eth2Addr::PeerId(keypair.peer_id()).to_string(); + let peer_id_str = pid_multiaddr.strip_prefix("/p2p/").unwrap_or(&pid_multiaddr); + + let mut p2p_addresses = Vec::new(); + if let Some(addr) = identify.tcp_ipv4 { + p2p_addresses.push(format!("/ip4/{}/tcp/{}/p2p/{}", addr.ip(), addr.port(), peer_id_str)); + } + if let Some(addr) = identify.tcp_ipv6 { + p2p_addresses.push(format!("/ip6/{}/tcp/{}/p2p/{}", addr.ip(), addr.port(), peer_id_str)); + } + if let Some(addr) = identify.udp_ipv4 { + p2p_addresses.push(format!( + "/ip4/{}/udp/{}/quic-v1/p2p/{}", + addr.ip(), + addr.port(), + peer_id_str + )); + } + if let Some(addr) = identify.udp_ipv6 { + p2p_addresses.push(format!( + "/ip6/{}/udp/{}/quic-v1/p2p/{}", + addr.ip(), + addr.port(), + peer_id_str + )); + } + + let mut discovery_addresses = Vec::new(); + if let (Some(ip), Some(udp)) = (local_enr.ip4(), local_enr.udp4()) { + discovery_addresses.push(format!("/ip4/{}/udp/{}/p2p/{}", ip, udp, peer_id_str)); + } + if let (Some(ip), Some(udp)) = (local_enr.ip6(), local_enr.udp6()) { + discovery_addresses.push(format!("/ip6/{}/udp/{}/p2p/{}", ip, udp, peer_id_str)); + } + + let identity = Identity { + peer_id: peer_id_str.to_string(), + enr: local_enr.to_base64(), + p2p_addresses, + discovery_addresses, + metadata: Metadata { + seq_number: local_enr.seq().to_string(), + attnets: format!("0x{}", hex::encode(local_enr.attnets().unwrap_or([0u8; 8]))), + syncnets: format!("0x{:02x}", local_enr.syncnets().unwrap_or(0)), + custody_group_count: local_enr.cgc().unwrap_or(4).to_string(), + }, + }; + + serde_json::to_vec(&IdentityResponse { data: &identity }).unwrap() +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use super::*; + + #[test] + fn identity_json_fields_present() { + let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(kp.secret_key()).unwrap(); + let body = build_identity_json(&kp, &enr, &Identify::default()); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let data = &v["data"]; + assert!(data["peer_id"].as_str().is_some_and(|s| !s.is_empty())); + assert!(data["enr"].as_str().is_some_and(|s| s.starts_with("enr:"))); + assert!(data["metadata"]["seq_number"].as_str().is_some()); + assert!(data["metadata"]["attnets"].as_str().is_some_and(|s| s.starts_with("0x"))); + assert!(data["metadata"]["syncnets"].as_str().is_some_and(|s| s.starts_with("0x"))); + } + + #[test] + fn identity_p2p_address_format() { + let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(kp.secret_key()).unwrap(); + let mut identify = Identify::default(); + identify.tcp_ipv4 = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 9000)); + let body = build_identity_json(&kp, &enr, &identify); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let addrs = v["data"]["p2p_addresses"].as_array().unwrap(); + assert_eq!(addrs.len(), 1); + let addr = addrs[0].as_str().unwrap(); + assert!(addr.starts_with("/ip4/1.2.3.4/tcp/9000/p2p/"), "bad format: {addr}"); + } +} diff --git a/crates/beacon_api/src/ids.rs b/crates/beacon_api/src/ids.rs new file mode 100644 index 00000000..a6f9f417 --- /dev/null +++ b/crates/beacon_api/src/ids.rs @@ -0,0 +1,55 @@ +//! The forms an identifier arrives in. A bare `Uint64` is a slot for the +//! `state_id`/`block_id` of `params/index.yaml` and an epoch for the duties +//! endpoints, which declare that parameter themselves; the `0x`-prefixed +//! 32-byte root is a form those two parameters alone also take. Each +//! endpoint's keywords are its own. + +use silver_beacon_state_data::B256; + +/// How many validators one POST body may name. No schema that takes a list of +/// them sets a `maxItems`, and an unbounded list turns a 16 MiB body into +/// millions of entries to parse, check and answer inside a single request. A +/// quarter of a million is an order of magnitude past the largest single +/// validator client in production, against a mainnet registry of ~2M. +pub(crate) const MAX_BODY_IDS: usize = 256 * 1024; + +/// `u64::from_str` alone also accepts a leading `+`, which the schemas call an +/// invalid identifier rather than a number. +pub(crate) fn parse_uint64(text: &str) -> Option { + text.bytes().all(|byte| byte.is_ascii_digit()).then(|| text.parse().ok()).flatten() +} + +pub(crate) fn parse_root(text: &str) -> Option { + let mut root = B256::default(); + hex::decode_to_slice(text.strip_prefix("0x")?, &mut root).ok()?; + Some(root) +} + +/// Whether `text` spells exactly `bytes` bytes in the `0x`-prefixed hex of the +/// schemas' `pattern`, either case, for a field a handler checks and discards. +pub(crate) fn is_hex_bytes(text: &str, bytes: usize) -> bool { + text.strip_prefix("0x").is_some_and(|hex| { + hex.len() == 2 * bytes && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) +} + +/// The whole body of `validator/duties/sync/{epoch}` and +/// `validator/liveness/{epoch}`: an array of `Uint64` — quoted decimal +/// strings — with `minItems: 1`. Deduplicated, so a validator named twice is +/// answered once. +pub(crate) fn submitted_indices(body: &[u8]) -> Result, &'static str> { + let submitted: Vec<&str> = serde_json::from_slice(body).map_err(|_| "invalid request body")?; + if submitted.is_empty() { + return Err("no validator index in request body"); + } + if submitted.len() > MAX_BODY_IDS { + return Err("too many validator indices in request body"); + } + let mut indices = submitted + .iter() + .map(|text| parse_uint64(text).ok_or("invalid validator index")) + .collect::, _>>()?; + indices.sort_unstable(); + indices.dedup(); + Ok(indices) +} diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs new file mode 100644 index 00000000..af32b314 --- /dev/null +++ b/crates/beacon_api/src/json.rs @@ -0,0 +1,815 @@ +//! Beacon-API bodies are written by hand: the spec quotes every integer as a +//! decimal string and every byte array as lowercase `0x`-hex, and the +//! SSZ-backed containers have no Rust struct to hang `Serialize` on. +//! `serde_json` is reserved for bodies built once at startup (`identity.rs`). + +use silver_beacon_state_data::{B256, BLSSignature, BeaconBlockHeader, Checkpoint, Fork, Version}; + +use crate::{ + duties::{ProposerDuty, SyncDuty}, + validators::entry::{Validator, ValidatorEntry}, +}; + +const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; + +/// Appends JSON to a buffer the caller owns — fresh or reused is the caller's +/// affair. `start` is where this body begins, so bytes already in the buffer +/// are not siblings of the first value written. +pub(crate) struct Json<'a> { + out: &'a mut Vec, + start: usize, +} + +impl<'a> Json<'a> { + pub(crate) fn new(out: &'a mut Vec) -> Self { + let start = out.len(); + Self { out, start } + } + + pub(crate) fn begin_object(&mut self) { + self.separate(); + self.out.push(b'{'); + } + + pub(crate) fn end_object(&mut self) { + self.out.push(b'}'); + } + + pub(crate) fn begin_array(&mut self) { + self.separate(); + self.out.push(b'['); + } + + pub(crate) fn end_array(&mut self) { + self.out.push(b']'); + } + + pub(crate) fn key(&mut self, name: &str) { + debug_assert!(json_safe(name), "field name goes into JSON unescaped"); + self.separate(); + self.out.push(b'"'); + self.out.extend_from_slice(name.as_bytes()); + self.out.extend_from_slice(b"\":"); + } + + pub(crate) fn quoted_u64(&mut self, value: u64) { + self.separate(); + let mut digits = [0u8; 20]; + let mut written = 0; + let mut rest = value; + loop { + digits[19 - written] = b'0' + (rest % 10) as u8; + rest /= 10; + written += 1; + if rest == 0 { + break; + } + } + self.out.push(b'"'); + self.out.extend_from_slice(&digits[20 - written..]); + self.out.push(b'"'); + } + + pub(crate) fn hex(&mut self, bytes: &[u8]) { + self.separate(); + self.out.extend_from_slice(b"\"0x"); + let base = self.out.len(); + self.out.resize(base + bytes.len() * 2, 0); + hex::encode_to_slice(bytes, &mut self.out[base..]).expect("hex encode_to_slice"); + self.out.push(b'"'); + } + + pub(crate) fn bool(&mut self, value: bool) { + self.separate(); + self.out.extend_from_slice(if value { b"true".as_slice() } else { b"false".as_slice() }); + } + + pub(crate) fn string(&mut self, text: &str) { + self.separate(); + self.out.push(b'"'); + for byte in text.bytes() { + match byte { + b'"' => self.out.extend_from_slice(b"\\\""), + b'\\' => self.out.extend_from_slice(b"\\\\"), + 0x08 => self.out.extend_from_slice(b"\\b"), + 0x0c => self.out.extend_from_slice(b"\\f"), + b'\n' => self.out.extend_from_slice(b"\\n"), + b'\r' => self.out.extend_from_slice(b"\\r"), + b'\t' => self.out.extend_from_slice(b"\\t"), + // Everything else below 0x20 has no short escape; multi-byte + // UTF-8 needs none, since JSON strings carry it verbatim. + 0x00..=0x1f => { + self.out.extend_from_slice(b"\\u00"); + self.out.push(HEX_LOWER[(byte >> 4) as usize]); + self.out.push(HEX_LOWER[(byte & 0xf) as usize]); + } + _ => self.out.push(byte), + } + } + self.out.push(b'"'); + } + + /// A comma belongs between two siblings and nowhere else, and the previous + /// byte says which case this is: only `{`, `[` and `:` can be followed by + /// a value that is not a sibling of one already written. + fn separate(&mut self) { + if self.out.len() > self.start && !matches!(self.out.last(), Some(b'{' | b'[' | b':')) { + self.out.push(b','); + } + } +} + +/// The three scalars `getGenesis` answers with (`apis/beacon/genesis.yaml`). +pub(crate) struct GenesisData { + pub(crate) genesis_time: u64, + pub(crate) genesis_validators_root: B256, + pub(crate) genesis_fork_version: Version, +} + +/// The three `EpochState` checkpoints `getStateFinalityCheckpoints` answers +/// with (`apis/beacon/states/finality_checkpoints.yaml`), split out so a read +/// copies these and not `EpochState`'s 512-byte `proposer_lookahead`. +pub(crate) struct FinalityCheckpoints { + pub(crate) previous_justified: Checkpoint, + pub(crate) current_justified: Checkpoint, + pub(crate) finalized: Checkpoint, +} + +/// The five flags and slots `getSyncingStatus` answers with +/// (`apis/node/syncing.yaml`). +pub(crate) struct SyncingData { + pub(crate) head_slot: u64, + pub(crate) sync_distance: u64, + pub(crate) is_syncing: bool, + pub(crate) is_optimistic: bool, + pub(crate) el_offline: bool, +} + +pub(crate) struct PeerCountData { + pub(crate) connected: u64, + pub(crate) connecting: u64, +} + +/// What a read reports about the data it answers with; both flags are +/// required beside `data` by the `states/{state_id}` schemas and by the block +/// reads. +#[derive(Clone, Copy)] +pub(crate) struct ReadFlags { + pub(crate) execution_optimistic: bool, + pub(crate) finalized: bool, +} + +/// Containers, in the field order the beacon-API schemas declare. +impl Json<'_> { + pub(crate) fn data_envelope(&mut self, data: impl FnOnce(&mut Self)) { + self.begin_object(); + self.key("data"); + data(self); + self.end_object(); + } + + pub(crate) fn flagged_envelope(&mut self, flags: ReadFlags, data: impl FnOnce(&mut Self)) { + self.begin_object(); + self.key("execution_optimistic"); + self.bool(flags.execution_optimistic); + self.key("finalized"); + self.bool(flags.finalized); + self.key("data"); + data(self); + self.end_object(); + } + + /// `GetProposerDutiesResponse`: the epoch's dependent root beside `data`, + /// and no `finalized` flag. + pub(crate) fn dependent_envelope( + &mut self, + dependent_root: &B256, + execution_optimistic: bool, + data: impl FnOnce(&mut Self), + ) { + self.begin_object(); + self.key("dependent_root"); + self.hex(dependent_root); + self.key("execution_optimistic"); + self.bool(execution_optimistic); + self.key("data"); + data(self); + self.end_object(); + } + + /// `GetSyncCommitteeDutiesResponse`, whose schema requires that one flag + /// beside `data` and neither of the other two. + pub(crate) fn optimistic_envelope( + &mut self, + execution_optimistic: bool, + data: impl FnOnce(&mut Self), + ) { + self.begin_object(); + self.key("execution_optimistic"); + self.bool(execution_optimistic); + self.key("data"); + data(self); + self.end_object(); + } + + pub(crate) fn genesis(&mut self, genesis: &GenesisData) { + self.begin_object(); + self.key("genesis_time"); + self.quoted_u64(genesis.genesis_time); + self.key("genesis_validators_root"); + self.hex(&genesis.genesis_validators_root); + self.key("genesis_fork_version"); + self.hex(&genesis.genesis_fork_version); + self.end_object(); + } + + pub(crate) fn fork(&mut self, fork: &Fork) { + self.begin_object(); + self.key("previous_version"); + self.hex(&fork.previous_version); + self.key("current_version"); + self.hex(&fork.current_version); + self.key("epoch"); + self.quoted_u64(fork.epoch); + self.end_object(); + } + + pub(crate) fn checkpoint(&mut self, checkpoint: &Checkpoint) { + self.begin_object(); + self.key("epoch"); + self.quoted_u64(checkpoint.epoch); + self.key("root"); + self.hex(&checkpoint.root); + self.end_object(); + } + + pub(crate) fn syncing(&mut self, syncing: &SyncingData) { + self.begin_object(); + self.key("head_slot"); + self.quoted_u64(syncing.head_slot); + self.key("sync_distance"); + self.quoted_u64(syncing.sync_distance); + self.key("is_syncing"); + self.bool(syncing.is_syncing); + self.key("is_optimistic"); + self.bool(syncing.is_optimistic); + self.key("el_offline"); + self.bool(syncing.el_offline); + self.end_object(); + } + + /// All four buckets are required. + pub(crate) fn peer_count(&mut self, peers: &PeerCountData) { + self.begin_object(); + self.key("disconnected"); + self.quoted_u64(0); + self.key("connecting"); + self.quoted_u64(peers.connecting); + self.key("connected"); + self.quoted_u64(peers.connected); + self.key("disconnecting"); + self.quoted_u64(0); + self.end_object(); + } + + pub(crate) fn finality_checkpoints(&mut self, checkpoints: &FinalityCheckpoints) { + self.begin_object(); + self.key("previous_justified"); + self.checkpoint(&checkpoints.previous_justified); + self.key("current_justified"); + self.checkpoint(&checkpoints.current_justified); + self.key("finalized"); + self.checkpoint(&checkpoints.finalized); + self.end_object(); + } + + pub(crate) fn block_root(&mut self, root: &B256) { + self.begin_object(); + self.key("root"); + self.hex(root); + self.end_object(); + } + + pub(crate) fn block_header(&mut self, header: &BeaconBlockHeader) { + self.begin_object(); + self.key("slot"); + self.quoted_u64(header.slot); + self.key("proposer_index"); + self.quoted_u64(header.proposer_index); + self.key("parent_root"); + self.hex(&header.parent_root); + self.key("state_root"); + self.hex(&header.state_root); + self.key("body_root"); + self.hex(&header.body_root); + self.end_object(); + } + + pub(crate) fn signed_block_header( + &mut self, + header: &BeaconBlockHeader, + signature: &BLSSignature, + ) { + self.begin_object(); + self.key("message"); + self.block_header(header); + self.key("signature"); + self.hex(signature); + self.end_object(); + } + + pub(crate) fn block_header_data( + &mut self, + root: &B256, + canonical: bool, + header: &BeaconBlockHeader, + signature: &BLSSignature, + ) { + self.begin_object(); + self.key("root"); + self.hex(root); + self.key("canonical"); + self.bool(canonical); + self.key("header"); + self.signed_block_header(header, signature); + self.end_object(); + } + + pub(crate) fn validator(&mut self, validator: &Validator) { + self.begin_object(); + self.key("pubkey"); + self.hex(&validator.pubkey); + self.key("withdrawal_credentials"); + self.hex(&validator.withdrawal_credentials.0); + self.key("effective_balance"); + self.quoted_u64(validator.effective_balance); + let lifecycle = &validator.lifecycle; + self.key("slashed"); + self.bool(lifecycle.slashed); + self.key("activation_eligibility_epoch"); + self.quoted_u64(lifecycle.activation_eligibility_epoch); + self.key("activation_epoch"); + self.quoted_u64(lifecycle.activation_epoch); + self.key("exit_epoch"); + self.quoted_u64(lifecycle.exit_epoch); + self.key("withdrawable_epoch"); + self.quoted_u64(lifecycle.withdrawable_epoch); + self.end_object(); + } + + pub(crate) fn validator_entry(&mut self, entry: &ValidatorEntry) { + self.begin_object(); + self.key("index"); + self.quoted_u64(entry.index); + self.key("balance"); + self.quoted_u64(entry.balance); + self.key("status"); + self.string(entry.status.name()); + self.key("validator"); + self.validator(&entry.validator); + self.end_object(); + } + + pub(crate) fn validators(&mut self, entries: &[ValidatorEntry]) { + self.begin_array(); + for entry in entries { + self.validator_entry(entry); + } + self.end_array(); + } + + pub(crate) fn proposer_duty(&mut self, duty: &ProposerDuty) { + self.begin_object(); + self.key("pubkey"); + self.hex(&duty.pubkey); + self.key("validator_index"); + self.quoted_u64(duty.validator_index); + self.key("slot"); + self.quoted_u64(duty.slot); + self.end_object(); + } + + pub(crate) fn proposer_duties(&mut self, duties: &[ProposerDuty]) { + self.begin_array(); + for duty in duties { + self.proposer_duty(duty); + } + self.end_array(); + } + + pub(crate) fn sync_duty(&mut self, duty: &SyncDuty) { + debug_assert!( + !duty.committee_positions.is_empty(), + "the schema puts minItems: 1 on validator_sync_committee_indices", + ); + self.begin_object(); + self.key("pubkey"); + self.hex(&duty.pubkey); + self.key("validator_index"); + self.quoted_u64(duty.validator_index); + self.key("validator_sync_committee_indices"); + self.begin_array(); + for &position in &duty.committee_positions { + self.quoted_u64(position); + } + self.end_array(); + self.end_object(); + } + + pub(crate) fn sync_duties(&mut self, duties: &[SyncDuty]) { + self.begin_array(); + for duty in duties { + self.sync_duty(duty); + } + self.end_array(); + } + + pub(crate) fn liveness(&mut self, index: u64, is_live: bool) { + self.begin_object(); + self.key("index"); + self.quoted_u64(index); + self.key("is_live"); + self.bool(is_live); + self.end_object(); + } +} + +/// Whether `text` survives being spliced into JSON without escaping — the +/// guard for compile-time field names and messages, not for user input +/// ([`Json::string`] escapes). +pub(crate) fn json_safe(text: &str) -> bool { + !text.contains(['"', '\\']) +} + +#[cfg(test)] +mod tests { + use silver_beacon_state_data::{BLSPubkey, FAR_FUTURE_EPOCH, Withdrawals}; + + use super::*; + use crate::validators::status::{Lifecycle, Status}; + + fn write(render: impl FnOnce(&mut Json<'_>)) -> String { + let mut out = Vec::new(); + render(&mut Json::new(&mut out)); + String::from_utf8(out).unwrap() + } + + /// Byte-exact body plus a parse: a golden that is not valid JSON is a + /// golden that pinned a bug. + fn assert_body(render: impl FnOnce(&mut Json<'_>), expected: &str) { + let body = write(render); + assert_eq!(body, expected); + serde_json::from_str::(&body).expect("valid JSON"); + } + + #[test] + fn integers_are_quoted_decimal_strings() { + assert_eq!(write(|j| j.quoted_u64(0)), "\"0\""); + assert_eq!(write(|j| j.quoted_u64(7)), "\"7\""); + assert_eq!(write(|j| j.quoted_u64(10)), "\"10\""); + assert_eq!(write(|j| j.quoted_u64(1_606_824_023)), "\"1606824023\""); + assert_eq!(write(|j| j.quoted_u64(FAR_FUTURE_EPOCH)), "\"18446744073709551615\""); + assert_eq!(write(|j| j.quoted_u64(u64::MAX)), "\"18446744073709551615\""); + } + + #[test] + fn hex_is_lowercase_and_full_width_at_every_spec_size() { + assert_eq!(write(|j| j.hex(&[])), "\"0x\""); + assert_eq!(write(|j| j.hex(&[0x00, 0x0a, 0xff, 0xAB])), "\"0x000affab\""); + + for width in [4usize, 20, 32, 48, 96] { + let bytes = vec![0xdeu8; width]; + let rendered = write(|j| j.hex(&bytes)); + assert_eq!(rendered.len(), width * 2 + 4, "width {width}"); + assert!(rendered.starts_with("\"0x"), "width {width}: {rendered}"); + assert!(rendered.ends_with('"'), "width {width}: {rendered}"); + assert!(rendered[3..rendered.len() - 1].bytes().all(|b| b == b'd' || b == b'e')); + } + } + + #[test] + fn leading_zero_bytes_survive_hex_encoding() { + let mut root = [0u8; 32]; + root[31] = 1; + assert_eq!( + write(|j| j.hex(&root)), + "\"0x0000000000000000000000000000000000000000000000000000000000000001\"" + ); + } + + #[test] + fn bools_are_json_literals_not_strings() { + assert_eq!(write(|j| j.bool(true)), "true"); + assert_eq!(write(|j| j.bool(false)), "false"); + } + + #[test] + fn strings_escape_quotes_backslashes_and_control_bytes() { + assert_eq!(write(|j| j.string("active_ongoing")), "\"active_ongoing\""); + assert_eq!(write(|j| j.string("")), "\"\""); + assert_eq!(write(|j| j.string("a\"b")), "\"a\\\"b\""); + assert_eq!(write(|j| j.string("a\\b")), "\"a\\\\b\""); + assert_eq!(write(|j| j.string("\n\r\t")), "\"\\n\\r\\t\""); + assert_eq!(write(|j| j.string("\u{08}\u{0c}")), "\"\\b\\f\""); + assert_eq!(write(|j| j.string("\u{00}\u{01}\u{1f}")), "\"\\u0000\\u0001\\u001f\""); + assert_eq!(write(|j| j.string("\u{7f}")), "\"\u{7f}\""); + } + + #[test] + fn escaped_strings_round_trip_through_a_parser() { + let awkward = "silver/v0.1 \"quoted\"\\slashed\ttabbed\nnewline\u{01}\u{7f}é☃"; + let body = write(|j| j.string(awkward)); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed.as_str(), Some(awkward)); + } + + #[test] + fn siblings_are_comma_separated_and_openers_are_not() { + assert_body( + |j| { + j.begin_object(); + j.key("empty_object"); + j.begin_object(); + j.end_object(); + j.key("empty_array"); + j.begin_array(); + j.end_array(); + j.key("values"); + j.begin_array(); + j.quoted_u64(1); + j.quoted_u64(2); + j.bool(false); + j.begin_object(); + j.key("nested"); + j.hex(&[0xab]); + j.end_object(); + j.end_array(); + j.end_object(); + }, + "{\"empty_object\":{},\"empty_array\":[],\"values\":[\"1\",\"2\",false,{\"nested\":\"0xab\"}]}", + ); + } + + #[test] + fn a_body_appended_after_existing_bytes_gets_no_leading_comma() { + let mut out = b"HTTP-ish prefix}".to_vec(); + let mut json = Json::new(&mut out); + json.begin_object(); + json.key("epoch"); + json.quoted_u64(3); + json.end_object(); + assert_eq!(String::from_utf8(out).unwrap(), "HTTP-ish prefix}{\"epoch\":\"3\"}"); + } + + #[test] + fn sibling_objects_in_an_array_are_comma_separated() { + let mut out = Vec::new(); + let mut json = Json::new(&mut out); + json.begin_array(); + for epoch in 1..=2 { + json.begin_object(); + json.key("epoch"); + json.quoted_u64(epoch); + json.end_object(); + } + json.begin_object(); + json.end_object(); + json.end_array(); + assert_eq!(String::from_utf8(out).unwrap(), "[{\"epoch\":\"1\"},{\"epoch\":\"2\"},{}]"); + } + + /// Field names/order: `GenesisData`, `apis/beacon/genesis.yaml`. + #[test] + fn genesis_golden() { + let genesis = GenesisData { + genesis_time: 1_606_824_023, + genesis_validators_root: [0x4b; 32], + genesis_fork_version: [0x00, 0x00, 0x00, 0x01], + }; + assert_body( + |j| j.genesis(&genesis), + "{\"genesis_time\":\"1606824023\",\"genesis_validators_root\":\"0x4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b\",\"genesis_fork_version\":\"0x00000001\"}", + ); + } + + /// Field names/order: SSZ `Fork` container + /// (`apis/config/fork_schedule.yaml` and `apis/beacon/states/fork.yaml` + /// share it). + #[test] + fn fork_golden() { + let fork = Fork { + previous_version: [0x05, 0x00, 0x00, 0x00], + current_version: [0x06, 0x00, 0x00, 0x00], + epoch: 269_568, + }; + assert_body( + |j| j.fork(&fork), + "{\"previous_version\":\"0x05000000\",\"current_version\":\"0x06000000\",\"epoch\":\"269568\"}", + ); + } + + /// Field names/order: SSZ `Checkpoint` container, as used by + /// `apis/beacon/states/finality_checkpoints.yaml`. + #[test] + fn checkpoint_golden() { + let checkpoint = Checkpoint { epoch: 12_345, root: [0xa1; 32] }; + assert_body( + |j| j.checkpoint(&checkpoint), + "{\"epoch\":\"12345\",\"root\":\"0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\"}", + ); + } + + fn checkpoints() -> FinalityCheckpoints { + FinalityCheckpoints { + previous_justified: Checkpoint { epoch: 12_344, root: [0x01; 32] }, + current_justified: Checkpoint { epoch: 12_345, root: [0x02; 32] }, + finalized: Checkpoint { epoch: 12_343, root: [0x03; 32] }, + } + } + + /// Field names/order: `apis/beacon/states/finality_checkpoints.yaml` — the + /// one body that calls a container writer more than once. + #[test] + fn finality_checkpoints_golden() { + assert_body( + |j| j.finality_checkpoints(&checkpoints()), + "{\"previous_justified\":{\"epoch\":\"12344\",\ + \"root\":\"0x0101010101010101010101010101010101010101010101010101010101010101\"},\ + \"current_justified\":{\"epoch\":\"12345\",\ + \"root\":\"0x0202020202020202020202020202020202020202020202020202020202020202\"},\ + \"finalized\":{\"epoch\":\"12343\",\ + \"root\":\"0x0303030303030303030303030303030303030303030303030303030303030303\"}}", + ); + } + + /// Field names/order: `GetStateForkResponse` and its siblings, which + /// require both flags beside `data`. + #[test] + fn flagged_envelope_golden() { + let flags = ReadFlags { execution_optimistic: false, finalized: true }; + assert_body( + |j| j.flagged_envelope(flags, |j| j.checkpoint(&Checkpoint::default())), + "{\"execution_optimistic\":false,\"finalized\":true,\"data\":{\"epoch\":\"0\",\ + \"root\":\"0x0000000000000000000000000000000000000000000000000000000000000000\"}}", + ); + } + + /// The envelope's `finalized` is its own flag: a `data` field of the same + /// name must not overwrite or be overwritten by it. + #[test] + fn envelope_flags_and_data_of_the_same_name_both_survive() { + let flags = ReadFlags { execution_optimistic: true, finalized: false }; + let body = write(|j| j.flagged_envelope(flags, |j| j.finality_checkpoints(&checkpoints()))); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed["execution_optimistic"], true); + assert_eq!(parsed["finalized"], false); + assert_eq!(parsed["data"]["finalized"]["epoch"], "12343"); + } + + /// Field names/order: SSZ `BeaconBlockHeader` / `SignedBeaconBlockHeader`, + /// as used by `apis/beacon/blocks/header.yaml`. + #[test] + fn signed_block_header_golden() { + let header = BeaconBlockHeader { + slot: 7_654_321, + proposer_index: 4_242, + parent_root: [0x11; 32], + state_root: [0x22; 32], + body_root: [0x33; 32], + }; + assert_body( + |j| j.signed_block_header(&header, &[0x44; 96]), + "{\"message\":{\"slot\":\"7654321\",\"proposer_index\":\"4242\",\ + \"parent_root\":\"0x1111111111111111111111111111111111111111111111111111111111111111\",\ + \"state_root\":\"0x2222222222222222222222222222222222222222222222222222222222222222\",\ + \"body_root\":\"0x3333333333333333333333333333333333333333333333333333333333333333\"},\ + \"signature\":\"0x444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444\"}", + ); + } + + /// One validator with every field distinct, so a golden catches a + /// swapped pair as well as a renamed key. + fn one_validator() -> ValidatorEntry { + let mut pubkey = [0u8; 48]; + pubkey[0] = 0x93; + pubkey[47] = 0x07; + ValidatorEntry { + index: 0, + balance: 32_500_000_000, + status: Status::ActiveSlashed, + validator: Validator { + pubkey, + withdrawal_credentials: Withdrawals::eth1(&[0xab; 20]), + effective_balance: 32_000_000_000, + lifecycle: Lifecycle { + slashed: true, + activation_eligibility_epoch: 9, + activation_epoch: 10, + exit_epoch: FAR_FUTURE_EPOCH, + withdrawable_epoch: 8_192, + }, + }, + } + } + + /// Field names/order: SSZ `Validator` container, as inlined by + /// `apis/beacon/states/validators.yaml`. + #[test] + fn validator_golden() { + assert_body( + |j| j.validator(&one_validator().validator), + "{\"pubkey\":\"0x930000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007\",\ + \"withdrawal_credentials\":\"0x010000000000000000000000abababababababababababababababababababab\",\ + \"effective_balance\":\"32000000000\",\"slashed\":true,\ + \"activation_eligibility_epoch\":\"9\",\"activation_epoch\":\"10\",\ + \"exit_epoch\":\"18446744073709551615\",\"withdrawable_epoch\":\"8192\"}", + ); + } + + /// Field names/order: `ValidatorResponse` of + /// `apis/beacon/states/validators.yaml`. + #[test] + fn validator_entry_golden() { + let body = write(|j| j.validator_entry(&one_validator())); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed["index"], "0"); + assert_eq!(parsed["balance"], "32500000000"); + assert_eq!(parsed["status"], "active_slashed"); + assert_eq!(parsed["validator"]["effective_balance"], "32000000000"); + assert!(body.starts_with( + "{\"index\":\"0\",\"balance\":\"32500000000\",\"status\":\"active_slashed\",\"validator\":{" + )); + } + + /// The `data` array of `GetStateValidatorsResponse`: siblings separated, + /// and an empty result set still an array. + #[test] + fn validators_array_golden() { + assert_body(|j| j.validators(&[]), "[]"); + let entry = one_validator(); + let second = ValidatorEntry { index: 1, ..entry.clone() }; + let body = write(|j| j.validators(&[entry, second])); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + let entries = parsed.as_array().expect("an array"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["index"], "0"); + assert_eq!(entries[1]["index"], "1"); + } + + fn duty_pubkey() -> BLSPubkey { + let mut pubkey = [0u8; 48]; + pubkey[0] = 0xb0; + pubkey + } + + /// Field names/order: `ProposerDuty` of + /// `apis/validator/duties/proposer.yaml`. + #[test] + fn proposer_duty_golden() { + let duty = ProposerDuty { pubkey: duty_pubkey(), validator_index: 17, slot: 4_096 }; + assert_body( + |j| j.proposer_duty(&duty), + "{\"pubkey\":\"0xb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\ + \"validator_index\":\"17\",\"slot\":\"4096\"}", + ); + } + + /// Field names/order: `Altair.SyncDuty` of + /// `apis/validator/duties/sync.yaml` — the committee positions are a + /// list of quoted integers. + #[test] + fn sync_duty_golden() { + let duty = SyncDuty { + pubkey: duty_pubkey(), + validator_index: 17, + committee_positions: vec![3, 511], + }; + assert_body( + |j| j.sync_duty(&duty), + "{\"pubkey\":\"0xb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\ + \"validator_index\":\"17\",\"validator_sync_committee_indices\":[\"3\",\"511\"]}", + ); + } + + /// The `data` arrays of both duties responses: an epoch nobody in the + /// request proposes or sits in is an empty array, not an absent one. + #[test] + fn duty_arrays_survive_being_empty() { + assert_body(|j| j.proposer_duties(&[]), "[]"); + assert_body(|j| j.sync_duties(&[]), "[]"); + } + + /// Field names: `apis/validator/liveness.yaml`. + #[test] + fn liveness_golden() { + assert_body(|j| j.liveness(17, false), "{\"index\":\"17\",\"is_live\":false}"); + assert_body(|j| j.liveness(0, true), "{\"index\":\"0\",\"is_live\":true}"); + } + + #[test] + fn json_safe_rejects_what_would_break_an_unescaped_splice() { + assert!(json_safe("active_ongoing")); + assert!(!json_safe("say \"hi\"")); + assert!(!json_safe("back\\slash")); + } +} diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs new file mode 100644 index 00000000..94bb4e03 --- /dev/null +++ b/crates/beacon_api/src/lib.rs @@ -0,0 +1,18 @@ +mod blocks; +mod config; +mod duties; +mod identity; +mod ids; +mod json; +mod liveness; +mod node_status; +mod receipts; +mod response; +mod router; +mod routes; +mod server; +mod statics; +mod validators; + +pub use node_status::{ChainHead, NodeStatus, PeerCounts, SlotStatus}; +pub use server::BeaconApi; diff --git a/crates/beacon_api/src/liveness.rs b/crates/beacon_api/src/liveness.rs new file mode 100644 index 00000000..928119fd --- /dev/null +++ b/crates/beacon_api/src/liveness.rs @@ -0,0 +1,187 @@ +use crate::{ + ids::{parse_uint64, submitted_indices}, + response::Response, + router::Request, + routes::ApiCtx, +}; + +/// A validator is live when the node has observed it, and silver keeps no +/// record of what it has seen a validator do — so every index comes back +/// not-live, which the schema's "best-effort ... may indicate that a validator +/// is not live when it actually is" makes a legal answer. It is also the +/// dangerous direction: not-live is what clears doppelganger protection, so a +/// validator running twice starts attesting twice on this node's word. Hence +/// the warning on every call. +pub(crate) fn post_liveness(req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + let named = req.params.get("epoch").expect("{epoch} in the route pattern"); + let Some(epoch) = parse_uint64(named) else { + resp.error(400, "invalid epoch"); + return; + }; + let indices = match submitted_indices(req.body) { + Ok(indices) => indices, + Err(message) => { + resp.error(400, message); + return; + } + }; + + tracing::warn!( + epoch, + validators = indices.len(), + "liveness answered not-live for every validator: silver observes none, so a client \ + relying on this for doppelganger detection is not protected" + ); + resp.json_body(|json| { + json.data_envelope(|json| { + json.begin_array(); + for &index in &indices { + json.liveness(index, false); + } + json.end_array() + }) + }); +} + +#[cfg(test)] +mod tests { + use silver_httpcore::ParsedRequest; + + use crate::{ + ids::MAX_BODY_IDS, + router::Router, + routes::{ROUTES, preboot_ctx}, + }; + + fn dispatch(method: &str, epoch: &str, content_type: Option<&str>, body: &str) -> Vec { + let path = format!("/eth/v1/validator/liveness/{epoch}"); + let mut out = Vec::new(); + let req = ParsedRequest { + method, + path: &path, + query: "", + body: body.as_bytes(), + accept: None, + content_type, + eth_consensus_version: None, + version: 1, + keep_alive: true, + }; + Router::new(ROUTES).dispatch(&req, &preboot_ctx(), &mut out); + out + } + + fn post(epoch: &str, body: &str) -> Vec { + dispatch("POST", epoch, Some("application/json"), body) + } + + fn ok_body(response: &[u8]) -> String { + let text = String::from_utf8_lossy(response); + assert!( + text.starts_with("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{text}" + ); + text[text.find("\r\n\r\n").unwrap() + 4..].to_owned() + } + + fn assert_bad_request(response: &[u8], message: &str) { + let text = String::from_utf8_lossy(response); + assert!(text.starts_with("HTTP/1.1 400 Bad Request\r\n"), "{text}"); + assert!(text.ends_with(&format!("{{\"code\":400,\"message\":\"{message}\"}}")), "{text}"); + } + + /// Body shape: `PostLivenessResponseBody` of `apis/validator/liveness.yaml` + /// — a bare `data` wrapper around one `{index, is_live}` per validator, + /// the index a quoted decimal string and the flag bare. + #[test] + fn the_body_is_one_not_live_entry_per_validator() { + assert_eq!( + ok_body(&post("300", "[\"7\",\"0\"]")), + "{\"data\":[{\"index\":\"0\",\"is_live\":false},{\"index\":\"7\",\"is_live\":false}]}" + ); + serde_json::from_str::(&ok_body(&post("300", "[\"1\"]"))) + .expect("valid JSON"); + } + + /// One validator named twice is one entry, as it is for the sync duties + /// the same body shape serves. + #[test] + fn a_validator_named_twice_is_answered_once() { + assert_eq!( + ok_body(&post("300", "[\"4\",\"4\",\"4\"]")), + "{\"data\":[{\"index\":\"4\",\"is_live\":false}]}" + ); + } + + /// The answer is the same constant at every epoch, so refusing one would + /// refuse a request this node can answer as well as it answers any other. + /// The spec asks a node to support the current and previous epoch and + /// leaves earlier ones optional; it draws no upper bound at all. + #[test] + fn every_epoch_the_path_can_spell_is_answered() { + for epoch in ["0", "1", "300", "18446744073709551615"] { + assert_eq!( + ok_body(&post(epoch, "[\"1\"]")), + "{\"data\":[{\"index\":\"1\",\"is_live\":false}]}", + "{epoch}" + ); + } + } + + /// "Invalid epoch: -2" in the schema's own 400 example. + #[test] + fn an_epoch_naming_no_epoch_at_all_is_a_400() { + for epoch in ["-2", "+1", "banana", "", "1.5", "0x1", "18446744073709551616"] { + assert_bad_request(&post(epoch, "[\"1\"]"), "invalid epoch"); + } + } + + /// `PostLivenessRequestBody`: an array of `Uint64` with `minItems: 1`, + /// each a quoted decimal string. + #[test] + fn a_body_that_is_not_the_schema_s_index_array_is_a_400() { + for body in ["", "not json", "{}", "[1]", "[\"1\",2]"] { + assert_bad_request(&post("300", body), "invalid request body"); + } + assert_bad_request(&post("300", "[]"), "no validator index in request body"); + assert_bad_request(&post("300", "[\"-1\"]"), "invalid validator index"); + } + + /// The response carries an entry per index, so the cap bounds what one + /// request can make this node write as well as what it makes it read. + #[test] + fn more_indices_than_the_cap_is_a_400() { + let index = |i: usize| format!("\"{i}\""); + let at_cap = format!("[{}]", (0..MAX_BODY_IDS).map(index).collect::>().join(",")); + let past_cap = format!("[{}]", (0..=MAX_BODY_IDS).map(index).collect::>().join(",")); + + assert!(post("300", &at_cap).starts_with(b"HTTP/1.1 200 OK\r\n")); + assert_bad_request(&post("300", &past_cap), "too many validator indices in request body"); + } + + /// The schema declares no 415, so the content type is not this endpoint's + /// to judge: a body it can read is a body it answers. + #[test] + fn a_non_json_content_type_is_not_refused() { + let expected = "{\"data\":[{\"index\":\"1\",\"is_live\":false}]}"; + for content_type in [None, Some("application/octet-stream"), Some("text/plain")] { + let response = dispatch("POST", "300", content_type, "[\"1\"]"); + assert_eq!(ok_body(&response), expected, "{content_type:?}"); + } + } + + /// The schema declares a 503 for a syncing node, but this answer costs no + /// state read and would be the same one after the sync — and a 5xx here + /// takes the node out of a `go-eth2-client` rotation and marks it errored + /// in Teku. + #[test] + fn a_node_with_no_state_published_still_answers_200() { + assert!(post("300", "[\"1\"]").starts_with(b"HTTP/1.1 200 OK\r\n")); + } + + #[test] + fn the_route_takes_post_and_nothing_else() { + let response = dispatch("GET", "300", None, ""); + assert!(response.starts_with(b"HTTP/1.1 405 Method Not Allowed\r\n")); + } +} diff --git a/crates/beacon_api/src/node_status.rs b/crates/beacon_api/src/node_status.rs new file mode 100644 index 00000000..f113da6e --- /dev/null +++ b/crates/beacon_api/src/node_status.rs @@ -0,0 +1,142 @@ +use silver_beacon_state_data::{B256, Epoch, SLOTS_PER_EPOCH}; +use silver_common::ELSyncStatus; + +use crate::json::{PeerCountData, SyncingData}; + +/// `SyncingConfig::head_lag_threshold_slots`'s default: the lag past which +/// the node's own sync engine stops treating itself as at the head. +const SYNC_TOLERANCE_SLOTS: u64 = 8; + +/// The node's own condition, as against the chain state a +/// `BeaconStateReader` serves. Assembled and refreshed by its single +/// writer; handlers read one consistent snapshot per dispatch. +#[derive(Clone, Copy, Debug, Default)] +pub struct NodeStatus { + /// `None` until the beacon-state tile publishes its first status, + /// i.e. while the node has nothing to report a head against. + pub slots: Option, + pub syncing: bool, + pub el: ELSyncStatus, + pub peers: PeerCounts, +} + +/// What `getHealth` answers with: 200, the syncing code (206 unless the +/// request names another), or 503. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Health { + Ready, + Syncing, + Uninitialized, +} + +impl NodeStatus { + /// The head's own execution status: true until an EL verdict has verified + /// the head block's payload. + pub(crate) fn execution_optimistic(&self) -> bool { + self.slots.is_none_or(|slots| slots.head_optimistic) + } + + /// Returns `None` until the first status arrives. This node has no head + /// to name before then. + pub(crate) fn head(&self) -> Option { + self.slots.map(|slots| slots.head) + } + + /// The spec puts an optimistic or offline execution layer on the same + /// footing as a syncing beacon node — both mean "data served may be + /// incorrect" — and an EL we have not heard from yet is no better + /// evidence of readiness than one that is syncing. + pub(crate) fn health(&self) -> Health { + if self.slots.is_none() { + Health::Uninitialized + } else if self.is_syncing() || self.el != ELSyncStatus::Synced { + Health::Syncing + } else { + Health::Ready + } + } + + /// The schema has no way to say "no head", so a node with none reports + /// slot zero: reporting that state synced would send a validator client + /// to attest against nothing. + pub(crate) fn syncing_data(&self) -> SyncingData { + SyncingData { + head_slot: self.slots.map_or(0, |slots| slots.latest_block_slot), + sync_distance: self.sync_distance(), + is_syncing: self.is_syncing(), + is_optimistic: self.execution_optimistic(), + el_offline: self.el_offline(), + } + } + + pub(crate) fn peer_count_data(&self) -> PeerCountData { + PeerCountData { connected: self.peers.connected, connecting: self.peers.connecting } + } + + /// The epoch the chain is in, whatever epoch this node's head has reached: + /// what a duties endpoint tells a request that arrives before the head + /// does from one no chain schedules duties for. `None` until the first + /// status, which leaves nothing to judge either by. + pub(crate) fn wall_epoch(&self) -> Option { + self.slots.map(|slots| slots.wall_slot / SLOTS_PER_EPOCH) + } + + /// `syncing` alone would answer for the head this node is chasing, not the + /// one the chain is at: the control tile publishes a `SyncUpdate` only when + /// its target changes, so a node that has yet to find a peer to sync from + /// stays `false` however far behind it falls. + fn is_syncing(&self) -> bool { + self.syncing || self.sync_distance() > SYNC_TOLERANCE_SLOTS + } + + /// `u64::MAX` before the first head: no distance the schema can carry is + /// truthful there, and the zero it would otherwise report is the one value + /// every validator client reads as synced. + fn sync_distance(&self) -> u64 { + self.slots.map_or(u64::MAX, |slots| slots.sync_distance()) + } + + /// True while nothing has come back from the EL: an `Unknown` EL has + /// answered no healthcheck, which is no better evidence that it can be + /// reached than a failed one. A *syncing* EL answered, so it is reachable; + /// what it cannot yet do is reported by `is_optimistic`. + fn el_offline(&self) -> bool { + matches!(self.el, ELSyncStatus::Unknown | ELSyncStatus::Offline) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PeerCounts { + pub connected: u64, + pub connecting: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SlotStatus { + /// Holds the slot of the highest block this node imported, on whichever + /// branch it landed. A `sync_distance` of one is ordinary on a synced + /// node. The current slot's block lands partway into that slot. An empty + /// slot carries no block at all. + pub latest_block_slot: u64, + pub head: ChainHead, + pub wall_slot: u64, + pub head_optimistic: bool, +} + +impl SlotStatus { + /// Saturates instead of underflowing. A block can arrive ahead of the + /// wall clock, when a peer's block lands early in the slot. That case + /// reports zero distance. + pub fn sync_distance(&self) -> u64 { + self.wall_slot.saturating_sub(self.latest_block_slot) + } +} + +/// The block fork choice has settled on. This is not always the block the +/// published state came from. An import can land on a branch that fork choice +/// passes over. The two then name different blocks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ChainHead { + pub root: B256, + pub slot: u64, +} diff --git a/crates/beacon_api/src/receipts.rs b/crates/beacon_api/src/receipts.rs new file mode 100644 index 00000000..8431f90c --- /dev/null +++ b/crates/beacon_api/src/receipts.rs @@ -0,0 +1,420 @@ +//! The validator-client POSTs whose success response is a bare +//! acknowledgement. Each carries a preference or a subscription silver has +//! nothing wired to yet, and each schema's own model is fire-and-forget — a +//! subscription "cannot be certain the Beacon node will find peers", a +//! preparation carries "no guarantee that the beacon node will use the +//! supplied fee recipient" — so acknowledging a well-formed body is the whole +//! answer these endpoints owe, and the log line is where the gap shows. + +use serde::Deserialize; +use silver_beacon_state_data::{BLSPubkey, BLSSignature, ExecutionAddress}; + +use crate::{ + ids::{MAX_BODY_IDS, is_hex_bytes, parse_uint64}, + response::Response, + router::Request, + routes::ApiCtx, +}; + +/// The phrase `UnsupportedMediaType` carries in `types/http.yaml`. +const UNSUPPORTED_MEDIA_TYPE: &str = "Cannot read the supplied content type."; + +/// `registerValidator` is the only schema here that declares a 415, and the +/// only one that declares an SSZ request body beside the JSON one. +pub(crate) fn post_register_validator(req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + if !req.body_is_json() { + resp.error(415, UNSUPPORTED_MEDIA_TYPE); + return; + } + let Some(registrations) = received(req.body, resp, Registration::well_formed) else { + return; + }; + tracing::debug!( + count = registrations.len(), + "validator registrations discarded: silver reaches no builder network" + ); + resp.ok(); +} + +pub(crate) fn post_prepare_beacon_proposer( + req: &Request<'_>, + _ctx: &ApiCtx, + resp: &mut Response<'_>, +) { + let Some(preparations) = received(req.body, resp, ProposerPreparation::well_formed) else { + return; + }; + tracing::debug!( + count = preparations.len(), + "proposer preparations discarded: silver proposes no blocks" + ); + resp.ok(); +} + +pub(crate) fn post_beacon_committee_subscriptions( + req: &Request<'_>, + _ctx: &ApiCtx, + resp: &mut Response<'_>, +) { + let Some(subscriptions) = received(req.body, resp, CommitteeSubscription::well_formed) else { + return; + }; + tracing::debug!( + count = subscriptions.len(), + aggregators = subscriptions.iter().filter(|entry| entry.is_aggregator).count(), + "committee subscriptions discarded: silver steers no attestation subnet" + ); + resp.ok(); +} + +pub(crate) fn post_sync_committee_subscriptions( + req: &Request<'_>, + _ctx: &ApiCtx, + resp: &mut Response<'_>, +) { + let Some(subscriptions) = received(req.body, resp, SyncCommitteeSubscription::well_formed) + else { + return; + }; + tracing::debug!( + count = subscriptions.len(), + "sync committee subscriptions discarded: silver steers no sync subnet" + ); + resp.ok(); +} + +/// The entries a body carries, or `None` having answered the 400 its schema +/// declares. Entries borrow their scalars out of `body`, so an array naming a +/// whole 500k-validator operator costs its `&str` pairs and copies nothing. +fn received<'a, T: Deserialize<'a>>( + body: &'a [u8], + resp: &mut Response<'_>, + well_formed: impl Fn(&T) -> bool, +) -> Option> { + let Ok(entries) = serde_json::from_slice::>(body) else { + resp.error(400, "invalid request body"); + return None; + }; + if entries.len() > MAX_BODY_IDS { + resp.error(400, "too many entries in request body"); + return None; + } + if !entries.iter().all(well_formed) { + resp.error(400, "invalid entry in request body"); + return None; + } + Some(entries) +} + +/// `SignedValidatorRegistration` (`types/registration.yaml`). +#[derive(Deserialize)] +struct Registration<'a> { + #[serde(borrow)] + message: ValidatorRegistration<'a>, + signature: &'a str, +} + +#[derive(Deserialize)] +struct ValidatorRegistration<'a> { + fee_recipient: &'a str, + gas_limit: &'a str, + timestamp: &'a str, + pubkey: &'a str, +} + +impl Registration<'_> { + fn well_formed(&self) -> bool { + is_hex_bytes(self.message.fee_recipient, size_of::()) && + parse_uint64(self.message.gas_limit).is_some() && + parse_uint64(self.message.timestamp).is_some() && + is_hex_bytes(self.message.pubkey, size_of::()) && + is_hex_bytes(self.signature, size_of::()) + } +} + +/// One entry of `prepareBeaconProposer`'s body. An index the registry does not +/// hold is well formed: the schema has it "may become active at a later +/// epoch". +#[derive(Deserialize)] +struct ProposerPreparation<'a> { + validator_index: &'a str, + fee_recipient: &'a str, +} + +impl ProposerPreparation<'_> { + fn well_formed(&self) -> bool { + parse_uint64(self.validator_index).is_some() && + is_hex_bytes(self.fee_recipient, size_of::()) + } +} + +/// One entry of `SubscribeToBeaconCommitteeSubnetRequestBody`. +#[derive(Deserialize)] +struct CommitteeSubscription<'a> { + validator_index: &'a str, + committee_index: &'a str, + committees_at_slot: &'a str, + slot: &'a str, + is_aggregator: bool, +} + +impl CommitteeSubscription<'_> { + fn well_formed(&self) -> bool { + [self.validator_index, self.committee_index, self.committees_at_slot, self.slot] + .iter() + .all(|text| parse_uint64(text).is_some()) + } +} + +/// `Altair.SyncCommitteeSubscription`; the schema puts no minimum on +/// `sync_committee_indices`. +#[derive(Deserialize)] +struct SyncCommitteeSubscription<'a> { + validator_index: &'a str, + #[serde(borrow)] + sync_committee_indices: Vec<&'a str>, + until_epoch: &'a str, +} + +impl SyncCommitteeSubscription<'_> { + fn well_formed(&self) -> bool { + parse_uint64(self.validator_index).is_some() && + parse_uint64(self.until_epoch).is_some() && + self.sync_committee_indices.iter().all(|text| parse_uint64(text).is_some()) + } +} + +#[cfg(test)] +mod tests { + use silver_httpcore::ParsedRequest; + + use super::*; + use crate::{ + router::Router, + routes::{ROUTES, preboot_ctx}, + }; + + const REGISTER: &str = "/eth/v1/validator/register_validator"; + const PREPARE: &str = "/eth/v1/validator/prepare_beacon_proposer"; + const COMMITTEE_SUBS: &str = "/eth/v1/validator/beacon_committee_subscriptions"; + const SYNC_SUBS: &str = "/eth/v1/validator/sync_committee_subscriptions"; + + const BODYLESS_OK: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; + + const COMMITTEE_SUBSCRIPTION: &str = "{\"validator_index\":\"1\",\"committee_index\":\"2\",\ + \"committees_at_slot\":\"64\",\"slot\":\"12345\",\"is_aggregator\":true}"; + + const SYNC_SUBSCRIPTION: &str = "{\"validator_index\":\"1\",\ + \"sync_committee_indices\":[\"0\",\"7\"],\"until_epoch\":\"300\"}"; + + fn registration() -> String { + format!( + "{{\"message\":{{\"fee_recipient\":\"0x{fee}\",\"gas_limit\":\"30000000\",\ + \"timestamp\":\"1606824023\",\"pubkey\":\"0x{key}\"}},\"signature\":\"0x{sig}\"}}", + fee = hex::encode([0xab; 20]), + key = hex::encode([0xcd; 48]), + sig = hex::encode([0xef; 96]), + ) + } + + fn preparation() -> String { + format!("{{\"validator_index\":\"1\",\"fee_recipient\":\"0x{}\"}}", hex::encode([0xab; 20])) + } + + /// One well-formed entry per endpoint. + fn entries() -> [(&'static str, String); 4] { + [ + (REGISTER, registration()), + (PREPARE, preparation()), + (COMMITTEE_SUBS, COMMITTEE_SUBSCRIPTION.to_owned()), + (SYNC_SUBS, SYNC_SUBSCRIPTION.to_owned()), + ] + } + + fn dispatch(method: &str, path: &str, content_type: Option<&str>, body: &str) -> Vec { + let mut out = Vec::new(); + let req = ParsedRequest { + method, + path, + query: "", + body: body.as_bytes(), + accept: None, + content_type, + eth_consensus_version: None, + version: 1, + keep_alive: true, + }; + Router::new(ROUTES).dispatch(&req, &preboot_ctx(), &mut out); + out + } + + fn post(path: &str, content_type: Option<&str>, body: &str) -> Vec { + dispatch("POST", path, content_type, body) + } + + fn json_post(path: &str, body: &str) -> Vec { + post(path, Some("application/json"), body) + } + + fn assert_bad_request(response: &[u8], message: &str) { + let text = String::from_utf8_lossy(response); + assert!(text.starts_with("HTTP/1.1 400 Bad Request\r\n"), "{text}"); + assert!(text.ends_with(&format!("{{\"code\":400,\"message\":\"{message}\"}}")), "{text}"); + } + + /// Every receipt endpoint answers the same bodyless 200: none of the four + /// schemas declares content under it. None reads beacon state either, so + /// the whole suite runs against a node that has published none — which is + /// when a validator client first sends these. + #[test] + fn a_well_formed_body_is_acknowledged_with_a_bodyless_200() { + for (path, entry) in entries() { + assert_eq!(json_post(path, &format!("[{entry}]")), BODYLESS_OK, "{path}"); + assert_eq!(json_post(path, &format!("[{entry},{entry}]")), BODYLESS_OK, "{path}"); + } + } + + /// No schema here puts a `minItems` on its array, so an array naming + /// nothing is still a body the node has received. + #[test] + fn an_empty_array_is_acknowledged_rather_than_refused() { + for path in [REGISTER, PREPARE, COMMITTEE_SUBS, SYNC_SUBS] { + assert_eq!(json_post(path, "[]"), BODYLESS_OK, "{path}"); + } + } + + #[test] + fn a_body_that_is_not_the_schema_s_array_is_a_400() { + for path in [REGISTER, PREPARE, COMMITTEE_SUBS, SYNC_SUBS] { + for body in ["", "not json", "{}", "null", "[[]]", "[1]"] { + assert_bad_request(&json_post(path, body), "invalid request body"); + } + } + } + + /// The entry is the schema's object and its fields are still not the + /// values the schema's patterns spell. A field left unchecked here is one + /// the builder or the subnet would have to reject later, by which point + /// there is no response left to say so through. + #[test] + fn an_entry_whose_fields_the_schema_s_patterns_reject_is_a_400() { + let pubkey = format!("0x{}", hex::encode([0xcd; 48])); + let short_pubkey = format!("0x{}", hex::encode([0xcd; 47])); + for (path, entry) in [ + (REGISTER, registration().replace(&pubkey, &short_pubkey)), + (REGISTER, registration().replace("0xabab", "abab")), + (REGISTER, registration().replace("\"30000000\"", "\"0x1c9c380\"")), + (PREPARE, preparation().replace("\"1\"", "\"-1\"")), + (PREPARE, preparation().replace("\"1\"", "\"+1\"")), + (PREPARE, preparation().replace("0xabab", "0xzzzz")), + (COMMITTEE_SUBS, COMMITTEE_SUBSCRIPTION.replace("\"64\"", "\"banana\"")), + (SYNC_SUBS, SYNC_SUBSCRIPTION.replace("\"7\"", "\"7.0\"")), + ] { + let response = json_post(path, &format!("[{entry}]")); + assert_bad_request(&response, "invalid entry in request body"); + } + } + + /// A field of the wrong JSON type, or under a name the schema does not + /// declare, never reaches those checks: the array does not parse. + #[test] + fn an_entry_missing_a_field_the_schema_requires_is_a_400() { + for (path, entry) in [ + (REGISTER, registration().replace("\"30000000\"", "30000000")), + (REGISTER, registration().replace("\"gas_limit\"", "\"gasLimit\"")), + (COMMITTEE_SUBS, COMMITTEE_SUBSCRIPTION.replace("true", "\"true\"")), + (SYNC_SUBS, SYNC_SUBSCRIPTION.replace("\"300\"", "300")), + ] { + let response = json_post(path, &format!("[{entry}]")); + assert_bad_request(&response, "invalid request body"); + } + } + + /// An SSZ-first client keys its downgrade to JSON on the 415 alone. Teku + /// carries that downgrade in its registration request behind a flag its + /// shipped client hardcodes off; were it on, any other code here would + /// lose the registrations with no retry. + #[test] + fn a_non_json_content_type_is_a_415_on_the_one_schema_that_declares_it() { + let body = format!("[{}]", registration()); + for content_type in ["application/octet-stream", "APPLICATION/OCTET-STREAM", "text/plain"] { + let response = post(REGISTER, Some(content_type), &body); + assert!( + response.starts_with(b"HTTP/1.1 415 Unsupported Media Type\r\n"), + "{content_type}: {}", + String::from_utf8_lossy(&response) + ); + assert!(response.ends_with( + format!("{{\"code\":415,\"message\":\"{UNSUPPORTED_MEDIA_TYPE}\"}}").as_bytes() + )); + } + } + + /// The other three declare 400 and 500 and nothing else, so an SSZ body + /// there is answered as the unreadable JSON it is. + #[test] + fn a_non_json_content_type_is_never_a_415_where_no_schema_declares_one() { + for path in [PREPARE, COMMITTEE_SUBS, SYNC_SUBS] { + let response = post(path, Some("application/octet-stream"), "\u{0}\u{1}\u{2}"); + assert_bad_request(&response, "invalid request body"); + } + } + + /// A client naming no media type is sending JSON: the one media type worth + /// a 415 announces itself, and refusing a header-less POST would refuse a + /// request no schema calls invalid. + #[test] + fn a_body_naming_no_media_type_is_read_as_json() { + for (path, entry) in entries() { + let body = format!("[{entry}]"); + assert_eq!(post(path, None, &body), BODYLESS_OK, "{path}: absent"); + assert_eq!(post(path, Some(""), &body), BODYLESS_OK, "{path}: empty"); + } + } + + #[test] + fn a_json_content_type_carrying_parameters_is_still_json() { + let body = format!("[{}]", registration()); + for content_type in [ + "application/json; charset=utf-8", + "application/json;charset=UTF-8", + "Application/JSON", + ] { + assert_eq!(post(REGISTER, Some(content_type), &body), BODYLESS_OK, "{content_type}"); + } + } + + /// The cap answers before the entries are read, so an array no registry + /// could hold is refused for its length rather than for the first field in + /// it that fails. + #[test] + fn more_entries_than_the_cap_is_a_400_whatever_the_entries_hold() { + let entry = "{\"validator_index\":\"1\",\"fee_recipient\":\"0x\"}"; + let past_cap = format!("[{}]", vec![entry; MAX_BODY_IDS + 1].join(",")); + assert_bad_request(&json_post(PREPARE, &past_cap), "too many entries in request body"); + + let at_cap = format!("[{}]", vec![entry; MAX_BODY_IDS].join(",")); + assert_bad_request(&json_post(PREPARE, &at_cap), "invalid entry in request body"); + } + + /// Nothing in a JSON body's layout is the schema's: a client is free to + /// indent it and to emit an object's members in any order, and both are + /// the same body. + #[test] + fn indentation_and_member_order_do_not_change_the_body() { + let fee_recipient = format!("0x{}", hex::encode([0xab; 20])); + let pretty = format!( + "[\n {{\n \"fee_recipient\": \"{fee_recipient}\",\n\ + \t\"validator_index\" : \"1\"\n }}\n]\n" + ); + assert_eq!(json_post(PREPARE, &pretty), BODYLESS_OK, "{pretty}"); + } + + #[test] + fn every_receipt_route_takes_post_and_nothing_else() { + for path in [REGISTER, PREPARE, COMMITTEE_SUBS, SYNC_SUBS] { + let response = dispatch("GET", path, None, ""); + assert!(response.starts_with(b"HTTP/1.1 405 Method Not Allowed\r\n"), "{path}"); + } + } +} diff --git a/crates/beacon_api/src/response.rs b/crates/beacon_api/src/response.rs new file mode 100644 index 00000000..6d828405 --- /dev/null +++ b/crates/beacon_api/src/response.rs @@ -0,0 +1,258 @@ +use std::{fmt::Write, str}; + +use silver_httpcore::frame_response_with_headers; + +use crate::json::{Json, json_safe}; + +const JSON_CONTENT_TYPE: &str = "application/json"; + +pub(crate) struct Response<'a> { + out: &'a mut Vec, +} + +/// One entry of a beacon-API `IndexedErrorMessage.failures` list; `index` is +/// the item's position in the submitted list, not a validator index. +pub(crate) struct Failure<'a> { + pub(crate) index: usize, + pub(crate) message: &'a str, +} + +impl<'a> Response<'a> { + pub(crate) fn new(out: &'a mut Vec) -> Self { + Self { out } + } + + pub(crate) fn json(&mut self, body: &[u8]) { + self.send(200, Some(JSON_CONTENT_TYPE), &[], body); + } + + /// Renders a body, then frames it: `Content-Length` precedes the body on + /// the wire, so the render cannot go straight into the response buffer. + pub(crate) fn json_body(&mut self, render: impl FnOnce(&mut Json<'_>)) { + let mut body = Vec::new(); + render(&mut Json::new(&mut body)); + self.json(&body); + } + + pub(crate) fn empty(&mut self, content_type: &str) { + self.send(200, Some(content_type), &[], b""); + } + + /// The success of a schema that declares no content under its 200. + pub(crate) fn ok(&mut self) { + self.send(200, None, &[], b""); + } + + pub(crate) fn send( + &mut self, + code: u16, + content_type: Option<&str>, + headers: &[(&str, &str)], + body: &[u8], + ) { + if status_line(code).is_none() { + tracing::warn!("no reason phrase for status {code}"); + } + self.frame(code, content_type, headers, body); + } + + /// Bodyless response under a status silver did not choose: `syncing_status` + /// lets a client name any code the schema allows, so an unmapped one is + /// legal input polled every slot rather than the gap in [`status_line`] + /// that [`Response::send`] warns about. + pub(crate) fn status_only(&mut self, code: u16) { + self.frame(code, None, &[], b""); + } + + fn frame( + &mut self, + code: u16, + content_type: Option<&str>, + headers: &[(&str, &str)], + body: &[u8], + ) { + debug_assert!((100..=599).contains(&code), "not an HTTP status code: {code}"); + let bare = [ + b'0' + (code / 100) as u8, + b'0' + (code / 10 % 10) as u8, + b'0' + (code % 10) as u8, + b' ', + ]; + let status = status_line(code) + .unwrap_or_else(|| str::from_utf8(&bare).expect("three digits and a space")); + frame_response_with_headers(self.out, status, content_type, headers, body); + } + + /// Beacon-API error shape: `{"code":,"message":"..."}`. + pub(crate) fn error(&mut self, code: u16, message: &str) { + debug_assert!(json_safe(message), "message goes into JSON unescaped"); + let body = format!("{{\"code\":{code},\"message\":\"{message}\"}}"); + self.send(code, Some(JSON_CONTENT_TYPE), &[], body.as_bytes()); + } + + /// Beacon-API `IndexedErrorMessage` shape, for requests carrying a list of + /// items of which only some failed. The schema requires `failures` but + /// sets no minimum, so an empty list stays a well-formed body. + // Live with the first endpoint that validates a submitted list item by item. + #[allow(dead_code)] + pub(crate) fn indexed_error(&mut self, code: u16, message: &str, failures: &[Failure<'_>]) { + debug_assert!(json_safe(message), "message goes into JSON unescaped"); + let mut body = format!("{{\"code\":{code},\"message\":\"{message}\",\"failures\":["); + for (position, failure) in failures.iter().enumerate() { + debug_assert!(json_safe(failure.message), "message goes into JSON unescaped"); + if position > 0 { + body.push(','); + } + write!(body, "{{\"index\":{},\"message\":\"{}\"}}", failure.index, failure.message) + .unwrap(); + } + body.push_str("]}"); + self.send(code, Some(JSON_CONTENT_TYPE), &[], body.as_bytes()); + } +} + +/// `None` for codes this API has no phrase for; those still frame, with the +/// empty reason-phrase RFC 9112 §4.1 permits (the space before it is grammar, +/// not part of the phrase). +fn status_line(code: u16) -> Option<&'static str> { + Some(match code { + 200 => "200 OK", + 202 => "202 Accepted", + 206 => "206 Partial Content", + 400 => "400 Bad Request", + 404 => "404 Not Found", + 405 => "405 Method Not Allowed", + 406 => "406 Not Acceptable", + 414 => "414 URI Too Long", + 415 => "415 Unsupported Media Type", + 500 => "500 Internal Server Error", + 501 => "501 Not Implemented", + 503 => "503 Service Unavailable", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn framed(write: impl FnOnce(&mut Response<'_>)) -> Vec { + let mut out = Vec::new(); + write(&mut Response::new(&mut out)); + out + } + + #[test] + fn error_writes_status_line_and_json_body() { + let mut out = Vec::new(); + Response::new(&mut out).error(400, "invalid state_id"); + let expected: &[u8] = b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: 41\r\n\r\n{\"code\":400,\"message\":\"invalid state_id\"}"; + assert_eq!(out, expected); + } + + #[test] + fn json_frames_ok_with_content_type() { + let mut out = Vec::new(); + Response::new(&mut out).json(b"{\"data\":1}"); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 10\r\n\r\n{\"data\":1}" + ); + } + + #[test] + fn empty_frames_ok_with_zero_length_body() { + let mut out = Vec::new(); + Response::new(&mut out).empty("text/plain"); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 0\r\n\r\n" + ); + } + + #[test] + fn error_frames_any_mapped_status() { + let out = framed(|resp| resp.error(415, "unsupported media type")); + let expected: &[u8] = b"HTTP/1.1 415 Unsupported Media Type\r\nContent-Type: application/json\r\nContent-Length: 47\r\n\r\n{\"code\":415,\"message\":\"unsupported media type\"}"; + assert_eq!(out, expected); + } + + #[test] + fn send_frames_a_bodyless_status() { + let out = framed(|resp| resp.send(202, None, &[], b"")); + assert_eq!(out, b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n"); + } + + #[test] + fn send_emits_extra_headers_in_order() { + let out = framed(|resp| { + resp.send( + 200, + Some("application/octet-stream"), + &[("Eth-Consensus-Version", "fulu"), ("Eth-Execution-Payload-Blinded", "false")], + b"\x01\x02\x03", + ) + }); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nEth-Consensus-Version: fulu\r\nEth-Execution-Payload-Blinded: false\r\nContent-Length: 3\r\n\r\n\x01\x02\x03" + ); + } + + #[test] + fn unmapped_status_frames_with_an_empty_reason_phrase() { + let out = framed(|resp| resp.send(599, None, &[], b"")); + assert_eq!(out, b"HTTP/1.1 599 \r\nContent-Length: 0\r\n\r\n"); + } + + /// A `syncing_status` a client picked reaches the wire whether or not this + /// API has a phrase for it, and without the warning a mapped-code gap + /// deserves. + #[test] + fn status_only_frames_a_mapped_or_unmapped_code_the_same_way() { + assert_eq!( + framed(|resp| resp.status_only(206)), + b"HTTP/1.1 206 Partial Content\r\nContent-Length: 0\r\n\r\n" + ); + assert_eq!( + framed(|resp| resp.status_only(250)), + b"HTTP/1.1 250 \r\nContent-Length: 0\r\n\r\n" + ); + assert_eq!( + framed(|resp| resp.status_only(100)), + b"HTTP/1.1 100 \r\nContent-Length: 0\r\n\r\n" + ); + assert_eq!( + framed(|resp| resp.status_only(599)), + b"HTTP/1.1 599 \r\nContent-Length: 0\r\n\r\n" + ); + } + + #[test] + fn every_mapped_status_line_starts_with_its_own_code() { + for code in 100..=599u16 { + let Some(status) = status_line(code) else { continue }; + assert_eq!(status.split(' ').next(), Some(code.to_string().as_str()), "{status}"); + assert!(status.len() > 4, "reason phrase missing from {status}"); + } + } + + #[test] + fn indexed_error_lists_every_failure() { + let out = framed(|resp| { + resp.indexed_error(400, "some failures", &[ + Failure { index: 1, message: "invalid signature" }, + Failure { index: 3, message: "unknown validator" }, + ]) + }); + let expected: &[u8] = b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: 135\r\n\r\n{\"code\":400,\"message\":\"some failures\",\"failures\":[{\"index\":1,\"message\":\"invalid signature\"},{\"index\":3,\"message\":\"unknown validator\"}]}"; + assert_eq!(out, expected); + } + + #[test] + fn indexed_error_without_failures_keeps_the_required_empty_array() { + let out = framed(|resp| resp.indexed_error(400, "some failures", &[])); + let expected: &[u8] = b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: 52\r\n\r\n{\"code\":400,\"message\":\"some failures\",\"failures\":[]}"; + assert_eq!(out, expected); + } +} diff --git a/crates/beacon_api/src/router.rs b/crates/beacon_api/src/router.rs new file mode 100644 index 00000000..0bf90050 --- /dev/null +++ b/crates/beacon_api/src/router.rs @@ -0,0 +1,418 @@ +use silver_httpcore::{ParsedRequest, frame_response}; + +use crate::{response::Response, routes::ApiCtx}; + +const MAX_PARAMS: usize = 4; + +const JSON_MEDIA_TYPE: &str = "application/json"; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Method { + Get, + Post, +} + +impl Method { + fn parse(name: &str) -> Option { + match name { + "GET" => Some(Self::Get), + "POST" => Some(Self::Post), + _ => None, + } + } +} + +pub(crate) type Handler = fn(&Request<'_>, &ApiCtx, &mut Response<'_>); + +// `method` and `path` become live with a handler that answers on more than the +// route it was dispatched by; until then only tests read them. +#[allow(dead_code)] +pub(crate) struct Request<'a> { + pub(crate) method: Method, + pub(crate) path: &'a str, + pub(crate) params: Params<'a>, + pub(crate) query: &'a str, + pub(crate) content_type: Option<&'a str>, + pub(crate) body: &'a [u8], +} + +impl Request<'_> { + /// Whether the body is one this API will read as JSON — a request naming + /// no media type included. The schemas that declare a 415 are the ones + /// that also take an SSZ body, and a client sending SSZ says so: an + /// SSZ-first client keys its downgrade to JSON on the 415 alone, so any + /// other answer there loses the body with no retry. + pub(crate) fn body_is_json(&self) -> bool { + let Some(media_type) = self.media_type() else { + return true; + }; + media_type.eq_ignore_ascii_case(JSON_MEDIA_TYPE) + } + + /// The `Content-Type` header's media type, without the parameters that may + /// follow it. `None` for a header that names none at all. + fn media_type(&self) -> Option<&str> { + let content_type = self.content_type?; + let media_type = content_type.split(';').next().unwrap_or_default().trim(); + (!media_type.is_empty()).then_some(media_type) + } +} + +pub(crate) struct Params<'a> { + entries: [(&'static str, &'a str); MAX_PARAMS], + len: usize, +} + +impl<'a> Params<'a> { + pub(crate) fn get(&self, name: &str) -> Option<&'a str> { + self.entries[..self.len].iter().find(|(n, _)| *n == name).map(|&(_, value)| value) + } + + fn push(&mut self, name: &'static str, value: &'a str) { + self.entries[self.len] = (name, value); + self.len += 1; + } +} + +impl Default for Params<'_> { + fn default() -> Self { + Self { entries: [("", ""); MAX_PARAMS], len: 0 } + } +} + +enum Seg { + Lit(&'static str), + Param(&'static str), +} + +struct Route { + method: Method, + segs: Vec, + handler: Handler, +} + +impl Route { + fn capture<'p>(&self, path: &'p str) -> Option> { + let mut parts = path.strip_prefix('/')?.split('/'); + let mut params = Params::default(); + for seg in &self.segs { + let part = parts.next()?; + match seg { + Seg::Lit(lit) if *lit == part => {} + Seg::Param(name) => params.push(name, part), + Seg::Lit(_) => return None, + } + } + parts.next().is_none().then_some(params) + } +} + +pub(crate) struct Router { + routes: Vec, +} + +impl Router { + pub(crate) fn new(table: &[(Method, &'static str, Handler)]) -> Self { + let mut routes: Vec = Vec::with_capacity(table.len()); + for &(method, pattern, handler) in table { + let segs = compile(pattern); + assert!( + !routes.iter().any(|r| r.method == method && same_match_set(&r.segs, &segs)), + "duplicate route pattern: {pattern}" + ); + routes.push(Route { method, segs, handler }); + } + Self { routes } + } + + pub(crate) fn dispatch(&self, req: &ParsedRequest<'_>, ctx: &ApiCtx, out: &mut Vec) { + let method = Method::parse(req.method); + let mut path_known = false; + for route in &self.routes { + let Some(params) = route.capture(req.path) else { continue }; + if method != Some(route.method) { + path_known = true; + continue; + } + let request = Request { + method: route.method, + path: req.path, + params, + query: req.query, + content_type: req.content_type, + body: req.body, + }; + (route.handler)(&request, ctx, &mut Response::new(out)); + return; + } + if path_known { + Response::new(out).error(405, "method not allowed"); + } else { + tracing::warn!("unknown path: {}", req.path); + frame_response(out, "404 Not Found", None, b""); + } + } +} + +fn compile(pattern: &'static str) -> Vec { + let stripped = pattern + .strip_prefix('/') + .unwrap_or_else(|| panic!("route pattern must start with '/': {pattern}")); + let segs: Vec<_> = stripped + .split('/') + .map(|seg| match seg.strip_prefix('{') { + Some(name) => Seg::Param( + name.strip_suffix('}') + .unwrap_or_else(|| panic!("unterminated param in route pattern: {pattern}")), + ), + None => Seg::Lit(seg), + }) + .collect(); + let params = segs.iter().filter(|s| matches!(s, Seg::Param(_))).count(); + assert!(params <= MAX_PARAMS, "route pattern exceeds {MAX_PARAMS} params: {pattern}"); + segs +} + +/// Whether two compiled patterns match exactly the same set of paths — +/// param names don't affect matching, so they are ignored. +fn same_match_set(a: &[Seg], b: &[Seg]) -> bool { + a.len() == b.len() && + a.iter().zip(b).all(|pair| match pair { + (Seg::Lit(x), Seg::Lit(y)) => x == y, + (Seg::Param(_), Seg::Param(_)) => true, + _ => false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::routes::preboot_ctx; + + fn request<'a>(method: &'a str, path: &'a str) -> ParsedRequest<'a> { + ParsedRequest { + method, + path, + query: "", + body: b"", + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + } + } + + fn dispatch(router: &Router, method: &str, path: &str) -> Vec { + let mut out = Vec::new(); + router.dispatch(&request(method, path), &preboot_ctx(), &mut out); + out + } + + fn body(response: &[u8]) -> &[u8] { + let s = std::str::from_utf8(response).unwrap(); + &response[s.find("\r\n\r\n").unwrap() + 4..] + } + + fn first(_req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(b"first"); + } + + fn second(_req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(b"second"); + } + + fn echo_params(req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + let mut joined = String::new(); + for name in ["state_id", "epoch", "a", "b", "c", "d"] { + if let Some(value) = req.params.get(name) { + joined.push_str(name); + joined.push('='); + joined.push_str(value); + joined.push(';'); + } + } + resp.json(joined.as_bytes()); + } + + fn echo_query_body(req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + let mut joined = req.query.as_bytes().to_vec(); + joined.push(b'|'); + joined.extend_from_slice(req.body); + resp.json(&joined); + } + + fn echo_media_type(req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + let verdict = if req.body_is_json() { "json" } else { "other" }; + resp.json(format!("{:?}|{verdict}", req.media_type()).as_bytes()); + } + + fn posted_with(content_type: Option<&str>) -> Vec { + let router = Router::new(&[(Method::Post, "/submit", echo_media_type)]); + let mut out = Vec::new(); + let req = ParsedRequest { + method: "POST", + path: "/submit", + query: "", + body: b"", + accept: None, + content_type, + eth_consensus_version: None, + version: 1, + keep_alive: true, + }; + router.dispatch(&req, &preboot_ctx(), &mut out); + out + } + + #[test] + fn the_content_type_header_reaches_the_handler() { + assert_eq!( + body(&posted_with(Some("application/octet-stream"))), + b"Some(\"application/octet-stream\")|other" + ); + assert_eq!(body(&posted_with(None)), b"None|json"); + } + + /// RFC 9110 makes the media type case-insensitive and lets parameters + /// follow it; a header that names none at all leaves the body unlabelled, + /// which is the same verdict as sending no header. + #[test] + fn a_json_media_type_is_recognized_however_it_is_spelled() { + for header in [ + "application/json", + "Application/JSON", + "application/json; charset=utf-8", + " application/json ", + ] { + assert!(body(&posted_with(Some(header))).ends_with(b"|json"), "{header}"); + } + for header in ["application/octet-stream", "text/plain", "application/jsonx"] { + assert!(body(&posted_with(Some(header))).ends_with(b"|other"), "{header}"); + } + assert_eq!(body(&posted_with(Some(""))), b"None|json"); + assert_eq!(body(&posted_with(Some("; charset=utf-8"))), b"None|json"); + } + + #[test] + fn literal_route_dispatches_matching_handler() { + let router = Router::new(&[ + (Method::Get, "/eth/v1/node/identity", first), + (Method::Get, "/metrics", second), + ]); + assert_eq!(body(&dispatch(&router, "GET", "/eth/v1/node/identity")), b"first"); + assert_eq!(body(&dispatch(&router, "GET", "/metrics")), b"second"); + } + + #[test] + fn single_param_extracted_by_name() { + let router = Router::new(&[( + Method::Get, + "/eth/v1/beacon/states/{state_id}/finality_checkpoints", + echo_params, + )]); + let resp = dispatch(&router, "GET", "/eth/v1/beacon/states/head/finality_checkpoints"); + assert_eq!(body(&resp), b"state_id=head;"); + } + + #[test] + fn two_params_extracted_by_name() { + let router = + Router::new(&[(Method::Get, "/eth/v1/states/{state_id}/epochs/{epoch}", echo_params)]); + let resp = dispatch(&router, "GET", "/eth/v1/states/0xdead/epochs/42"); + assert_eq!(body(&resp), b"state_id=0xdead;epoch=42;"); + } + + #[test] + fn url_encoded_param_value_passed_through_verbatim() { + let router = Router::new(&[(Method::Get, "/states/{state_id}", echo_params)]); + let resp = dispatch(&router, "GET", "/states/0x1234%2Fabc%20d"); + assert_eq!(body(&resp), b"state_id=0x1234%2Fabc%20d;"); + } + + #[test] + fn query_and_body_reach_handler() { + let router = Router::new(&[(Method::Post, "/submit", echo_query_body)]); + let mut out = Vec::new(); + let req = ParsedRequest { + method: "POST", + path: "/submit", + query: "k=v", + body: b"payload", + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + }; + router.dispatch(&req, &preboot_ctx(), &mut out); + assert_eq!(body(&out), b"k=v|payload"); + } + + #[test] + fn unmatched_path_gets_bare_404() { + let router = Router::new(&[( + Method::Get, + "/eth/v1/beacon/states/{state_id}/finality_checkpoints", + echo_params, + )]); + let expected: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; + assert_eq!(dispatch(&router, "GET", "/not/real"), expected); + assert_eq!(dispatch(&router, "GET", "/eth/v1/beacon/states/head"), expected, "prefix"); + assert_eq!( + dispatch(&router, "GET", "/eth/v1/beacon/states/head/finality_checkpoints/x"), + expected, + "longer than pattern" + ); + } + + #[test] + fn matched_path_wrong_method_gets_405() { + let router = Router::new(&[(Method::Get, "/metrics", first)]); + let resp = dispatch(&router, "POST", "/metrics"); + assert!(resp.starts_with(b"HTTP/1.1 405 Method Not Allowed\r\n")); + assert_eq!(body(&resp), br#"{"code":405,"message":"method not allowed"}"#); + } + + #[test] + fn unknown_method_gets_405_on_known_path_else_404() { + let router = Router::new(&[(Method::Get, "/metrics", first)]); + assert!(dispatch(&router, "PUT", "/metrics").starts_with(b"HTTP/1.1 405")); + assert!(dispatch(&router, "PUT", "/nope").starts_with(b"HTTP/1.1 404")); + } + + #[test] + fn same_pattern_distinct_methods_dispatch_by_method() { + let router = Router::new(&[ + (Method::Get, "/eth/v1/thing", first), + (Method::Post, "/eth/v1/thing", second), + ]); + assert_eq!(body(&dispatch(&router, "GET", "/eth/v1/thing")), b"first"); + assert_eq!(body(&dispatch(&router, "POST", "/eth/v1/thing")), b"second"); + } + + #[test] + #[should_panic(expected = "duplicate route pattern")] + fn duplicate_pattern_panics_at_init() { + Router::new(&[(Method::Get, "/a/b", first), (Method::Get, "/a/b", second)]); + } + + #[test] + #[should_panic(expected = "duplicate route pattern")] + fn duplicate_modulo_param_names_panics_at_init() { + Router::new(&[(Method::Get, "/a/{x}/c", first), (Method::Get, "/a/{y}/c", second)]); + } + + #[test] + fn four_param_pattern_matches() { + let router = Router::new(&[(Method::Get, "/{a}/{b}/{c}/{d}", echo_params)]); + let resp = dispatch(&router, "GET", "/1/2/3/4"); + assert_eq!(body(&resp), b"a=1;b=2;c=3;d=4;"); + } + + #[test] + #[should_panic(expected = "exceeds 4 params")] + fn fifth_param_panics_at_init() { + Router::new(&[(Method::Get, "/{a}/{b}/{c}/{d}/{e}", echo_params)]); + } +} diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs new file mode 100644 index 00000000..3efe8229 --- /dev/null +++ b/crates/beacon_api/src/routes.rs @@ -0,0 +1,856 @@ +#[cfg(test)] +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +#[cfg(test)] +use silver_beacon_state_data::{B256, BeaconStateOwner, Slot}; +use silver_beacon_state_data::{BeaconStateReader, SpecConfig, StateReadView}; +#[cfg(test)] +use silver_common::ELSyncStatus; +use silver_common::{Enr, Identify, Keypair}; +use silver_httpcore::Query; + +#[cfg(test)] +use crate::{ChainHead, PeerCounts, SlotStatus}; +use crate::{ + NodeStatus, + blocks::{get_block_header, get_block_root}, + duties::{get_proposer_duties, get_proposer_duties_v2, post_sync_duties}, + ids::{parse_root, parse_uint64}, + json::{FinalityCheckpoints, GenesisData, Json, ReadFlags}, + liveness::post_liveness, + node_status::Health, + receipts::{ + post_beacon_committee_subscriptions, post_prepare_beacon_proposer, post_register_validator, + post_sync_committee_subscriptions, + }, + response::Response, + router::{Handler, Method, Request}, + statics::StaticBodies, + validators::{get_state_validator, get_state_validators, post_state_validators}, +}; + +const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; + +/// The status a syncing node reports when the request names no other one. +const DEFAULT_SYNCING_STATUS: u16 = 206; + +pub(crate) const ROUTES: &[(Method, &str, Handler)] = &[ + (Method::Get, "/eth/v1/beacon/blocks/{block_id}/root", get_block_root), + (Method::Get, "/eth/v1/beacon/genesis", genesis), + (Method::Get, "/eth/v1/beacon/headers/{block_id}", get_block_header), + ( + Method::Get, + "/eth/v1/beacon/states/{state_id}/finality_checkpoints", + state_finality_checkpoints, + ), + (Method::Get, "/eth/v1/beacon/states/{state_id}/fork", state_fork), + (Method::Get, "/eth/v1/beacon/states/{state_id}/validators", get_state_validators), + (Method::Post, "/eth/v1/beacon/states/{state_id}/validators", post_state_validators), + ( + Method::Get, + "/eth/v1/beacon/states/{state_id}/validators/{validator_id}", + get_state_validator, + ), + (Method::Get, "/eth/v1/config/deposit_contract", deposit_contract), + (Method::Get, "/eth/v1/config/fork_schedule", fork_schedule), + (Method::Get, "/eth/v1/config/spec", spec), + (Method::Get, "/eth/v1/node/health", health), + (Method::Get, "/eth/v1/node/identity", identity), + (Method::Get, "/eth/v1/node/peer_count", peer_count), + (Method::Get, "/eth/v1/node/syncing", syncing), + (Method::Get, "/eth/v1/node/version", version), + ( + Method::Post, + "/eth/v1/validator/beacon_committee_subscriptions", + post_beacon_committee_subscriptions, + ), + (Method::Get, "/eth/v1/validator/duties/proposer/{epoch}", get_proposer_duties), + (Method::Post, "/eth/v1/validator/duties/sync/{epoch}", post_sync_duties), + (Method::Post, "/eth/v1/validator/liveness/{epoch}", post_liveness), + (Method::Post, "/eth/v1/validator/prepare_beacon_proposer", post_prepare_beacon_proposer), + (Method::Post, "/eth/v1/validator/register_validator", post_register_validator), + ( + Method::Post, + "/eth/v1/validator/sync_committee_subscriptions", + post_sync_committee_subscriptions, + ), + (Method::Get, "/eth/v2/validator/duties/proposer/{epoch}", get_proposer_duties_v2), + (Method::Get, "/metrics", metrics), +]; + +pub(crate) struct ApiCtx { + pub(crate) statics: StaticBodies, + pub(crate) state: BeaconStateReader, + pub(crate) node_status: NodeStatus, +} + +impl ApiCtx { + pub(crate) fn new( + keypair: &Keypair, + local_enr: &Enr, + identify: &Identify, + spec: &SpecConfig, + state: BeaconStateReader, + ) -> Self { + Self { + statics: StaticBodies::new(keypair, local_enr, identify, spec), + state, + node_status: NodeStatus::default(), + } + } + + /// Reads the published state, or answers `code`/`message` while the node + /// has published none. The endpoint picks that code. The state reads' + /// schemas declare no 503, while the duties' schemas declare no 404. + pub(crate) fn read_state_or( + &self, + resp: &mut Response<'_>, + code: u16, + message: &str, + read: impl Fn(StateReadView<'_>) -> R, + ) -> Option { + let result = self.state.read(&read); + if result.is_none() { + resp.error(code, message); + } + result + } + + /// Resolves `{state_id}` and reads from the state it names, alongside the + /// flags those schemas require beside `data`. `read` runs under the seqlock + /// and is re-run whole on retry, so it lifts out what the body needs and + /// rendering happens afterwards. + pub(crate) fn state_read( + &self, + req: &Request<'_>, + resp: &mut Response<'_>, + read: impl Fn(StateReadView<'_>) -> R, + ) -> Option> { + let state_id = req.params.get("state_id").expect("{state_id} in the route pattern"); + if state_id != "head" { + if is_recognized_state_id(state_id) { + resp.error(404, "state not found"); + } else { + resp.error(400, "invalid state_id"); + } + return None; + } + + let execution_optimistic = self.node_status.execution_optimistic(); + let read = |view: StateReadView<'_>| StateRead { + flags: ReadFlags { + execution_optimistic, + // Genesis is the only state that is its own finalized history: + // finalization trails the current epoch, so past genesis the + // finalized checkpoint is always behind the state's own slot. + finalized: view.slot.state().slot == 0, + }, + data: read(view), + }; + self.read_state_or(resp, 404, "state not found", read) + } + + /// A `{state_id}` read whose body is the envelope around `render`, for the + /// endpoints that answer whatever the state holds. + pub(crate) fn state_response( + &self, + req: &Request<'_>, + resp: &mut Response<'_>, + read: impl Fn(StateReadView<'_>) -> R, + render: impl FnOnce(&mut Json<'_>, &R), + ) { + let Some(state) = self.state_read(req, resp, read) else { + return; + }; + resp.json_body(|json| json.flagged_envelope(state.flags, |json| render(json, &state.data))); + } +} + +/// One state read: the flags describe the snapshot `data` came from. +pub(crate) struct StateRead { + pub(crate) flags: ReadFlags, + pub(crate) data: R, +} + +/// Whether `state_id` is one of the forms the schemas define — the `head`, +/// `genesis`, `justified` and `finalized` keywords, a slot, or a state root. +/// Anything else identifies no state at all, which the schemas answer 400, +/// where a recognized form silver cannot serve is a 404. +fn is_recognized_state_id(state_id: &str) -> bool { + matches!(state_id, "head" | "genesis" | "justified" | "finalized") || + parse_uint64(state_id).is_some() || + parse_root(state_id).is_some() +} + +fn genesis(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(genesis) = + ctx.read_state_or(resp, 404, "Chain genesis info is not yet known", |view| GenesisData { + genesis_time: view.imm.genesis_time, + genesis_validators_root: view.imm.genesis_validators_root, + genesis_fork_version: view.imm.genesis_fork_version, + }) + else { + return; + }; + resp.json_body(|json| json.data_envelope(|json| json.genesis(&genesis))); +} + +/// This reads no beacon state, and its schema declares no code but 200, so a +/// node before bootstrap answers out of the status it has. +fn syncing(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let syncing = ctx.node_status.syncing_data(); + resp.json_body(|json| json.data_envelope(|json| json.syncing(&syncing))); +} + +fn peer_count(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let peers = ctx.node_status.peer_count_data(); + resp.json_body(|json| json.data_envelope(|json| json.peer_count(&peers))); +} + +fn state_fork(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + ctx.state_response(req, resp, |view| *view.epoch.fork(), |json, fork| json.fork(fork)); +} + +fn state_finality_checkpoints(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + ctx.state_response( + req, + resp, + |view| { + let epoch = view.epoch.state(); + FinalityCheckpoints { + previous_justified: epoch.previous_justified_checkpoint, + current_justified: epoch.current_justified_checkpoint, + finalized: epoch.finalized_checkpoint, + } + }, + |json, checkpoints| json.finality_checkpoints(checkpoints), + ); +} + +fn identity(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(&ctx.statics.identity); +} + +fn version(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(&ctx.statics.version); +} + +fn spec(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(&ctx.statics.spec); +} + +fn fork_schedule(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(&ctx.statics.fork_schedule); +} + +fn deposit_contract(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(&ctx.statics.deposit_contract); +} + +/// Health is the status code and nothing else — the schema gives this +/// endpoint no response body at any code. +fn health(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(syncing_status) = syncing_status(req.query) else { + resp.error(400, "invalid syncing_status"); + return; + }; + let code = match ctx.node_status.health() { + Health::Ready => 200, + Health::Syncing => syncing_status, + Health::Uninitialized => 503, + }; + resp.status_only(code); +} + +/// The optional `syncing_status` query parameter, which replaces the code a +/// syncing node reports. `None` for a value outside the 100..=599 the schema +/// allows, which the spec answers with a 400. +fn syncing_status(query: &str) -> Option { + let named = + Query::new(query).find_map(|(name, value)| (name == "syncing_status").then_some(value)); + match named { + Some(value) => value.parse().ok().filter(|code| (100..=599).contains(code)), + None => Some(DEFAULT_SYNCING_STATUS), + } +} + +fn metrics(_req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.empty(METRICS_CONTENT_TYPE); +} + +/// Never-published reader: `read` yields `None`, as on a node before +/// bootstrap. +#[cfg(test)] +pub(crate) fn preboot_ctx() -> ApiCtx { + test_ctx(&SpecConfig::mainnet(), BeaconStateOwner::empty_test(0).reader()) +} + +/// A node fully caught up with the chain. Its head is the block `head_root` +/// names, at `head_slot`. +#[cfg(test)] +pub(crate) fn synced_status(head_slot: Slot, head_root: B256) -> NodeStatus { + NodeStatus { + slots: Some(SlotStatus { + latest_block_slot: head_slot, + head: ChainHead { root: head_root, slot: head_slot }, + wall_slot: head_slot, + head_optimistic: false, + }), + syncing: false, + el: ELSyncStatus::Synced, + peers: PeerCounts::default(), + } +} + +#[cfg(test)] +pub(crate) fn test_ctx(spec: &SpecConfig, state: BeaconStateReader) -> ApiCtx { + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(keypair.secret_key()).unwrap(); + let mut identify = Identify::default(); + identify.tcp_ipv4 = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 9000)); + ApiCtx::new(&keypair, &enr, &identify, spec, state) +} + +#[cfg(test)] +mod tests { + use silver_beacon_state_data::{ + BeaconState, Checkpoint, EpochState, EpochStateFinalized, Fork, SLOTS_PER_EPOCH, + }; + use silver_common::AGENT_VERSION; + use silver_httpcore::ParsedRequest; + + use super::*; + use crate::router::Router; + + /// Wire bytes the pre-table implementation produced for these exact + /// inputs (captured before the table dispatch landed). + const GOLDEN_IDENTITY: &str = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 478\r\n\r\n{\"data\":{\"peer_id\":\"16Uiu2HAmEWQnHq2jLKJypwVnVoQeFCULuyop6atvq2eWjYSUjzNi\",\"enr\":\"enr:-HW4QFVim6voTojjE-JbeUF0GPFRcqmWxgqgJ8-tXE5hh9PFTQSCwUJPHY_61U3Wvzi6OGrvJfb6KNjNpw4Q18sNL_sBgmlkgnY0iXNlY3AyNTZrMaEDG4TFVnsSZECZXT7VqroFZdceGDRgSBn_nBf16dXdB48\",\"p2p_addresses\":[\"/ip4/1.2.3.4/tcp/9000/p2p/16Uiu2HAmEWQnHq2jLKJypwVnVoQeFCULuyop6atvq2eWjYSUjzNi\"],\"discovery_addresses\":[],\"metadata\":{\"seq_number\":\"1\",\"attnets\":\"0x0000000000000000\",\"syncnets\":\"0x00\",\"custody_group_count\":\"4\"}}}"; + + fn get(router: &Router, ctx: &ApiCtx, path: &str) -> Vec { + query_get(router, ctx, path, "") + } + + fn query_get(router: &Router, ctx: &ApiCtx, path: &str, query: &str) -> Vec { + let mut out = Vec::new(); + let req = ParsedRequest { + method: "GET", + path, + query, + body: b"", + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + }; + router.dispatch(&req, ctx, &mut out); + out + } + + fn body(response: &[u8]) -> &[u8] { + let s = std::str::from_utf8(response).unwrap(); + &response[s.find("\r\n\r\n").unwrap() + 4..] + } + + #[test] + fn identity_wire_bytes_match_pre_table_implementation() { + let router = Router::new(ROUTES); + let resp = get(&router, &preboot_ctx(), "/eth/v1/node/identity"); + assert_eq!(std::str::from_utf8(&resp).unwrap(), GOLDEN_IDENTITY); + } + + #[test] + fn identity_content_length_matches_body() { + let router = Router::new(ROUTES); + let resp = get(&router, &preboot_ctx(), "/eth/v1/node/identity"); + let s = std::str::from_utf8(&resp).unwrap(); + let header_end = s.find("\r\n\r\n").unwrap(); + let cl: usize = s[..header_end] + .lines() + .find(|l| l.to_ascii_lowercase().starts_with("content-length:")) + .unwrap() + .split(':') + .nth(1) + .unwrap() + .trim() + .parse() + .unwrap(); + assert_eq!(cl, s[header_end + 4..].len()); + } + + #[test] + fn version_body_carries_this_build_s_agent_version() { + let router = Router::new(ROUTES); + let resp = get(&router, &preboot_ctx(), "/eth/v1/node/version"); + assert!(resp.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n")); + assert_eq!( + std::str::from_utf8(body(&resp)).unwrap(), + format!("{{\"data\":{{\"version\":\"{AGENT_VERSION}\"}}}}") + ); + } + + /// Config is boot-time data, so these three answer before the node has a + /// state to read — a validator client polls them while silver is still + /// syncing. + #[test] + fn config_endpoints_answer_before_bootstrap() { + let router = Router::new(ROUTES); + for path in [ + "/eth/v1/config/spec", + "/eth/v1/config/fork_schedule", + "/eth/v1/config/deposit_contract", + ] { + let resp = get(&router, &preboot_ctx(), path); + assert!( + resp.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{path}" + ); + let parsed: serde_json::Value = serde_json::from_slice(body(&resp)).expect(path); + assert!(parsed.get("data").is_some(), "{path}"); + assert_eq!(parsed.as_object().unwrap().len(), 1, "{path}: bare data wrapper"); + } + } + + fn ready() -> NodeStatus { + synced_status(100, [0xb0; 32]) + } + + fn head_at(latest_block_slot: Slot, wall_slot: Slot) -> NodeStatus { + let mut status = synced_status(latest_block_slot, [0xb0; 32]); + status.slots = Some(SlotStatus { wall_slot, ..status.slots.unwrap() }); + status + } + + fn health_response(status: NodeStatus, query: &str) -> Vec { + let mut ctx = preboot_ctx(); + ctx.node_status = status; + query_get(&Router::new(ROUTES), &ctx, "/eth/v1/node/health", query) + } + + #[test] + fn health_is_503_until_the_first_slot_status_arrives() { + assert_eq!( + health_response(NodeStatus::default(), ""), + b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n" + ); + } + + #[test] + fn health_is_200_only_when_both_layers_are_synced() { + assert_eq!(health_response(ready(), ""), b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + + for el in [ELSyncStatus::Unknown, ELSyncStatus::Syncing, ELSyncStatus::Offline] { + let resp = health_response(NodeStatus { el, ..ready() }, ""); + assert!(resp.starts_with(b"HTTP/1.1 206 Partial Content\r\n"), "{el:?}"); + } + let resp = health_response(NodeStatus { syncing: true, ..ready() }, ""); + assert_eq!(resp, b"HTTP/1.1 206 Partial Content\r\nContent-Length: 0\r\n\r\n"); + } + + #[test] + fn syncing_status_replaces_the_206_and_nothing_else() { + let syncing = NodeStatus { syncing: true, ..ready() }; + assert!(health_response(syncing, "syncing_status=200").starts_with(b"HTTP/1.1 200 OK\r\n")); + assert!(health_response(syncing, "syncing_status=503").starts_with(b"HTTP/1.1 503 ")); + assert!(health_response(ready(), "syncing_status=503").starts_with(b"HTTP/1.1 200 OK\r\n")); + assert!( + health_response(NodeStatus::default(), "syncing_status=200") + .starts_with(b"HTTP/1.1 503 ") + ); + assert!(health_response(syncing, "other=1").starts_with(b"HTTP/1.1 206 ")); + } + + /// A code the schema allows but this API has no phrase for still frames, + /// with the empty reason phrase RFC 9112 §4.1 permits. + #[test] + fn a_syncing_status_with_no_reason_phrase_still_frames() { + let syncing = NodeStatus { syncing: true, ..ready() }; + assert_eq!( + health_response(syncing, "syncing_status=250"), + b"HTTP/1.1 250 \r\nContent-Length: 0\r\n\r\n" + ); + } + + #[test] + fn a_syncing_status_outside_the_schema_s_range_is_a_400() { + for query in [ + "syncing_status=99", + "syncing_status=600", + "syncing_status=", + "syncing_status=abc", + "syncing_status=-1", + "syncing_status=70000", + ] { + let resp = health_response(ready(), query); + assert!(resp.starts_with(b"HTTP/1.1 400 Bad Request\r\n"), "{query}"); + assert_eq!( + body(&resp), + br#"{"code":400,"message":"invalid syncing_status"}"#, + "{query}" + ); + } + } + + /// Both node-status endpoints answer from `NodeStatus` alone, so a + /// never-published reader is the whole context they need. + fn status_body(status: NodeStatus, path: &str) -> String { + let mut ctx = preboot_ctx(); + ctx.node_status = status; + let resp = get(&Router::new(ROUTES), &ctx, path); + assert!( + resp.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{path}: {}", + String::from_utf8_lossy(&resp) + ); + String::from_utf8(body(&resp).to_vec()).unwrap() + } + + fn syncing_data(status: NodeStatus) -> serde_json::Value { + let body = status_body(status, "/eth/v1/node/syncing"); + let mut parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + parsed["data"].take() + } + + /// Body shape: `apis/node/syncing.yaml` — five required fields, slots + /// quoted and flags bare. + #[test] + fn syncing_body_reports_the_head_and_both_layers() { + assert_eq!( + status_body(ready(), "/eth/v1/node/syncing"), + "{\"data\":{\"head_slot\":\"100\",\"sync_distance\":\"0\",\"is_syncing\":false,\ + \"is_optimistic\":false,\"el_offline\":false}}" + ); + } + + /// `syncing.yaml` declares no 503, and a node with nothing published has + /// an answer: no head, the farthest distance the schema can carry, and + /// every flag in the not-usable direction. + #[test] + fn syncing_answers_before_bootstrap_as_a_node_with_no_head() { + assert_eq!( + status_body(NodeStatus::default(), "/eth/v1/node/syncing"), + "{\"data\":{\"head_slot\":\"0\",\"sync_distance\":\"18446744073709551615\",\ + \"is_syncing\":true,\"is_optimistic\":true,\"el_offline\":true}}" + ); + } + + /// The sync flag answers for the head this node is *chasing*: the control + /// tile publishes a `SyncUpdate` only when its target changes, so a node + /// that has found no peer to sync from carries `syncing: false` however + /// far behind the chain it falls. + #[test] + fn is_syncing_is_true_past_the_head_tolerance_whatever_the_sync_flag() { + assert_eq!(syncing_data(NodeStatus { syncing: true, ..ready() })["is_syncing"], true); + let within = syncing_data(head_at(992, 1_000)); + assert_eq!(within["is_syncing"], false, "within the head tolerance"); + assert_eq!(syncing_data(head_at(991, 1_000))["is_syncing"], true, "past the tolerance"); + assert_eq!( + syncing_data(head_at(10, 1_000_000))["is_syncing"], + true, + "a node that never found a peer to sync from is still syncing" + ); + } + + /// One node, one answer: a validator client gating on `/node/health` and + /// reading the head from `/node/syncing` must not see the two disagree. + #[test] + fn health_reports_syncing_wherever_the_syncing_endpoint_does() { + let far_behind = head_at(10, 1_000_000); + assert_eq!(syncing_data(far_behind)["is_syncing"], true); + assert!(health_response(far_behind, "").starts_with(b"HTTP/1.1 206 Partial Content\r\n")); + } + + /// The same flag the state envelopes carry: the head's own execution + /// status, not a constant and not a reading of the node's sync state. + #[test] + fn syncing_is_optimistic_is_the_head_s_own_status() { + assert_eq!(syncing_data(with_head_optimistic(true))["is_optimistic"], true); + assert_eq!(syncing_data(with_head_optimistic(false))["is_optimistic"], false); + let syncing_node = NodeStatus { syncing: true, ..with_head_optimistic(false) }; + assert_eq!(syncing_data(syncing_node)["is_optimistic"], false); + let offline_el = NodeStatus { el: ELSyncStatus::Offline, ..with_head_optimistic(false) }; + assert_eq!(syncing_data(offline_el)["is_optimistic"], false); + } + + /// An EL that answered a healthcheck is reachable whatever it answered; + /// one that has never answered is no more reachable than a failed one. + #[test] + fn el_offline_is_true_only_while_the_el_has_answered_nothing() { + for (el, offline) in [ + (ELSyncStatus::Unknown, true), + (ELSyncStatus::Offline, true), + (ELSyncStatus::Syncing, false), + (ELSyncStatus::Synced, false), + ] { + let data = syncing_data(NodeStatus { el, ..ready() }); + assert_eq!(data["el_offline"], offline, "{el:?}"); + assert_eq!(data["is_syncing"], false, "{el:?}: the EL is not the node's own sync"); + } + } + + #[test] + fn sync_distance_is_the_wall_clock_gap_and_never_underflows() { + assert_eq!(syncing_data(head_at(90, 100))["sync_distance"], "10"); + assert_eq!(syncing_data(head_at(100, 100))["sync_distance"], "0"); + let head_ahead = syncing_data(head_at(101, 100)); + assert_eq!(head_ahead["sync_distance"], "0", "head ahead of the wall slot"); + assert_eq!(syncing_data(head_at(0, u64::MAX))["sync_distance"], "18446744073709551615"); + } + + /// Body shape: `apis/node/peer_count.yaml` — all four buckets required, + /// so the two silver does not track are zero rather than absent. + #[test] + fn peer_count_body_carries_all_four_buckets() { + let peers = PeerCounts { connected: 56, connecting: 34 }; + assert_eq!( + status_body(NodeStatus { peers, ..ready() }, "/eth/v1/node/peer_count"), + "{\"data\":{\"disconnected\":\"0\",\"connecting\":\"34\",\"connected\":\"56\",\ + \"disconnecting\":\"0\"}}" + ); + assert_eq!( + status_body(NodeStatus::default(), "/eth/v1/node/peer_count"), + "{\"data\":{\"disconnected\":\"0\",\"connecting\":\"0\",\"connected\":\"0\",\ + \"disconnecting\":\"0\"}}" + ); + } + + #[test] + fn metrics_response_valid_prometheus_format() { + let router = Router::new(ROUTES); + let resp = get(&router, &preboot_ctx(), "/metrics"); + let s = std::str::from_utf8(&resp).unwrap(); + assert!(s.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(s.contains("text/plain; version=0.0.4; charset=utf-8")); + assert_eq!(body(&resp), b""); + } + + #[test] + fn unknown_path_returns_404() { + let router = Router::new(ROUTES); + let resp = get(&router, &preboot_ctx(), "/not/real"); + assert_eq!(resp, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + } + + #[test] + fn events_returns_404_v1_defers_sse_clients_poll() { + let router = Router::new(ROUTES); + let resp = get(&router, &preboot_ctx(), "/eth/v1/events"); + assert_eq!(resp, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + } + + /// First slot of the epoch two past [`epoch_state`]'s finalized + /// checkpoint — normal operation, where head is not the finalized state. + const HEAD_SLOT: u64 = 12_345 * SLOTS_PER_EPOCH; + + fn epoch_state() -> EpochState { + EpochState { + fork: Fork { + previous_version: [0x05, 0x00, 0x00, 0x00], + current_version: [0x06, 0x00, 0x00, 0x00], + epoch: 269_568, + }, + previous_justified_checkpoint: Checkpoint { epoch: 12_344, root: [0x01; 32] }, + current_justified_checkpoint: Checkpoint { epoch: 12_345, root: [0x02; 32] }, + finalized_checkpoint: Checkpoint { epoch: 12_343, root: [0x03; 32] }, + ..Default::default() + } + } + + /// A synced node with its one state published — every distinct value these + /// endpoints read is set, so a golden catches a swapped field. + fn published_ctx(epoch: EpochState, slot: u64) -> ApiCtx { + let mut state = BeaconState::for_test(EpochStateFinalized::from_state(epoch), &[], slot); + state.immutable.genesis_time = 1_606_824_023; + state.immutable.genesis_validators_root = [0x4b; 32]; + state.immutable.genesis_fork_version = [0x00, 0x00, 0x00, 0x01]; + + let mut owner = BeaconStateOwner::new(state); + let anchor = owner.roll_fresh(); + owner.publish_state_id(anchor); + + let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); + ctx.node_status = ready(); + ctx + } + + fn state_paths(state_id: &str) -> [String; 2] { + [ + format!("/eth/v1/beacon/states/{state_id}/fork"), + format!("/eth/v1/beacon/states/{state_id}/finality_checkpoints"), + ] + } + + fn state_body(ctx: &ApiCtx, path: &str) -> String { + let resp = get(&Router::new(ROUTES), ctx, path); + assert!( + resp.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{path}: {}", + String::from_utf8_lossy(&resp) + ); + String::from_utf8(body(&resp).to_vec()).unwrap() + } + + /// Body shape: `apis/beacon/genesis.yaml` — a bare `data` wrapper, the one + /// state read that carries no envelope flags. + #[test] + fn genesis_body_is_a_bare_data_wrapper() { + let ctx = published_ctx(epoch_state(), HEAD_SLOT); + assert_eq!( + state_body(&ctx, "/eth/v1/beacon/genesis"), + "{\"data\":{\"genesis_time\":\"1606824023\",\ + \"genesis_validators_root\":\"0x4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b\",\ + \"genesis_fork_version\":\"0x00000001\"}}" + ); + } + + /// Body shape: `apis/beacon/states/fork.yaml`. + #[test] + fn state_fork_body_is_the_envelope_around_the_fork() { + let ctx = published_ctx(epoch_state(), HEAD_SLOT); + assert_eq!( + state_body(&ctx, "/eth/v1/beacon/states/head/fork"), + "{\"execution_optimistic\":false,\"finalized\":false,\ + \"data\":{\"previous_version\":\"0x05000000\",\"current_version\":\"0x06000000\",\ + \"epoch\":\"269568\"}}" + ); + } + + /// Body shape: `apis/beacon/states/finality_checkpoints.yaml`. + #[test] + fn finality_checkpoints_body_is_the_envelope_around_three_checkpoints() { + let ctx = published_ctx(epoch_state(), HEAD_SLOT); + assert_eq!( + state_body(&ctx, "/eth/v1/beacon/states/head/finality_checkpoints"), + "{\"execution_optimistic\":false,\"finalized\":false,\"data\":{\ + \"previous_justified\":{\"epoch\":\"12344\",\ + \"root\":\"0x0101010101010101010101010101010101010101010101010101010101010101\"},\ + \"current_justified\":{\"epoch\":\"12345\",\ + \"root\":\"0x0202020202020202020202020202020202020202020202020202020202020202\"},\ + \"finalized\":{\"epoch\":\"12343\",\ + \"root\":\"0x0303030303030303030303030303030303030303030303030303030303030303\"}}}" + ); + } + + fn assert_state_not_found(ctx: &ApiCtx, state_id: &str) { + for path in state_paths(state_id) { + let resp = get(&Router::new(ROUTES), ctx, &path); + assert!(resp.starts_with(b"HTTP/1.1 404 Not Found\r\n"), "{path}"); + assert_eq!(body(&resp), br#"{"code":404,"message":"state not found"}"#, "{path}"); + } + } + + /// Silver publishes one state, the head. `justified` and `finalized` name + /// states it does not keep, and their checkpoints differ from the head's, + /// so answering them with head data would be a wrong answer rather than a + /// missing one. + #[test] + fn only_head_reads_the_published_state() { + let ctx = published_ctx(epoch_state(), HEAD_SLOT); + for path in state_paths("head") { + assert!(state_body(&ctx, &path).starts_with("{\"execution_optimistic\":false,")); + } + assert_state_not_found(&ctx, "justified"); + assert_state_not_found(&ctx, "finalized"); + } + + /// A state silver does not keep — no historical states, and the head is + /// the only one published; a slot or root form is 404 even when it is the + /// published state's own, which nothing here can check. + #[test] + fn a_state_id_naming_a_state_silver_does_not_keep_is_404() { + let ctx = published_ctx(epoch_state(), HEAD_SLOT); + let head_slot = HEAD_SLOT.to_string(); + for state_id in ["genesis", "0", &head_slot, &format!("0x{}", "ab".repeat(32))] { + assert_state_not_found(&ctx, state_id); + } + } + + /// `Invalid state ID` in the schemas: a value that identifies no state at + /// all is a 400, not the 404 an unavailable state gets. + #[test] + fn a_state_id_naming_no_state_at_all_is_400() { + let ctx = published_ctx(epoch_state(), HEAD_SLOT); + let short_root = format!("0x{}", "ab".repeat(31)); + let unhex_root = format!("0x{}", "zz".repeat(32)); + for state_id in + ["current", "banana", "", "-1", "+5", "0x", "1.5", &short_root, &unhex_root, "HEAD"] + { + for path in state_paths(state_id) { + let resp = get(&Router::new(ROUTES), &ctx, &path); + assert!(resp.starts_with(b"HTTP/1.1 400 Bad Request\r\n"), "{path}"); + assert_eq!(body(&resp), br#"{"code":400,"message":"invalid state_id"}"#, "{path}"); + } + } + } + + /// Neither endpoint's schema declares a 503, so a node with no state + /// published answers 404 — genesis with the phrase its own schema names. + #[test] + fn state_reads_are_404_before_bootstrap() { + let ctx = preboot_ctx(); + let resp = get(&Router::new(ROUTES), &ctx, "/eth/v1/beacon/genesis"); + assert!(resp.starts_with(b"HTTP/1.1 404 Not Found\r\n")); + assert_eq!(body(&resp), br#"{"code":404,"message":"Chain genesis info is not yet known"}"#); + assert_state_not_found(&ctx, "head"); + } + + /// The `state_id` verdict does not depend on there being a state to read. + #[test] + fn an_invalid_state_id_is_answered_before_the_state_is_read() { + for path in state_paths("banana") { + let resp = get(&Router::new(ROUTES), &preboot_ctx(), &path); + assert!(resp.starts_with(b"HTTP/1.1 400 Bad Request\r\n"), "{path}"); + } + } + + fn with_head_optimistic(head_optimistic: bool) -> NodeStatus { + let ready = ready(); + NodeStatus { slots: Some(SlotStatus { head_optimistic, ..ready.slots.unwrap() }), ..ready } + } + + /// The envelope flag is the head's own execution status, not a reading of + /// how far behind the node is: an unverified head is optimistic with both + /// layers reporting themselves synced, and a verified one is not while they + /// do not. A state read served before the first status announces a head is + /// optimistic — nothing has vouched for that head's payload yet. + #[test] + fn execution_optimistic_is_the_head_s_own_status() { + let mut ctx = published_ctx(epoch_state(), HEAD_SLOT); + for (status, want) in [ + (with_head_optimistic(true), "true"), + (with_head_optimistic(false), "false"), + (NodeStatus { syncing: true, ..with_head_optimistic(false) }, "false"), + (NodeStatus { el: ELSyncStatus::Offline, ..with_head_optimistic(false) }, "false"), + (NodeStatus { syncing: true, ..with_head_optimistic(true) }, "true"), + (NodeStatus::default(), "true"), + ] { + ctx.node_status = status; + for path in state_paths("head") { + assert!( + state_body(&ctx, &path) + .starts_with(&format!("{{\"execution_optimistic\":{want},")), + "{status:?} {path}" + ); + } + } + } + + /// `finalized` describes the state served, and genesis is the only state + /// that is its own finalized history. + #[test] + fn finalized_is_true_only_for_the_genesis_state() { + let genesis_epoch = EpochState { + previous_justified_checkpoint: Checkpoint::default(), + current_justified_checkpoint: Checkpoint::default(), + finalized_checkpoint: Checkpoint::default(), + ..epoch_state() + }; + let at_genesis = published_ctx(genesis_epoch, 0); + let past_genesis = published_ctx(epoch_state(), HEAD_SLOT); + for path in state_paths("head") { + let flags = "{\"execution_optimistic\":false,\"finalized\":"; + assert!(state_body(&at_genesis, &path).starts_with(&format!("{flags}true,"))); + assert!(state_body(&past_genesis, &path).starts_with(&format!("{flags}false,"))); + } + } +} diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs new file mode 100644 index 00000000..4158bce9 --- /dev/null +++ b/crates/beacon_api/src/server.rs @@ -0,0 +1,1084 @@ +use std::{ + collections::HashMap, + io::{self, Read, Write}, + time::{Duration, Instant}, +}; + +use mio::{Events, Interest, Registry, Token, event::Event}; +use silver_beacon_state_data::{BeaconStateReader, SpecConfig}; +use silver_common::{Enr, Identify, Keypair}; +use silver_httpcore::{ + AfterResponse, Bind, Listener, ParsedRequest, ServerConnection, Stream, TokenRange, +}; + +use crate::{ + NodeStatus, + router::Router, + routes::{ApiCtx, ROUTES}, +}; + +const MAX_SWEEP_INTERVAL: Duration = Duration::from_secs(1); + +/// nginx's `lingering_close` caps: how long a connection that has already +/// answered may wait between the peer's bytes, and how long the whole drain +/// may run before the slot is taken back. +struct Linger { + idle: Duration, + total: Duration, +} + +impl Default for Linger { + fn default() -> Self { + Self { idle: Duration::from_secs(5), total: Duration::from_secs(30) } + } +} + +struct Connection { + stream: Stream, + http: ServerConnection, + last_activity: Instant, + linger_since: Option, +} + +impl Connection { + /// Reads what the peer is still sending only to drop it: nothing on this + /// connection will be parsed again, and the reading is what keeps the + /// answer already written from dying with the socket. + fn drain_discarded(&mut self, now: Instant) -> io::Result { + loop { + match self.stream.read(self.http.discard_space()) { + Ok(0) => return Ok(true), + Ok(_) => self.last_activity = now, + Err(e) if would_block(&e) => return Ok(false), + Err(e) if interrupted(&e) => continue, + // However the peer ended it, the connection is over. + Err(_) => return Ok(true), + } + } + } + + /// A lingering connection has answered already, so it lives by the linger + /// caps rather than by the idle deadline that holds a served connection + /// open for its client's next request. + fn expired(&self, now: Instant, idle_timeout: Duration, linger: &Linger) -> bool { + let quiet_for = now.duration_since(self.last_activity); + match self.linger_since { + Some(since) => quiet_for > linger.idle || now.duration_since(since) > linger.total, + None => quiet_for > idle_timeout, + } + } + + fn handle_event, &mut Vec)>( + &mut self, + registry: &Registry, + event: &Event, + now: Instant, + request_handler: &F, + ) -> io::Result { + if self.linger_since.is_some() { + return self.drain_discarded(now); + } + + if event.is_readable() { + // A full buffer is not yet a verdict: a body declared past the cap + // is answered from headers already buffered. Exhaustion ends the + // connection only once there is nothing left to answer with. + let mut exhausted = None; + loop { + let space = match self.http.read_space() { + Ok(space) => space, + Err(e) => { + exhausted = Some(e); + break; + } + }; + match self.stream.read(space) { + Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), + Ok(n) => { + self.last_activity = now; + self.http.commit_read(n); + } + Err(e) if would_block(&e) => break, + Err(e) if interrupted(&e) => continue, + Err(e) => return Err(e), + } + } + + if self.http.dispatch(request_handler) { + registry.reregister(&mut self.stream, event.token(), Interest::WRITABLE)?; + } else if let Some(e) = exhausted { + return Err(e); + } + return Ok(false); + } + + if event.is_writable() { + if !self.http.pending_write().is_empty() { + loop { + match self.stream.write(self.http.pending_write()) { + Ok(0) => { + return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")) + } + Ok(n) => { + self.last_activity = now; + self.http.commit_write(n); + if self.http.pending_write().is_empty() { + break; + } + } + Err(e) if would_block(&e) => return Ok(false), + Err(e) if interrupted(&e) => continue, + Err(e) => return Err(e), + } + } + match self.http.after_response(request_handler) { + AfterResponse::Close => return Ok(true), + AfterResponse::Linger => { + // The FIN tells the peer its answer is whole while the + // socket stays readable, so a body still on its way is + // drained instead of resetting the connection that + // carried the answer. + self.stream.shutdown_write()?; + self.linger_since = Some(now); + registry.reregister(&mut self.stream, event.token(), Interest::READABLE)?; + return self.drain_discarded(now); + } + AfterResponse::ResponsePending => { + registry.reregister(&mut self.stream, event.token(), Interest::WRITABLE)? + } + AfterResponse::AwaitRequest => { + registry.reregister(&mut self.stream, event.token(), Interest::READABLE)? + } + } + } + return Ok(false); + } + + Ok(false) + } +} + +/// Schedules the idle scan so that `pump` walks the connection map at most +/// once per `interval` instead of on every busy-poll iteration. +struct IdleSweep { + timeout: Duration, + interval: Duration, + next: Instant, +} + +impl IdleSweep { + fn new(timeout: Duration) -> Self { + let interval = MAX_SWEEP_INTERVAL.min(timeout / 4); + Self { timeout, interval, next: Instant::now() + interval } + } + + fn due(&mut self, now: Instant) -> bool { + if now < self.next { + return false; + } + self.next = now + self.interval; + true + } +} + +pub struct BeaconApi { + registry: Registry, + tokens: TokenRange, + listeners: Vec, + max_connections: usize, + idle: IdleSweep, + linger: Linger, + next_connection_offset: usize, + connections: HashMap, + router: Router, + ctx: ApiCtx, +} + +impl BeaconApi { + #[allow(clippy::too_many_arguments)] + pub fn new( + registry: &Registry, + tokens: TokenRange, + binds: &[Bind], + max_connections: usize, + idle_timeout: Duration, + keypair: &Keypair, + local_enr: Enr, + identify: &Identify, + spec: &SpecConfig, + state: BeaconStateReader, + ) -> Self { + assert!(!binds.is_empty(), "beacon api needs at least one bind"); + let tokens_needed = binds.len().checked_add(max_connections); + assert!( + tokens_needed.is_some_and(|needed| needed <= tokens.span()), + "beacon api needs a token per listener and per connection: {} listeners plus a cap \ + of {max_connections} does not fit a span of {}", + binds.len(), + tokens.span() + ); + + let registry = registry.try_clone().expect("mio Registry::try_clone failed"); + let listeners = binds + .iter() + .enumerate() + .map(|(index, bind)| { + let mut listener = Listener::bind(bind) + .unwrap_or_else(|e| panic!("beacon api bind {bind:?}: {e}")); + registry.register(&mut listener, tokens.at(index), Interest::READABLE).unwrap(); + listener + }) + .collect::>(); + + Self { + registry, + tokens, + max_connections, + idle: IdleSweep::new(idle_timeout), + linger: Linger::default(), + next_connection_offset: listeners.len(), + listeners, + connections: HashMap::new(), + router: Router::new(ROUTES), + ctx: ApiCtx::new(keypair, &local_enr, identify, spec, state), + } + } + + pub fn local_addrs(&self) -> Vec { + self.listeners.iter().map(Listener::local_addr).collect() + } + + /// In-place update seam for the status's single writer. + pub fn node_status_mut(&mut self) -> &mut NodeStatus { + &mut self.ctx.node_status + } + + pub fn pump(&mut self, events: &Events) -> bool { + let now = Instant::now(); + + let mut did_work = false; + for event in events.iter() { + // The batch is the whole loop's; only tokens inside this server's + // range are its own sockets. + let Some(offset) = self.tokens.offset_of(event.token()) else { continue }; + did_work |= if offset < self.listeners.len() { + self.accept_all(offset, now) + } else { + self.serve(event, now) + }; + } + + if self.idle.due(now) { + did_work |= self.close_expired(now); + } + + did_work + } + + fn accept_all(&mut self, listener_index: usize, now: Instant) -> bool { + let mut did_work = false; + loop { + let mut stream = match self.listeners[listener_index].accept() { + Ok(stream) => stream, + Err(e) if would_block(&e) => break, + Err(e) => { + tracing::warn!("accept failed: {e}"); + break; + } + }; + + did_work = true; + // Accept-and-close at the cap: with edge-triggered registration, + // leaving the stream in the backlog would go silent until the next + // SYN retriggers the listener. + if self.connections.len() >= self.max_connections { + tracing::warn!( + "beacon api connection cap {} reached, dropping new connection", + self.max_connections + ); + continue; + } + let token = self.take_connection_token(); + self.registry.register(&mut stream, token, Interest::READABLE).unwrap(); + self.connections.insert(token, Connection { + stream, + http: ServerConnection::new(), + last_activity: now, + linger_since: None, + }); + } + did_work + } + + fn serve(&mut self, event: &Event, now: Instant) -> bool { + let token = event.token(); + let Some(conn) = self.connections.get_mut(&token) else { return false }; + match conn.handle_event(&self.registry, event, now, &|req, out| { + self.router.dispatch(req, &self.ctx, out) + }) { + Ok(true) => { + let _ = self.registry.deregister(&mut conn.stream); + self.connections.remove(&token); + } + Ok(false) => {} + Err(e) => { + tracing::warn!("connection error: {e}"); + let _ = self.registry.deregister(&mut conn.stream); + self.connections.remove(&token); + } + }; + true + } + + /// Connections close in any order while the cursor only advances, so the + /// offset it lands on may still be held. The range holds every socket the + /// server can register at once, so probing forward ends on a free one. + fn take_connection_token(&mut self) -> Token { + assert!( + self.connections.len() < self.max_connections, + "beacon api connection cap {} must gate every token taken", + self.max_connections + ); + loop { + let offset = self.next_connection_offset; + self.next_connection_offset = + if offset + 1 >= self.tokens.span() { self.listeners.len() } else { offset + 1 }; + let token = self.tokens.at(offset); + if !self.connections.contains_key(&token) { + return token; + } + } + } + + fn close_expired(&mut self, now: Instant) -> bool { + let Self { connections, registry, idle, linger, .. } = self; + let before = connections.len(); + connections.retain(|_, conn| { + if !conn.expired(now, idle.timeout, linger) { + return true; + } + match conn.linger_since { + Some(since) => tracing::warn!( + "beacon api connection still sending {:?} after its answer, closing", + now.duration_since(since) + ), + None => tracing::warn!( + "beacon api connection idle for {:?}, closing", + now.duration_since(conn.last_activity) + ), + } + let _ = registry.deregister(&mut conn.stream); + false + }); + connections.len() != before + } +} + +fn would_block(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::WouldBlock +} + +fn interrupted(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::Interrupted +} + +#[cfg(test)] +mod tests { + use std::{ + net::{SocketAddr, TcpStream}, + os::unix::net::UnixStream, + path::Path, + thread::JoinHandle, + time::Instant, + }; + + use silver_beacon_state_data::BeaconStateOwner; + use silver_httpcore::Readiness; + + use super::*; + + /// Longer than any test's 10 s spin deadline: the idle sweep never reaps. + const LONG_TIMEOUT: Duration = Duration::from_secs(60); + + /// The sole tenant of its readiness loop, which the tile owns in + /// production and every test here owns for itself. + struct Server { + readiness: Readiness, + api: BeaconApi, + } + + impl Server { + fn new( + tokens: TokenRange, + binds: &[Bind], + max_connections: usize, + idle_timeout: Duration, + ) -> Self { + let readiness = Readiness::new(1024); + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + let api = BeaconApi::new( + readiness.registry(), + tokens, + binds, + max_connections, + idle_timeout, + &keypair, + local_enr, + &Identify::default(), + &SpecConfig::mainnet(), + BeaconStateOwner::empty_test(0).reader(), + ); + Self { readiness, api } + } + + fn pump(&mut self) -> bool { + self.readiness.wait(Duration::ZERO); + self.api.pump(self.readiness.events()) + } + } + + fn server_bound_to(binds: &[Bind], max_connections: usize, idle_timeout: Duration) -> Server { + Server::new(TokenRange::whole(), binds, max_connections, idle_timeout) + } + + fn server_with(max_connections: usize, idle_timeout: Duration) -> Server { + server_bound_to(&[Bind::parse("127.0.0.1:0")], max_connections, idle_timeout) + } + + fn tcp_addrs(server: &Server) -> Vec { + server + .api + .local_addrs() + .into_iter() + .map(|bind| { + let Bind::Tcp(addr) = bind else { panic!("expected tcp bind") }; + addr + }) + .collect() + } + + fn tcp_addr(server: &Server) -> SocketAddr { + tcp_addrs(server)[0] + } + + fn pump_until(server: &mut Server, msg: &str, mut done: impl FnMut(&Server) -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !done(server) { + assert!(Instant::now() < deadline, "timeout: {msg}"); + server.pump(); + std::thread::sleep(Duration::from_millis(1)); + } + } + + fn serve(server: &mut Server, client: JoinHandle, msg: &str) -> T { + pump_until(server, msg, |_| client.is_finished()); + client.join().unwrap() + } + + fn serve_both( + server: &mut Server, + first: JoinHandle, + second: JoinHandle, + msg: &str, + ) -> (T, T) { + pump_until(server, msg, |_| first.is_finished() && second.is_finished()); + (first.join().unwrap(), second.join().unwrap()) + } + + /// Connection tokens must stay inside this server's share of the loop and + /// above its listener offsets: a wrap that lands on a listener would have + /// the server answering an accept socket as if it were a connection, and + /// one that leaves the range would collide with another tenant. + #[test] + fn connection_tokens_wrap_inside_the_range_above_the_listeners() { + let span = 8; + let tokens = TokenRange::new(64, span); + let binds = [Bind::parse("127.0.0.1:0"), Bind::parse("127.0.0.1:0")]; + let mut server = Server::new(tokens, &binds, span - binds.len(), LONG_TIMEOUT); + + let assigned = std::iter::repeat_with(|| server.api.take_connection_token()) + .take(2 * span) + .collect::>(); + + assert_eq!(assigned[0], Token(64 + binds.len()), "the first token clears the listeners"); + for token in &assigned { + let offset = tokens.offset_of(*token).expect("token inside the server's range"); + assert!(offset >= binds.len(), "{token:?} aliases a listener"); + } + assert_eq!(assigned[span - binds.len()], assigned[0], "the wrap lands where it started"); + } + + /// A range with no room for every socket at once has nowhere for the + /// connection allocator to probe to, so it is refused at construction. + #[test] + #[should_panic(expected = "does not fit a span")] + fn a_range_too_small_for_the_connection_cap_is_rejected() { + Server::new(TokenRange::new(0, 8), &[Bind::parse("127.0.0.1:0")], 64, LONG_TIMEOUT); + } + + /// Connections close in any order while the cursor only advances, so the + /// offset it wraps onto can still belong to a connection that outlived a + /// later one. Handing that offset out again replaces the map entry, which + /// drops the older connection and closes its socket unannounced. + #[test] + fn a_recycled_offset_skips_the_connection_still_holding_it() { + let span = 3; + let tokens = TokenRange::new(64, span); + let binds = [Bind::parse("127.0.0.1:0")]; + let mut server = Server::new(tokens, &binds, span - binds.len(), LONG_TIMEOUT); + let addr = tcp_addr(&server); + + let long_lived = connect(addr); + long_lived.set_nonblocking(true).unwrap(); + pump_until(&mut server, "long-lived connection accepted", |server| { + server.api.connections.len() == 1 + }); + let held = *server.api.connections.keys().next().expect("one connection"); + assert_eq!(held, tokens.at(binds.len()), "the first connection clears the listeners"); + + // Takes the last offset of the range and gives it straight back, + // leaving the cursor wrapped onto the offset still held above. + drop(connect(addr)); + pump_until(&mut server, "short-lived connection accepted and reaped", |server| { + server.api.connections.len() == 1 && server.api.next_connection_offset == binds.len() + }); + + let _newcomer = connect(addr); + let mut probe = [0u8; 1]; + pump_until(&mut server, "newcomer accepted", |server| { + server.api.connections.len() == 2 || matches!((&long_lived).read(&mut probe), Ok(0)) + }); + assert_eq!( + server.api.connections.len(), + 2, + "the newcomer took the offset a live connection holds" + ); + assert!( + matches!((&long_lived).read(&mut probe), Err(e) if would_block(&e)), + "the long-lived connection lost the socket its offset was handed away with" + ); + } + + fn connect(addr: SocketAddr) -> TcpStream { + let stream = TcpStream::connect(addr).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + stream + } + + fn connect_uds(path: &Path) -> UnixStream { + let stream = UnixStream::connect(path).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + stream + } + + fn get_identity(mut stream: impl Read + Write) -> Vec { + write!( + stream, + "GET /eth/v1/node/identity HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + response + } + + fn assert_identity_ok(response: &[u8]) { + let text = String::from_utf8_lossy(response); + assert!(text.starts_with("HTTP/1.1 200 OK\r\n"), "unexpected response: {text}"); + assert!(text.contains("\"peer_id\""), "identity body missing: {text}"); + } + + fn read_to_eof(mut stream: impl Read) -> Vec { + let mut received = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + match stream.read(&mut chunk) { + Ok(0) => return received, + Ok(n) => received.extend_from_slice(&chunk[..n]), + Err(e) => panic!("client read: {e}"), + } + } + } + + const PAYLOAD_TOO_LARGE: &[u8] = + b"HTTP/1.1 413 Payload Too Large\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"; + + /// go-eth2-client and Prysm post a whole validator set unchunked, so the + /// declared length is on the wire long before the body is. + fn declare_oversized_body(stream: &mut impl Write) { + write!( + stream, + "POST /eth/v1/validator/register_validator HTTP/1.1\r\nHost: x\r\n\ + Content-Length: {}\r\n\r\n", + 64 << 20 + ) + .unwrap(); + } + + /// Keeps the body coming after the answer must already have been framed, + /// slowly enough that the server is answering mid-stream rather than after + /// the last byte. Every write and the final read has to succeed: a peer + /// that hangs up on the unread body breaks the send long before the answer + /// can be read back. + fn stream_body_past_the_answer(mut stream: impl Read + Write) -> io::Result> { + declare_oversized_body(&mut stream); + let chunk = vec![b'b'; 64 << 10]; + for _ in 0..64 { + stream.write_all(&chunk)?; + std::thread::sleep(Duration::from_millis(1)); + } + let mut answer = Vec::new(); + stream.read_to_end(&mut answer)?; + Ok(answer) + } + + #[test] + #[should_panic(expected = "at least one bind")] + fn an_empty_bind_list_is_rejected() { + server_bound_to(&[], 64, LONG_TIMEOUT); + } + + #[test] + fn every_tcp_listener_serves_the_api() { + let mut server = server_bound_to( + &[Bind::parse("127.0.0.1:0"), Bind::parse("127.0.0.1:0")], + 64, + LONG_TIMEOUT, + ); + + let addrs = tcp_addrs(&server); + assert_eq!(addrs.len(), 2, "one resolved address per bind"); + assert_ne!(addrs[0], addrs[1], "each bind resolves to its own port"); + assert!(addrs.iter().all(|addr| addr.port() != 0), "port-0 binds resolve: {addrs:?}"); + + let (first_addr, second_addr) = (addrs[0], addrs[1]); + let (first, second) = serve_both( + &mut server, + std::thread::spawn(move || get_identity(connect(first_addr))), + std::thread::spawn(move || get_identity(connect(second_addr))), + "both tcp listeners served", + ); + assert_identity_ok(&first); + assert_identity_ok(&second); + } + + #[test] + fn tcp_and_uds_listeners_serve_side_by_side() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("api.sock"); + let mut server = server_bound_to( + &[Bind::parse("127.0.0.1:0"), Bind::Unix(socket.clone())], + 64, + LONG_TIMEOUT, + ); + + let addrs = server.api.local_addrs(); + let [Bind::Tcp(tcp_addr), Bind::Unix(uds_path)] = &addrs[..] else { + panic!("expected a tcp bind and a uds bind: {addrs:?}") + }; + assert_eq!(uds_path, &socket); + + let tcp_addr = *tcp_addr; + let (over_tcp, over_uds) = serve_both( + &mut server, + std::thread::spawn(move || get_identity(connect(tcp_addr))), + std::thread::spawn(move || get_identity(connect_uds(&socket))), + "tcp and uds listeners served", + ); + assert_identity_ok(&over_tcp); + assert_identity_ok(&over_uds); + } + + /// The cap counts connections, not listeners: a slot held through one + /// listener refuses clients arriving on any other. + #[test] + fn connection_cap_is_shared_across_listeners() { + let mut server = server_bound_to( + &[Bind::parse("127.0.0.1:0"), Bind::parse("127.0.0.1:0")], + 1, + LONG_TIMEOUT, + ); + let addrs = tcp_addrs(&server); + let (held, other) = (addrs[0], addrs[1]); + + let held_open = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(held); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + let mut response = Vec::new(); + let mut chunk = [0u8; 1024]; + while !response.windows(4).any(|w| w == b"\r\n\r\n") { + let n = stream.read(&mut chunk).unwrap(); + assert!(n > 0, "server closed the held connection"); + response.extend_from_slice(&chunk[..n]); + } + stream + }), + "first listener's client took the only slot", + ); + + assert_eq!(server.api.connections.len(), 1); + assert!( + server.api.connections.keys().all(|token| token.0 >= 2), + "connection tokens must clear the listener range: {:?}", + server.api.connections.keys().collect::>() + ); + + let denied = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(other); + let _ = write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n"); + let mut chunk = [0u8; 1024]; + stream.read(&mut chunk) + }), + "second listener's client refused at the cap", + ); + assert!( + !matches!(denied, Ok(n) if n > 0), + "a slot held on one listener must refuse the other: {denied:?}" + ); + + drop(held_open); + pump_until(&mut server, "closed connection reaped", |server| { + server.api.connections.is_empty() + }); + + let response = serve( + &mut server, + std::thread::spawn(move || get_identity(connect(other))), + "second listener served once the slot freed", + ); + assert_identity_ok(&response); + } + + #[test] + fn connection_cap_drops_excess_then_recovers() { + let mut server = server_with(1, LONG_TIMEOUT); + let addr = tcp_addr(&server); + + let held_open = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(addr); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + let mut response = Vec::new(); + let mut chunk = [0u8; 1024]; + while !response.windows(4).any(|w| w == b"\r\n\r\n") { + let n = stream.read(&mut chunk).unwrap(); + assert!(n > 0, "server closed the first connection"); + response.extend_from_slice(&chunk[..n]); + } + assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); + stream + }), + "first client served", + ); + + let denied = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(addr); + let _ = write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n"); + let mut chunk = [0u8; 1024]; + stream.read(&mut chunk) + }), + "second client dropped at cap", + ); + assert!( + !matches!(denied, Ok(n) if n > 0), + "connection over the cap must not be served: {denied:?}" + ); + + drop(held_open); + pump_until(&mut server, "closed connection reaped", |server| { + server.api.connections.is_empty() + }); + + let response = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(addr); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + response + }), + "third client served after the slot freed", + ); + assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); + } + + /// A partial request that never completes holds its slot until the idle + /// deadline reaps it. Definitively malformed input gets 400-and-close + /// at parse time. + #[test] + fn partial_request_is_reaped_after_the_idle_deadline() { + let mut server = server_with(64, Duration::from_millis(200)); + let addr = tcp_addr(&server); + + let received = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(addr); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n").unwrap(); + read_to_eof(stream) + }), + "partial request reaped", + ); + + assert!(received.is_empty(), "half a request must not be answered: {received:?}"); + assert!(server.api.connections.is_empty(), "reaped connection must leave the map"); + } + + /// An operator large enough to declare more body than the read buffer + /// holds gets a status back rather than a connection that goes quiet. + #[test] + fn a_body_declared_past_the_read_cap_is_answered_with_413() { + let mut server = server_with(64, LONG_TIMEOUT); + let addr = tcp_addr(&server); + + let received = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(addr); + declare_oversized_body(&mut stream); + read_to_eof(stream) + }), + "oversized declaration accepted", + ); + + assert_eq!(received, PAYLOAD_TOO_LARGE, "{}", String::from_utf8_lossy(&received)); + pump_until(&mut server, "answered connection closed on the peer's own close", |server| { + server.api.connections.is_empty() + }); + } + + /// The reason the answer outlives the request: a client still pushing a + /// body the server has already refused must be left to finish and read the + /// whole status. A connection broken under it is a transport error to its + /// caller and costs the node its place in the rotation, where a 413 costs + /// nothing. + #[test] + fn a_client_still_streaming_when_the_413_is_framed_reads_all_of_it() { + let mut server = server_with(64, LONG_TIMEOUT); + let addr = tcp_addr(&server); + + let received = serve( + &mut server, + std::thread::spawn(move || stream_body_past_the_answer(connect(addr))), + "413 delivered to a client still sending", + ); + + assert_answer_survived(received); + pump_until(&mut server, "lingering connection closed once the peer went away", |server| { + server.api.connections.is_empty() + }); + } + + /// Unix sockets take the same half-close, so the drain ends on the peer's + /// own close there too rather than running to the linger cap. + #[test] + fn a_client_still_streaming_over_uds_reads_all_of_the_413() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("api.sock"); + let mut server = server_bound_to(&[Bind::Unix(socket.clone())], 64, LONG_TIMEOUT); + + let received = serve( + &mut server, + std::thread::spawn(move || stream_body_past_the_answer(connect_uds(&socket))), + "413 delivered over uds to a client still sending", + ); + + assert_answer_survived(received); + pump_until( + &mut server, + "lingering uds connection closed once the peer went away", + |server| server.api.connections.is_empty(), + ); + } + + fn assert_answer_survived(received: io::Result>) { + match received { + Ok(answer) => { + assert_eq!(answer, PAYLOAD_TOO_LARGE, "{}", String::from_utf8_lossy(&answer)) + } + Err(e) => panic!("the client's connection did not survive its answer: {e}"), + } + } + + /// Draining an answered connection is bounded: one client cannot hold a + /// slot for as long as it cares to keep sending. + #[test] + fn a_client_that_never_stops_sending_is_dropped_at_the_linger_cap() { + let mut server = server_with(64, Duration::from_millis(800)); + // A peer that never pauses keeps the wait between reads at zero, so the + // total cap is the only one that can end it. + server.api.linger = + Linger { idle: Duration::from_millis(400), total: Duration::from_millis(200) }; + let addr = tcp_addr(&server); + + let flooding = std::thread::spawn(move || { + let mut stream = connect(addr); + declare_oversized_body(&mut stream); + let chunk = vec![b'b'; 64 << 10]; + let deadline = Instant::now() + Duration::from_secs(9); + while Instant::now() < deadline { + if stream.write_all(&chunk).is_err() { + return true; + } + } + false + }); + + let midway = Instant::now() + Duration::from_millis(100); + pump_until(&mut server, "server pumped past the answer", |_| Instant::now() >= midway); + assert_eq!( + server.api.connections.len(), + 1, + "the answered connection must drain, not close" + ); + + pump_until(&mut server, "flooding client dropped at the linger cap", |server| { + server.api.connections.is_empty() + }); + assert!(flooding.join().unwrap(), "the server must be the one to end it"); + } + + /// A peer that neither sends nor closes after its answer holds a slot for + /// the wait between reads, not for the whole draining window — and not for + /// the far longer deadline that keeps a served connection available. + #[test] + fn a_lingering_connection_that_goes_quiet_is_dropped_at_the_idle_cap() { + let idle_timeout = Duration::from_secs(2); + let mut server = server_with(64, idle_timeout); + server.api.linger = + Linger { idle: Duration::from_millis(100), total: Duration::from_secs(30) }; + let addr = tcp_addr(&server); + + let (release, on_release) = std::sync::mpsc::channel::<()>(); + let holding = std::thread::spawn(move || { + let mut stream = connect(addr); + declare_oversized_body(&mut stream); + let answer = read_to_eof(&mut stream); + let _ = on_release.recv(); + answer + }); + + pump_until(&mut server, "oversized declaration accepted", |server| { + server.api.connections.len() == 1 + }); + let answered = Instant::now(); + pump_until(&mut server, "quiet lingering connection dropped at the idle cap", |server| { + server.api.connections.is_empty() + }); + let held_for = answered.elapsed(); + assert!(held_for < idle_timeout / 2, "held for {held_for:?}, as if it were still serving"); + + release.send(()).unwrap(); + assert_eq!(holding.join().unwrap(), PAYLOAD_TOO_LARGE); + } + + #[test] + fn idle_keep_alive_connection_is_reaped_after_the_idle_deadline() { + let idle_timeout = Duration::from_millis(200); + let mut server = server_with(64, idle_timeout); + let addr = tcp_addr(&server); + + let (received, alive_for) = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(addr); + // Timed from before the request: the server's activity stamp + // cannot predate it, so the deadline it enforces is at least + // this long. + let sent_at = Instant::now(); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + (read_to_eof(stream), sent_at.elapsed()) + }), + "idle keep-alive connection reaped", + ); + + assert!(received.starts_with(b"HTTP/1.1 200 OK\r\n")); + assert!(alive_for >= idle_timeout, "closed before the deadline, after {alive_for:?}"); + assert!(server.api.connections.is_empty(), "reaped connection must leave the map"); + } + + #[test] + fn traffic_refreshes_the_idle_deadline() { + let idle_timeout = Duration::from_millis(400); + let mut server = server_with(64, idle_timeout); + let addr = tcp_addr(&server); + + // Five requests spaced a quarter of the deadline apart run well past it + // in total; each read/write must push the deadline out. + let _still_open = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(addr); + let mut chunk = [0u8; 1024]; + for i in 0..5 { + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + let n = stream.read(&mut chunk).unwrap(); + assert!(n > 0, "server closed a connection that kept transferring (#{i})"); + std::thread::sleep(idle_timeout / 4); + } + stream + }), + "keep-alive client kept alive by its own traffic", + ); + + assert_eq!(server.api.connections.len(), 1, "an active connection must survive the sweep"); + } + + /// Connection exhaustion scenario end to end: a hung client owns the only + /// slot, so every other client is refused until the sweep frees it. + #[test] + fn idle_sweep_frees_a_slot_held_at_the_cap() { + let mut server = server_with(1, Duration::from_millis(800)); + let addr = tcp_addr(&server); + + let hung = std::thread::spawn(move || { + let mut stream = connect(addr); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n").unwrap(); + read_to_eof(stream) + }); + pump_until(&mut server, "hung client holds the only slot", |server| { + server.api.connections.len() == 1 + }); + + let denied = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(addr); + let _ = write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n"); + let mut chunk = [0u8; 1024]; + stream.read(&mut chunk) + }), + "second client refused while the slot is held", + ); + assert!( + !matches!(denied, Ok(n) if n > 0), + "the held slot must refuse other clients: {denied:?}" + ); + + assert!(serve(&mut server, hung, "hung client reaped").is_empty()); + + let response = serve( + &mut server, + std::thread::spawn(move || { + let mut stream = connect(addr); + write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + response + }), + "fresh client served once the sweep freed the slot", + ); + assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); + } +} diff --git a/crates/beacon_api/src/statics.rs b/crates/beacon_api/src/statics.rs new file mode 100644 index 00000000..6db0b9ff --- /dev/null +++ b/crates/beacon_api/src/statics.rs @@ -0,0 +1,49 @@ +use silver_beacon_state_data::SpecConfig; +use silver_common::{AGENT_VERSION, Enr, Identify, Keypair}; + +use crate::{ + config::{deposit_contract_body, fork_schedule_body, spec_body}, + identity::build_identity_json, + json::Json, +}; + +/// Bodies whose every input is known at boot. Rendering them once leaves +/// their handlers a buffer copy, and keeps the spec table — the largest body +/// silver serves — off the request path entirely. +pub(crate) struct StaticBodies { + pub(crate) identity: Vec, + pub(crate) version: Vec, + pub(crate) spec: Vec, + pub(crate) fork_schedule: Vec, + pub(crate) deposit_contract: Vec, +} + +impl StaticBodies { + pub(crate) fn new( + keypair: &Keypair, + local_enr: &Enr, + identify: &Identify, + spec: &SpecConfig, + ) -> Self { + Self { + identity: build_identity_json(keypair, local_enr, identify), + version: version_body(), + spec: spec_body(spec), + fork_schedule: fork_schedule_body(spec), + deposit_contract: deposit_contract_body(spec), + } + } +} + +fn version_body() -> Vec { + let mut out = Vec::new(); + let mut json = Json::new(&mut out); + json.begin_object(); + json.key("data"); + json.begin_object(); + json.key("version"); + json.string(AGENT_VERSION); + json.end_object(); + json.end_object(); + out +} diff --git a/crates/beacon_api/src/validators/entry.rs b/crates/beacon_api/src/validators/entry.rs new file mode 100644 index 00000000..290a48ab --- /dev/null +++ b/crates/beacon_api/src/validators/entry.rs @@ -0,0 +1,96 @@ +use silver_beacon_state_data::{BLSPubkey, Epoch, StateReadView, ValidatorsView, Withdrawals}; + +use crate::validators::{ + filter::Filter, + status::{Lifecycle, Status}, +}; + +/// SSZ `Validator`, copied out of the columnar registry — the read closure +/// owns everything the body needs so rendering happens outside the seqlock. +#[derive(Clone, PartialEq, Eq, Debug)] +pub(crate) struct Validator { + pub(crate) pubkey: BLSPubkey, + pub(crate) withdrawal_credentials: Withdrawals, + pub(crate) effective_balance: u64, + pub(crate) lifecycle: Lifecycle, +} + +/// `ValidatorResponse` of `types/api.yaml`. +#[derive(Clone, PartialEq, Eq, Debug)] +pub(crate) struct ValidatorEntry { + pub(crate) index: u64, + pub(crate) balance: u64, + pub(crate) status: Status, + pub(crate) validator: Validator, +} + +impl Validator { + fn read(validators: &ValidatorsView<'_>, index: usize, lifecycle: Lifecycle) -> Self { + Self { + pubkey: *validators.pubkey(index), + withdrawal_credentials: *validators.credentials(index), + effective_balance: validators.effective_balance(index), + lifecycle, + } + } +} + +impl ValidatorEntry { + pub(crate) fn read(view: &StateReadView<'_>, index: u32, epoch: Epoch) -> Self { + Self::accepted(view, index, epoch, &Filter::default()) + .expect("a filter naming no status accepts every status") + } + + /// The entry at `index` unless `filter` rejects its status. The pubkey and + /// credentials copies the body needs are paid only for a validator the + /// answer carries; deriving the status costs the five lifecycle columns. + fn accepted( + view: &StateReadView<'_>, + index: u32, + epoch: Epoch, + filter: &Filter, + ) -> Option { + let ix = index as usize; + let balance = view.balances.get(ix); + let lifecycle = Lifecycle::read(&view.validators, ix); + let status = Status::of(&lifecycle, balance, epoch); + filter.accepts(status).then(|| Self { + index: index as u64, + balance, + status, + validator: Validator::read(&view.validators, ix, lifecycle), + }) + } + + /// Every validator `filter` selects, in registry order. Runs under the + /// seqlock and is re-run whole on a finalize retry, so it must stay a pure + /// function of `view`: it owns its result and touches nothing else. + /// + /// `count()` spans the fork delta's `appended` vec, which + /// [`silver_beacon_state_data::BeaconStateReader::read`] does not list + /// among the reads that are safe to make optimistically. Re-reading it per + /// step stops a concurrent shrink from indexing past the registry, which + /// latching one bound up front would not. + pub(crate) fn matching(view: &StateReadView<'_>, filter: &Filter) -> Vec { + let epoch = view.slot.current_epoch(); + let in_registry = |index: &u32| (*index as usize) < view.validators.count(); + match filter.resolve_ids(&view.validators) { + Some(indices) => indices + .into_iter() + .filter(in_registry) + .filter_map(|index| Self::accepted(view, index, epoch, filter)) + .collect(), + None => { + let mut entries = Vec::new(); + let mut index = 0; + while in_registry(&index) { + if let Some(entry) = Self::accepted(view, index, epoch, filter) { + entries.push(entry); + } + index += 1; + } + entries + } + } + } +} diff --git a/crates/beacon_api/src/validators/filter.rs b/crates/beacon_api/src/validators/filter.rs new file mode 100644 index 00000000..95a84572 --- /dev/null +++ b/crates/beacon_api/src/validators/filter.rs @@ -0,0 +1,349 @@ +use serde::Deserialize; +use silver_beacon_state_data::{BLSPubkey, ValidatorsView}; +use silver_httpcore::Query; + +use crate::{ + ids::MAX_BODY_IDS, + response::Response, + validators::status::{Status, StatusMask}, +}; + +/// `maxItems` on the GET `id` array (`apis/beacon/states/validators.yaml`). +const MAX_QUERY_IDS: usize = 64; + +pub(crate) enum ValidatorId { + Index(u64), + Pubkey(BLSPubkey), +} + +impl ValidatorId { + /// `None` for a value that names no validator at all, which the schemas + /// answer 400 — distinct from a well-formed id no validator carries. + pub(crate) fn parse(text: &str) -> Option { + let Some(hex) = text.strip_prefix("0x") else { + // `u64::from_str` alone also accepts a leading `+`. + return text + .bytes() + .all(|byte| byte.is_ascii_digit()) + .then(|| text.parse().ok()) + .flatten() + .map(Self::Index); + }; + let mut pubkey = [0u8; 48]; + hex::decode_to_slice(hex, &mut pubkey).ok()?; + Some(Self::Pubkey(pubkey)) + } + + pub(crate) fn resolve(&self, validators: &ValidatorsView<'_>) -> Option { + match self { + Self::Index(index) => { + u32::try_from(*index).ok().filter(|&i| (i as usize) < validators.count()) + } + Self::Pubkey(pubkey) => validators.find_by_pubkey(pubkey), + } + } +} + +/// The `id`/`status` pair both list endpoints filter on. Naming no id and no +/// status is the spec's "no filtering on that attribute", which selects the +/// whole registry. +#[derive(Default)] +pub(crate) struct Filter { + ids: Vec, + statuses: StatusMask, +} + +impl Filter { + /// `id` and `status` repeat, and each occurrence may itself be a + /// comma-separated list — no id or status value can contain a comma, so + /// accepting both spellings can never split a legal value. + pub(crate) fn from_query(query: &str) -> Result { + let mut filter = Self::default(); + for (name, value) in Query::new(query) { + match name.as_ref() { + "id" => { + for item in value.split(',') { + if filter.ids.len() == MAX_QUERY_IDS { + return Err(FilterError::TooManyQueryIds); + } + filter.ids.push(ValidatorId::parse(item).ok_or(FilterError::InvalidId)?); + } + } + "status" => { + for item in value.split(',') { + let mask = StatusMask::parse(item).ok_or(FilterError::InvalidStatus)?; + filter.statuses.insert(mask); + } + } + _ => {} + } + } + Ok(filter) + } + + pub(crate) fn from_body(body: &[u8]) -> Result { + let parsed: RequestBody = + serde_json::from_slice(body).map_err(|_| FilterError::InvalidBody)?; + let submitted_ids = parsed.ids.unwrap_or_default(); + if submitted_ids.len() > MAX_BODY_IDS { + return Err(FilterError::TooManyBodyIds); + } + + let mut filter = Self::default(); + for id in submitted_ids { + filter.ids.push(ValidatorId::parse(&id).ok_or(FilterError::InvalidId)?); + } + for status in parsed.statuses.unwrap_or_default() { + let mask = StatusMask::parse(&status).ok_or(FilterError::InvalidStatus)?; + filter.statuses.insert(mask); + } + Ok(filter) + } + + /// The indices this filter's ids name, deduplicated and in registry order; + /// `None` when it names none, which selects the whole registry. Ids that + /// resolve to no validator are dropped — the schemas return no information + /// for them rather than an error. + pub(crate) fn resolve_ids(&self, validators: &ValidatorsView<'_>) -> Option> { + if self.ids.is_empty() { + return None; + } + let mut indices: Vec<_> = self.ids.iter().filter_map(|id| id.resolve(validators)).collect(); + indices.sort_unstable(); + indices.dedup(); + Some(indices) + } + + pub(crate) fn accepts(&self, status: Status) -> bool { + self.statuses.accepts(status) + } +} + +/// A request naming a filter this endpoint cannot answer from. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum FilterError { + InvalidId, + InvalidStatus, + TooManyQueryIds, + TooManyBodyIds, + InvalidBody, +} + +impl FilterError { + pub(crate) fn respond(self, resp: &mut Response<'_>) { + match self { + Self::InvalidId => resp.error(400, "invalid validator id"), + Self::InvalidStatus => resp.error(400, "invalid validator status"), + Self::TooManyQueryIds => resp.error(414, "too many validator ids in request"), + Self::TooManyBodyIds => resp.error(400, "too many validator ids in request body"), + Self::InvalidBody => resp.error(400, "invalid request body"), + } + } +} + +/// `postStateValidators`' request body; either list may be absent or `null`. +#[derive(Deserialize)] +struct RequestBody { + ids: Option>, + statuses: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(query: &str) -> Vec { + Filter::from_query(query) + .unwrap() + .ids + .iter() + .map(|id| match id { + ValidatorId::Index(index) => index.to_string(), + ValidatorId::Pubkey(pubkey) => format!("0x{}", hex::encode(pubkey)), + }) + .collect() + } + + fn pubkey_text(byte: u8) -> String { + format!("0x{}", hex::encode([byte; 48])) + } + + #[test] + fn ids_repeat_or_come_comma_separated_or_both() { + assert_eq!(ids("id=1&id=2"), ["1", "2"]); + assert_eq!(ids("id=1,2,3"), ["1", "2", "3"]); + assert_eq!(ids("id=1,2&id=3"), ["1", "2", "3"]); + assert_eq!(ids(""), Vec::::new()); + assert_eq!(ids("status=active"), Vec::::new()); + } + + #[test] + fn a_pubkey_id_keeps_its_48_bytes_whatever_case_it_arrives_in() { + let key = pubkey_text(0xab); + assert_eq!(ids(&format!("id={key}")), [key.clone()]); + assert_eq!(ids(&format!("id={}", key.to_uppercase().replace("0X", "0x"))), [key]); + } + + #[test] + fn a_query_value_reaches_the_filter_percent_decoded() { + assert_eq!(ids("id=%31%32"), ["12"]); + } + + #[test] + fn statuses_repeat_or_come_comma_separated() { + let filter = Filter::from_query("status=active_ongoing,exited&status=pending").unwrap(); + assert!(filter.accepts(Status::ActiveOngoing)); + assert!(filter.accepts(Status::ExitedSlashed)); + assert!(filter.accepts(Status::PendingQueued)); + assert!(!filter.accepts(Status::WithdrawalDone)); + } + + #[test] + fn no_status_filter_accepts_every_status() { + let filter = Filter::from_query("id=1").unwrap(); + assert!(filter.accepts(Status::ActiveOngoing)); + assert!(filter.accepts(Status::WithdrawalDone)); + } + + /// The `status` array carries no `maxItems`, so what bounds the sweep is + /// that the filter holds a set: a value repeated 100k times leaves the + /// same mask as naming it once, and the same O(1) test per validator. + #[test] + fn a_status_repeated_to_the_size_of_a_request_collapses_to_one_mask() { + let once = Filter::from_query("status=active").unwrap(); + let repeated = "active,".repeat(100_000); + let flooded = Filter::from_query(&format!("status={}", repeated.trim_end_matches(','))) + .expect("every item is a legal status"); + assert_eq!(flooded.statuses, once.statuses); + assert!(flooded.ids.is_empty()); + } + + /// A value that names no validator is malformed input, not an unknown + /// validator: the empty item a stray comma or a bare `id=` produces + /// included, since answering it as "no filter" would serve the whole + /// registry to a client that asked for nothing. + #[test] + fn an_id_that_names_no_validator_at_all_is_rejected() { + for query in [ + "id=", + "id=1,,2", + "id=-1", + "id=+1", + "id=1.5", + "id=abc", + "id=0x", + "id=0xzz", + &format!("id=0x{}", "ab".repeat(47)), + &format!("id=0x{}", "ab".repeat(49)), + "id=18446744073709551616", + ] { + assert_eq!(Filter::from_query(query).err(), Some(FilterError::InvalidId), "{query}"); + } + } + + #[test] + fn a_status_that_is_not_in_the_schema_is_rejected() { + for query in ["status=", "status=active,bogus", "status=ACTIVE"] { + assert_eq!( + Filter::from_query(query).err(), + Some(FilterError::InvalidStatus), + "{query}" + ); + } + } + + /// `maxItems: 64` on the GET `id` array; the 65th is over the cap however + /// the client spells the list. + #[test] + fn the_65th_query_id_is_a_414() { + let sixty_four = (0..64).map(|i| i.to_string()).collect::>().join(","); + assert_eq!(Filter::from_query(&format!("id={sixty_four}")).unwrap().ids.len(), 64); + assert_eq!( + Filter::from_query(&format!("id={sixty_four}&id=64")).err(), + Some(FilterError::TooManyQueryIds) + ); + assert_eq!( + Filter::from_query(&format!("id={sixty_four},64")).err(), + Some(FilterError::TooManyQueryIds) + ); + } + + /// The POST body exists to carry lists longer than the GET query allows, + /// so the 64-id cap is not applied to it — but its own bound is. + #[test] + fn the_post_body_takes_far_more_ids_than_a_query_may_and_still_has_a_bound() { + let list = |count: usize| { + let many: Vec = (0..count).map(|i| i.to_string()).collect(); + serde_json::json!({ "ids": many }).to_string() + }; + assert_eq!(Filter::from_body(list(1_000).as_bytes()).unwrap().ids.len(), 1_000); + assert_eq!( + Filter::from_body(list(MAX_BODY_IDS).as_bytes()).unwrap().ids.len(), + MAX_BODY_IDS + ); + assert_eq!( + Filter::from_body(list(MAX_BODY_IDS + 1).as_bytes()).err(), + Some(FilterError::TooManyBodyIds) + ); + } + + #[test] + fn an_absent_null_or_empty_body_list_filters_on_nothing() { + for body in [ + "{}", + r#"{"ids":null,"statuses":null}"#, + r#"{"ids":[],"statuses":[]}"#, + r#"{"ids":[],"unknown_field":3}"#, + ] { + let filter = Filter::from_body(body.as_bytes()).expect(body); + assert!(filter.ids.is_empty(), "{body}"); + assert!(filter.accepts(Status::WithdrawalDone), "{body}"); + } + } + + #[test] + fn a_body_that_is_not_the_schema_s_object_is_rejected() { + for body in ["", "not json", "[]", r#"{"ids":"1"}"#, r#"{"ids":[1]}"#] { + assert_eq!(Filter::from_body(body.as_bytes()).err(), Some(FilterError::InvalidBody)); + } + } + + #[test] + fn body_ids_and_statuses_are_validated_the_same_way_a_query_s_are() { + assert_eq!( + Filter::from_body(br#"{"ids":["1","banana"]}"#).err(), + Some(FilterError::InvalidId) + ); + assert_eq!( + Filter::from_body(br#"{"statuses":["nope"]}"#).err(), + Some(FilterError::InvalidStatus) + ); + } + + /// 414 is declared on the GET alone, so the bound the body carries has to + /// answer with a code the POST declares. + #[test] + fn each_refusal_answers_with_a_code_the_verb_that_raises_it_declares() { + let status_line = |error: FilterError| { + let mut out = Vec::new(); + error.respond(&mut Response::new(&mut out)); + String::from_utf8(out).unwrap().lines().next().unwrap().to_owned() + }; + assert_eq!(status_line(FilterError::TooManyQueryIds), "HTTP/1.1 414 URI Too Long"); + for error in [ + FilterError::TooManyBodyIds, + FilterError::InvalidBody, + FilterError::InvalidId, + FilterError::InvalidStatus, + ] { + assert_eq!(status_line(error), "HTTP/1.1 400 Bad Request", "{error:?}"); + } + } + + /// Commas separate values in a query string only; a body carries a real + /// array, so a comma inside one of its strings is part of that string. + #[test] + fn a_body_id_is_not_split_on_commas() { + assert_eq!(Filter::from_body(br#"{"ids":["1,2"]}"#).err(), Some(FilterError::InvalidId)); + } +} diff --git a/crates/beacon_api/src/validators/mod.rs b/crates/beacon_api/src/validators/mod.rs new file mode 100644 index 00000000..b23924b6 --- /dev/null +++ b/crates/beacon_api/src/validators/mod.rs @@ -0,0 +1,650 @@ +pub(crate) mod entry; +mod filter; +pub(crate) mod status; + +use silver_beacon_state_data::StateReadView; + +use crate::{ + response::Response, + router::Request, + routes::ApiCtx, + validators::{ + entry::ValidatorEntry, + filter::{Filter, ValidatorId}, + }, +}; + +pub(crate) fn get_state_validators(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + match Filter::from_query(req.query) { + Ok(filter) => respond_with_matches(req, ctx, resp, &filter), + Err(error) => error.respond(resp), + } +} + +/// Same answer as the GET, from a body that carries lists too long for a +/// query string. +pub(crate) fn post_state_validators(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + match Filter::from_body(req.body) { + Ok(filter) => respond_with_matches(req, ctx, resp, &filter), + Err(error) => error.respond(resp), + } +} + +pub(crate) fn get_state_validator(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let named = req.params.get("validator_id").expect("{validator_id} in the route pattern"); + let Some(validator_id) = ValidatorId::parse(named) else { + resp.error(400, "invalid validator_id"); + return; + }; + let read = |view: StateReadView<'_>| { + let index = validator_id.resolve(&view.validators)?; + Some(ValidatorEntry::read(&view, index, view.slot.current_epoch())) + }; + let Some(state) = ctx.state_read(req, resp, read) else { + return; + }; + match &state.data { + Some(entry) => resp.json_body(|json| { + json.flagged_envelope(state.flags, |json| json.validator_entry(entry)) + }), + None => resp.error(404, "validator not found"), + } +} + +fn respond_with_matches(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>, filter: &Filter) { + let Some(state) = ctx.state_read(req, resp, |view| ValidatorEntry::matching(&view, filter)) + else { + return; + }; + resp.json_body(|json| json.flagged_envelope(state.flags, |json| json.validators(&state.data))); +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use silver_beacon_state_data::{ + BeaconState, BeaconStateOwner, Epoch, EpochStateFinalized, FAR_FUTURE_EPOCH, + SLOTS_PER_EPOCH, SpecConfig, StateId, ValSeed, Withdrawals, + }; + use silver_httpcore::ParsedRequest; + + use super::*; + use crate::{ + json::Json, + router::Router, + routes::{ROUTES, preboot_ctx, synced_status, test_ctx}, + }; + + const HEAD_EPOCH: Epoch = 100; + const HEAD_SLOT: u64 = HEAD_EPOCH * SLOTS_PER_EPOCH; + const VALIDATORS_PATH: &str = "/eth/v1/beacon/states/head/validators"; + + /// One validator per status at [`HEAD_EPOCH`], in status order, so an + /// index in [`one_per_status`] doubles as the status it must carry. + const STATUSES: [&str; 9] = [ + "pending_initialized", + "pending_queued", + "active_ongoing", + "active_exiting", + "active_slashed", + "exited_unslashed", + "exited_slashed", + "withdrawal_possible", + "withdrawal_done", + ]; + + /// The three fields `ValSeed` does not carry, applied through a writer. + struct Extra { + slashed: bool, + activation_eligibility_epoch: Epoch, + withdrawable_epoch: Epoch, + } + + fn pubkey_of(index: usize) -> [u8; 48] { + [0xa0 + index as u8; 48] + } + + fn pubkey_text(index: usize) -> String { + format!("0x{}", hex::encode(pubkey_of(index))) + } + + fn credentials_of(index: usize) -> Withdrawals { + Withdrawals::eth1(&[0xc0 + index as u8; 20]) + } + + /// Nine validators, one per status at [`HEAD_EPOCH`], with every field + /// distinct enough that a golden catches a swapped one. + fn one_per_status() -> (Vec, Vec) { + let far = FAR_FUTURE_EPOCH; + // effective balance activation exit slashed elig withdrawable + let rows: [(u64, u64, Epoch, Epoch, bool, Epoch, Epoch); 9] = [ + (1_000_000_000, 1_000_000_000, far, far, false, far, far), + (32_000_000_000, 32_000_000_000, 105, far, false, 98, far), + (32_000_000_000, 32_100_000_000, 10, far, false, 8, far), + (32_000_000_000, 32_200_000_000, 10, 110, false, 8, 366), + (32_000_000_000, 16_300_000_000, 10, 110, true, 8, 8_300), + (32_000_000_000, 32_400_000_000, 10, 90, false, 8, 101), + (16_000_000_000, 16_500_000_000, 10, 90, true, 8, 8_290), + (32_000_000_000, 32_600_000_000, 10, 90, false, 8, 95), + (0, 0, 10, 90, false, 8, 95), + ]; + rows.iter() + .enumerate() + .map(|(index, &(effective, balance, activation, exit, slashed, elig, withdrawable))| { + ( + ValSeed { + pubkey: pubkey_of(index), + withdrawal_credentials: credentials_of(index), + effective_balance: effective, + balance, + activation_epoch: activation, + exit_epoch: exit, + }, + Extra { + slashed, + activation_eligibility_epoch: elig, + withdrawable_epoch: withdrawable, + }, + ) + }) + .unzip() + } + + fn published_state( + seeds: &[ValSeed], + extras: &[Extra], + slot: u64, + ) -> (BeaconStateOwner, StateId) { + let mut owner = BeaconStateOwner::new(BeaconState::for_test( + EpochStateFinalized::default(), + seeds, + slot, + )); + let anchor = owner.roll_fresh(); + let (mut writer, _, _) = owner.apply_block_view(anchor); + for (index, extra) in extras.iter().enumerate() { + let index = index as u32; + writer.validators.set_slashed(index, extra.slashed); + writer + .validators + .set_activation_eligibility_epoch(index, extra.activation_eligibility_epoch); + writer.validators.set_withdrawable_epoch(index, extra.withdrawable_epoch); + } + let head = writer.commit(None, None); + owner.publish_state_id(head); + (owner, head) + } + + fn published_ctx(seeds: &[ValSeed], extras: &[Extra], slot: u64) -> ApiCtx { + let (owner, _) = published_state(seeds, extras, slot); + let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); + ctx.node_status = synced_status(slot, [0xb0; 32]); + ctx + } + + fn registry_ctx() -> ApiCtx { + let (seeds, extras) = one_per_status(); + published_ctx(&seeds, &extras, HEAD_SLOT) + } + + /// More validators than the widest query string could name, all but one + /// of them active_ongoing. + const BULK_REGISTRY: usize = 20_000; + + fn bulk_registry_ctx() -> ApiCtx { + let seeds: Vec<_> = (0..BULK_REGISTRY) + .map(|index| { + let mut pubkey = [0u8; 48]; + pubkey[..8].copy_from_slice(&(index as u64).to_le_bytes()); + ValSeed { + pubkey, + withdrawal_credentials: Withdrawals::ZERO, + effective_balance: 32_000_000_000, + balance: 32_000_000_000, + activation_epoch: if index == 0 { FAR_FUTURE_EPOCH } else { 10 }, + exit_epoch: FAR_FUTURE_EPOCH, + } + }) + .collect(); + // `ValSeed`'s registry defaults are already this test's `Extra`s: + // unslashed, with no eligibility and no withdrawable epoch. + published_ctx(&seeds, &[], HEAD_SLOT) + } + + fn request<'a>( + method: &'a str, + path: &'a str, + query: &'a str, + body: &'a [u8], + ) -> ParsedRequest<'a> { + ParsedRequest { + method, + path, + query, + body, + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + } + } + + fn dispatch(ctx: &ApiCtx, req: &ParsedRequest<'_>) -> Vec { + let mut out = Vec::new(); + Router::new(ROUTES).dispatch(req, ctx, &mut out); + out + } + + fn get(ctx: &ApiCtx, path: &str, query: &str) -> Vec { + dispatch(ctx, &request("GET", path, query, b"")) + } + + fn post(ctx: &ApiCtx, path: &str, body: &str) -> Vec { + dispatch(ctx, &request("POST", path, "", body.as_bytes())) + } + + fn body(response: &[u8]) -> &[u8] { + let text = std::str::from_utf8(response).unwrap(); + &response[text.find("\r\n\r\n").unwrap() + 4..] + } + + fn ok_body(response: &[u8]) -> String { + assert!( + response.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{}", + String::from_utf8_lossy(response) + ); + String::from_utf8(body(response).to_vec()).unwrap() + } + + /// The `index`/`status` pairs a list body carries, in the order it lists + /// them. + fn listed(response: &[u8]) -> Vec<(u64, String)> { + let parsed: serde_json::Value = serde_json::from_str(&ok_body(response)).unwrap(); + parsed["data"] + .as_array() + .expect("data is an array") + .iter() + .map(|entry| { + ( + entry["index"].as_str().unwrap().parse().unwrap(), + entry["status"].as_str().unwrap().to_owned(), + ) + }) + .collect() + } + + fn indices(response: &[u8]) -> Vec { + listed(response).into_iter().map(|(index, _)| index).collect() + } + + fn assert_error(response: &[u8], status: &str, code: u16, message: &str) { + assert!( + response.starts_with(format!("HTTP/1.1 {status}\r\n").as_bytes()), + "{}", + String::from_utf8_lossy(response) + ); + assert_eq!( + std::str::from_utf8(body(response)).unwrap(), + format!("{{\"code\":{code},\"message\":\"{message}\"}}") + ); + } + + /// Body shape: `GetStateValidatorsResponse` of + /// `apis/beacon/states/validators.yaml` — both envelope flags, then a + /// `data` array of `ValidatorResponse` with every integer a quoted decimal + /// string and every byte string lowercase `0x`-hex. + #[test] + fn validators_body_is_the_envelope_around_a_validator_response_array() { + let expected = format!( + "{{\"execution_optimistic\":false,\"finalized\":false,\"data\":[\ + {{\"index\":\"0\",\"balance\":\"1000000000\",\"status\":\"pending_initialized\",\ + \"validator\":{{\"pubkey\":\"0x{a0}\",\"withdrawal_credentials\":\"0x{c0}\",\ + \"effective_balance\":\"1000000000\",\"slashed\":false,\ + \"activation_eligibility_epoch\":\"18446744073709551615\",\ + \"activation_epoch\":\"18446744073709551615\",\ + \"exit_epoch\":\"18446744073709551615\",\ + \"withdrawable_epoch\":\"18446744073709551615\"}}}},\ + {{\"index\":\"4\",\"balance\":\"16300000000\",\"status\":\"active_slashed\",\ + \"validator\":{{\"pubkey\":\"0x{a4}\",\"withdrawal_credentials\":\"0x{c4}\",\ + \"effective_balance\":\"32000000000\",\"slashed\":true,\ + \"activation_eligibility_epoch\":\"8\",\"activation_epoch\":\"10\",\ + \"exit_epoch\":\"110\",\"withdrawable_epoch\":\"8300\"}}}}]}}", + a0 = hex::encode(pubkey_of(0)), + c0 = hex::encode(credentials_of(0).0), + a4 = hex::encode(pubkey_of(4)), + c4 = hex::encode(credentials_of(4).0), + ); + assert_eq!(ok_body(&get(®istry_ctx(), VALIDATORS_PATH, "id=0,4")), expected); + serde_json::from_str::(&expected).expect("valid JSON"); + } + + #[test] + fn an_empty_result_set_keeps_the_envelope_and_an_empty_array() { + assert_eq!( + ok_body(&get(®istry_ctx(), VALIDATORS_PATH, "id=999")), + "{\"execution_optimistic\":false,\"finalized\":false,\"data\":[]}" + ); + } + + /// Every status the schema names, against a registry holding one of each. + #[test] + fn each_status_is_derived_from_the_validator_s_epochs_at_the_state_s_epoch() { + let expected: Vec<_> = + STATUSES.iter().enumerate().map(|(i, s)| (i as u64, s.to_string())).collect(); + assert_eq!(listed(&get(®istry_ctx(), VALIDATORS_PATH, "")), expected); + } + + /// The boundaries the derivation keys on are the epoch of the state being + /// read: the same registry one epoch on moves validators across them. + #[test] + fn a_later_state_epoch_moves_validators_across_the_status_boundaries() { + let (seeds, extras) = one_per_status(); + let next = published_ctx(&seeds, &extras, (HEAD_EPOCH + 1) * SLOTS_PER_EPOCH); + // Index 5 becomes withdrawable at epoch 101 and still holds a balance; + // index 3's exit at epoch 110 has not arrived yet. + assert_eq!(listed(&get(&next, VALIDATORS_PATH, "id=5"))[0].1, "withdrawal_possible"); + assert_eq!(listed(&get(&next, VALIDATORS_PATH, "id=3"))[0].1, "active_exiting"); + } + + #[test] + fn ids_select_by_index_by_pubkey_and_by_both_at_once() { + let ctx = registry_ctx(); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "id=3")), [3]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, &format!("id={}", pubkey_text(6)))), [6]); + assert_eq!( + indices(&get(&ctx, VALIDATORS_PATH, &format!("id=3&id={}&id=1", pubkey_text(6)))), + [1, 3, 6] + ); + } + + /// `uniqueItems` in the schema: one validator named twice, by either + /// spelling, is still one entry. + #[test] + fn a_validator_named_twice_is_listed_once() { + let ctx = registry_ctx(); + let query = format!("id=2,2&id={}", pubkey_text(2)); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, &query)), [2]); + } + + /// "If an index or public key does not match any known validator, no + /// information will be returned but this will not cause an error." + #[test] + fn an_id_matching_no_validator_is_left_out_rather_than_erroring() { + let ctx = registry_ctx(); + let unknown_key = format!("0x{}", hex::encode([0xff; 48])); + assert!(indices(&get(&ctx, VALIDATORS_PATH, "id=9")).is_empty()); + assert!(indices(&get(&ctx, VALIDATORS_PATH, &format!("id={unknown_key}"))).is_empty()); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, &format!("id=2&id=9&id={unknown_key}"))), [ + 2 + ]); + } + + #[test] + fn statuses_select_by_exact_name_and_by_stage() { + let ctx = registry_ctx(); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=active_slashed")), [4]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=pending")), [0, 1]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=active")), [2, 3, 4]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=exited")), [5, 6]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=withdrawal")), [7, 8]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=pending,withdrawal")), [0, 1, 7, 8]); + } + + #[test] + fn the_two_filters_narrow_each_other() { + let ctx = registry_ctx(); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "id=2,3,7&status=active")), [2, 3]); + assert!(indices(&get(&ctx, VALIDATORS_PATH, "id=2&status=exited")).is_empty()); + } + + /// The POST body exists only to carry longer lists, so the same filter + /// must produce the same bytes down either verb. + #[test] + fn post_and_get_answer_the_same_filter_with_the_same_bytes() { + let ctx = registry_ctx(); + for (query, json_body) in [ + ("", "{}"), + ("id=2,3", r#"{"ids":["2","3"]}"#), + ("status=active", r#"{"statuses":["active"]}"#), + ("id=2,3,7&status=active", r#"{"ids":["2","3","7"],"statuses":["active"]}"#), + ] { + assert_eq!( + ok_body(&get(&ctx, VALIDATORS_PATH, query)), + ok_body(&post(&ctx, VALIDATORS_PATH, json_body)), + "{query}" + ); + } + } + + #[test] + fn a_pubkey_id_reaches_the_registry_index_through_the_post_body_too() { + let ctx = registry_ctx(); + let json_body = format!(r#"{{"ids":["{}"]}}"#, pubkey_text(6)); + assert_eq!(indices(&post(&ctx, VALIDATORS_PATH, &json_body)), [6]); + } + + #[test] + fn a_query_naming_no_validator_or_status_at_all_is_a_400() { + let ctx = registry_ctx(); + assert_error( + &get(&ctx, VALIDATORS_PATH, "id=banana"), + "400 Bad Request", + 400, + "invalid validator id", + ); + assert_error( + &get(&ctx, VALIDATORS_PATH, "status=banana"), + "400 Bad Request", + 400, + "invalid validator status", + ); + } + + #[test] + fn more_query_ids_than_the_schema_allows_is_a_414() { + let ids = (0..65).map(|i| i.to_string()).collect::>().join(","); + assert_error( + &get(®istry_ctx(), VALIDATORS_PATH, &format!("id={ids}")), + "414 URI Too Long", + 414, + "too many validator ids in request", + ); + } + + #[test] + fn a_post_body_that_is_not_the_schema_s_object_is_a_400() { + let ctx = registry_ctx(); + for body in ["not json", "", "[]"] { + assert_error( + &post(&ctx, VALIDATORS_PATH, body), + "400 Bad Request", + 400, + "invalid request body", + ); + } + } + + /// Body shape: `GetStateValidatorResponse` of + /// `apis/beacon/states/validator.yaml` — one `ValidatorResponse`, not an + /// array holding one. + #[test] + fn the_single_validator_body_is_the_envelope_around_one_entry() { + let ctx = registry_ctx(); + let by_index = ok_body(&get(&ctx, "/eth/v1/beacon/states/head/validators/2", "")); + let path = format!("/eth/v1/beacon/states/head/validators/{}", pubkey_text(2)); + assert_eq!(by_index, ok_body(&get(&ctx, &path, ""))); + assert!( + by_index.starts_with( + "{\"execution_optimistic\":false,\"finalized\":false,\"data\":{\"index\":\"2\",\ + \"balance\":\"32100000000\",\"status\":\"active_ongoing\",\"validator\":{" + ), + "{by_index}" + ); + let parsed: serde_json::Value = serde_json::from_str(&by_index).unwrap(); + assert!(parsed["data"].is_object(), "one entry, not an array"); + } + + /// The list endpoint leaves out an id it cannot resolve; this one has + /// nothing left to return, so its schema answers 404. + #[test] + fn a_single_validator_id_matching_no_validator_is_a_404() { + let ctx = registry_ctx(); + for id in ["9", &format!("0x{}", hex::encode([0xff; 48]))] { + assert_error( + &get(&ctx, &format!("/eth/v1/beacon/states/head/validators/{id}"), ""), + "404 Not Found", + 404, + "validator not found", + ); + } + } + + /// A segment naming no validator at all is malformed whatever state it was + /// asked against, so it is answered ahead of the state_id — including + /// ahead of the 404 a state silver does not keep would get. + #[test] + fn a_validator_id_naming_no_validator_at_all_is_a_400() { + let ctx = registry_ctx(); + for id in ["banana", "-1", "+1", "0x", "0xzz", "18446744073709551616", ""] { + for state_id in ["head", "finalized", "banana"] { + assert_error( + &get(&ctx, &format!("/eth/v1/beacon/states/{state_id}/validators/{id}"), ""), + "400 Bad Request", + 400, + "invalid validator_id", + ); + } + } + } + + fn all_three_routes(state_id: &str) -> [(String, &'static str); 3] { + [ + (format!("/eth/v1/beacon/states/{state_id}/validators"), "GET"), + (format!("/eth/v1/beacon/states/{state_id}/validators"), "POST"), + (format!("/eth/v1/beacon/states/{state_id}/validators/0"), "GET"), + ] + } + + fn answer(ctx: &ApiCtx, path: &str, method: &str) -> Vec { + match method { + "POST" => post(ctx, path, "{}"), + _ => get(ctx, path, ""), + } + } + + /// Silver publishes one state, the head. Every other identifier the + /// schemas define names a state it does not keep; anything else names no + /// state at all. All three routes answer by the same table. + #[test] + fn the_state_id_table_is_the_one_every_state_read_answers_by() { + let ctx = registry_ctx(); + for (path, method) in all_three_routes("head") { + assert!(answer(&ctx, &path, method).starts_with(b"HTTP/1.1 200 OK\r\n"), "{path}"); + } + let root = format!("0x{}", "ab".repeat(32)); + for state_id in ["genesis", "justified", "finalized", "0", "3200", &root] { + for (path, method) in all_three_routes(state_id) { + assert_error(&answer(&ctx, &path, method), "404 Not Found", 404, "state not found"); + } + } + for state_id in ["current", "banana", "-1", "+5", "0x", "1.5", "HEAD"] { + for (path, method) in all_three_routes(state_id) { + assert_error( + &answer(&ctx, &path, method), + "400 Bad Request", + 400, + "invalid state_id", + ); + } + } + } + + /// The table carries both verbs on the list route and only GET on the + /// single-validator one, so a POST there is a 405 rather than a 404. + #[test] + fn the_route_table_takes_the_two_verbs_the_schemas_declare() { + let ctx = registry_ctx(); + assert!( + post(&ctx, "/eth/v1/beacon/states/head/validators/0", "{}") + .starts_with(b"HTTP/1.1 405 Method Not Allowed\r\n") + ); + assert!( + dispatch(&ctx, &request("PUT", VALIDATORS_PATH, "", b"")) + .starts_with(b"HTTP/1.1 405 Method Not Allowed\r\n") + ); + } + + #[test] + fn every_validator_route_is_404_before_the_first_state_is_published() { + let ctx = preboot_ctx(); + for (path, method) in all_three_routes("head") { + assert_error(&answer(&ctx, &path, method), "404 Not Found", 404, "state not found"); + } + } + + /// [`BeaconStateReader::read`] re-runs its closure whole when a finalize + /// lands mid-read, so the sweep must be a pure function of the view: two + /// runs against one view produce the same entries, and rendering them + /// twice produces two identical bodies rather than one doubled body. + #[test] + fn re_running_the_sweep_against_one_view_repeats_it_rather_than_extending_it() { + let (seeds, extras) = one_per_status(); + let (owner, head) = published_state(&seeds, &extras, HEAD_SLOT); + let view = owner.read_view(head); + let filter = Filter::from_query("status=active").unwrap(); + + let first = ValidatorEntry::matching(&view, &filter); + let second = ValidatorEntry::matching(&view, &filter); + assert_eq!(first, second); + assert_eq!(first.len(), 3); + + let render = |entries: &[ValidatorEntry]| { + let mut out = Vec::new(); + Json::new(&mut out).validators(entries); + out + }; + assert_eq!(render(&first), render(&second)); + assert_eq!(render(&second).len(), render(&first).len()); + } + + /// "If the supplied list is empty (i.e. the value is `[]`) or the property + /// is omitted then all validators will be returned" — whatever the + /// registry holds, down either verb, and with the status filter alone + /// answerable the same way. + #[test] + fn an_empty_filter_returns_the_whole_registry_however_large_it_is() { + let ctx = bulk_registry_ctx(); + let unfiltered = get(&ctx, VALIDATORS_PATH, ""); + assert_eq!(listed(&unfiltered).len(), BULK_REGISTRY); + assert_eq!(post(&ctx, VALIDATORS_PATH, "{}"), unfiltered); + assert_eq!(post(&ctx, VALIDATORS_PATH, r#"{"ids":[]}"#), unfiltered); + assert_eq!(post(&ctx, VALIDATORS_PATH, r#"{"ids":null,"statuses":null}"#), unfiltered); + + let active = get(&ctx, VALIDATORS_PATH, "status=active"); + assert_eq!(listed(&active).len(), BULK_REGISTRY - 1, "index 0 is still pending"); + assert_eq!(post(&ctx, VALIDATORS_PATH, r#"{"statuses":["active"]}"#), active); + } + + /// The schemas put no `maxItems` on `status`, and a request may repeat one + /// value as many times as it has bytes for. A value no validator carries + /// is the worst case — nothing short-circuits — so this is the sweep the + /// registry-times-list cost would show up in: it must stay one sweep. + #[test] + fn a_status_repeated_across_a_whole_request_still_costs_one_sweep() { + let ctx = bulk_registry_ctx(); + let flood = vec!["status=exited_slashed"; 100_000].join("&"); + + let started = Instant::now(); + let flooded = get(&ctx, VALIDATORS_PATH, &flood); + let elapsed = started.elapsed(); + + assert_eq!(flooded, get(&ctx, VALIDATORS_PATH, "status=exited_slashed")); + assert!(listed(&flooded).is_empty(), "no validator here is exited_slashed"); + // One sweep of this registry is well under a millisecond; the + // registry-times-list product would be 2e9 status comparisons. + assert!(elapsed < Duration::from_secs(5), "{elapsed:?} for one sweep"); + } +} diff --git a/crates/beacon_api/src/validators/status.rs b/crates/beacon_api/src/validators/status.rs new file mode 100644 index 00000000..250b22c5 --- /dev/null +++ b/crates/beacon_api/src/validators/status.rs @@ -0,0 +1,327 @@ +use silver_beacon_state_data::{Epoch, FAR_FUTURE_EPOCH, ValidatorsView}; + +/// `ValidatorStatus` of `types/api.yaml`, derived from the validator's epochs +/// and balance against the epoch of the state being read. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Status { + PendingInitialized, + PendingQueued, + ActiveOngoing, + ActiveExiting, + ActiveSlashed, + ExitedUnslashed, + ExitedSlashed, + WithdrawalPossible, + WithdrawalDone, +} + +/// The `Validator` columns a status is derived from, apart from the balance — +/// all a sweep needs before it knows whether the filter keeps this validator. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) struct Lifecycle { + pub(crate) slashed: bool, + pub(crate) activation_eligibility_epoch: Epoch, + pub(crate) activation_epoch: Epoch, + pub(crate) exit_epoch: Epoch, + pub(crate) withdrawable_epoch: Epoch, +} + +impl Lifecycle { + pub(crate) fn read(validators: &ValidatorsView<'_>, index: usize) -> Self { + Self { + slashed: validators.is_slashed(index), + activation_eligibility_epoch: validators.activation_eligibility_epoch(index), + activation_epoch: validators.activation_epoch(index), + exit_epoch: validators.exit_epoch(index), + withdrawable_epoch: validators.withdrawable_epoch(index), + } + } +} + +impl Status { + pub(crate) fn of(lifecycle: &Lifecycle, balance: u64, epoch: Epoch) -> Self { + if epoch < lifecycle.activation_epoch { + if lifecycle.activation_eligibility_epoch == FAR_FUTURE_EPOCH { + Self::PendingInitialized + } else { + Self::PendingQueued + } + } else if epoch < lifecycle.exit_epoch { + if lifecycle.exit_epoch == FAR_FUTURE_EPOCH { + Self::ActiveOngoing + } else if lifecycle.slashed { + Self::ActiveSlashed + } else { + Self::ActiveExiting + } + } else if epoch < lifecycle.withdrawable_epoch { + if lifecycle.slashed { Self::ExitedSlashed } else { Self::ExitedUnslashed } + } else if balance == 0 { + Self::WithdrawalDone + } else { + Self::WithdrawalPossible + } + } + + pub(crate) fn name(self) -> &'static str { + match self { + Self::PendingInitialized => "pending_initialized", + Self::PendingQueued => "pending_queued", + Self::ActiveOngoing => "active_ongoing", + Self::ActiveExiting => "active_exiting", + Self::ActiveSlashed => "active_slashed", + Self::ExitedUnslashed => "exited_unslashed", + Self::ExitedSlashed => "exited_slashed", + Self::WithdrawalPossible => "withdrawal_possible", + Self::WithdrawalDone => "withdrawal_done", + } + } + + fn parse(name: &str) -> Option { + Some(match name { + "pending_initialized" => Self::PendingInitialized, + "pending_queued" => Self::PendingQueued, + "active_ongoing" => Self::ActiveOngoing, + "active_exiting" => Self::ActiveExiting, + "active_slashed" => Self::ActiveSlashed, + "exited_unslashed" => Self::ExitedUnslashed, + "exited_slashed" => Self::ExitedSlashed, + "withdrawal_possible" => Self::WithdrawalPossible, + "withdrawal_done" => Self::WithdrawalDone, + _ => return None, + }) + } + + fn bit(self) -> u16 { + 1 << self as u16 + } +} + +/// The statuses a `status` filter selects, one bit per [`Status`]. A set is +/// what the schema's `uniqueItems` asks for, and it keeps the per-validator +/// test a single mask test however long the submitted list was. +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] +pub(crate) struct StatusMask(u16); + +impl StatusMask { + /// The nine exact status names, and the four coarse ones each covering the + /// statuses of one stage of a validator's life. + pub(crate) fn parse(name: &str) -> Option { + let bits = match name { + "pending" => Status::PendingInitialized.bit() | Status::PendingQueued.bit(), + "active" => { + Status::ActiveOngoing.bit() | + Status::ActiveExiting.bit() | + Status::ActiveSlashed.bit() + } + "exited" => Status::ExitedUnslashed.bit() | Status::ExitedSlashed.bit(), + "withdrawal" => Status::WithdrawalPossible.bit() | Status::WithdrawalDone.bit(), + _ => Status::parse(name)?.bit(), + }; + Some(Self(bits)) + } + + pub(crate) fn insert(&mut self, other: Self) { + self.0 |= other.0; + } + + /// The empty mask is the spec's "no filtering on that attribute". + pub(crate) fn accepts(self, status: Status) -> bool { + self.0 == 0 || self.0 & status.bit() != 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL: [Status; 9] = [ + Status::PendingInitialized, + Status::PendingQueued, + Status::ActiveOngoing, + Status::ActiveExiting, + Status::ActiveSlashed, + Status::ExitedUnslashed, + Status::ExitedSlashed, + Status::WithdrawalPossible, + Status::WithdrawalDone, + ]; + + const STAGES: [&str; 4] = ["pending", "active", "exited", "withdrawal"]; + + /// Every activated validator with no exit filed, the overwhelming + /// majority of a live registry. + fn active_ongoing() -> Lifecycle { + Lifecycle { + slashed: false, + activation_eligibility_epoch: 5, + activation_epoch: 10, + exit_epoch: FAR_FUTURE_EPOCH, + withdrawable_epoch: FAR_FUTURE_EPOCH, + } + } + + fn status_at(lifecycle: &Lifecycle, epoch: Epoch) -> Status { + Status::of(lifecycle, 32_000_000_000, epoch) + } + + /// The activation epoch is the first epoch of membership, not the last + /// epoch of the queue. + #[test] + fn pending_becomes_active_at_the_activation_epoch() { + let lifecycle = active_ongoing(); + assert_eq!(status_at(&lifecycle, 9), Status::PendingQueued); + assert_eq!(status_at(&lifecycle, 10), Status::ActiveOngoing); + assert_eq!(status_at(&lifecycle, 11), Status::ActiveOngoing); + } + + /// A deposit that has not been through an eligibility sweep yet carries no + /// eligibility epoch, which is what separates the two pending statuses. + #[test] + fn pending_is_initialized_until_an_eligibility_epoch_is_set() { + let initialized = Lifecycle { + activation_eligibility_epoch: FAR_FUTURE_EPOCH, + activation_epoch: FAR_FUTURE_EPOCH, + ..active_ongoing() + }; + assert_eq!(status_at(&initialized, 0), Status::PendingInitialized); + assert_eq!(status_at(&initialized, 1_000_000), Status::PendingInitialized); + + let queued = Lifecycle { activation_eligibility_epoch: 3, ..initialized }; + assert_eq!(status_at(&queued, 0), Status::PendingQueued); + } + + /// A filed exit and a slashing both schedule an exit; the validator stays + /// active — and the two apart — until that epoch arrives. + #[test] + fn a_scheduled_exit_splits_the_active_statuses_until_the_exit_epoch() { + let exiting = Lifecycle { exit_epoch: 20, withdrawable_epoch: 30, ..active_ongoing() }; + assert_eq!(status_at(&exiting, 19), Status::ActiveExiting); + assert_eq!(status_at(&exiting, 20), Status::ExitedUnslashed); + + let slashed = Lifecycle { slashed: true, ..exiting }; + assert_eq!(status_at(&slashed, 19), Status::ActiveSlashed); + assert_eq!(status_at(&slashed, 20), Status::ExitedSlashed); + } + + #[test] + fn exited_becomes_withdrawable_at_the_withdrawable_epoch() { + let exited = Lifecycle { exit_epoch: 20, withdrawable_epoch: 30, ..active_ongoing() }; + assert_eq!(status_at(&exited, 29), Status::ExitedUnslashed); + assert_eq!(status_at(&exited, 30), Status::WithdrawalPossible); + + let slashed = Lifecycle { slashed: true, ..exited }; + assert_eq!(status_at(&slashed, 29), Status::ExitedSlashed); + assert_eq!(status_at(&slashed, 30), Status::WithdrawalPossible); + } + + /// The one status the epochs alone cannot tell: past the withdrawable + /// epoch it is the balance that says whether the funds have moved. + #[test] + fn a_withdrawn_balance_is_the_only_difference_between_the_two_withdrawal_statuses() { + let withdrawable = Lifecycle { exit_epoch: 20, withdrawable_epoch: 30, ..active_ongoing() }; + assert_eq!(Status::of(&withdrawable, 1, 30), Status::WithdrawalPossible); + assert_eq!(Status::of(&withdrawable, 0, 30), Status::WithdrawalDone); + assert_eq!(Status::of(&withdrawable, 0, 29), Status::ExitedUnslashed); + } + + /// A validator slashed while active is scheduled to exit, so a slashed + /// flag never reaches the active_ongoing branch in practice — but the + /// derivation must not depend on that, since `exit_epoch` is what the + /// spec keys on. + #[test] + fn a_slashed_flag_alone_does_not_end_an_active_validator() { + let slashed = Lifecycle { slashed: true, ..active_ongoing() }; + assert_eq!(status_at(&slashed, 1_000_000), Status::ActiveOngoing); + } + + #[test] + fn every_status_name_parses_back_to_its_own_status() { + for status in ALL { + assert_eq!(Status::parse(status.name()), Some(status), "{}", status.name()); + } + assert_eq!(Status::parse("pending"), None, "a stage name is not an exact status"); + assert_eq!(Status::parse("Active_Ongoing"), None, "status names are case-sensitive"); + assert_eq!(Status::parse(""), None); + } + + /// Each of the four coarse names covers its own statuses and no others, + /// and every status falls under exactly one. + #[test] + fn a_stage_filter_accepts_exactly_the_statuses_of_its_stage() { + for stage in STAGES { + let mask = StatusMask::parse(stage).expect(stage); + let accepted: Vec<_> = + ALL.into_iter().filter(|&s| mask.accepts(s)).map(Status::name).collect(); + assert!( + accepted.iter().all(|name| name.starts_with(stage)), + "{stage} accepted {accepted:?}" + ); + assert!(!accepted.is_empty(), "{stage} accepted nothing"); + } + + for status in ALL { + let stages = STAGES + .into_iter() + .filter(|stage| StatusMask::parse(stage).unwrap().accepts(status)) + .count(); + assert_eq!(stages, 1, "{} falls under {stages} stages", status.name()); + } + } + + #[test] + fn an_exact_filter_accepts_only_its_own_status() { + for status in ALL { + let mask = StatusMask::parse(status.name()).expect(status.name()); + for other in ALL { + assert_eq!(mask.accepts(other), other == status, "{}", other.name()); + } + } + } + + #[test] + fn a_name_that_is_neither_a_status_nor_a_stage_parses_to_nothing() { + for name in ["", "withdrawn", "active_", "pending_initialised", "exit", "ACTIVE"] { + assert_eq!(StatusMask::parse(name), None, "{name}"); + } + } + + /// One bit per status, so the nine exact names partition the mask and a + /// stage is exactly the union of the statuses it covers. + #[test] + fn a_mask_of_every_exact_name_is_the_union_of_every_stage() { + let union = |names: &[&str]| { + let mut mask = StatusMask::default(); + for name in names { + mask.insert(StatusMask::parse(name).expect(name)); + } + mask + }; + let exact: Vec<_> = ALL.iter().map(|s| s.name()).collect(); + assert_eq!(union(&exact), union(&STAGES)); + assert_eq!(union(&exact).0.count_ones(), ALL.len() as u32); + } + + /// `uniqueItems` on the schema's `status`/`statuses` arrays: inserting a + /// value again leaves the mask, and so the sweep's cost, unchanged. + #[test] + fn inserting_a_status_twice_leaves_the_same_mask() { + let active = StatusMask::parse("active").unwrap(); + let mut repeated = active; + for _ in 0..1_000 { + repeated.insert(active); + } + assert_eq!(repeated, active); + } + + /// An empty mask filters on nothing; a mask that names something filters + /// on exactly that. + #[test] + fn an_empty_mask_accepts_every_status() { + let empty = StatusMask::default(); + for status in ALL { + assert!(empty.accepts(status), "{}", status.name()); + } + assert!(!StatusMask::parse("pending").unwrap().accepts(Status::ActiveOngoing)); + } +} diff --git a/crates/beacon_state/data/src/column/roots.rs b/crates/beacon_state/data/src/column/roots.rs index 11f2ea07..ebd0bb5c 100644 --- a/crates/beacon_state/data/src/column/roots.rs +++ b/crates/beacon_state/data/src/column/roots.rs @@ -39,6 +39,27 @@ impl RootsView<'_, BlockRoots> { self.get(slot as usize % SLOTS_PER_HISTORICAL_ROOT) } + /// [`Self::at_slot`] while the ring still records `slot`: it covers the + /// `SLOTS_PER_HISTORICAL_ROOT` slots below `state_slot`, whose own entry is + /// written by the `process_slot` that leaves it. An empty slot's entry + /// repeats the last block's root, as the spec accessor's own does. + pub fn recorded_at(&self, slot: Slot, state_slot: Slot) -> Option { + let recorded = slot < state_slot && state_slot <= slot + SLOTS_PER_HISTORICAL_ROOT as u64; + recorded.then(|| self.at_slot(slot)) + } + + /// The root of the block proposed at `slot`, when the ring proves one was: + /// `process_slot` repeats the previous entry through a slot that carried no + /// block, so an entry differing from its predecessor is the mark of a block + /// of the slot's own. + pub fn proposed_at(&self, slot: Slot, state_slot: Slot) -> Option { + let root = self.recorded_at(slot, state_slot)?; + let Some(previous) = slot.checked_sub(1) else { + return Some(root); + }; + (self.recorded_at(previous, state_slot)? != root).then_some(root) + } + /// Whether the ring holds `root` at or below `from_slot`. Fork choice is /// what writes a root here, so membership means "seen and validated". /// Walks most-recent-first: a queried parent is virtually always a slot or diff --git a/crates/beacon_state/data/src/column/tests.rs b/crates/beacon_state/data/src/column/tests.rs index 60e2ef88..70429879 100644 --- a/crates/beacon_state/data/src/column/tests.rs +++ b/crates/beacon_state/data/src/column/tests.rs @@ -1,5 +1,6 @@ use super::{ - BalancesGroup, BalancesWriteView, BlockRootsGroup, ColumnGroup, ColumnSpec, RandaoMixesGroup, + BalancesGroup, BalancesWriteView, BlockRootsGroup, BlockRootsId, ColumnGroup, ColumnSpec, + RandaoMixesGroup, }; use crate::{ merkle::{MerkleStack, hash_b256_vector, hash_uint64_list, hash_uint64_vector}, @@ -451,6 +452,73 @@ fn block_roots_contains_scans_back_from_the_given_slot() { assert!(!reader.contains(&[0xCC; 32], 101)); } +/// Slot the ring fixtures leave their state at: high enough that +/// `slot % SLOTS_PER_HISTORICAL_ROOT` is a wrapped index rather than the slot. +const RECORDED_STATE_SLOT: u64 = SLOTS_PER_HISTORICAL_ROOT as u64 + 500; + +fn root_of(slot: u64) -> B256 { + let mut root = [0xA0; 32]; + root[24..].copy_from_slice(&slot.to_be_bytes()); + root +} + +/// A ring filled the way `process_slot` fills it: every slot below +/// `RECORDED_STATE_SLOT` records the newest block's root, and `empty` repeats +/// its predecessor's the way a slot that carried no block does. +fn recorded_ring(empty: Option) -> (BlockRootsGroup, BlockRootsId) { + let mut g = BlockRootsGroup::zeroed_vector(); + let mut wv = g.roll_fresh(); + for slot in RECORDED_STATE_SLOT - SLOTS_PER_HISTORICAL_ROOT as u64..RECORDED_STATE_SLOT { + let named = if empty == Some(slot) { slot - 1 } else { slot }; + wv.set((slot % SLOTS_PER_HISTORICAL_ROOT as u64) as u32, root_of(named)); + } + let id = wv.commit(); + (g, id) +} + +/// An entry differing from its predecessor is a block of the slot's own; a +/// repeated one is a slot that carried none. Reading the entry itself asks +/// only that the ring still cover the slot, so an empty slot answers with the +/// root it repeats. +#[test] +fn block_roots_name_the_slots_that_carried_a_block() { + let empty = RECORDED_STATE_SLOT - 2; + let (g, id) = recorded_ring(Some(empty)); + let reader = g.view(id); + let state_slot = RECORDED_STATE_SLOT; + + assert_eq!(reader.at_slot(empty), root_of(empty - 1), "the ring answers at the wrapped index"); + + assert_eq!(reader.proposed_at(empty - 1, state_slot), Some(root_of(empty - 1))); + assert_eq!(reader.proposed_at(empty, state_slot), None); + assert_eq!(reader.proposed_at(empty + 1, state_slot), Some(root_of(empty + 1))); + + assert_eq!(reader.recorded_at(empty, state_slot), Some(root_of(empty - 1))); + assert_eq!(reader.recorded_at(empty + 1, state_slot), Some(root_of(empty + 1))); +} + +/// The ring covers the `SLOTS_PER_HISTORICAL_ROOT` slots below the state's own, +/// and naming a block needs its predecessor's entry too — so the floor itself +/// cannot be named however distinct its entry is, and the state's own slot has +/// no entry until the `process_slot` that leaves it. +#[test] +fn block_roots_bound_which_slots_can_be_named() { + let (g, id) = recorded_ring(None); + let reader = g.view(id); + let state_slot = RECORDED_STATE_SLOT; + let floor = state_slot - SLOTS_PER_HISTORICAL_ROOT as u64; + + assert_eq!(reader.proposed_at(floor + 1, state_slot), Some(root_of(floor + 1))); + assert_eq!(reader.proposed_at(floor, state_slot), None, "the floor has no predecessor"); + assert_eq!(reader.proposed_at(floor - 1, state_slot), None, "below the floor"); + assert_eq!(reader.proposed_at(state_slot, state_slot), None, "the state's own slot"); + assert_eq!(reader.proposed_at(state_slot + 1, state_slot), None, "past it"); + + assert_eq!(reader.recorded_at(floor, state_slot), Some(root_of(floor))); + assert_eq!(reader.recorded_at(floor - 1, state_slot), None, "below the floor"); + assert_eq!(reader.recorded_at(state_slot, state_slot), None, "the state's own slot"); +} + /// A block's reveal accumulates into the current epoch's bucket; the boundary /// copy seeds the next epoch from it, and the next epoch's reveals accumulate /// on top without disturbing the finished epoch. diff --git a/crates/beacon_state/data/src/epoch/delta.rs b/crates/beacon_state/data/src/epoch/delta.rs index 2b7af8e4..b4885355 100644 --- a/crates/beacon_state/data/src/epoch/delta.rs +++ b/crates/beacon_state/data/src/epoch/delta.rs @@ -2,7 +2,7 @@ use super::{EpochGroup, EpochId, finalized::EpochStateFinalized, ptc_window::Ptc use crate::{ gloas::{PTC_WINDOW_LEN, PtcCommittee}, ring::{Reset, Slot as RingSlot}, - types::{Epoch, EpochState, Fork, Version}, + types::{B256, Epoch, EpochState, Fork, SLOTS_PER_EPOCH, Slot, Version}, }; #[derive(Clone, Default)] @@ -46,6 +46,21 @@ impl<'a> EpochView<'a> { self.delta.map_or(&self.base.state, |d| &d.state) } + /// The block the finalized checkpoint names. Zero is the placeholder a + /// chain carries until its first finalization, and names none. + pub fn finalized_block_root(&self) -> Option { + let root = self.state().finalized_checkpoint.root; + (root != [0u8; 32]).then_some(root) + } + + /// Whether finalization covers a block of this chain at `slot`: the + /// finalized checkpoint names the newest block at or before its epoch's + /// first slot, so every block at or below that slot is that one or an + /// ancestor. + pub fn finalizes_slot(&self, slot: Slot) -> bool { + slot <= self.state().finalized_checkpoint.epoch.saturating_mul(SLOTS_PER_EPOCH) + } + #[inline] pub fn ptc_window(&self) -> &'a PtcWindow { self.delta.map_or(&self.base.ptc_window, |d| &d.ptc_window) diff --git a/crates/beacon_state/data/src/epoch/tests.rs b/crates/beacon_state/data/src/epoch/tests.rs index 036ef3c7..499fdf13 100644 --- a/crates/beacon_state/data/src/epoch/tests.rs +++ b/crates/beacon_state/data/src/epoch/tests.rs @@ -3,9 +3,12 @@ use crate::{ PtcWindow, gloas::{PTC_SIZE, PTC_WINDOW_LEN, PtcCommittee, zeroed_ptc_window}, merkle::{B256, MerkleStack, hash_uint64_vector, hash_vector}, - types::SLOTS_PER_EPOCH, + types::{Checkpoint, SLOTS_PER_EPOCH}, }; +const FIN_SLOT: u64 = 100; +const FIN_EPOCH: u64 = FIN_SLOT / SLOTS_PER_EPOCH; + #[test] fn finalize_replaces_state() { let mut g = EpochGroup::new(EpochStateFinalized::default()); @@ -116,3 +119,28 @@ fn ptc_window_hash_root_matches_oracle() { assert_eq!(g.finalized_view().ptc_window().hash_root(), before); assert_eq!(g.finalized_view().ptc_window().hash_root(), oracle_root(&g.finalized().ptc_window)); } + +/// Finalization covers every slot up to the checkpoint epoch's first: the +/// checkpoint names the newest block at or before it, so a block anywhere +/// below is that one or an ancestor. The epoch is state-derived, so its +/// boundary is computed without overflowing. +#[test] +fn finalization_covers_the_slots_up_to_the_checkpoint_epoch_s_first() { + const BOUNDARY: u64 = FIN_EPOCH * SLOTS_PER_EPOCH; + + let mut base = EpochStateFinalized::default(); + base.state.finalized_checkpoint = Checkpoint { epoch: FIN_EPOCH, root: [0x77; 32] }; + let g = EpochGroup::new(base); + let view = g.finalized_view(); + + assert!(view.finalizes_slot(0)); + assert!(view.finalizes_slot(BOUNDARY)); + assert!(!view.finalizes_slot(BOUNDARY + 1)); + assert_eq!(view.finalized_block_root(), Some([0x77; 32])); + + let mut unfinalized = EpochStateFinalized::default(); + unfinalized.state.finalized_checkpoint.epoch = u64::MAX; + let g = EpochGroup::new(unfinalized); + assert!(g.finalized_view().finalizes_slot(u64::MAX), "no epoch may overflow the boundary"); + assert_eq!(g.finalized_view().finalized_block_root(), None, "the pre-finalization zero"); +} diff --git a/crates/beacon_state/data/src/lib.rs b/crates/beacon_state/data/src/lib.rs index e4a21e4d..3d68acc8 100644 --- a/crates/beacon_state/data/src/lib.rs +++ b/crates/beacon_state/data/src/lib.rs @@ -27,7 +27,7 @@ pub use pending::{ PendingGroup, PendingId, PendingView, PendingWriteView, QueueItem, QueueView, QueueWriteView, }; pub use ring::{Id, Reset}; -pub use silver_chain_spec::{BlobParameters, SpecConfig}; +pub use silver_chain_spec::{BlobParameters, ForkName, SpecConfig}; pub(crate) use silver_ssz::{merkle, progressive}; pub use slot_state::{ EpochBalances, EpochBalancesRow, SlotStateFinalized, SlotStateGroup, SlotStateId, diff --git a/crates/beacon_state/data/src/types.rs b/crates/beacon_state/data/src/types.rs index b308b9b7..e62a8e41 100644 --- a/crates/beacon_state/data/src/types.rs +++ b/crates/beacon_state/data/src/types.rs @@ -55,6 +55,7 @@ pub const TIMELY_HEAD_FLAG: u8 = 1 << 2; pub const PARTICIPATION_FLAGS: [u8; 3] = [TIMELY_SOURCE_FLAG, TIMELY_TARGET_FLAG, TIMELY_HEAD_FLAG]; pub const PARTICIPATION_WEIGHTS: [u64; 3] = [14, 26, 14]; pub const SYNC_COMMITTEE_SIZE: usize = 512; +pub const EPOCHS_PER_SYNC_COMMITTEE_PERIOD: u64 = 256; pub const MAX_ETH1_VOTES: usize = 2048; pub const MIN_SEED_LOOKAHEAD: u64 = 1; pub const PROPOSER_LOOKAHEAD_SIZE: usize = diff --git a/crates/beacon_state/tile/src/stf/epoch.rs b/crates/beacon_state/tile/src/stf/epoch.rs index 2d389f44..a874d839 100644 --- a/crates/beacon_state/tile/src/stf/epoch.rs +++ b/crates/beacon_state/tile/src/stf/epoch.rs @@ -3,11 +3,12 @@ use core::cmp::min; use flux_profiler::timed; pub(crate) use silver_beacon_state_data::EFFECTIVE_BALANCE_INCREMENT; use silver_beacon_state_data::{ - self as common, Checkpoint, EPOCHS_PER_SLASHINGS_VECTOR, Epoch, EpochBalances, EpochView, - EpochWriteView, Eth1WriteView, HistoricalSummary, LongtailGroup, LongtailId, LongtailWriteView, - MIN_SEED_LOOKAHEAD, PARTICIPATION_FLAGS, PARTICIPATION_WEIGHTS, PROPOSER_LOOKAHEAD_SIZE, - SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT, SYNC_COMMITTEE_SIZE, SlotStateWriteView, - SpecConfig, StateWriterView, TIMELY_TARGET_FLAG, ValidatorsView, + self as common, Checkpoint, EPOCHS_PER_SLASHINGS_VECTOR, EPOCHS_PER_SYNC_COMMITTEE_PERIOD, + Epoch, EpochBalances, EpochView, EpochWriteView, Eth1WriteView, HistoricalSummary, + LongtailGroup, LongtailId, LongtailWriteView, MIN_SEED_LOOKAHEAD, PARTICIPATION_FLAGS, + PARTICIPATION_WEIGHTS, PROPOSER_LOOKAHEAD_SIZE, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT, + SYNC_COMMITTEE_SIZE, SlotStateWriteView, SpecConfig, StateWriterView, TIMELY_TARGET_FLAG, + ValidatorsView, }; use crate::{ @@ -25,7 +26,6 @@ use crate::{ }; pub const EPOCHS_PER_ETH1_VOTING_PERIOD: u64 = 64; -pub const EPOCHS_PER_SYNC_COMMITTEE_PERIOD: u64 = 256; pub(crate) const WEIGHT_DENOMINATOR: u64 = 64; pub(crate) const PROPOSER_WEIGHT: u64 = 8; diff --git a/crates/beacon_state/tile/src/stf/mod.rs b/crates/beacon_state/tile/src/stf/mod.rs index 036c1522..3d810db7 100644 --- a/crates/beacon_state/tile/src/stf/mod.rs +++ b/crates/beacon_state/tile/src/stf/mod.rs @@ -26,10 +26,9 @@ pub(crate) use epoch::{ is_valid_builder_deposit_signature, unrealized_checkpoints, }; pub use epoch::{ - EPOCHS_PER_ETH1_VOTING_PERIOD, EPOCHS_PER_SYNC_COMMITTEE_PERIOD, HISTORICAL_SUMMARY_PERIOD, - MAX_PENDING_DEPOSITS_PER_EPOCH, integer_sqrt, is_valid_deposit_signature, - process_effective_balance_updates, process_epoch, process_eth1_data_reset, - process_historical_summaries_update, process_inactivity_updates, + EPOCHS_PER_ETH1_VOTING_PERIOD, HISTORICAL_SUMMARY_PERIOD, MAX_PENDING_DEPOSITS_PER_EPOCH, + integer_sqrt, is_valid_deposit_signature, process_effective_balance_updates, process_epoch, + process_eth1_data_reset, process_historical_summaries_update, process_inactivity_updates, process_justification_and_finalization, process_participation_flag_updates, process_pending_consolidations, process_pending_deposits, process_proposer_lookahead, process_randao_mixes_reset, process_registry_updates, process_rewards_and_penalties, diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 36120bf9..5202ad12 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -20,7 +20,7 @@ use silver_config::{PendingBounds, SyncingConfig}; use crate::{ bls, - fork_choice::{FORK_CHOICE_NODES_HINT, ForkChoice, PayloadStatus}, + fork_choice::{ExecutionStatus, FORK_CHOICE_NODES_HINT, ForkChoice, PayloadStatus}, merkle, ssz_hash, stf, tile::{ attestation_pool::AttestationPool, attestation_root_memo::AttestationRootMemo, @@ -421,11 +421,10 @@ impl BeaconStateTile { ); } - fn status_payload(&mut self) -> [u8; STATUS_V2_SIZE] { + fn status_payload(&mut self, head_root: B256, head_idx: Option) -> [u8; STATUS_V2_SIZE] { let fork_digest = self.fork_digest(); - let head_root = self.fork_choice.find_head(); - let (slot, mut finalized) = match self.fork_choice.find_node_idx(&head_root) { + let (slot, mut finalized) = match head_idx { Some(idx) => { let n = self.fork_choice.node(idx); (n.slot, n.checkpoints.finalized) @@ -464,10 +463,17 @@ impl BeaconStateTile { } fn status_event(&mut self) -> BeaconStateEvent { + let head_root = self.fork_choice.find_head(); + let head_idx = self.fork_choice.find_node_idx(&head_root); + let head_optimistic = head_idx.is_none_or(|idx| { + self.fork_choice.node(idx).execution_status != ExecutionStatus::Valid + }); + BeaconStateEvent::Status { - ssz: self.status_payload(), + ssz: self.status_payload(head_root, head_idx), latest_block_slot: self.last_applied_block_slot(), wall_slot: self.ticker.current_slot(), + head_optimistic, enr_fork_id: self.enr_fork_id(), } } diff --git a/crates/beacon_state/tile/src/tile/block.rs b/crates/beacon_state/tile/src/tile/block.rs index 525a8566..3e0346f5 100644 --- a/crates/beacon_state/tile/src/tile/block.rs +++ b/crates/beacon_state/tile/src/tile/block.rs @@ -14,13 +14,13 @@ use crate::{ ssz_hash, stf, }; -struct AppliedBlock { - id: StateId, - justified: Checkpoint, - finalized: Checkpoint, - unrealized: (Checkpoint, Checkpoint), - execution_block_hash: B256, - bid_block_hash: B256, +pub(super) struct AppliedBlock { + pub(super) id: StateId, + pub(super) justified: Checkpoint, + pub(super) finalized: Checkpoint, + pub(super) unrealized: (Checkpoint, Checkpoint), + pub(super) execution_block_hash: B256, + pub(super) bid_block_hash: B256, } impl BeaconStateTile { @@ -265,7 +265,7 @@ impl BeaconStateTile { } #[timed] - fn publish_applied_block( + pub(super) fn publish_applied_block( &mut self, parsed: &ParsedBlock, block_data: &[u8], diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index 06ed989e..ac2c894c 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -11,11 +11,11 @@ use silver_common::{ ssz_view::{ ATTESTATION_DATA_SIZE, AttestationView, PROPOSER_SLASHING_SIZE, SIGNED_AGG_PROOF_MIN, SIGNED_BLS_CHANGE_SIZE, SIGNED_VOLUNTARY_EXIT_SIZE, SignedAggregateAndProofView, - SingleAttestationView, + SingleAttestationView, StatusView, }, }; -use super::*; +use super::{block::AppliedBlock, *}; use crate::{ fork_choice::{BlockImport, PayloadStatus}, stf::AttestationVote, @@ -314,6 +314,133 @@ fn slot_advance_crosses_two_epoch_boundaries() { assert_eq!(tile.head_state_slot(), 66); } +/// Every status event carries the execution status of the head its `ssz` +/// names: an imported block is optimistic until an EL verdict lifts it, while +/// the checkpoint anchor is valid before any EL exchange. +#[test] +fn status_event_carries_the_head_s_execution_status() { + const CHILD_ROOT: B256 = [0x0C; 32]; + + let head_optimistic = |tile: &mut BeaconStateTile| match tile.status_event() { + BeaconStateEvent::Status { ssz, head_optimistic, .. } => { + assert_eq!(*StatusView::head_root(&ssz), tile.fork_choice.find_head()); + head_optimistic + } + ev => panic!("status_event produced {ev:?}"), + }; + + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 10); + assert!(!head_optimistic(&mut tile), "the trusted anchor is valid"); + + let anchor_cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; + tile.fork_choice.on_block(BlockImport { + slot: 11, + block_root: CHILD_ROOT, + parent_root: ANCHOR_ROOT, + execution_block_hash: [0u8; 32], + justified: anchor_cp, + finalized: anchor_cp, + unrealized_justified: anchor_cp, + unrealized_finalized: anchor_cp, + state_id: tile.last_applied, + bid_block_hash: [0u8; 32], + parent_payload_status: PayloadStatus::Full, + payload_verified: true, + is_gloas: false, + }); + assert_eq!(tile.fork_choice.find_head(), CHILD_ROOT); + assert!(head_optimistic(&mut tile)); + + tile.fork_choice.on_payload_valid(&CHILD_ROOT); + assert!(!head_optimistic(&mut tile)); +} + +fn status_head_root(tile: &mut BeaconStateTile) -> B256 { + match tile.status_event() { + BeaconStateEvent::Status { ssz, .. } => *StatusView::head_root(&ssz), + ev => panic!("status_event produced {ev:?}"), + } +} + +/// The status event alone names a block just applied. The state that block +/// produced cannot name it until the next slot, as the assertions below spell +/// out. The empty slots between two blocks keep naming the same block. +#[test] +fn a_just_applied_block_is_named_by_the_status_event_alone() { + const CHILD_SLOT: Slot = 11; + const CHILD_ROOT: B256 = [0x0C; 32]; + + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 10); + assert_eq!(status_head_root(&mut tile), ANCHOR_ROOT); + + let header = BeaconBlockHeader { + slot: CHILD_SLOT, + proposer_index: 0, + parent_root: ANCHOR_ROOT, + state_root: [0u8; 32], + body_root: [0u8; 32], + }; + // The state as the STF leaves it: the `process_slot` out of the anchor's + // slot recorded the anchor block's root, then `process_block_header` + // installed the child's header with the zero `state_root` the block + // arrived with. + let child_slot_idx = { + let mut g = tile.state.write(); + let mut sw = g.slot_states.roll_from(tile.last_applied.slot_idx); + sw.advance_slot(); + sw.state_mut().latest_block_header = header; + sw.commit() + }; + let child_block_roots_idx = { + let mut g = tile.state.write(); + let mut w = g.block_roots.roll_from(tile.last_applied.block_roots_idx); + w.set(((CHILD_SLOT - 1) % SLOTS_PER_HISTORICAL_ROOT as u64) as u32, ANCHOR_ROOT); + w.commit() + }; + let anchor_cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; + tile.publish_applied_block( + &ParsedBlock { + header, + block_root: CHILD_ROOT, + has_data_columns: false, + parent_state_id: tile.last_applied, + is_gloas: false, + parent_payload_status: PayloadStatus::Full, + }, + &[], + AppliedBlock { + id: StateId { + slot_idx: child_slot_idx, + block_roots_idx: child_block_roots_idx, + ..tile.last_applied + }, + justified: anchor_cp, + finalized: anchor_cp, + unrealized: (anchor_cp, anchor_cp), + execution_block_hash: [0u8; 32], + bid_block_hash: [0u8; 32], + }, + ); + + assert_eq!(status_head_root(&mut tile), CHILD_ROOT); + let (state_root, recorded) = tile + .reader() + .read(&|v| { + ( + v.slot.state().latest_block_header.state_root, + v.block_roots.proposed_at(CHILD_SLOT, v.slot.slot_number()), + ) + }) + .unwrap(); + assert_eq!(state_root, [0u8; 32], "the published state does not name its own block"); + assert_eq!(recorded, None, "nor does the ring, until the next slot"); + + tile.on_slot_start(CHILD_SLOT + 1); + assert_eq!(status_head_root(&mut tile), CHILD_ROOT, "an empty slot changes no head"); +} + #[test] fn block_unknown_parent_rejected() { let mut tile = make_tile(); @@ -1674,7 +1801,7 @@ fn multi_fork_finalize_promotes_and_rebases() { tile.last_applied = d_id; tile.last_applied_block_root = D_ROOT; tile.fork_choice.finalized_checkpoint = f_cp; - // Republish so the seqlock control matches the new head. + // Republish so the seqlock control names the new head's bundle. tile.state.publish_state_id(d_id); // Sanity: pre-finalize state. diff --git a/crates/beacon_state/tile/tests/common.rs b/crates/beacon_state/tile/tests/common.rs index 3c55bf2a..e699dae3 100644 --- a/crates/beacon_state/tile/tests/common.rs +++ b/crates/beacon_state/tile/tests/common.rs @@ -133,14 +133,22 @@ impl OutboundKind { } } +/// EF fixtures are generated with the fork under test active from genesis, +/// so the config must activate it there too: a block's signature is verified +/// against the fork version the config says is active at the block's epoch, +/// and these fixtures sit in the first epochs. +fn fulu_from_genesis() -> SpecConfig { + SpecConfig { fulu_fork_epoch: 0, ..SpecConfig::mainnet() } +} + impl Harness { pub fn new(wall_slot: u64, checkpoint_ssz: &[u8]) -> Self { Self::build(wall_slot, |ticker, gc, rc, ec, repc| { - let state = BeaconState::from_checkpoint(checkpoint_ssz, &SpecConfig::mainnet(), &[]) + let state = BeaconState::from_checkpoint(checkpoint_ssz, &fulu_from_genesis(), &[]) .unwrap_or_else(|e| panic!("decompose checkpoint: {e}")); BeaconStateTile::new( ticker, - Arc::new(SpecConfig::mainnet()), + Arc::new(fulu_from_genesis()), &SyncingConfig::default(), gc, rc, @@ -331,7 +339,7 @@ impl Harness { // Decompose the EF post-state into per-tier finalized bases with an // empty anchored delta, then hash via `StateWriterView` (mirrors // ef_common). - let mut bs = BeaconState::decompose(post_ssz, &SpecConfig::mainnet(), None) + let mut bs = BeaconState::decompose(post_ssz, &fulu_from_genesis(), None) .expect("decompose post.ssz"); // Anchor a fresh fork at the decoded base and hold its writers; diff --git a/crates/beacon_state/tile/tests/ef_common.rs b/crates/beacon_state/tile/tests/ef_common.rs index f7a79266..1e4f1294 100644 --- a/crates/beacon_state/tile/tests/ef_common.rs +++ b/crates/beacon_state/tile/tests/ef_common.rs @@ -414,7 +414,10 @@ pub fn ef_tile(state: silver_beacon_state_data::BeaconState) -> BeaconStateTile TCache::producer("ef_replay", 1 << 16), ); - let mut spec = SpecConfig::mainnet(); + // Fixtures are generated with the fork under test active from genesis, + // and they sit in the first epochs: a block signature is verified against + // the fork version the config says is active at the block's epoch. + let mut spec = SpecConfig { fulu_fork_epoch: 0, ..SpecConfig::mainnet() }; if state.is_finalized_post_gloas() { spec.gloas_fork_epoch = 0; } diff --git a/crates/beacon_state/tile/tests/ef_epoch_processing.rs b/crates/beacon_state/tile/tests/ef_epoch_processing.rs index a2fa2d25..d4063f41 100644 --- a/crates/beacon_state/tile/tests/ef_epoch_processing.rs +++ b/crates/beacon_state/tile/tests/ef_epoch_processing.rs @@ -5,7 +5,8 @@ mod ef_common; use ef_common::{ LoadedState, compare_states, iter_test_cases, load_state, load_state_gloas, spec_tests_dir, }; -use silver_beacon_state::stf::{self, EPOCHS_PER_SYNC_COMMITTEE_PERIOD, HISTORICAL_SUMMARY_PERIOD}; +use silver_beacon_state::stf::{self, HISTORICAL_SUMMARY_PERIOD}; +use silver_beacon_state_data::EPOCHS_PER_SYNC_COMMITTEE_PERIOD; /// Gloas EF config: mainnet preset with Gloas active from genesis, so the /// `cfg.is_gloas_at(epoch)`-gated STF branches fire on the loaded Gloas states. fn gloas_cfg() -> silver_beacon_state_data::SpecConfig { diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index 79f9f6cb..ad3d91b2 100644 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true version.workspace = true [dependencies] +silver_application_boundary.workspace = true silver_beacon_state.workspace = true silver_beacon_state_data.workspace = true silver_columns.workspace = true @@ -17,7 +18,7 @@ silver_gossip.workspace = true silver_network.workspace = true silver_peer.workspace = true silver_storage.workspace = true -silver_engine.workspace = true +silver_httpcore.workspace = true clap.workspace = true flux.workspace = true diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 64b59d5b..6d89aa62 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -7,6 +7,7 @@ use flux::{ use mimalloc::MiMalloc; use quinn_proto::{Endpoint, EndpointConfig}; use rand::RngCore; +use silver_application_boundary::ApplicationBoundaryTile; use silver_beacon_state::{BeaconStateTile, SlotTicker}; use silver_beacon_state_data::{BeaconState, SLOTS_PER_EPOCH}; use silver_columns::tile::DataColumnsTile; @@ -19,8 +20,8 @@ use silver_common::{ use silver_config::Config; use silver_control::Controller; use silver_discovery::{DiscV5, Discovery}; -use silver_engine::EngineTile; use silver_gossip::GossipHandler; +use silver_httpcore::Bind; use silver_network::{Context, NetworkTile, P2p}; use silver_peer::PeerManager; use silver_storage::{latest_local_checkpoint, tile::StorageTile}; @@ -138,6 +139,7 @@ fn main() -> Result<(), Box> { None, ), ); + let identify = config.identify()?; let p2p_context = Context { gossip_producer: incoming_gossip_producer, gossip_consumer: outgoing_gossip_producer @@ -145,7 +147,7 @@ fn main() -> Result<(), Box> { .random_access("p2p_outgoing_gossip", true)?, rpc_producer: incoming_rpc_producer, rpc_consumer: outgoing_rpc_producer.cache_ref().random_access("p2p_outgoing_rpc", true)?, - identify: Some(ProtoIdentify::from((&config.identify()?, &keypair))), + identify: Some(ProtoIdentify::from((&identify, &keypair))), }; let now = Instant::now(); @@ -262,7 +264,17 @@ fn main() -> Result<(), Box> { el_producer, ); - let engine_tile = EngineTile::new( + let beacon_api_binds = + config.beacon_api_bind().iter().map(String::as_str).map(Bind::parse).collect::>(); + let application_boundary_tile = ApplicationBoundaryTile::new( + &beacon_api_binds, + config.beacon_api_max_connections(), + config.beacon_api_idle_timeout(), + &keypair, + local_enr, + &identify, + &spec, + beacon_state_tile.reader(), config.engine_config(), ssz_gossip_consumer_eng, incoming_rpc_consumer_eng, @@ -281,7 +293,11 @@ fn main() -> Result<(), Box> { TileConfig::new(3, Some(ThreadNiceness::Highest)), ); attach_tile(storage_tile, scoped_spine, TileConfig::new(4, Some(ThreadNiceness::Highest))); - attach_tile(engine_tile, scoped_spine, TileConfig::new(5, Some(ThreadNiceness::Highest))); + attach_tile( + application_boundary_tile, + scoped_spine, + TileConfig::new(5, Some(ThreadNiceness::Highest)), + ); attach_tile( data_columns_tile, scoped_spine, @@ -328,12 +344,24 @@ fn load_config() -> Result { if args.iter().any(|a| a == "--unsafe-no-el") { config = config.with_unsafe_no_el(true); } + if let Some(binds) = + args.iter().position(|a| a == "--beacon-api-bind").and_then(|i| args.get(i + 1)) + { + config = config.with_beacon_api_bind(comma_separated(binds)); + } tracing::info!("loaded config: {config:#?}"); Ok(config) } +/// List form for CLI flags whose config counterpart is a TOML array. A comma +/// is neither valid in a `SocketAddr` nor sane in a socket path, so it can +/// never be part of one value. +fn comma_separated(value: &str) -> Vec { + value.split(',').map(str::to_owned).collect() +} + fn load_checkpoint(config: &Config) -> Result<(Vec, Vec), std::io::Error> { let chain_config = config.chain_config(); match &chain_config.checkpoint_file { @@ -368,3 +396,24 @@ fn load_checkpoint(config: &Config) -> Result<(Vec, Vec), std::io::Error }, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn beacon_api_bind_flag_takes_one_value_or_a_comma_separated_list() { + assert_eq!(comma_separated("0.0.0.0:5051"), ["0.0.0.0:5051"]); + + let binds = comma_separated("0.0.0.0:5051,[::1]:5052,/run/silver/beacon.sock") + .iter() + .map(String::as_str) + .map(Bind::parse) + .collect::>(); + assert_eq!(binds, [ + Bind::Tcp("0.0.0.0:5051".parse().unwrap()), + Bind::Tcp("[::1]:5052".parse().unwrap()), + Bind::Unix("/run/silver/beacon.sock".into()), + ]); + } +} diff --git a/crates/common/src/spine/messages.rs b/crates/common/src/spine/messages.rs index a14e0029..ada16c1d 100644 --- a/crates/common/src/spine/messages.rs +++ b/crates/common/src/spine/messages.rs @@ -705,6 +705,7 @@ pub enum BeaconStateEvent { ssz: [u8; STATUS_V2_SIZE], latest_block_slot: u64, wall_slot: u64, + head_optimistic: bool, enr_fork_id: [u8; 16], }, PersistBlock { @@ -962,9 +963,10 @@ pub enum EngineResp { } /// Sync status of the attached execution layer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[repr(u8)] pub enum ELSyncStatus { + #[default] Unknown = 0, Syncing = 1, Synced = 2, diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index fd8ab746..94706ed7 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -12,6 +12,7 @@ secp256k1.workspace = true serde.workspace = true toml.workspace = true hex.workspace = true +tracing.workspace = true [lints] workspace = true diff --git a/crates/config/chain_spec/Cargo.toml b/crates/config/chain_spec/Cargo.toml index 97e7cf7c..9478cba8 100644 --- a/crates/config/chain_spec/Cargo.toml +++ b/crates/config/chain_spec/Cargo.toml @@ -9,5 +9,8 @@ version.workspace = true serde.workspace = true hex.workspace = true +[dev-dependencies] +toml.workspace = true + [lints] workspace = true diff --git a/crates/config/chain_spec/src/lib.rs b/crates/config/chain_spec/src/lib.rs index 76bc13ef..6d8c22b7 100644 --- a/crates/config/chain_spec/src/lib.rs +++ b/crates/config/chain_spec/src/lib.rs @@ -1,9 +1,21 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; const fn default_u64() -> u64 { V } +/// Fork versions are written big-endian in every upstream config +/// (`0x06000000`), so the literal in a `#[serde(default)]` reads as the +/// config file does. +const fn default_fork_version() -> [u8; 4] { + V.to_be_bytes() +} + +/// `FAR_FUTURE_EPOCH`: a fork with no scheduled activation. +const fn unscheduled() -> u64 { + u64::MAX +} + /// Mainnet preset; every network we support uses it. const SLOTS_PER_EPOCH: u64 = 32; @@ -16,34 +28,132 @@ pub struct BlobParameters { pub max_blobs_per_block: u64, } +/// Every fork silver's config can name, in activation order. The set is +/// closed and minted upstream, so it is an enum rather than a table +/// (ADR-0003); forks past Gloas are added here as the spec schedules them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum ForkName { + Phase0, + Altair, + Bellatrix, + Capella, + Deneb, + Electra, + Fulu, + Gloas, +} + +impl ForkName { + /// Activation order: every cascade over the fork table walks it from the + /// end, and the beacon-API fork schedule is served in this order. + pub const ALL: [Self; 8] = [ + Self::Phase0, + Self::Altair, + Self::Bellatrix, + Self::Capella, + Self::Deneb, + Self::Electra, + Self::Fulu, + Self::Gloas, + ]; + + /// Lowercase spec spelling, as the wire wants it in + /// `Eth-Consensus-Version` and in the `version` field of a beacon-API + /// body. + pub fn name(self) -> &'static str { + match self { + Self::Phase0 => "phase0", + Self::Altair => "altair", + Self::Bellatrix => "bellatrix", + Self::Capella => "capella", + Self::Deneb => "deneb", + Self::Electra => "electra", + Self::Fulu => "fulu", + Self::Gloas => "gloas", + } + } +} + /// Per-network spec parameters that vary across mainnet / testnets / devnets. /// /// Compile-time array dimensions (`SLOTS_PER_EPOCH`, /// `SYNC_COMMITTEE_SIZE`, etc.) stay hardcoded — every real testnet uses /// the mainnet preset; only the spec "minimal" preset differs and we don't /// support running it. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub struct SpecConfig { + /// Upstream `CONFIG_NAME`, which the canonical mainnet/sepolia/hoodi + /// configs all set and devnet files are the ones to omit. Teku's + /// `--network auto` preloads the builtin config this names, and client + /// test suites assert the key is present, so `network_name` derives one + /// from `genesis_fork_version` rather than serving none. + #[serde(default, deserialize_with = "name_unless_empty")] + pub config_name: Option, /// Genesis (phase-0) fork version. Used as the `current_version` in the /// genesis fork-data root, which is the domain mixed into deposit /// signatures (`DOMAIN_DEPOSIT`). 0x00000000 mainnet, 0x10000910 Hoodi. - #[serde(default = "default_genesis_fork_version", with = "hex_0x")] + #[serde(default = "default_fork_version::<0x00000000>", with = "hex_0x")] pub genesis_fork_version: [u8; 4], + /// Genesis generation parameters. Silver starts from a checkpoint and + /// never derives a genesis state, so it reads none of these; they are + /// carried so the config it serves describes the network a client joined. + #[serde(default = "default_u64::<16_384>")] + pub min_genesis_active_validator_count: u64, + #[serde(default = "default_u64::<1_606_824_000>")] + pub min_genesis_time: u64, + #[serde(default = "default_u64::<604_800>")] + pub genesis_delay: u64, + /// Altair through Electra gate none of silver's own consensus — it runs + /// Fulu and Gloas only. They are carried because a validator client + /// derives signing domains for historical epochs from the fork schedule + /// this node publishes. + #[serde(default = "default_fork_version::<0x01000000>", with = "hex_0x")] + pub altair_fork_version: [u8; 4], + #[serde(default = "default_u64::<74240>")] + pub altair_fork_epoch: u64, + #[serde(default = "default_fork_version::<0x02000000>", with = "hex_0x")] + pub bellatrix_fork_version: [u8; 4], + #[serde(default = "default_u64::<144896>")] + pub bellatrix_fork_epoch: u64, + /// Merge transition parameters. Every network silver can join is already + /// past its merge, so these gate nothing here; they are carried because a + /// client that builds its whole runtime spec from the served config aborts + /// on a missing key. + #[serde(default = "default_terminal_total_difficulty", with = "quoted_u128")] + pub terminal_total_difficulty: u128, + #[serde(default, with = "hex_0x")] + pub terminal_block_hash: [u8; 32], + #[serde(default = "unscheduled")] + pub terminal_block_hash_activation_epoch: u64, /// Capella fork version. Withdrawal-credential domain on Capella+. /// 0x03000000 mainnet, 0x40000910 Hoodi. - #[serde(default = "default_capella_fork_version", with = "hex_0x")] + #[serde(default = "default_fork_version::<0x03000000>", with = "hex_0x")] pub capella_fork_version: [u8; 4], + #[serde(default = "default_u64::<194048>")] + pub capella_fork_epoch: u64, + #[serde(default = "default_fork_version::<0x04000000>", with = "hex_0x")] + pub deneb_fork_version: [u8; 4], + #[serde(default = "default_u64::<269568>")] + pub deneb_fork_epoch: u64, + #[serde(default = "default_fork_version::<0x05000000>", with = "hex_0x")] + pub electra_fork_version: [u8; 4], + /// Doubles as the epoch of the active blob params when no + /// `blob_schedule` entry applies. + #[serde(default = "default_u64::<364032>")] + pub electra_fork_epoch: u64, /// Fulu fork version. Mixed into every Fulu /// fork digest. - #[serde(default = "default_fulu_fork_version", with = "hex_0x")] + #[serde(default = "default_fork_version::<0x06000000>", with = "hex_0x")] pub fulu_fork_version: [u8; 4], + #[serde(default = "default_u64::<411392>")] + pub fulu_fork_epoch: u64, /// Gloas (EIP-7732) fork version, compared against /// `state.fork.current_version` to gate Gloas state logic. - #[serde(default = "default_gloas_fork_version", with = "hex_0x")] + #[serde(default = "default_fork_version::<0x07000000>", with = "hex_0x")] pub gloas_fork_version: [u8; 4], /// Gloas activation epoch. - #[serde(default = "default_gloas_fork_epoch")] + #[serde(default = "unscheduled")] pub gloas_fork_epoch: u64, /// Per-epoch override on `max_blobs_per_block` (EIP-7892). Sorted by /// `epoch`; the active entry is the highest-epoch entry whose epoch @@ -51,18 +161,43 @@ pub struct SpecConfig { /// defaults (`electra_fork_epoch`, `max_blobs_per_block_electra`). #[serde(default = "default_blob_schedule")] pub blob_schedule: Vec, - /// Activation epoch of the Electra fork — used - /// as the epoch field of the active blob params when no `blob_schedule` - /// entry applies. - #[serde(default = "default_u64::<364032>")] - pub electra_fork_epoch: u64, /// Blob count active between Electra /// activation and the first BPO upgrade. 9 mainnet. #[serde(default = "default_u64::<9>")] pub max_blobs_per_block_electra: u64, + /// Blob-sidecar gossip and req/resp limits. Fulu replaced sidecars with + /// data columns, so silver's own networking uses the column parameters + /// instead; these describe the pre-Fulu topics a client may still ask + /// about. + #[serde(default = "default_u64::<6>")] + pub blob_sidecar_subnet_count: u64, + #[serde(default = "default_u64::<9>")] + pub blob_sidecar_subnet_count_electra: u64, + #[serde(default = "default_u64::<768>")] + pub max_request_blob_sidecars: u64, + #[serde(default = "default_u64::<1152>")] + pub max_request_blob_sidecars_electra: u64, + #[serde(default = "default_u64::<4096>")] + pub min_epochs_for_blob_sidecars_requests: u64, + /// Deposit contract identity. Silver follows no eth1 deposit stream, so + /// nothing here is verified against; it is carried so the node can tell a + /// validator client which contract the network it joined deposits to. + #[serde(default = "default_u64::<1>")] + pub deposit_chain_id: u64, + #[serde(default = "default_u64::<1>")] + pub deposit_network_id: u64, + #[serde(default = "default_deposit_contract_address", with = "hex_0x")] + pub deposit_contract_address: [u8; 20], /// Seconds per beacon chain slot. 12 mainnet; testnets may use shorter. #[serde(default = "default_u64::<12>")] pub seconds_per_slot: u64, + /// Eth1 following parameters. Silver follows no eth1 deposit stream, so + /// nothing here is used; they are carried so a client can tell which eth1 + /// chain the network it joined votes on. + #[serde(default = "default_u64::<14>")] + pub seconds_per_eth1_block: u64, + #[serde(default = "default_u64::<2048>")] + pub eth1_follow_distance: u64, /// Minimum activation period before a validator may voluntarily exit. #[serde(default = "default_u64::<256>")] pub shard_committee_period: u64, @@ -122,24 +257,18 @@ pub struct SpecConfig { pub ejection_balance: u64, } -fn default_genesis_fork_version() -> [u8; 4] { - [0x00, 0x00, 0x00, 0x00] -} - -fn default_capella_fork_version() -> [u8; 4] { - [0x03, 0x00, 0x00, 0x00] +/// Mainnet's merge threshold, crossed 2022-09-15. +const fn default_terminal_total_difficulty() -> u128 { + 58_750_000_000_000_000_000_000 } -fn default_fulu_fork_version() -> [u8; 4] { - [0x06, 0x00, 0x00, 0x00] -} - -fn default_gloas_fork_version() -> [u8; 4] { - [0x07, 0x00, 0x00, 0x00] -} - -fn default_gloas_fork_epoch() -> u64 { - u64::MAX +/// Mainnet deposit contract, live since 2020-11-04. Hoodi reuses the very +/// same address. +fn default_deposit_contract_address() -> [u8; 20] { + [ + 0x00, 0x00, 0x00, 0x00, 0x21, 0x9a, 0xb5, 0x40, 0x35, 0x6c, 0xbb, 0x83, 0x9c, 0xbe, 0x05, + 0x30, 0x3d, 0x77, 0x05, 0xfa, + ] } fn default_blob_schedule() -> Vec { @@ -149,21 +278,58 @@ fn default_blob_schedule() -> Vec { }] } -/// Serde adapter for `0x`-prefixed lowercase hex (`0x06000000`), which is -/// the format used by upstream `consensus-specs/configs/*.yaml` for all -/// fork-version fields. The bare `hex::serde` adapter rejects the prefix. +/// `CONFIG_NAME: ''` names no network, so it reads as a name absent rather +/// than as a network called "". +fn name_unless_empty<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(Option::::deserialize(d)?.filter(|name| !name.is_empty())) +} + +/// Serde adapter for `0x`-prefixed hex (`0x06000000`), which is the format +/// used by upstream `consensus-specs/configs/*.yaml` for fork versions and +/// the deposit contract address. The bare `hex::serde` adapter rejects the +/// prefix. mod hex_0x { use serde::{Deserialize, Deserializer, Serializer, de::Error}; - pub fn serialize(bytes: &[u8; 4], s: S) -> Result { + pub fn serialize( + bytes: &[u8; N], + s: S, + ) -> Result { s.serialize_str(&format!("0x{}", hex::encode(bytes))) } - pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 4], D::Error> { + pub fn deserialize<'de, const N: usize, D: Deserializer<'de>>( + d: D, + ) -> Result<[u8; N], D::Error> { let s: String = Deserialize::deserialize(d)?; let body = s.strip_prefix("0x").unwrap_or(&s); let v = hex::decode(body).map_err(D::Error::custom)?; - v.try_into().map_err(|_: Vec| D::Error::custom("expected 4-byte hex")) + v.try_into().map_err(|_: Vec| D::Error::custom(format!("expected {N}-byte hex"))) + } +} + +/// Serde adapter for a decimal too wide for the `i64` a TOML integer holds +/// (mainnet's `TERMINAL_TOTAL_DIFFICULTY` needs 76 bits), so it is written +/// quoted — as the beacon-API also serves it. A testnet's small value is +/// accepted either quoted or bare. +mod quoted_u128 { + use serde::{Deserialize, Deserializer, Serializer, de::Error}; + + pub fn serialize(value: &u128, s: S) -> Result { + s.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Written { + Quoted(String), + Bare(u64), + } + match Written::deserialize(d)? { + Written::Quoted(text) => text.parse().map_err(D::Error::custom), + Written::Bare(value) => Ok(value.into()), + } } } @@ -179,6 +345,58 @@ impl SpecConfig { } } + pub fn fork_at(&self, epoch: u64) -> ForkName { + if epoch >= self.gloas_fork_epoch { + ForkName::Gloas + } else if epoch >= self.fulu_fork_epoch { + ForkName::Fulu + } else if epoch >= self.electra_fork_epoch { + ForkName::Electra + } else if epoch >= self.deneb_fork_epoch { + ForkName::Deneb + } else if epoch >= self.capella_fork_epoch { + ForkName::Capella + } else if epoch >= self.bellatrix_fork_epoch { + ForkName::Bellatrix + } else if epoch >= self.altair_fork_epoch { + ForkName::Altair + } else { + ForkName::Phase0 + } + } + + #[inline] + pub fn fork_at_slot(&self, slot: u64) -> ForkName { + self.fork_at(slot / SLOTS_PER_EPOCH) + } + + pub fn fork_version(&self, fork: ForkName) -> [u8; 4] { + match fork { + ForkName::Phase0 => self.genesis_fork_version, + ForkName::Altair => self.altair_fork_version, + ForkName::Bellatrix => self.bellatrix_fork_version, + ForkName::Capella => self.capella_fork_version, + ForkName::Deneb => self.deneb_fork_version, + ForkName::Electra => self.electra_fork_version, + ForkName::Fulu => self.fulu_fork_version, + ForkName::Gloas => self.gloas_fork_version, + } + } + + /// `u64::MAX` for a fork this network has not scheduled. + pub fn fork_epoch(&self, fork: ForkName) -> u64 { + match fork { + ForkName::Phase0 => 0, + ForkName::Altair => self.altair_fork_epoch, + ForkName::Bellatrix => self.bellatrix_fork_epoch, + ForkName::Capella => self.capella_fork_epoch, + ForkName::Deneb => self.deneb_fork_epoch, + ForkName::Electra => self.electra_fork_epoch, + ForkName::Fulu => self.fulu_fork_epoch, + ForkName::Gloas => self.gloas_fork_epoch, + } + } + /// Whether `epoch` is at or past the Gloas activation. #[inline] pub fn is_gloas_at(&self, epoch: u64) -> bool { @@ -203,7 +421,7 @@ impl SpecConfig { #[inline] pub fn fork_version_at(&self, epoch: u64) -> [u8; 4] { - if self.is_gloas_at(epoch) { self.gloas_fork_version } else { self.fulu_fork_version } + self.fork_version(self.fork_at(epoch)) } /// `(next_fork_version, next_fork_epoch)` for the ENR `eth2` field at @@ -217,43 +435,92 @@ impl SpecConfig { } } + /// What this node calls the network it runs: `CONFIG_NAME` when the + /// config file names one, and the name its `genesis_fork_version` carries + /// otherwise. pub fn network_name(&self) -> String { + if let Some(name) = &self.config_name { + return name.clone(); + } + match self.known_network() { + Some(name) => name.to_owned(), + // go-eth2-client turns every `0x`-prefixed hex spec value into a + // byte slice, so a devnet named after its bare fork version + // would break its consumers reading `CONFIG_NAME` as a string. + None => format!("devnet-{}", hex::encode(self.genesis_fork_version)), + } + } + + /// The network `genesis_fork_version` picks out, for the networks silver + /// knows by name; `None` for a devnet. + fn known_network(&self) -> Option<&'static str> { match self.genesis_fork_version { - [0x00, 0x00, 0x00, 0x00] => "mainnet".to_owned(), - [0x90, 0x00, 0x00, 0x69] => "sepolia".to_owned(), - [0x10, 0x00, 0x09, 0x10] => "hoodi".to_owned(), - version => format!("0x{}", hex::encode(version)), + [0x00, 0x00, 0x00, 0x00] => Some("mainnet"), + [0x90, 0x00, 0x00, 0x69] => Some("sepolia"), + [0x10, 0x00, 0x09, 0x10] => Some("hoodi"), + _ => None, } } - /// Hoodi testnet (launched 2025-03-17). Differs from mainnet in fork - /// versions and a few fork epochs only — preset dimensions, validator - /// lifecycle, inactivity, slashing, and churn scalars are all identical - /// to mainnet (see `eth-clients/hoodi/metadata/config.yaml`). - /// - /// Diffs from mainnet: - /// - All pre-Fulu forks (Altair → Electra) activated at epoch 0 except - /// Electra, which activated at epoch 2048. - /// - `*_FORK_VERSION` pattern is `0xN0000910` (N = fork ordinal) instead - /// of mainnet's `0x0N000000`. - /// - Hoodi-specific `BLOB_SCHEDULE` entries should be cross-checked - /// against the upstream config file before long-running use. + /// The network `genesis_fork_version` picks out when an explicit + /// `CONFIG_NAME` contradicts it — a misconfiguration, since a validator + /// client trusts both. A devnet fork version picks out nothing, so it + /// contradicts no name. + pub fn misnamed_network(&self) -> Option<&'static str> { + let known = self.known_network()?; + (self.config_name.as_deref()? != known).then_some(known) + } + + /// Hoodi testnet (launched 2025-03-17), transcribed from + /// `eth-clients/hoodi/metadata/config.yaml` as of 2026-08-19. Preset + /// dimensions, validator lifecycle, inactivity, slashing and churn + /// scalars are all mainnet's; what differs is the `0xN0000910` + /// fork-version pattern (N = fork ordinal), the fork epochs, genesis time + /// and delay, `SECONDS_PER_ETH1_BLOCK`, a `TERMINAL_TOTAL_DIFFICULTY` of + /// 0 (Hoodi merged at genesis), the deposit chain/network ids, and its own + /// `BLOB_SCHEDULE`. pub fn hoodi() -> Self { Self { + config_name: None, // Hoodi fork-version pattern is `0xN0000910`. - genesis_fork_version: [0x10, 0x00, 0x09, 0x10], - capella_fork_version: [0x40, 0x00, 0x09, 0x10], - fulu_fork_version: [0x70, 0x00, 0x09, 0x10], - gloas_fork_version: [0x80, 0x00, 0x09, 0x10], - gloas_fork_epoch: u64::MAX, - // No BPO entries spec'd on Hoodi at time of writing. Empty ⇒ - // always fall back to (`electra_fork_epoch`, - // `max_blobs_per_block_electra`). - blob_schedule: vec![], + genesis_fork_version: default_fork_version::<0x10000910>(), + min_genesis_active_validator_count: 16_384, + min_genesis_time: 1_742_212_800, + genesis_delay: 600, + altair_fork_version: default_fork_version::<0x20000910>(), + altair_fork_epoch: 0, + bellatrix_fork_version: default_fork_version::<0x30000910>(), + bellatrix_fork_epoch: 0, + terminal_total_difficulty: 0, + terminal_block_hash: [0; 32], + terminal_block_hash_activation_epoch: unscheduled(), + capella_fork_version: default_fork_version::<0x40000910>(), + capella_fork_epoch: 0, + deneb_fork_version: default_fork_version::<0x50000910>(), + deneb_fork_epoch: 0, + electra_fork_version: default_fork_version::<0x60000910>(), electra_fork_epoch: 2048, + fulu_fork_version: default_fork_version::<0x70000910>(), + fulu_fork_epoch: 50688, + gloas_fork_version: default_fork_version::<0x80000910>(), + gloas_fork_epoch: unscheduled(), + blob_schedule: vec![ + BlobParameters { epoch: 52480, max_blobs_per_block: 15 }, + BlobParameters { epoch: 54016, max_blobs_per_block: 21 }, + ], max_blobs_per_block_electra: 9, - // Identical to mainnet preset / config below this line. + blob_sidecar_subnet_count: 6, + blob_sidecar_subnet_count_electra: 9, + max_request_blob_sidecars: 768, + max_request_blob_sidecars_electra: 1152, + min_epochs_for_blob_sidecars_requests: 4096, + deposit_chain_id: 560048, + deposit_network_id: 560048, + deposit_contract_address: default_deposit_contract_address(), seconds_per_slot: 12, + seconds_per_eth1_block: 12, + eth1_follow_distance: 2048, + // Identical to mainnet preset / config below this line. shard_committee_period: 256, min_validator_withdrawability_delay: 256, max_seed_lookahead: 4, @@ -275,15 +542,41 @@ impl SpecConfig { pub fn mainnet() -> Self { Self { - genesis_fork_version: default_genesis_fork_version(), - capella_fork_version: default_capella_fork_version(), - fulu_fork_version: default_fulu_fork_version(), - gloas_fork_version: default_gloas_fork_version(), - gloas_fork_epoch: default_gloas_fork_epoch(), - blob_schedule: default_blob_schedule(), + config_name: None, + genesis_fork_version: default_fork_version::<0x00000000>(), + min_genesis_active_validator_count: 16_384, + min_genesis_time: 1_606_824_000, + genesis_delay: 604_800, + altair_fork_version: default_fork_version::<0x01000000>(), + altair_fork_epoch: 74240, + bellatrix_fork_version: default_fork_version::<0x02000000>(), + bellatrix_fork_epoch: 144896, + terminal_total_difficulty: default_terminal_total_difficulty(), + terminal_block_hash: [0; 32], + terminal_block_hash_activation_epoch: unscheduled(), + capella_fork_version: default_fork_version::<0x03000000>(), + capella_fork_epoch: 194048, + deneb_fork_version: default_fork_version::<0x04000000>(), + deneb_fork_epoch: 269568, + electra_fork_version: default_fork_version::<0x05000000>(), electra_fork_epoch: 364032, + fulu_fork_version: default_fork_version::<0x06000000>(), + fulu_fork_epoch: 411392, + gloas_fork_version: default_fork_version::<0x07000000>(), + gloas_fork_epoch: unscheduled(), + blob_schedule: default_blob_schedule(), max_blobs_per_block_electra: 9, + blob_sidecar_subnet_count: 6, + blob_sidecar_subnet_count_electra: 9, + max_request_blob_sidecars: 768, + max_request_blob_sidecars_electra: 1152, + min_epochs_for_blob_sidecars_requests: 4096, + deposit_chain_id: 1, + deposit_network_id: 1, + deposit_contract_address: default_deposit_contract_address(), seconds_per_slot: 12, + seconds_per_eth1_block: 14, + eth1_follow_distance: 2048, shard_committee_period: 256, min_validator_withdrawability_delay: 256, max_seed_lookahead: 4, @@ -314,6 +607,219 @@ impl Default for SpecConfig { mod tests { use super::*; + /// Every default is the mainnet value from + /// `ethereum/consensus-specs` `configs/mainnet.yaml` (fork versions and + /// epochs, `BLOB_SCHEDULE`, `DEPOSIT_CHAIN_ID` / `DEPOSIT_NETWORK_ID` / + /// `DEPOSIT_CONTRACT_ADDRESS`), so a config file naming only its + /// network's diffs still describes mainnet everywhere else. + #[test] + fn toml_defaults_are_mainnet() { + let spec: SpecConfig = toml::from_str("").unwrap(); + assert_eq!(spec, SpecConfig::mainnet()); + + assert_eq!(spec.altair_fork_epoch, 74240); + assert_eq!(spec.bellatrix_fork_epoch, 144896); + assert_eq!(spec.capella_fork_epoch, 194048); + assert_eq!(spec.deneb_fork_epoch, 269568); + assert_eq!(spec.electra_fork_epoch, 364032); + assert_eq!(spec.fulu_fork_epoch, 411392); + assert_eq!(spec.gloas_fork_epoch, u64::MAX); + assert_eq!(spec.deposit_chain_id, 1); + assert_eq!(spec.deposit_network_id, 1); + assert_eq!(spec.network_name(), "mainnet"); + assert_eq!(spec.min_genesis_time, 1_606_824_000); + assert_eq!(spec.genesis_delay, 604_800); + assert_eq!(spec.seconds_per_eth1_block, 14); + assert_eq!(spec.terminal_total_difficulty, 58_750_000_000_000_000_000_000); + assert_eq!(spec.terminal_block_hash, [0; 32]); + assert_eq!(spec.terminal_block_hash_activation_epoch, u64::MAX); + } + + /// `TERMINAL_TOTAL_DIFFICULTY` outgrows the `i64` a TOML integer holds, so + /// the wide value has to survive being written as a string. + #[test] + fn terminal_total_difficulty_parses_quoted_or_bare() { + let quoted: SpecConfig = + toml::from_str(r#"TERMINAL_TOTAL_DIFFICULTY = "58750000000000000000000""#).unwrap(); + assert_eq!(quoted.terminal_total_difficulty, 58_750_000_000_000_000_000_000); + + let bare: SpecConfig = toml::from_str("TERMINAL_TOTAL_DIFFICULTY = 0").unwrap(); + assert_eq!(bare.terminal_total_difficulty, 0); + } + + #[test] + fn hoodi_matches_its_upstream_config_file() { + let spec = SpecConfig::hoodi(); + assert_eq!(spec.network_name(), "hoodi"); + assert_eq!(spec.min_genesis_time, 1_742_212_800); + assert_eq!(spec.genesis_delay, 600); + assert_eq!(spec.seconds_per_eth1_block, 12); + assert_eq!(spec.terminal_total_difficulty, 0); + assert_eq!(spec.blob_schedule, [ + BlobParameters { epoch: 52480, max_blobs_per_block: 15 }, + BlobParameters { epoch: 54016, max_blobs_per_block: 21 }, + ]); + assert_eq!(spec.min_genesis_active_validator_count, 16_384, "mainnet's value"); + assert_eq!(spec.eth1_follow_distance, 2048, "mainnet's value"); + } + + #[test] + fn every_fork_field_is_overridable() { + let spec: SpecConfig = toml::from_str( + r#" + ALTAIR_FORK_VERSION = "0x20000910" + ALTAIR_FORK_EPOCH = 0 + FULU_FORK_EPOCH = 50688 + DEPOSIT_CHAIN_ID = 560048 + "#, + ) + .unwrap(); + assert_eq!(spec.altair_fork_version, [0x20, 0x00, 0x09, 0x10]); + assert_eq!(spec.altair_fork_epoch, 0); + assert_eq!(spec.fulu_fork_epoch, 50688); + assert_eq!(spec.deposit_chain_id, 560048); + assert_eq!(spec.bellatrix_fork_epoch, 144896, "untouched fields keep the mainnet default"); + } + + /// Upstream writes the address checksummed (mixed case); `hex::decode` + /// must not be handed it case-sensitively. + #[test] + fn deposit_contract_address_parses_checksummed_hex() { + let spec: SpecConfig = toml::from_str( + r#"DEPOSIT_CONTRACT_ADDRESS = "0x00000000219ab540356cBB839Cbe05303d7705Fa""#, + ) + .unwrap(); + assert_eq!(spec.deposit_contract_address, SpecConfig::mainnet().deposit_contract_address); + assert_eq!(spec.deposit_contract_address[4], 0x21); + } + + #[test] + fn fork_at_switches_on_each_activation_epoch() { + let spec = SpecConfig::mainnet(); + assert_eq!(spec.fork_at(0), ForkName::Phase0); + + for (epoch, before, after) in [ + (spec.altair_fork_epoch, ForkName::Phase0, ForkName::Altair), + (spec.bellatrix_fork_epoch, ForkName::Altair, ForkName::Bellatrix), + (spec.capella_fork_epoch, ForkName::Bellatrix, ForkName::Capella), + (spec.deneb_fork_epoch, ForkName::Capella, ForkName::Deneb), + (spec.electra_fork_epoch, ForkName::Deneb, ForkName::Electra), + (spec.fulu_fork_epoch, ForkName::Electra, ForkName::Fulu), + ] { + assert_eq!(spec.fork_at(epoch - 1), before, "epoch {epoch} - 1"); + assert_eq!(spec.fork_at(epoch), after, "epoch {epoch}"); + assert_eq!(spec.fork_at(epoch + 1), after, "epoch {epoch} + 1"); + } + + assert_eq!(spec.fork_at(u64::MAX - 1), ForkName::Fulu, "Gloas is unscheduled on mainnet"); + } + + /// A validator client derives signing domains for historical epochs from + /// the versions this node reports, so every fork in the table — not just + /// the two silver's own consensus runs — must map to its own version. + #[test] + fn fork_version_at_returns_the_version_of_the_fork_active_then() { + let spec = SpecConfig::mainnet(); + for (epoch, version) in [ + (0, spec.genesis_fork_version), + (spec.altair_fork_epoch, spec.altair_fork_version), + (spec.bellatrix_fork_epoch, spec.bellatrix_fork_version), + (spec.capella_fork_epoch, spec.capella_fork_version), + (spec.deneb_fork_epoch, spec.deneb_fork_version), + (spec.deneb_fork_epoch + 1, spec.deneb_fork_version), + (spec.electra_fork_epoch - 1, spec.deneb_fork_version), + (spec.electra_fork_epoch, spec.electra_fork_version), + (spec.fulu_fork_epoch, spec.fulu_fork_version), + (u64::MAX - 1, spec.fulu_fork_version), + ] { + assert_eq!(spec.fork_version_at(epoch), version, "epoch {epoch}"); + } + } + + /// Hoodi activates altair through deneb all at epoch 0, so the genesis + /// version is never the active one and the cascade must report the + /// highest fork sharing that epoch. + #[test] + fn fork_version_at_on_hoodi_reports_the_highest_fork_sharing_an_epoch() { + let spec = SpecConfig::hoodi(); + for (epoch, version) in [ + (0, spec.deneb_fork_version), + (spec.electra_fork_epoch - 1, spec.deneb_fork_version), + (spec.electra_fork_epoch, spec.electra_fork_version), + (spec.fulu_fork_epoch - 1, spec.electra_fork_version), + (spec.fulu_fork_epoch, spec.fulu_fork_version), + ] { + assert_eq!(spec.fork_version_at(epoch), version, "epoch {epoch}"); + } + } + + #[test] + fn every_fork_maps_to_its_own_version_and_epoch() { + let spec = SpecConfig::mainnet(); + assert_eq!(ForkName::ALL.map(|fork| spec.fork_version(fork)), [ + spec.genesis_fork_version, + spec.altair_fork_version, + spec.bellatrix_fork_version, + spec.capella_fork_version, + spec.deneb_fork_version, + spec.electra_fork_version, + spec.fulu_fork_version, + spec.gloas_fork_version, + ]); + assert_eq!(ForkName::ALL.map(|fork| spec.fork_epoch(fork)), [ + 0, + spec.altair_fork_epoch, + spec.bellatrix_fork_epoch, + spec.capella_fork_epoch, + spec.deneb_fork_epoch, + spec.electra_fork_epoch, + spec.fulu_fork_epoch, + u64::MAX, + ]); + assert!(ForkName::ALL.is_sorted(), "ALL is in activation order"); + } + + #[test] + fn next_fork_still_announces_the_scheduled_gloas_activation() { + let scheduled = SpecConfig { gloas_fork_epoch: 500_000, ..SpecConfig::mainnet() }; + assert_eq!(scheduled.next_fork(499_999), (scheduled.gloas_fork_version, 500_000)); + assert_eq!(scheduled.next_fork(500_000), (scheduled.gloas_fork_version, u64::MAX)); + + let mainnet = SpecConfig::mainnet(); + assert_eq!( + mainnet.next_fork(mainnet.fulu_fork_epoch), + (mainnet.fulu_fork_version, u64::MAX) + ); + } + + #[test] + fn fork_at_slot_switches_on_the_activation_epoch_boundary() { + let spec = SpecConfig { gloas_fork_epoch: 500_000, ..SpecConfig::mainnet() }; + let first_gloas_slot = 500_000 * SLOTS_PER_EPOCH; + assert_eq!(spec.fork_at_slot(first_gloas_slot - 1), ForkName::Fulu); + assert_eq!(spec.fork_at_slot(first_gloas_slot), ForkName::Gloas); + } + + /// These strings go on the wire in `Eth-Consensus-Version` and in the + /// `version` field of every versioned beacon-API body. + #[test] + fn fork_names_match_the_wire_spelling() { + assert_eq!( + [ + ForkName::Phase0, + ForkName::Altair, + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ForkName::Electra, + ForkName::Fulu, + ForkName::Gloas, + ] + .map(ForkName::name), + ["phase0", "altair", "bellatrix", "capella", "deneb", "electra", "fulu", "gloas"] + ); + } + /// The constructors and the lookup read `genesis_fork_version` from /// opposite ends; a typo in either shows up here. #[test] @@ -322,10 +828,72 @@ mod tests { assert_eq!(SpecConfig::hoodi().network_name(), "hoodi"); } + /// A config file naming only a network's diffs still identifies it: the + /// name follows `GENESIS_FORK_VERSION`, not the mainnet defaults filling + /// in around it. #[test] - fn a_devnet_is_named_by_its_fork_version() { - let devnet = + fn a_nameless_config_is_named_by_its_fork_version() { + let sepolia: SpecConfig = toml::from_str(r#"GENESIS_FORK_VERSION = "0x90000069""#).unwrap(); + assert_eq!(sepolia.config_name, None); + assert_eq!(sepolia.network_name(), "sepolia"); + } + + /// go-eth2-client decodes any `0x`-prefixed hex spec value into a byte + /// slice, so a devnet's name must not look like one. + #[test] + fn a_devnet_is_named_by_its_unprefixed_fork_version() { + let devnet: SpecConfig = toml::from_str(r#"GENESIS_FORK_VERSION = "0x10000038""#).unwrap(); + assert_eq!(devnet.network_name(), "devnet-10000038"); + + let literal = SpecConfig { genesis_fork_version: [0x10, 0x00, 0x00, 0x38], ..SpecConfig::mainnet() }; - assert_eq!(devnet.network_name(), "0x10000038"); + assert_eq!(literal.network_name(), "devnet-10000038"); + } + + #[test] + fn an_empty_config_name_is_no_name_at_all() { + let spec: SpecConfig = toml::from_str(r#"CONFIG_NAME = """#).unwrap(); + assert_eq!(spec.config_name, None); + assert_eq!(spec.network_name(), "mainnet"); + } + + #[test] + fn an_explicit_config_name_wins_over_the_fork_version() { + let spec: SpecConfig = toml::from_str( + r#" + CONFIG_NAME = "my-devnet" + GENESIS_FORK_VERSION = "0x10000038" + "#, + ) + .unwrap(); + assert_eq!(spec.network_name(), "my-devnet"); + } + + #[test] + fn only_a_named_network_can_be_misnamed() { + let misnamed = + SpecConfig { config_name: Some("mainnet".to_owned()), ..SpecConfig::hoodi() }; + assert_eq!(misnamed.misnamed_network(), Some("hoodi")); + assert_eq!(misnamed.network_name(), "mainnet", "the explicit name is still served"); + + let agreeing = SpecConfig { config_name: Some("hoodi".to_owned()), ..SpecConfig::hoodi() }; + assert_eq!(agreeing.misnamed_network(), None); + + assert_eq!(SpecConfig::hoodi().misnamed_network(), None, "nothing to disagree with"); + + let devnet = SpecConfig { + config_name: Some("mainnet".to_owned()), + genesis_fork_version: [0x10, 0x00, 0x00, 0x38], + ..SpecConfig::mainnet() + }; + assert_eq!(devnet.misnamed_network(), None, "a devnet fork version names no network"); + } + + /// The comparison is deliberately exact: Teku looks its builtin config up + /// by the name verbatim, and every upstream config spells it lowercase. + #[test] + fn a_miscased_name_still_disagrees_with_its_fork_version() { + let spec = SpecConfig { config_name: Some("Mainnet".to_owned()), ..SpecConfig::mainnet() }; + assert_eq!(spec.misnamed_network(), Some("mainnet")); } } diff --git a/crates/config/src/engine_config.rs b/crates/config/src/engine_config.rs index 718cf84a..f22680f4 100644 --- a/crates/config/src/engine_config.rs +++ b/crates/config/src/engine_config.rs @@ -4,6 +4,17 @@ fn default_tcache_size() -> usize { 2 << 24 } +fn default_max_connections() -> usize { + 32 +} + +// Clears every engine-api per-method minimum-wait floor (the highest is +// getPayloadBodiesBy* at 10 s) with margin: this deadline breaks wedged +// connections, it is not a latency target. +fn default_request_timeout_secs() -> u64 { + 12 +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct EngineConfig { pub execution_endpoint: String, @@ -11,6 +22,11 @@ pub struct EngineConfig { pub jwt_secret: String, #[serde(default = "default_tcache_size")] pub incoming_engine_resp_tcache_size: usize, + #[serde(default = "default_max_connections")] + pub max_connections: usize, + /// Measured from enqueue, so it also covers a connect that never completes. + #[serde(default = "default_request_timeout_secs")] + pub request_timeout_secs: u64, /// Unsafe testing mode: do not connect to the EL. The engine tile answers /// every spine request with a synthetic VALID response. Lets the CL run /// without an execution client. Never enable in production. @@ -24,6 +40,8 @@ impl Default for EngineConfig { execution_endpoint: "http://localhost:8551".into(), jwt_secret: "0".into(), incoming_engine_resp_tcache_size: 2 << 24, + max_connections: 32, + request_timeout_secs: default_request_timeout_secs(), unsafe_no_el: false, } } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index d2bd2a35..19326e80 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,4 +1,7 @@ -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, + time::Duration, +}; pub use chain_config::ChainConfig; pub use discovery_config::DiscoveryConfig; @@ -33,6 +36,10 @@ const fn default_u64() -> u64 { V } +fn default_beacon_api_bind() -> Vec { + vec!["0.0.0.0:5051".into()] +} + fn default_data_dir() -> String { std::env::home_dir() .and_then(|mut b| { @@ -122,6 +129,16 @@ pub struct Config { data_storage_dir: String, #[serde(default)] engine_config: EngineConfig, + /// Each entry is a TCP `addr:port` or a unix socket path; the API serves + /// all of them at once. + #[serde(default = "default_beacon_api_bind")] + beacon_api_bind: Vec, + #[serde(default = "default_usize::<64>")] + beacon_api_max_connections: usize, + /// Refreshed by any byte read or written, so a slow but progressing + /// transfer never trips it. + #[serde(default = "default_u64::<75>")] + beacon_api_idle_timeout_secs: u64, #[serde(default)] disable_weak_subjectivity_check: bool, } @@ -156,6 +173,9 @@ impl Config { outgoing_rpc_tcache_size: 2 << 24, // ssz data_storage_dir: default_data_dir(), engine_config: Default::default(), + beacon_api_bind: default_beacon_api_bind(), + beacon_api_max_connections: 64, + beacon_api_idle_timeout_secs: 75, disable_weak_subjectivity_check: false, } } @@ -165,7 +185,19 @@ impl Config { /// external IP, ports, secret key) here, so no source edits are needed. pub fn from_file>(path: P) -> Result { let text = std::fs::read_to_string(path)?; - Ok(toml::from_str(&text)?) + let config: Self = toml::from_str(&text)?; + + let spec = &config.chain_config.spec; + if let Some(network) = spec.misnamed_network() { + tracing::warn!( + config_name = %spec.network_name(), + genesis_fork_version = %hex::encode(spec.genesis_fork_version), + network, + "CONFIG_NAME disagrees with the network GENESIS_FORK_VERSION names" + ); + } + + Ok(config) } pub fn with_discovery_port(mut self, port: u16) -> Self { @@ -208,6 +240,21 @@ impl Config { self } + pub fn with_beacon_api_bind(mut self, binds: Vec) -> Self { + self.beacon_api_bind = binds; + self + } + + pub fn with_beacon_api_max_connections(mut self, max: usize) -> Self { + self.beacon_api_max_connections = max; + self + } + + pub fn with_beacon_api_idle_timeout_secs(mut self, secs: u64) -> Self { + self.beacon_api_idle_timeout_secs = secs; + self + } + pub fn keypair(&self) -> Result { Keypair::from_secret(&self.secret_key) } @@ -336,6 +383,18 @@ impl Config { self.engine_config.clone() } + pub fn beacon_api_bind(&self) -> &[String] { + &self.beacon_api_bind + } + + pub fn beacon_api_max_connections(&self) -> usize { + self.beacon_api_max_connections + } + + pub fn beacon_api_idle_timeout(&self) -> Duration { + Duration::from_secs(self.beacon_api_idle_timeout_secs) + } + pub fn disable_weak_subjectivity_check(&self) -> bool { self.disable_weak_subjectivity_check } @@ -366,6 +425,77 @@ mod tests { assert_eq!(cfg.next_fork_epoch, u64::MAX); assert_eq!(cfg.supported_protocols().unwrap().len(), 11); assert_eq!(cfg.gossip_topics().unwrap().len(), 8); + assert_eq!(cfg.beacon_api_bind(), ["0.0.0.0:5051"]); + assert_eq!(cfg.beacon_api_max_connections(), 64); + assert_eq!(cfg.beacon_api_idle_timeout(), Duration::from_secs(75)); + } + + /// A devnet copying mainnet's `CONFIG_NAME` still runs — `from_file` + /// warns about the contradiction rather than rejecting the file, since + /// only the operator can say which half is the typo. + #[test] + fn a_config_name_contradicting_its_fork_version_still_loads() { + let path = + std::env::temp_dir().join(format!("silver_misnamed_{}.toml", std::process::id())); + std::fs::write( + &path, + r#" + secret_key = "1111111111111111111111111111111111111111111111111111111111111111" + fork_digest = "8c9f62fe" + next_fork_version = "06000000" + + [chain_config.spec] + CONFIG_NAME = "mainnet" + GENESIS_FORK_VERSION = "0x10000910" + "#, + ) + .unwrap(); + + let cfg = Config::from_file(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + + assert_eq!(cfg.chain_config.spec.misnamed_network(), Some("hoodi")); + assert_eq!(cfg.chain_config.spec.network_name(), "mainnet"); + } + + #[test] + fn beacon_api_bind_toml_array_keeps_every_entry() { + let toml_str = r#" + secret_key = "1111111111111111111111111111111111111111111111111111111111111111" + fork_digest = "8c9f62fe" + next_fork_version = "06000000" + beacon_api_bind = ["0.0.0.0:5051", "127.0.0.1:5052", "/run/silver/beacon.sock"] + "#; + let cfg: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(cfg.beacon_api_bind(), [ + "0.0.0.0:5051", + "127.0.0.1:5052", + "/run/silver/beacon.sock" + ]); + } + + #[test] + fn builder_sets_beacon_api_bind() { + let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0); + assert_eq!(cfg.beacon_api_bind(), ["0.0.0.0:5051"]); + let cfg = cfg.with_beacon_api_bind(vec!["/run/beacon.sock".into()]); + assert_eq!(cfg.beacon_api_bind(), ["/run/beacon.sock"]); + } + + #[test] + fn builder_sets_beacon_api_max_connections() { + let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0); + assert_eq!(cfg.beacon_api_max_connections(), 64); + let cfg = cfg.with_beacon_api_max_connections(2); + assert_eq!(cfg.beacon_api_max_connections(), 2); + } + + #[test] + fn builder_sets_beacon_api_idle_timeout() { + let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0); + assert_eq!(cfg.beacon_api_idle_timeout(), Duration::from_secs(75)); + let cfg = cfg.with_beacon_api_idle_timeout_secs(5); + assert_eq!(cfg.beacon_api_idle_timeout(), Duration::from_secs(5)); } #[test] diff --git a/crates/engine/src/http.rs b/crates/engine/src/http.rs deleted file mode 100644 index fee7deef..00000000 --- a/crates/engine/src/http.rs +++ /dev/null @@ -1,474 +0,0 @@ -use std::{ - io::{self, Read, Write}, - net::{SocketAddr, ToSocketAddrs}, -}; - -use mio::{Events, Interest, Poll, Token, net::TcpStream}; - -use crate::{EngineError, JwtSecret}; - -// Sized for the largest expected EL response: getPayload with a full -// blobsBundle (~21 blobs × 256 KB hex-encoded + execution payload -// transactions). -const READ_BUF_CAPACITY: usize = 10 * 1024 * 1024; - -// Sized for the largest expected outgoing request: newPayload with a full -// block (~30M gas of transactions, hex-encoded in JSON) plus HTTP headers. -const WRITE_BUF_CAPACITY: usize = 10 * 1024 * 1024; - -enum Conn { - Disconnected, - Connecting(TcpStream), - Connected(TcpStream), -} - -struct HttpConnection { - endpoint: String, - host: String, - jwt: JwtSecret, - token: Token, - conn: Conn, - addr: Option, - in_flight: Option, - pending_id: Option, - write_buf: Vec, - write_pos: usize, - read_buf: Vec, - read_offset: usize, - // Cached from the first read of the current response; zero = not yet parsed. - response_header_end: usize, - response_total: usize, // header_end + content_length -} - -impl HttpConnection { - fn new(endpoint: String, jwt: JwtSecret, token: Token) -> Self { - let host = endpoint - .trim_start_matches("http://") - .split('/') - .next() - .unwrap_or("localhost") - .to_string(); - Self { - endpoint, - host, - jwt, - token, - conn: Conn::Disconnected, - addr: None, - pending_id: None, - write_buf: Vec::with_capacity(WRITE_BUF_CAPACITY), - write_pos: 0, - in_flight: None, - read_buf: Vec::with_capacity(READ_BUF_CAPACITY), - read_offset: 0, - response_header_end: 0, - response_total: 0, - } - } -} - -fn http_is_free(t: &HttpConnection) -> bool { - t.in_flight.is_none() && t.pending_id.is_none() -} - -fn http_enqueue(t: &mut HttpConnection, rpc_id: u64, body: &[u8], poll: &mut Poll) { - debug_assert!(t.in_flight.is_none() && t.pending_id.is_none(), "enqueue on busy connection"); - let bearer = t.jwt.bearer_token(); - build_request_into(&mut t.write_buf, &t.host, body, bearer, true); - t.pending_id = Some(rpc_id); - t.write_pos = 0; - - // matches! borrows t.conn transiently, freeing it before the function call - // below. - if matches!(t.conn, Conn::Disconnected) { - http_connect(t, poll); - } else if matches!(t.conn, Conn::Connected(_)) { - http_set_interest(&mut t.conn, t.token, poll, Interest::READABLE | Interest::WRITABLE); - } -} - -fn http_poll(t: &mut HttpConnection, events: &Events, poll: &mut Poll, on_complete: &mut F) -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - for event in events.iter() { - if event.token() != t.token { - continue; - } - if matches!(t.conn, Conn::Connecting(_)) { - if event.is_error() || event.is_read_closed() || event.is_write_closed() { - http_on_error(t, poll, on_complete, "connect failed"); - break; - } - if event.is_writable() { - // Take ownership to inspect peer_addr and transition state atomically. - let Conn::Connecting(stream) = std::mem::replace(&mut t.conn, Conn::Disconnected) - else { - unreachable!() - }; - if stream.peer_addr().is_ok() { - t.conn = Conn::Connected(stream); - let interest = if t.pending_id.is_none() { - Interest::READABLE - } else { - Interest::READABLE | Interest::WRITABLE - }; - http_set_interest(&mut t.conn, t.token, poll, interest); - } else { - t.conn = Conn::Connecting(stream); - http_on_error(t, poll, on_complete, "connect failed"); - break; - } - } - } else if matches!(t.conn, Conn::Connected(_)) { - if event.is_error() { - http_on_error(t, poll, on_complete, "connection error"); - break; - } - if event.is_writable() { - let result = { - let Conn::Connected(stream) = &mut t.conn else { unreachable!() }; - http_do_write( - stream, - &mut t.pending_id, - &t.write_buf, - &mut t.write_pos, - &mut t.in_flight, - ) - }; - if let Err(e) = result { - let msg = e.to_string(); - http_on_error(t, poll, on_complete, &msg); - break; - } - let interest = if t.pending_id.is_none() { - Interest::READABLE - } else { - Interest::READABLE | Interest::WRITABLE - }; - http_set_interest(&mut t.conn, t.token, poll, interest); - } - if event.is_readable() { - // Drain data before checking is_read_closed: when the remote - // sends a response + FIN in one exchange (EPOLLIN|EPOLLRDHUP), - // we must read the response first. http_do_read returns Err on - // EOF, so the break below covers that close path too. - let result = { - let Conn::Connected(stream) = &mut t.conn else { unreachable!() }; - http_do_read( - stream, - &mut t.in_flight, - &mut t.read_buf, - &mut t.read_offset, - &mut t.response_header_end, - &mut t.response_total, - on_complete, - ) - }; - if let Err(e) = result { - let msg = e.to_string(); - http_on_error(t, poll, on_complete, &msg); - break; - } - } - if event.is_read_closed() { - // Remote closed with no (more) data — in_flight will never get - // a response. - http_on_error(t, poll, on_complete, "connection closed"); - break; - } - } - } -} - -fn http_connect(t: &mut HttpConnection, poll: &mut Poll) { - let addr = if let Some(a) = t.addr { - a - } else { - match parse_addr(&t.endpoint) { - Ok(a) => { - t.addr = Some(a); - a - } - Err(e) => { - tracing::warn!("resolve failed for {}: {e}", t.endpoint); - return; - } - } - }; - match TcpStream::connect(addr) { - Ok(mut stream) => { - if poll.registry().register(&mut stream, t.token, Interest::WRITABLE).is_ok() { - t.conn = Conn::Connecting(stream); - } - } - Err(e) => tracing::warn!("connect error: {e}"), - } -} - -fn http_do_write( - stream: &mut TcpStream, - pending_id: &mut Option, - write_buf: &[u8], - write_pos: &mut usize, - in_flight: &mut Option, -) -> io::Result<()> { - if pending_id.is_some() { - loop { - match stream.write(&write_buf[*write_pos..]) { - Ok(0) => break, - Ok(n) => { - *write_pos += n; - if *write_pos == write_buf.len() { - *in_flight = pending_id.take(); - *write_pos = 0; - break; - } - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, - Err(e) => return Err(e), - } - } - } - Ok(()) -} - -fn http_do_read( - stream: &mut TcpStream, - in_flight: &mut Option, - read_buf: &mut Vec, - read_offset: &mut usize, - response_header_end: &mut usize, - response_total: &mut usize, - on_complete: &mut F, -) -> io::Result<()> -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - loop { - // Deliver if a complete response is already buffered. - if *response_total > 0 && read_buf.len() - *read_offset >= *response_total { - if let Some(rpc_id) = in_flight.take() { - let start = *read_offset + *response_header_end; - let end = *read_offset + *response_total; - on_complete(rpc_id, Ok(&mut read_buf[start..end])); - } - *read_offset += *response_total; - *response_header_end = 0; - *response_total = 0; - if *read_offset == read_buf.len() { - read_buf.clear(); - *read_offset = 0; - } - continue; - } - - let want = if *response_total > 0 { - // Know total size; read exactly the remaining bytes. - *response_total - (read_buf.len() - *read_offset) - } else { - // Headers not yet parsed; 4096 covers any realistic HTTP response header. - 4096 - }; - - let base = read_buf.len(); - read_buf.resize(base + want, 0); - match stream.read(&mut read_buf[base..]) { - Ok(0) => { - return Err(io::Error::new(io::ErrorKind::ConnectionReset, "eof")); - } - Ok(n) => { - read_buf.truncate(base + n); - if *response_total == 0 { - match try_parse_headers(&read_buf[*read_offset..]) { - Ok(Some((hend, cl))) => { - *response_header_end = hend; - *response_total = hend + cl; - } - Ok(None) => {} // headers still incomplete - Err(e) => { - return Err(io::Error::new(io::ErrorKind::InvalidData, e.to_string())); - } - } - } - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => { - read_buf.truncate(base); - break; - } - Err(e) => { - return Err(e); - } - } - } - Ok(()) -} - -fn http_on_error(t: &mut HttpConnection, poll: &mut Poll, on_complete: &mut F, msg: &str) -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - tracing::warn!("{msg}"); - let err = msg.to_string(); - if let Some(rpc_id) = t.in_flight.take() { - on_complete(rpc_id, Err(EngineError::Http(err.clone()))); - } - if let Some(rpc_id) = t.pending_id.take() { - on_complete(rpc_id, Err(EngineError::Http(err.clone()))); - } - t.write_pos = 0; - t.read_buf.clear(); - t.read_offset = 0; - t.response_header_end = 0; - t.response_total = 0; - let old = std::mem::replace(&mut t.conn, Conn::Disconnected); - if let Conn::Connecting(mut stream) | Conn::Connected(mut stream) = old { - let _ = poll.registry().deregister(&mut stream); - } -} - -fn http_set_interest(conn: &mut Conn, token: Token, poll: &mut Poll, interest: Interest) { - let stream = match conn { - Conn::Connecting(s) | Conn::Connected(s) => s, - Conn::Disconnected => return, - }; - let _ = poll.registry().reregister(stream, token, interest); -} - -// Connection helper functions -fn build_request_into(buf: &mut Vec, host: &str, json: &[u8], bearer: &str, keep_alive: bool) { - use std::io::Write as _; - let connection = if keep_alive { "keep-alive" } else { "close" }; - buf.clear(); - // SAFETY: Vec's io::Write impl is infallible. - write!( - buf, - "POST / HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n\ - Content-Length: {len}\r\nAuthorization: {bearer}\r\nConnection: {connection}\r\n\r\n", - len = json.len(), - ) - .unwrap(); - buf.extend_from_slice(json); -} - -fn parse_addr(endpoint: &str) -> io::Result { - let hostport = endpoint.trim_start_matches("http://").split('/').next().unwrap_or(endpoint); - hostport - .to_socket_addrs()? - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "no address resolved")) -} - -// Returns (header_end, content_length) when headers are complete, None if -// partial. -fn try_parse_headers(buf: &[u8]) -> Result, EngineError> { - let mut headers = [httparse::EMPTY_HEADER; 32]; - let mut resp = httparse::Response::new(&mut headers); - let header_end = match resp.parse(buf) { - Ok(httparse::Status::Complete(n)) => n, - Ok(httparse::Status::Partial) => return Ok(None), - Err(e) => return Err(EngineError::Http(format!("httparse: {e}"))), - }; - match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { - Some(h) if h.value.iter().all(|b| b.is_ascii_digit()) => { - let cl = h.value.iter().copied().fold(0usize, |acc, b| acc * 10 + (b - b'0') as usize); - Ok(Some((header_end, cl))) - } - Some(_) => Err(EngineError::Http("invalid Content-Length".into())), - None => Err(EngineError::Http("missing Content-Length".into())), - } -} - -pub(crate) struct HttpPool { - connections: Vec, - endpoint: String, - jwt: JwtSecret, -} - -impl HttpPool { - pub(crate) fn new(endpoint: String, jwt: JwtSecret) -> Self { - let connections = vec![HttpConnection::new(endpoint.clone(), jwt.clone(), Token(0))]; - Self { connections, endpoint, jwt } - } -} - -pub(crate) fn http_pool_enqueue(pool: &mut HttpPool, rpc_id: u64, body: &[u8], poll: &mut Poll) { - if let Some(conn) = pool.connections.iter_mut().find(|c| http_is_free(c)) { - http_enqueue(conn, rpc_id, body, poll); - } else { - let mut new_conn = HttpConnection::new( - pool.endpoint.clone(), - pool.jwt.clone(), - Token(pool.connections.len()), - ); - http_enqueue(&mut new_conn, rpc_id, body, poll); - pool.connections.push(new_conn); - } -} - -pub(crate) fn poll_http_pool( - pool: &mut HttpPool, - events: &Events, - poll: &mut Poll, - on_complete: &mut F, -) where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - for conn in &mut pool.connections { - http_poll(conn, events, poll, on_complete); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_response(body: &[u8]) -> Vec { - let header = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", - body.len() - ); - let mut buf = header.into_bytes(); - buf.extend_from_slice(body); - buf - } - - #[test] - fn headers_complete_returns_offsets() { - let body = br#"{"jsonrpc":"2.0","id":1,"result":true}"#; - let buf = make_response(body); - let (hend, cl) = try_parse_headers(&buf).unwrap().unwrap(); - assert_eq!(cl, body.len()); - assert_eq!(hend + cl, buf.len()); - } - - #[test] - fn headers_partial_returns_none() { - let partial = b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n"; - assert!(try_parse_headers(partial).unwrap().is_none()); - } - - #[test] - fn headers_complete_body_incomplete_still_returns_offsets() { - // try_parse_headers only cares about headers; body completeness is the caller's - // job. - let body = br#"{"result":1}"#; - let mut buf = make_response(body); - buf.truncate(buf.len() - 3); - let (hend, cl) = try_parse_headers(&buf).unwrap().unwrap(); - assert_eq!(cl, body.len()); - assert!(buf.len() < hend + cl); - } - - #[test] - fn missing_content_length_is_error() { - let buf = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{}"; - assert!(try_parse_headers(buf).is_err()); - } - - #[test] - fn invalid_content_length_is_error() { - let buf = b"HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n{}"; - assert!(try_parse_headers(buf).is_err()); - } -} diff --git a/crates/engine/src/ipc.rs b/crates/engine/src/ipc.rs deleted file mode 100644 index 2945fc58..00000000 --- a/crates/engine/src/ipc.rs +++ /dev/null @@ -1,267 +0,0 @@ -use std::{ - io::{self, Read, Write}, - path::PathBuf, -}; - -use mio::{Events, Interest, Poll, Token, net::UnixStream}; - -use crate::EngineError; - -// Sized for the largest expected EL response: getPayload with a full -// blobsBundle (~21 blobs × 256 KB hex-encoded + execution payload -// transactions). -const READ_BUF_CAPACITY: usize = 10 * 1024 * 1024; - -// Sized for the largest expected outgoing request: newPayload with a full -// block (~30M gas of transactions, hex-encoded in JSON). -const WRITE_BUF_CAPACITY: usize = 10 * 1024 * 1024; - -#[derive(PartialEq)] -enum State { - Disconnected, - Connecting, - Connected, -} - -struct IpcTransport { - path: PathBuf, - token: Token, - stream: Option, - state: State, - pending_id: Option, - write_buf: Vec, - write_pos: usize, - in_flight: Option, - read_buf: Vec, - read_offset: usize, -} - -impl IpcTransport { - pub(crate) fn new(path: String, token: Token) -> Self { - Self { - path: PathBuf::from(path), - token, - stream: None, - state: State::Disconnected, - pending_id: None, - write_buf: Vec::with_capacity(WRITE_BUF_CAPACITY), - write_pos: 0, - in_flight: None, - read_buf: Vec::with_capacity(READ_BUF_CAPACITY), - read_offset: 0, - } - } -} - -fn ipc_is_free(t: &IpcTransport) -> bool { - t.in_flight.is_none() && t.pending_id.is_none() -} - -fn ipc_enqueue(t: &mut IpcTransport, rpc_id: u64, body: &[u8], poll: &mut Poll) { - t.write_buf.clear(); - t.write_buf.extend_from_slice(body); - t.write_buf.push(b'\n'); - t.pending_id = Some(rpc_id); - t.write_pos = 0; - - match t.state { - State::Disconnected => ipc_connect(t, poll), - State::Connected => ipc_set_interest(t, poll, Interest::READABLE | Interest::WRITABLE), - State::Connecting => {} - } -} - -fn ipc_poll(t: &mut IpcTransport, events: &Events, poll: &mut Poll, on_complete: &mut F) -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - for event in events.iter() { - if event.token() != t.token { - continue; - } - match t.state { - State::Disconnected => {} - State::Connecting => { - if event.is_writable() { - // is_error/is_write_closed flags are not reliable; use - // take_error() (getsockopt SO_ERROR) as the authoritative check. - let err = t.stream.as_ref().and_then(|s| s.take_error().ok()).flatten(); - if let Some(e) = err { - ipc_on_error(t, poll, on_complete, &e.to_string()); - break; - } - t.state = State::Connected; - let interest = if t.pending_id.is_none() { - Interest::READABLE - } else { - Interest::READABLE | Interest::WRITABLE - }; - ipc_set_interest(t, poll, interest); - } - } - State::Connected => { - if event.is_error() || event.is_read_closed() { - ipc_on_error(t, poll, on_complete, "ipc connection lost"); - break; - } - if event.is_writable() { - if let Err(e) = ipc_do_write(t) { - let msg = e.to_string(); - ipc_on_error(t, poll, on_complete, &msg); - break; - } - let interest = if t.pending_id.is_none() { - Interest::READABLE - } else { - Interest::READABLE | Interest::WRITABLE - }; - ipc_set_interest(t, poll, interest); - } - if event.is_readable() { - if let Err(e) = ipc_do_read(t, on_complete) { - let msg = e.to_string(); - ipc_on_error(t, poll, on_complete, &msg); - break; - } - } - } - } - } -} - -fn ipc_connect(t: &mut IpcTransport, poll: &mut Poll) { - match UnixStream::connect(&t.path) { - Ok(mut stream) => { - if poll.registry().register(&mut stream, t.token, Interest::WRITABLE).is_ok() { - t.stream = Some(stream); - t.state = State::Connecting; - } - } - Err(e) => tracing::warn!("connect error: {e}"), - } -} - -fn ipc_do_write(t: &mut IpcTransport) -> io::Result<()> { - if t.pending_id.is_some() { - let stream = t.stream.as_mut().unwrap(); - loop { - match stream.write(&t.write_buf[t.write_pos..]) { - Ok(0) => return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")), - Ok(n) => { - t.write_pos += n; - if t.write_pos == t.write_buf.len() { - t.in_flight = t.pending_id.take(); - t.write_pos = 0; - break; - } - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, - Err(e) => return Err(e), - } - } - } - Ok(()) -} - -fn ipc_do_read(t: &mut IpcTransport, on_complete: &mut F) -> io::Result<()> -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - let stream = t.stream.as_mut().unwrap(); - loop { - let base = t.read_buf.len(); - t.read_buf.resize(base + READ_BUF_CAPACITY, 0); - match stream.read(&mut t.read_buf[base..]) { - Ok(0) => { - t.read_buf.truncate(base); - return Err(io::Error::new(io::ErrorKind::ConnectionReset, "eof")); - } - Ok(n) => { - t.read_buf.truncate(base + n); - while let Some(rel) = t.read_buf[t.read_offset..].iter().position(|&b| b == b'\n') { - let offset = t.read_offset; - let end = offset + rel; - if let Some(rpc_id) = t.in_flight { - on_complete(rpc_id, Ok(&mut t.read_buf[offset..end])); - } - t.read_offset = end + 1; - } - if t.read_offset == t.read_buf.len() { - t.read_buf.clear(); - t.read_offset = 0; - } - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => { - t.read_buf.truncate(base); - break; - } - Err(e) => { - t.read_buf.truncate(base); - return Err(e); - } - } - } - Ok(()) -} - -fn ipc_on_error(t: &mut IpcTransport, poll: &mut Poll, on_complete: &mut F, msg: &str) -where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - tracing::warn!("{msg}"); - let err = msg.to_string(); - if let Some(rpc_id) = t.in_flight.take() { - on_complete(rpc_id, Err(EngineError::Ipc(err.clone()))); - } - if let Some(rpc_id) = t.pending_id.take() { - on_complete(rpc_id, Err(EngineError::Ipc(err.clone()))); - } - t.write_pos = 0; - t.read_buf.clear(); - t.read_offset = 0; - if let Some(mut stream) = t.stream.take() { - let _ = poll.registry().deregister(&mut stream); - } - t.state = State::Disconnected; -} - -fn ipc_set_interest(t: &mut IpcTransport, poll: &mut Poll, interest: Interest) { - if let Some(stream) = t.stream.as_mut() { - let _ = poll.registry().reregister(stream, t.token, interest); - } -} - -pub(crate) struct IpcPool { - connections: Vec, - path: String, -} - -impl IpcPool { - pub(crate) fn new(path: String) -> Self { - let connections = vec![IpcTransport::new(path.clone(), Token(0))]; - Self { connections, path } - } -} - -pub(crate) fn ipc_pool_enqueue(pool: &mut IpcPool, rpc_id: u64, body: &[u8], poll: &mut Poll) { - if let Some(conn) = pool.connections.iter_mut().find(|c| ipc_is_free(c)) { - ipc_enqueue(conn, rpc_id, body, poll); - } else { - let mut new_conn = IpcTransport::new(pool.path.clone(), Token(pool.connections.len())); - ipc_enqueue(&mut new_conn, rpc_id, body, poll); - pool.connections.push(new_conn); - } -} - -pub(crate) fn poll_ipc_pool( - pool: &mut IpcPool, - events: &Events, - poll: &mut Poll, - on_complete: &mut F, -) where - F: FnMut(u64, Result<&mut [u8], EngineError>), -{ - for conn in &mut pool.connections { - ipc_poll(conn, events, poll, on_complete); - } -} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs deleted file mode 100644 index b0e36fea..00000000 --- a/crates/engine/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -mod client; -mod error; -mod http; -mod ipc; -mod jwt; -mod req_handlers; -mod resp_handlers; -pub mod tile; -mod types; - -pub use client::EngineClient; -pub use error::EngineError; -pub use jwt::JwtSecret; -pub use tile::EngineTile; diff --git a/crates/engine/Cargo.toml b/crates/engine_api/Cargo.toml similarity index 65% rename from crates/engine/Cargo.toml rename to crates/engine_api/Cargo.toml index c733a4e1..b0f521f8 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine_api/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "silver_engine" +name = "silver_engine_api" edition.workspace = true repository.workspace = true rust-version.workspace = true @@ -11,17 +11,25 @@ base64.workspace = true flux.workspace = true hex.workspace = true hmac.workspace = true -httparse.workspace = true +httparse = { workspace = true, optional = true } mio.workspace = true rustc-hash.workspace = true serde.workspace = true simd-json.workspace = true sha2.workspace = true silver_common.workspace = true +silver_httpcore.workspace = true thiserror.workspace = true tracing.workspace = true +[features] +# Exposes the `test_el` fake execution client to dependents' tests. +test-el = ["dep:httparse"] + [dev-dependencies] +httparse.workspace = true +silver_engine_api = { workspace = true, features = ["test-el"] } +tempfile = "3" tracing-subscriber.workspace = true [lints] diff --git a/crates/engine/src/tile.rs b/crates/engine_api/src/api.rs similarity index 67% rename from crates/engine/src/tile.rs rename to crates/engine_api/src/api.rs index 4d4e6470..6c12ce49 100644 --- a/crates/engine/src/tile.rs +++ b/crates/engine_api/src/api.rs @@ -1,21 +1,24 @@ use std::time::{Duration, Instant}; -use flux::{spine::SpineAdapter, tile::Tile}; +use flux::spine::SpineAdapter; +use mio::{Events, Registry}; use silver_common::{ ELSyncStatus, EngineHealthEvent, EngineReq, SilverSpine, TProducer, TRandomAccess, }; use silver_config::EngineConfig; +use silver_httpcore::TokenRange; use crate::{ EngineClient, - client::{ReqKind, exchange_capabilities, get_client_version, get_sync_status, poll}, + client::{ReqKind, exchange_capabilities, get_client_version, get_sync_status}, + pool::HEALTHCHECK_OVERSHOOT, req_handlers::{handle_request, handle_request_no_el}, resp_handlers::*, }; const HEALTHCHECK_INTERVAL: Duration = Duration::from_secs(10); -pub struct EngineTile { +pub struct EngineApi { /// `None` in unsafe no-EL testing mode — see /// [`EngineConfig::unsafe_no_el`]. pub client: Option, @@ -32,51 +35,33 @@ pub struct EngineTile { scratch: Vec, } -impl Tile for EngineTile { - fn loop_body(&mut self, adapter: &mut SpineAdapter) { - self.rpc_consumer.free(); - self.gossip_consumer.free(); - - if self.client.is_none() { - // Unsafe no-EL testing mode: report healthy once so peers don't - // gate on EL liveness, then answer every request with VALID. - if self.first_run { - adapter.produce(EngineHealthEvent { sync_status: ELSyncStatus::Synced }); - self.first_run = false; - } - let resp_producer = &mut self.resp_producer; - adapter.consume(|req: EngineReq, producers| { - handle_request_no_el(resp_producer, &req, producers) - }); - return; - } - adapter.consume(|req: EngineReq, producers| { - handle_request( - self.client.as_mut().unwrap(), - &mut self.gossip_consumer, - &mut self.rpc_consumer, - &req, - producers, - ); - }); - self.spin(adapter); +impl EngineApi { + /// Sockets the pool can hold registered at once: one per pooled + /// connection, plus the healthcheck's overshoot of the cap. + pub const fn max_sockets(max_connections: usize) -> usize { + max_connections + HEALTHCHECK_OVERSHOOT } -} -impl EngineTile { pub fn new( + registry: &Registry, + tokens: TokenRange, config: EngineConfig, gossip_consumer: TRandomAccess, rpc_consumer: TRandomAccess, resp_producer: TProducer, ) -> Self { let client = if config.unsafe_no_el { - tracing::warn!( - "engine tile in UNSAFE no-EL testing mode: answering all requests VALID" - ); + tracing::warn!("engine api in UNSAFE no-EL testing mode: answering all requests VALID"); None } else { - Some(EngineClient::new(&config.execution_endpoint, &config.jwt_secret)) + Some(EngineClient::new( + registry, + tokens, + &config.execution_endpoint, + &config.jwt_secret, + config.max_connections, + Duration::from_secs(config.request_timeout_secs), + )) }; Self { client, @@ -92,7 +77,50 @@ impl EngineTile { } } - fn spin(&mut self, adapter: &mut SpineAdapter) { + /// Last status the EL reported to `eth_syncing`; `Unknown` until the + /// first healthcheck completes. + pub fn sync_status(&self) -> ELSyncStatus { + self.sync_status + } + + pub fn intake(&mut self, adapter: &mut SpineAdapter) { + self.rpc_consumer.free(); + self.gossip_consumer.free(); + + if self.client.is_none() { + // Unsafe no-EL testing mode: report healthy once so peers don't + // gate on EL liveness, then answer every request with VALID. + if self.first_run { + adapter.produce(EngineHealthEvent { sync_status: ELSyncStatus::Synced }); + self.sync_status = ELSyncStatus::Synced; + self.first_run = false; + } + let resp_producer = &mut self.resp_producer; + adapter.consume(|req: EngineReq, producers| { + handle_request_no_el(resp_producer, &req, producers) + }); + return; + } + // Requests stay queued on the spine while every connection is busy and + // the pool is at max_connections; intake resumes as completions free + // connections. + while self.client.as_ref().unwrap().has_capacity() { + let consumed = adapter.consume_one(|req: EngineReq, producers| { + handle_request( + self.client.as_mut().unwrap(), + &mut self.gossip_consumer, + &mut self.rpc_consumer, + &req, + producers, + ); + }); + if !consumed { + break; + } + } + } + + pub fn spin(&mut self, adapter: &mut SpineAdapter, events: &Events) { let mut negotiated_get_payload_method: Option<&'static str> = None; { @@ -106,14 +134,16 @@ impl EngineTile { sync_status, .. } = self; - // Only reached in EL mode; loop_body returns early otherwise. - let client = client.as_mut().expect("spin without EL client"); + let Some(client) = client.as_mut() else { return }; - if !*healthcheck_pending && Instant::now() >= *healthcheck_deadline { + if !*healthcheck_pending && + Instant::now() >= *healthcheck_deadline && + client.has_capacity() + { run_healthcheck(client, first_run, healthcheck_pending, healthcheck_deadline); } - poll(client, |req_kind, response| match req_kind { + client.dispatch(events, |req_kind, response| match req_kind { ReqKind::Capabilities => { negotiated_get_payload_method = Some(handle_capabilities_response(response)); } diff --git a/crates/engine/src/client.rs b/crates/engine_api/src/client.rs similarity index 72% rename from crates/engine/src/client.rs rename to crates/engine_api/src/client.rs index 737e2849..6321ed41 100644 --- a/crates/engine/src/client.rs +++ b/crates/engine_api/src/client.rs @@ -1,21 +1,19 @@ -use std::time::Duration; +use std::{path::PathBuf, time::Duration}; -use mio::{Events, Poll}; +use mio::{Events, Registry}; use rustc_hash::FxHashMap; use silver_common::merkle::B256; +use silver_httpcore::TokenRange; use crate::{ EngineError, JwtSecret, - http::{HttpPool, http_pool_enqueue, poll_http_pool}, - ipc::{IpcPool, ipc_pool_enqueue, poll_ipc_pool}, + pool::{Endpoint, HttpPool}, types::{ ForkchoiceState, PayloadAttributesV3, write_new_payload_params_fulu, write_new_payload_params_gloas, }, }; -const EVENTS_CAPACITY: usize = 16; - // Sized for the largest expected outgoing request: newPayload with a full block // (~30M gas of transactions, hex-encoded in JSON). const SCRATCH_CAPACITY: usize = 10 * 1024 * 1024; @@ -45,15 +43,9 @@ pub enum ReqKind { GetPayloadBodiesByRange(u64), } -enum Transport { - Http(HttpPool), - Ipc(IpcPool), -} - pub struct EngineClient { - transport: Transport, - poll: Poll, - events: Events, + pool: HttpPool, + registry: Registry, id: u64, pending_requests: FxHashMap, pub get_payload_method: &'static str, @@ -61,12 +53,54 @@ pub struct EngineClient { } impl EngineClient { - pub fn new(endpoint: impl Into, jwt: &str) -> Self { + pub fn new( + registry: &Registry, + tokens: TokenRange, + endpoint: &str, + jwt: &str, + max_connections: usize, + request_timeout: Duration, + ) -> Self { + Self::with_endpoint( + registry, + tokens, + parse_endpoint(endpoint), + jwt, + max_connections, + request_timeout, + ) + } + + pub fn new_uds( + registry: &Registry, + tokens: TokenRange, + path: impl Into, + jwt: &str, + max_connections: usize, + request_timeout: Duration, + ) -> Self { + Self::with_endpoint( + registry, + tokens, + Endpoint::Uds(path.into()), + jwt, + max_connections, + request_timeout, + ) + } + + fn with_endpoint( + registry: &Registry, + tokens: TokenRange, + endpoint: Endpoint, + jwt: &str, + max_connections: usize, + request_timeout: Duration, + ) -> Self { let jwt = JwtSecret::from_file(jwt).unwrap_or_else(|e| panic!("invalid JWT secret: {e}")); Self { - transport: Transport::Http(HttpPool::new(endpoint.into(), jwt)), - poll: Poll::new().expect("mio Poll::new failed"), - events: Events::with_capacity(EVENTS_CAPACITY), + pool: HttpPool::new(endpoint, jwt, tokens, max_connections, request_timeout), + registry: registry.try_clone().expect("mio Registry::try_clone failed"), id: 1, pending_requests: FxHashMap::default(), get_payload_method: "engine_getPayloadV3", @@ -74,16 +108,36 @@ impl EngineClient { } } - pub fn new_ipc(path: impl Into) -> Self { - Self { - transport: Transport::Ipc(IpcPool::new(path.into())), - poll: Poll::new().expect("mio Poll::new failed"), - events: Events::with_capacity(EVENTS_CAPACITY), - id: 1, - pending_requests: FxHashMap::default(), - get_payload_method: "engine_getPayloadV3", - scratch: Vec::with_capacity(SCRATCH_CAPACITY), - } + pub fn has_capacity(&self) -> bool { + self.pool.has_capacity() + } + + /// Drives the I/O the batch reports ready, calling + /// `on_complete(req_kind, raw_body)` for each RPC it finishes. Raw bytes + /// are the full HTTP response body; handlers parse them as needed. + pub fn dispatch(&mut self, events: &Events, mut on_complete: F) + where + F: FnMut(ReqKind, Result<&mut [u8], EngineError>), + { + let Self { pool, registry, pending_requests, .. } = self; + pool.dispatch_events(events, registry, &mut |rpc_id, res| { + if let Some(req_kind) = pending_requests.remove(&rpc_id) { + on_complete(req_kind, res); + } + }); + } +} + +fn parse_endpoint(endpoint: &str) -> Endpoint { + if endpoint.starts_with("http://") { + Endpoint::Http(endpoint.to_string()) + } else if endpoint.contains("://") { + panic!( + "unsupported execution_endpoint scheme (only http:// or a unix socket path): \ + {endpoint}" + ) + } else { + Endpoint::Uds(PathBuf::from(endpoint)) } } @@ -114,10 +168,7 @@ fn enqueue(c: &mut EngineClient, rpc_id: u64, body: &simd_json::OwnedValue) { tracing::warn!("failed to serialize RPC body: {e}"); return; } - match &mut c.transport { - Transport::Http(p) => http_pool_enqueue(p, rpc_id, &c.scratch, &mut c.poll), - Transport::Ipc(p) => ipc_pool_enqueue(p, rpc_id, &c.scratch, &mut c.poll), - } + c.pool.enqueue(rpc_id, &c.scratch, &c.registry); } pub fn send_fcu( @@ -168,10 +219,7 @@ fn send_new_payload_request_impl( c.scratch.extend_from_slice(b",\"id\":"); append_decimal_u64(rpc_id, &mut c.scratch); c.scratch.push(b'}'); - match &mut c.transport { - Transport::Http(p) => http_pool_enqueue(p, rpc_id, &c.scratch, &mut c.poll), - Transport::Ipc(p) => ipc_pool_enqueue(p, rpc_id, &c.scratch, &mut c.poll), - } + c.pool.enqueue(rpc_id, &c.scratch, &c.registry); c.pending_requests.insert(rpc_id, ReqKind::NewPayload(block_root)); Ok(()) } @@ -255,35 +303,34 @@ pub fn get_client_version(c: &mut EngineClient) { c.pending_requests.insert(id, ReqKind::ClientVersion); } -/// Drive I/O, calling `on_complete(req_kind, raw_body)` for each finished RPC. -/// Raw bytes are the full HTTP/IPC response body; handlers parse them as -/// needed. -pub fn poll(c: &mut EngineClient, mut on_complete: F) -where - F: FnMut(ReqKind, Result<&mut [u8], EngineError>), -{ - c.poll.poll(&mut c.events, Some(Duration::ZERO)).ok(); - let EngineClient { transport, events, poll, pending_requests, .. } = c; - match transport { - Transport::Http(p) => poll_http_pool(p, events, poll, &mut |rpc_id, res| { - if let Some(req_kind) = pending_requests.remove(&rpc_id) { - on_complete(req_kind, res); - } - }), - Transport::Ipc(p) => poll_ipc_pool(p, events, poll, &mut |rpc_id, res| { - if let Some(req_kind) = pending_requests.remove(&rpc_id) { - on_complete(req_kind, res); - } - }), - } -} - #[cfg(test)] mod tests { use simd_json::prelude::ValueAsScalar; use super::*; + #[test] + fn endpoint_http_scheme_parses_to_http() { + assert!(matches!( + parse_endpoint("http://localhost:8551"), + Endpoint::Http(e) if e == "http://localhost:8551" + )); + } + + #[test] + fn endpoint_bare_path_parses_to_uds() { + assert!(matches!( + parse_endpoint("/run/reth/engine.sock"), + Endpoint::Uds(p) if p == std::path::Path::new("/run/reth/engine.sock") + )); + } + + #[test] + #[should_panic(expected = "unsupported execution_endpoint scheme")] + fn endpoint_unknown_scheme_panics() { + parse_endpoint("https://localhost:8551"); + } + #[test] fn next_id_returns_current_then_increments() { let mut id = 1u64; diff --git a/crates/engine/src/error.rs b/crates/engine_api/src/error.rs similarity index 89% rename from crates/engine/src/error.rs rename to crates/engine_api/src/error.rs index bc6fae82..237ab410 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine_api/src/error.rs @@ -10,8 +10,6 @@ pub enum EngineError { Json(#[from] simd_json::Error), #[error("jwt: {0}")] Jwt(String), - #[error("ipc: {0}")] - Ipc(String), #[error("ssz: {0}")] Ssz(String), } diff --git a/crates/engine/src/jwt.rs b/crates/engine_api/src/jwt.rs similarity index 100% rename from crates/engine/src/jwt.rs rename to crates/engine_api/src/jwt.rs diff --git a/crates/engine_api/src/lib.rs b/crates/engine_api/src/lib.rs new file mode 100644 index 00000000..bf0a9940 --- /dev/null +++ b/crates/engine_api/src/lib.rs @@ -0,0 +1,17 @@ +mod api; +mod client; +mod error; +mod jwt; +mod pool; +mod req_handlers; +mod resp_handlers; +#[cfg(any(test, feature = "test-el"))] +pub mod test_el; +mod types; + +pub use api::EngineApi; +pub use client::EngineClient; +#[cfg(feature = "test-el")] +pub use client::{ReqKind, send_new_payload}; +pub use error::EngineError; +pub use jwt::JwtSecret; diff --git a/crates/engine_api/src/pool.rs b/crates/engine_api/src/pool.rs new file mode 100644 index 00000000..e1e62393 --- /dev/null +++ b/crates/engine_api/src/pool.rs @@ -0,0 +1,662 @@ +use std::{ + io::{self, Read, Write}, + net::{SocketAddr, ToSocketAddrs}, + path::PathBuf, + time::{Duration, Instant}, +}; + +use mio::{Events, Interest, Registry, Token, event::Event}; +use silver_httpcore::{ClientConnection, Stream, TokenRange, frame_request}; + +use crate::{EngineError, JwtSecret}; + +// Sized for the largest expected EL response: getPayload with a full +// blobsBundle (~21 blobs × 256 KB hex-encoded + execution payload +// transactions). +const READ_BUF_CAPACITY: usize = 10 * 1024 * 1024; + +// Sized for the largest expected outgoing request: newPayload with a full +// block (~30M gas of transactions, hex-encoded in JSON) plus HTTP headers. +const WRITE_BUF_CAPACITY: usize = 10 * 1024 * 1024; + +/// The first-run healthcheck trio issues three requests against one +/// `has_capacity` gate, so the pool can exceed `max_connections` by two +/// connections, once. +pub(crate) const HEALTHCHECK_OVERSHOOT: usize = 2; + +#[derive(Clone)] +pub(crate) enum Endpoint { + Http(String), + Uds(PathBuf), +} + +impl Endpoint { + fn host(&self) -> String { + match self { + Self::Http(endpoint) => endpoint + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("localhost") + .to_string(), + Self::Uds(_) => "localhost".to_string(), + } + } +} + +enum Conn { + Disconnected, + Connecting(Stream), + Connected(Stream), +} + +struct PooledConnection { + endpoint: Endpoint, + host: String, + jwt: JwtSecret, + token: Token, + conn: Conn, + addr: Option, + machine: ClientConnection, + in_flight: Option, + pending_id: Option, + request_started: Option, +} + +impl PooledConnection { + fn new(endpoint: Endpoint, jwt: JwtSecret, token: Token) -> Self { + let host = endpoint.host(); + Self { + endpoint, + host, + jwt, + token, + conn: Conn::Disconnected, + addr: None, + machine: ClientConnection::with_capacity(READ_BUF_CAPACITY, WRITE_BUF_CAPACITY), + in_flight: None, + pending_id: None, + request_started: None, + } + } + + fn is_free(&self) -> bool { + self.in_flight.is_none() && self.pending_id.is_none() + } + + /// Age is measured from enqueue rather than from the write hitting the + /// wire, so a connect that never completes expires on the same deadline. + fn expired(&self, now: Instant, timeout: Duration) -> bool { + self.request_started.is_some_and(|started| now.duration_since(started) > timeout) + } + + fn enqueue(&mut self, rpc_id: u64, body: &[u8], registry: &Registry) { + debug_assert!(self.is_free(), "enqueue on busy connection"); + let out = self.machine.begin_request(); + frame_request(out, &self.host, body, Some(self.jwt.bearer_token()), true); + self.pending_id = Some(rpc_id); + self.request_started = Some(Instant::now()); + + match self.conn { + Conn::Disconnected => self.connect(registry), + Conn::Connected(_) => self.update_interest(registry), + Conn::Connecting(_) => {} + } + } + + fn handle_event(&mut self, event: &Event, registry: &Registry, on_complete: &mut F) + where + F: FnMut(u64, Result<&mut [u8], EngineError>), + { + debug_assert_eq!(event.token(), self.token, "event routed to the wrong connection"); + match &self.conn { + Conn::Disconnected => {} + Conn::Connecting(stream) => { + if event.is_error() || event.is_read_closed() || event.is_write_closed() { + self.fail(registry, on_complete, "connect failed"); + return; + } + if event.is_writable() { + if stream.connect_complete().is_ok() { + let Conn::Connecting(stream) = + std::mem::replace(&mut self.conn, Conn::Disconnected) + else { + unreachable!() + }; + self.conn = Conn::Connected(stream); + self.update_interest(registry); + } else { + self.fail(registry, on_complete, "connect failed"); + } + } + } + Conn::Connected(_) => { + if event.is_error() { + self.fail(registry, on_complete, "connection error"); + return; + } + if event.is_writable() { + if let Err(e) = self.do_write() { + let msg = e.to_string(); + self.fail(registry, on_complete, &msg); + return; + } + self.update_interest(registry); + } + if event.is_readable() { + // Drain data before checking is_read_closed: when the + // remote sends a response + FIN in one exchange + // (EPOLLIN|EPOLLRDHUP), we must read the response first. + // do_read returns Err on EOF, so the return below covers + // that close path too. + if let Err(e) = self.do_read(on_complete) { + let msg = e.to_string(); + self.fail(registry, on_complete, &msg); + return; + } + } + if event.is_read_closed() { + // Remote closed with no (more) data — in_flight will + // never get a response. + self.fail(registry, on_complete, "connection closed"); + } + } + } + } + + fn connect(&mut self, registry: &Registry) { + let stream = match &self.endpoint { + Endpoint::Http(endpoint) => { + let addr = if let Some(a) = self.addr { + a + } else { + match parse_addr(endpoint) { + Ok(a) => { + self.addr = Some(a); + a + } + Err(e) => { + tracing::warn!("resolve failed for {endpoint}: {e}"); + return; + } + } + }; + Stream::connect_tcp(addr) + } + Endpoint::Uds(path) => Stream::connect_uds(path), + }; + match stream { + Ok(mut stream) => { + if registry.register(&mut stream, self.token, Interest::WRITABLE).is_ok() { + self.conn = Conn::Connecting(stream); + } + } + Err(e) => tracing::warn!("connect error: {e}"), + } + } + + fn do_write(&mut self) -> io::Result<()> { + if self.pending_id.is_none() { + return Ok(()); + } + let Self { conn, machine, pending_id, in_flight, .. } = self; + let Conn::Connected(stream) = conn else { return Ok(()) }; + loop { + match stream.write(machine.pending_write()) { + Ok(0) => break, + Ok(n) => { + machine.commit_write(n); + if machine.pending_write().is_empty() { + *in_flight = pending_id.take(); + break; + } + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => return Err(e), + } + } + Ok(()) + } + + fn do_read(&mut self, on_complete: &mut F) -> io::Result<()> + where + F: FnMut(u64, Result<&mut [u8], EngineError>), + { + let Self { conn, machine, in_flight, request_started, .. } = self; + let Conn::Connected(stream) = conn else { return Ok(()) }; + loop { + while let Some(body) = machine.take_response() { + if let Some(rpc_id) = in_flight.take() { + *request_started = None; + on_complete(rpc_id, Ok(body)); + } + } + match stream.read(machine.read_space()) { + Ok(0) => return Err(io::Error::new(io::ErrorKind::ConnectionReset, "eof")), + Ok(n) => machine.commit_read(n)?, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => return Err(e), + } + } + Ok(()) + } + + fn fail(&mut self, registry: &Registry, on_complete: &mut F, msg: &str) + where + F: FnMut(u64, Result<&mut [u8], EngineError>), + { + tracing::warn!("{msg}"); + let err = msg.to_string(); + if let Some(rpc_id) = self.in_flight.take() { + on_complete(rpc_id, Err(EngineError::Http(err.clone()))); + } + if let Some(rpc_id) = self.pending_id.take() { + on_complete(rpc_id, Err(EngineError::Http(err.clone()))); + } + self.request_started = None; + self.machine.reset(); + let old = std::mem::replace(&mut self.conn, Conn::Disconnected); + if let Conn::Connecting(mut stream) | Conn::Connected(mut stream) = old { + let _ = registry.deregister(&mut stream); + } + } + + fn update_interest(&mut self, registry: &Registry) { + let interest = if self.pending_id.is_none() { + Interest::READABLE + } else { + Interest::READABLE | Interest::WRITABLE + }; + let stream = match &mut self.conn { + Conn::Connecting(s) | Conn::Connected(s) => s, + Conn::Disconnected => return, + }; + let _ = registry.reregister(stream, self.token, interest); + } +} + +fn parse_addr(endpoint: &str) -> io::Result { + let hostport = endpoint.trim_start_matches("http://").split('/').next().unwrap_or(endpoint); + hostport + .to_socket_addrs()? + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "no address resolved")) +} + +pub(crate) struct HttpPool { + connections: Vec, + endpoint: Endpoint, + jwt: JwtSecret, + tokens: TokenRange, + max_connections: usize, + request_timeout: Duration, +} + +impl HttpPool { + pub(crate) fn new( + endpoint: Endpoint, + jwt: JwtSecret, + tokens: TokenRange, + max_connections: usize, + request_timeout: Duration, + ) -> Self { + let tokens_needed = max_connections.checked_add(HEALTHCHECK_OVERSHOOT); + assert!( + tokens_needed.is_some_and(|needed| needed <= tokens.span()), + "engine api needs a token per pooled connection: a cap of {max_connections} plus the \ + healthcheck's overshoot of {HEALTHCHECK_OVERSHOOT} does not fit a span of {}", + tokens.span() + ); + let connections = vec![PooledConnection::new(endpoint.clone(), jwt.clone(), tokens.at(0))]; + Self { connections, endpoint, jwt, tokens, max_connections, request_timeout } + } + + /// `enqueue` never refuses work; every caller gates on this before + /// submitting. + pub(crate) fn has_capacity(&self) -> bool { + self.connections.iter().any(PooledConnection::is_free) || + self.connections.len() < self.max_connections + } + + pub(crate) fn enqueue(&mut self, rpc_id: u64, body: &[u8], registry: &Registry) { + if let Some(conn) = self.connections.iter_mut().find(|c| c.is_free()) { + conn.enqueue(rpc_id, body, registry); + } else { + let mut new_conn = PooledConnection::new( + self.endpoint.clone(), + self.jwt.clone(), + self.tokens.at(self.connections.len()), + ); + new_conn.enqueue(rpc_id, body, registry); + self.connections.push(new_conn); + } + } + + pub(crate) fn dispatch_events( + &mut self, + events: &Events, + registry: &Registry, + on_complete: &mut F, + ) where + F: FnMut(u64, Result<&mut [u8], EngineError>), + { + self.fail_stranded_and_expired(registry, on_complete); + + // The batch is the whole loop's, and a pooled connection's token is + // `tokens.at(its index)`, so one pass indexing by offset costs + // O(events) where a pass per connection costs O(connections × events). + for event in events.iter() { + let Some(index) = self.tokens.offset_of(event.token()) else { continue }; + let Some(conn) = self.connections.get_mut(index) else { continue }; + conn.handle_event(event, registry, on_complete); + } + } + + fn fail_stranded_and_expired(&mut self, registry: &Registry, on_complete: &mut F) + where + F: FnMut(u64, Result<&mut [u8], EngineError>), + { + let now = Instant::now(); + for conn in &mut self.connections { + // Disconnected with a request pending means connect() could not + // even start (resolve/connect/register error): no event will ever + // arrive for it, so fail the rpc here or it is stranded forever. + if matches!(conn.conn, Conn::Disconnected) && conn.pending_id.is_some() { + conn.fail(registry, on_complete, "connect failed to start"); + } else if conn.expired(now, self.request_timeout) { + conn.fail(registry, on_complete, "request timed out"); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::{os::unix::net::UnixListener, path::Path}; + + use silver_httpcore::Readiness; + use tempfile::TempDir; + + use super::*; + use crate::{ + EngineClient, + client::{ReqKind, send_fcu}, + test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}, + types::ForkchoiceState, + }; + + /// Longer than any test's 10 s spin deadline: the sweep never fires. + const LONG_TIMEOUT: Duration = Duration::from_secs(60); + + /// The sole tenant of its readiness loop, which the tile shares with the + /// beacon-api server in production and each test here owns for itself. + struct Client { + readiness: Readiness, + engine: EngineClient, + } + + impl Client { + fn uds( + socket: &Path, + jwt: &Path, + max_connections: usize, + request_timeout: Duration, + ) -> Self { + let readiness = Readiness::new(16); + let engine = EngineClient::new_uds( + readiness.registry(), + TokenRange::whole(), + socket, + jwt.to_str().unwrap(), + max_connections, + request_timeout, + ); + Self { readiness, engine } + } + + fn poll(&mut self, on_complete: F) + where + F: FnMut(ReqKind, Result<&mut [u8], EngineError>), + { + self.readiness.wait(Duration::ZERO); + self.engine.dispatch(self.readiness.events(), on_complete); + } + } + + fn fcu_state(byte: u8) -> ForkchoiceState { + ForkchoiceState { + head_block_hash: [byte; 32], + safe_block_hash: [byte; 32], + finalized_block_hash: [byte; 32], + } + } + + fn spin_until(deadline_msg: &str, mut done: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !done() { + assert!(Instant::now() < deadline, "timeout: {deadline_msg}"); + std::thread::sleep(Duration::from_millis(1)); + } + } + + #[test] + fn uds_round_trip_resolves_correlation_with_jwt() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let mut el = FakeEl::uds(&socket); + + let mut client = Client::uds(&socket, &jwt_path, 32, LONG_TIMEOUT); + let block_root = [7u8; 32]; + send_fcu(&mut client.engine, block_root, fcu_state(1), None); + + let mut responded = false; + let mut completed: Option<([u8; 32], Vec)> = None; + spin_until("fcu round trip over uds", || { + client.poll(|kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + completed = Some((root, response.expect("fcu response").to_vec())); + }); + el.pump(); + if !responded && !el.requests.is_empty() { + let request = &el.requests[0]; + assert_eq!(request.method, "engine_forkchoiceUpdatedV3"); + let auth = request.authorization.as_deref().expect("JWT header sent over UDS"); + let token = auth.strip_prefix("Bearer ").expect("bearer scheme"); + assert_eq!(token.split('.').count(), 3, "three-part JWT"); + assert!( + request.body.contains(&format!("\"headBlockHash\":\"0x{}\"", "01".repeat(32))) + ); + el.respond(0, FCU_VALID_RESULT); + responded = true; + } + completed.is_some() + }); + + let (root, body) = completed.unwrap(); + assert_eq!(root, block_root, "completion correlated to the issued request"); + assert!(String::from_utf8(body).unwrap().contains("VALID")); + } + + #[test] + fn connect_failure_fails_rpc_and_frees_connection() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let missing_socket = dir.path().join("missing.sock"); + + // max_connections = 1: after the failure, has_capacity() can only be + // true again if the zombie connection was actually freed. + let mut client = Client::uds(&missing_socket, &jwt_path, 1, LONG_TIMEOUT); + let block_root = [3u8; 32]; + send_fcu(&mut client.engine, block_root, fcu_state(3), None); + assert!(!client.engine.has_capacity(), "request occupies the only connection"); + + let mut failed: Option<[u8; 32]> = None; + spin_until("connect failure surfaces as rpc error", || { + client.poll(|kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + assert!(response.is_err(), "unstartable connect must fail the rpc"); + failed = Some(root); + }); + failed.is_some() + }); + + assert_eq!(failed.unwrap(), block_root); + assert!(client.engine.has_capacity(), "failed connection must be reusable"); + } + + #[test] + fn transport_error_fails_in_flight_request() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let mut el = FakeEl::uds(&socket); + + let mut client = Client::uds(&socket, &jwt_path, 32, LONG_TIMEOUT); + let block_root = [9u8; 32]; + send_fcu(&mut client.engine, block_root, fcu_state(2), None); + + let mut request_seen = false; + let mut failure: Option<[u8; 32]> = None; + spin_until("in-flight request failed on connection close", || { + client.poll(|kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + assert!(response.is_err(), "closed connection must fail the rpc"); + failure = Some(root); + }); + el.pump(); + if !request_seen && !el.requests.is_empty() { + el.close_connection_of(0); + request_seen = true; + } + failure.is_some() + }); + + assert_eq!(failure.unwrap(), block_root); + } + + #[test] + fn unanswered_request_times_out_and_frees_connection() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let mut el = FakeEl::uds(&socket); + + let mut client = Client::uds(&socket, &jwt_path, 1, Duration::from_millis(200)); + send_fcu(&mut client.engine, [1u8; 32], fcu_state(1), None); + + let mut timed_out: Option<[u8; 32]> = None; + spin_until("unanswered request times out", || { + client.poll(|kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + assert!(response.is_err(), "unanswered request must fail the rpc"); + timed_out = Some(root); + }); + el.pump(); + timed_out.is_some() + }); + + assert_eq!(timed_out.unwrap(), [1u8; 32]); + assert_eq!(el.requests.len(), 1, "the EL received the request it never answered"); + assert!(client.engine.has_capacity(), "timed-out connection must be reusable"); + + send_fcu(&mut client.engine, [2u8; 32], fcu_state(2), None); + let mut answered = false; + let mut completed: Option<[u8; 32]> = None; + spin_until("next request served on the freed connection", || { + client.poll(|kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + assert!(response.is_ok(), "answered request must succeed"); + completed = Some(root); + }); + el.pump(); + if !answered && el.requests.len() == 2 { + el.respond(1, FCU_VALID_RESULT); + answered = true; + } + completed.is_some() + }); + assert_eq!(completed.unwrap(), [2u8; 32]); + } + + #[test] + fn request_answered_within_the_deadline_does_not_time_out() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let mut el = FakeEl::uds(&socket); + + let mut client = Client::uds(&socket, &jwt_path, 1, Duration::from_secs(2)); + send_fcu(&mut client.engine, [4u8; 32], fcu_state(4), None); + + let answer_at = Instant::now() + Duration::from_millis(400); + let mut answered = false; + let mut completed: Option<[u8; 32]> = None; + spin_until("slow but in-deadline response succeeds", || { + client.poll(|kind, response| { + let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; + assert!(response.is_ok(), "response inside the deadline must not fail"); + completed = Some(root); + }); + el.pump(); + if !answered && !el.requests.is_empty() && Instant::now() >= answer_at { + el.respond(0, FCU_VALID_RESULT); + answered = true; + } + completed.is_some() + }); + assert_eq!(completed.unwrap(), [4u8; 32]); + } + + /// A range with no room for the healthcheck's overshoot would have the + /// pool allocating into a neighbouring tenant's tokens, so it is refused + /// at construction. + #[test] + #[should_panic(expected = "does not fit a span")] + fn a_range_too_small_for_the_connection_cap_is_rejected() { + let dir = TempDir::new().unwrap(); + let jwt = JwtSecret::from_file(write_jwt(dir.path()).to_str().unwrap()).unwrap(); + HttpPool::new( + Endpoint::Uds(dir.path().join("engine.sock")), + jwt, + TokenRange::new(0, 8), + 8, + LONG_TIMEOUT, + ); + } + + /// A blackholed connect (SYN dropped) is not cheaply reproducible in a unit + /// test, so the pool is driven directly: with no events ever delivered the + /// connection stays in `Connecting`, which is the state such a connect is + /// stuck in, and the deadline must still fire. + #[test] + fn pending_request_times_out_while_still_connecting() { + let dir = TempDir::new().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let _listener = UnixListener::bind(&socket).unwrap(); + + let jwt = JwtSecret::from_file(jwt_path.to_str().unwrap()).unwrap(); + let mut pool = HttpPool::new( + Endpoint::Uds(socket), + jwt, + TokenRange::whole(), + 1, + Duration::from_millis(100), + ); + let readiness = Readiness::new(1); + + pool.enqueue(7, b"{}", readiness.registry()); + assert!(matches!(pool.connections[0].conn, Conn::Connecting(_))); + assert!(!pool.has_capacity()); + + std::thread::sleep(Duration::from_millis(150)); + let mut failed: Option<(u64, bool)> = None; + pool.dispatch_events(readiness.events(), readiness.registry(), &mut |rpc_id, response| { + failed = Some((rpc_id, response.is_err())); + }); + + assert_eq!(failed, Some((7, true)), "a stuck connect must fail its rpc"); + assert!(pool.has_capacity(), "timed-out connection must be reusable"); + } +} diff --git a/crates/engine/src/req_handlers.rs b/crates/engine_api/src/req_handlers.rs similarity index 100% rename from crates/engine/src/req_handlers.rs rename to crates/engine_api/src/req_handlers.rs diff --git a/crates/engine/src/resp_handlers.rs b/crates/engine_api/src/resp_handlers.rs similarity index 100% rename from crates/engine/src/resp_handlers.rs rename to crates/engine_api/src/resp_handlers.rs diff --git a/crates/engine_api/src/test_el.rs b/crates/engine_api/src/test_el.rs new file mode 100644 index 00000000..8fbbb9d2 --- /dev/null +++ b/crates/engine_api/src/test_el.rs @@ -0,0 +1,186 @@ +use std::{ + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + os::unix::net::{UnixListener, UnixStream}, + path::{Path, PathBuf}, +}; + +use simd_json::prelude::{ValueAsScalar, ValueObjectAccess}; + +pub const FCU_VALID_RESULT: &str = r#"{"payloadStatus":{"status":"VALID","latestValidHash":null,"validationError":null},"payloadId":null}"#; + +pub fn write_jwt(dir: &Path) -> PathBuf { + let path = dir.join("jwt.hex"); + std::fs::write(&path, "0000000000000000000000000000000000000000000000000000000000000000") + .unwrap(); + path +} + +enum ElListener { + Tcp(TcpListener), + Uds(UnixListener), +} + +enum ElStream { + Tcp(TcpStream), + Uds(UnixStream), +} + +impl Read for ElStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + Self::Tcp(s) => s.read(buf), + Self::Uds(s) => s.read(buf), + } + } +} + +impl Write for ElStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Self::Tcp(s) => s.write(buf), + Self::Uds(s) => s.write(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Self::Tcp(s) => s.flush(), + Self::Uds(s) => s.flush(), + } + } +} + +pub struct ElRequest { + conn: usize, + pub id: u64, + pub method: String, + pub authorization: Option, + pub body: String, +} + +/// Deterministic single-threaded fake execution client: accepts connections +/// and buffers requests on `pump`, answers only when the test says so. +pub struct FakeEl { + listener: ElListener, + conns: Vec>, + read_bufs: Vec>, + pub requests: Vec, +} + +impl FakeEl { + pub fn tcp() -> (Self, String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + (Self::new(ElListener::Tcp(listener)), endpoint) + } + + pub fn uds(path: &Path) -> Self { + let listener = UnixListener::bind(path).unwrap(); + listener.set_nonblocking(true).unwrap(); + Self::new(ElListener::Uds(listener)) + } + + fn new(listener: ElListener) -> Self { + Self { listener, conns: Vec::new(), read_bufs: Vec::new(), requests: Vec::new() } + } + + pub fn pump(&mut self) { + loop { + let accepted = match &self.listener { + ElListener::Tcp(l) => l.accept().map(|(s, _)| { + s.set_nonblocking(true).unwrap(); + ElStream::Tcp(s) + }), + ElListener::Uds(l) => l.accept().map(|(s, _)| { + s.set_nonblocking(true).unwrap(); + ElStream::Uds(s) + }), + }; + match accepted { + Ok(stream) => { + self.conns.push(Some(stream)); + self.read_bufs.push(Vec::new()); + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => panic!("accept: {e}"), + } + } + + for i in 0..self.conns.len() { + let Some(stream) = self.conns[i].as_mut() else { continue }; + let mut chunk = [0u8; 65536]; + let mut closed = false; + loop { + match stream.read(&mut chunk) { + Ok(0) => { + closed = true; + break; + } + Ok(n) => self.read_bufs[i].extend_from_slice(&chunk[..n]), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => panic!("read: {e}"), + } + } + if closed { + self.conns[i] = None; + } + while let Some((consumed, request)) = parse_request(i, &self.read_bufs[i]) { + self.requests.push(request); + self.read_bufs[i].drain(..consumed); + } + } + } + + pub fn respond(&mut self, request_index: usize, result_json: &str) { + let request = &self.requests[request_index]; + let body = format!(r#"{{"jsonrpc":"2.0","id":{},"result":{result_json}}}"#, request.id); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + let stream = self.conns[request.conn].as_mut().expect("respond on closed connection"); + let mut bytes = response.as_bytes(); + while !bytes.is_empty() { + match stream.write(bytes) { + Ok(n) => bytes = &bytes[n..], + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => panic!("write: {e}"), + } + } + } + + pub fn close_connection_of(&mut self, request_index: usize) { + self.conns[self.requests[request_index].conn] = None; + } +} + +fn parse_request(conn: usize, buf: &[u8]) -> Option<(usize, ElRequest)> { + let mut headers = [httparse::EMPTY_HEADER; 32]; + let mut request = httparse::Request::new(&mut headers); + let header_end = match request.parse(buf) { + Ok(httparse::Status::Complete(n)) => n, + _ => return None, + }; + let content_length: usize = headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("content-length")) + .and_then(|h| std::str::from_utf8(h.value).ok()?.trim().parse().ok()) + .expect("request without Content-Length"); + if buf.len() < header_end + content_length { + return None; + } + let authorization = headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("authorization")) + .map(|h| String::from_utf8(h.value.to_vec()).unwrap()); + + let body = String::from_utf8(buf[header_end..header_end + content_length].to_vec()).unwrap(); + let mut json = body.clone().into_bytes(); + let json = simd_json::to_borrowed_value(&mut json).expect("request body is JSON"); + let id = json.get("id").and_then(|v| v.as_u64()).expect("rpc id"); + let method = json.get("method").and_then(|v| v.as_str()).expect("rpc method").to_string(); + + Some((header_end + content_length, ElRequest { conn, id, method, authorization, body })) +} diff --git a/crates/engine/src/types.rs b/crates/engine_api/src/types.rs similarity index 100% rename from crates/engine/src/types.rs rename to crates/engine_api/src/types.rs diff --git a/crates/engine/testdata/empty_var_payload.ssz b/crates/engine_api/testdata/empty_var_payload.ssz similarity index 100% rename from crates/engine/testdata/empty_var_payload.ssz rename to crates/engine_api/testdata/empty_var_payload.ssz diff --git a/crates/engine/testdata/get_payload_tcache.bin b/crates/engine_api/testdata/get_payload_tcache.bin similarity index 100% rename from crates/engine/testdata/get_payload_tcache.bin rename to crates/engine_api/testdata/get_payload_tcache.bin diff --git a/crates/engine/testdata/large_extra_payload.ssz b/crates/engine_api/testdata/large_extra_payload.ssz similarity index 100% rename from crates/engine/testdata/large_extra_payload.ssz rename to crates/engine_api/testdata/large_extra_payload.ssz diff --git a/crates/engine/testdata/many_tx_payload.ssz b/crates/engine_api/testdata/many_tx_payload.ssz similarity index 100% rename from crates/engine/testdata/many_tx_payload.ssz rename to crates/engine_api/testdata/many_tx_payload.ssz diff --git a/crates/engine/testdata/sample_payload.ssz b/crates/engine_api/testdata/sample_payload.ssz similarity index 100% rename from crates/engine/testdata/sample_payload.ssz rename to crates/engine_api/testdata/sample_payload.ssz diff --git a/crates/engine/testdata/signed_block.ssz b/crates/engine_api/testdata/signed_block.ssz similarity index 100% rename from crates/engine/testdata/signed_block.ssz rename to crates/engine_api/testdata/signed_block.ssz diff --git a/crates/engine/testdata/signed_block_params.json b/crates/engine_api/testdata/signed_block_params.json similarity index 100% rename from crates/engine/testdata/signed_block_params.json rename to crates/engine_api/testdata/signed_block_params.json diff --git a/crates/engine/testdata/tx_multi.bin b/crates/engine_api/testdata/tx_multi.bin similarity index 100% rename from crates/engine/testdata/tx_multi.bin rename to crates/engine_api/testdata/tx_multi.bin diff --git a/crates/engine/testdata/tx_single.bin b/crates/engine_api/testdata/tx_single.bin similarity index 100% rename from crates/engine/testdata/tx_single.bin rename to crates/engine_api/testdata/tx_single.bin diff --git a/crates/engine/testdata/withdrawals.bin b/crates/engine_api/testdata/withdrawals.bin similarity index 100% rename from crates/engine/testdata/withdrawals.bin rename to crates/engine_api/testdata/withdrawals.bin diff --git a/crates/engine_api/tests/newpayload_alloc.rs b/crates/engine_api/tests/newpayload_alloc.rs new file mode 100644 index 00000000..eaa32c0d --- /dev/null +++ b/crates/engine_api/tests/newpayload_alloc.rs @@ -0,0 +1,128 @@ +//! Pins the newPayload hot-path invariant: once every buffer is warm (scratch, +//! connection write buffer, JWT token cache, pending-request map), the SSZ→JSON +//! transcode + frame + enqueue path performs zero heap allocations. + +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use silver_engine_api::{ + EngineClient, ReqKind, send_new_payload, + test_el::{FakeEl, write_jwt}, +}; +use silver_httpcore::{Readiness, TokenRange}; + +thread_local! { + static ALLOCATION_EVENTS: Cell = const { Cell::new(0) }; +} + +fn allocation_events() -> u64 { + ALLOCATION_EVENTS.with(Cell::get) +} + +struct CountingAllocator; + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOCATION_EVENTS.with(|c| c.set(c.get() + 1)); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOCATION_EVENTS.with(|c| c.set(c.get() + 1)); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + ALLOCATION_EVENTS.with(|c| c.set(c.get() + 1)); + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static GLOBAL: CountingAllocator = CountingAllocator; + +const SIGNED_BLOCK_SSZ: &[u8] = include_bytes!("../testdata/signed_block.ssz"); +const NEW_PAYLOAD_VALID: &str = + r#"{"status":"VALID","latestValidHash":null,"validationError":null}"#; + +fn unix_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() +} + +fn complete_round_trip( + readiness: &mut Readiness, + client: &mut EngineClient, + el: &mut FakeEl, + request_index: usize, +) { + let deadline = Instant::now() + Duration::from_secs(10); + let mut responded = false; + let mut done = false; + while !done { + assert!(Instant::now() < deadline, "timeout: newPayload round trip {request_index}"); + el.pump(); + if !responded && el.requests.len() > request_index { + assert_eq!(el.requests[request_index].method, "engine_newPayloadV4"); + el.respond(request_index, NEW_PAYLOAD_VALID); + responded = true; + } + readiness.wait(Duration::ZERO); + client.dispatch(readiness.events(), |kind, response| { + assert!(matches!(kind, ReqKind::NewPayload(_))); + response.expect("newPayload response"); + done = true; + }); + std::thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +fn warm_new_payload_send_allocates_nothing() { + let dir = tempfile::tempdir().unwrap(); + let jwt_path = write_jwt(dir.path()); + let socket = dir.path().join("engine.sock"); + let mut el = FakeEl::uds(&socket); + let mut readiness = Readiness::new(16); + let mut client = EngineClient::new_uds( + readiness.registry(), + TokenRange::whole(), + &socket, + jwt_path.to_str().unwrap(), + 4, + Duration::from_secs(60), + ); + + send_new_payload(&mut client, SIGNED_BLOCK_SSZ, [0u8; 32]).unwrap(); + complete_round_trip(&mut readiness, &mut client, &mut el, 0); + let mut request_index = 1; + assert!(allocation_events() > 0, "counting allocator must observe the cold path"); + + // The JWT bearer token is cached per wall-clock second, so a warm send and + // the measured send must land in the same second for the token recompute + // to stay out of the measured window; retry on the rare rollover. + for _ in 0..5 { + let second = unix_secs(); + send_new_payload(&mut client, SIGNED_BLOCK_SSZ, [1u8; 32]).unwrap(); + complete_round_trip(&mut readiness, &mut client, &mut el, request_index); + request_index += 1; + + let before = allocation_events(); + send_new_payload(&mut client, SIGNED_BLOCK_SSZ, [2u8; 32]).unwrap(); + let events = allocation_events() - before; + + complete_round_trip(&mut readiness, &mut client, &mut el, request_index); + request_index += 1; + if unix_secs() == second { + assert_eq!(events, 0, "warm newPayload send performed {events} heap allocations"); + return; + } + } + panic!("wall clock crossed a second boundary on every attempt"); +} diff --git a/crates/httpcore/Cargo.toml b/crates/httpcore/Cargo.toml new file mode 100644 index 00000000..237e1c7a --- /dev/null +++ b/crates/httpcore/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "silver_httpcore" +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +httparse.workspace = true +mio.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/httpcore/src/client.rs b/crates/httpcore/src/client.rs new file mode 100644 index 00000000..293c6bf3 --- /dev/null +++ b/crates/httpcore/src/client.rs @@ -0,0 +1,343 @@ +use std::io::{self, Write}; + +// 4096 covers any realistic HTTP response header block; once headers are +// parsed, reads are sized to exactly the remaining Content-Length. +const HEADER_READ_LEN: usize = 4096; + +pub struct ClientConnection { + write_buf: Vec, + write_pos: usize, + read_buf: Vec, + read_end: usize, + read_offset: usize, + response_header_end: usize, + response_total: usize, +} + +impl ClientConnection { + pub fn with_capacity(read_capacity: usize, write_capacity: usize) -> Self { + Self { + write_buf: Vec::with_capacity(write_capacity), + write_pos: 0, + read_buf: Vec::with_capacity(read_capacity), + read_end: 0, + read_offset: 0, + response_header_end: 0, + response_total: 0, + } + } + + pub fn begin_request(&mut self) -> &mut Vec { + debug_assert!( + self.pending_write().is_empty(), + "one request in flight per connection: previous request not fully written" + ); + self.write_buf.clear(); + self.write_pos = 0; + &mut self.write_buf + } + + pub fn pending_write(&self) -> &[u8] { + &self.write_buf[self.write_pos..] + } + + pub fn commit_write(&mut self, n: usize) { + debug_assert!(self.write_pos + n <= self.write_buf.len()); + self.write_pos += n; + } + + pub fn read_space(&mut self) -> &mut [u8] { + if self.read_offset != 0 && self.read_offset == self.read_end { + self.read_end = 0; + self.read_offset = 0; + } + let want = if self.response_total > 0 { + self.response_total - (self.read_end - self.read_offset) + } else { + HEADER_READ_LEN + }; + debug_assert!(want > 0, "complete response pending: take_response before reading more"); + if self.read_buf.len() < self.read_end + want { + self.read_buf.resize(self.read_end + want, 0); + } + &mut self.read_buf[self.read_end..self.read_end + want] + } + + pub fn commit_read(&mut self, n: usize) -> io::Result<()> { + debug_assert!(self.read_end + n <= self.read_buf.len()); + self.read_end += n; + if self.response_total == 0 { + if let Some((header_end, content_length)) = + parse_response_head(&self.read_buf[self.read_offset..self.read_end])? + { + self.response_header_end = header_end; + self.response_total = header_end + content_length; + } + } + Ok(()) + } + + pub fn take_response(&mut self) -> Option<&mut [u8]> { + if self.response_total == 0 || self.read_end - self.read_offset < self.response_total { + return None; + } + let start = self.read_offset + self.response_header_end; + let end = self.read_offset + self.response_total; + self.read_offset = end; + self.response_header_end = 0; + self.response_total = 0; + Some(&mut self.read_buf[start..end]) + } + + pub fn reset(&mut self) { + self.write_buf.clear(); + self.write_pos = 0; + self.read_end = 0; + self.read_offset = 0; + self.response_header_end = 0; + self.response_total = 0; + } +} + +// Returns (header_end, content_length) when headers are complete, None if +// partial. Content-Length framing only: a response without it is an error, +// chunked transfer encoding is unsupported. +fn parse_response_head(buf: &[u8]) -> io::Result> { + let mut headers = [httparse::EMPTY_HEADER; 32]; + let mut resp = httparse::Response::new(&mut headers); + let header_end = match resp.parse(buf) { + Ok(httparse::Status::Complete(n)) => n, + Ok(httparse::Status::Partial) => return Ok(None), + Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("httparse: {e}"))), + }; + match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { + Some(h) if !h.value.is_empty() && h.value.iter().all(|b| b.is_ascii_digit()) => { + let cl = h.value.iter().copied().fold(0usize, |acc, b| acc * 10 + (b - b'0') as usize); + Ok(Some((header_end, cl))) + } + Some(_) => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid Content-Length")), + None => Err(io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length")), + } +} + +pub fn frame_request( + out: &mut Vec, + host: &str, + body: &[u8], + authorization: Option<&str>, + keep_alive: bool, +) { + let connection = if keep_alive { "keep-alive" } else { "close" }; + match authorization { + Some(bearer) => write!( + out, + "POST / HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n\ + Content-Length: {len}\r\nAuthorization: {bearer}\r\nConnection: {connection}\r\n\r\n", + len = body.len(), + ), + None => write!( + out, + "POST / HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n\ + Content-Length: {len}\r\nConnection: {connection}\r\n\r\n", + len = body.len(), + ), + } + .unwrap(); + out.extend_from_slice(body); +} + +#[cfg(test)] +mod tests { + use super::*; + + const BODY: &[u8] = br#"{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}"#; + const BEARER: &str = "Bearer aGVhZGVy.cGF5bG9hZA.c2ln"; + + fn machine() -> ClientConnection { + ClientConnection::with_capacity(4096, 4096) + } + + fn make_response(body: &[u8]) -> Vec { + let mut buf = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes(); + buf.extend_from_slice(body); + buf + } + + fn feed(conn: &mut ClientConnection, bytes: &[u8]) -> io::Result<()> { + let space = conn.read_space(); + let n = bytes.len().min(space.len()); + assert_eq!(n, bytes.len(), "test chunk exceeds offered read space"); + space[..n].copy_from_slice(bytes); + conn.commit_read(n) + } + + // Captured verbatim from the engine crate's `build_request_into` before the + // extraction (2026-08-17); the framed request must stay byte-identical. + #[test] + fn golden_request_bytes_keep_alive() { + let mut conn = machine(); + frame_request(conn.begin_request(), "localhost:8551", BODY, Some(BEARER), true); + let expected: Vec = [ + b"POST / HTTP/1.1\r\nHost: localhost:8551\r\nContent-Type: application/json\r\n\ + Content-Length: 59\r\nAuthorization: Bearer aGVhZGVy.cGF5bG9hZA.c2ln\r\n\ + Connection: keep-alive\r\n\r\n" + .as_ref(), + BODY, + ] + .concat(); + assert_eq!(conn.pending_write(), expected); + } + + #[test] + fn golden_request_bytes_connection_close() { + let mut conn = machine(); + frame_request(conn.begin_request(), "localhost:8551", BODY, Some(BEARER), false); + let expected: Vec = [ + b"POST / HTTP/1.1\r\nHost: localhost:8551\r\nContent-Type: application/json\r\n\ + Content-Length: 59\r\nAuthorization: Bearer aGVhZGVy.cGF5bG9hZA.c2ln\r\n\ + Connection: close\r\n\r\n" + .as_ref(), + BODY, + ] + .concat(); + assert_eq!(conn.pending_write(), expected); + } + + #[test] + fn frame_request_without_authorization_omits_header() { + let mut out = Vec::new(); + frame_request(&mut out, "localhost:8551", b"{}", None, true); + let text = String::from_utf8(out).unwrap(); + assert!(!text.contains("Authorization")); + assert!(text.contains("Content-Length: 2\r\n")); + } + + #[test] + fn request_drained_in_small_chunks() { + let mut conn = machine(); + frame_request(conn.begin_request(), "localhost:8551", BODY, Some(BEARER), true); + let expected = conn.pending_write().to_vec(); + + let mut wire = Vec::new(); + while !conn.pending_write().is_empty() { + let chunk_len = conn.pending_write().len().min(3); + wire.extend_from_slice(&conn.pending_write()[..chunk_len]); + conn.commit_write(chunk_len); + } + assert_eq!(wire, expected); + } + + #[test] + fn response_fed_one_byte_at_a_time() { + let mut conn = machine(); + let body = br#"{"jsonrpc":"2.0","id":1,"result":false}"#; + let response = make_response(body); + + for (i, byte) in response.iter().enumerate() { + assert!(conn.take_response().is_none(), "byte {i}"); + feed(&mut conn, &[*byte]).unwrap(); + } + assert_eq!(conn.take_response().unwrap(), body); + assert!(conn.take_response().is_none()); + } + + #[test] + fn headers_complete_body_incomplete_returns_none() { + let mut conn = machine(); + let mut response = make_response(br#"{"result":1}"#); + response.truncate(response.len() - 3); + feed(&mut conn, &response).unwrap(); + assert!(conn.take_response().is_none()); + feed(&mut conn, br#":1}"#).unwrap(); + assert_eq!(conn.take_response().unwrap(), br#"{"result":1}"#.as_ref()); + } + + #[test] + fn partial_headers_return_none_without_error() { + let mut conn = machine(); + feed(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n").unwrap(); + assert!(conn.take_response().is_none()); + } + + #[test] + fn missing_content_length_is_error() { + let mut conn = machine(); + let err = feed(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{}") + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(err.to_string(), "missing Content-Length"); + } + + #[test] + fn invalid_content_length_is_error() { + let mut conn = machine(); + let err = feed(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n{}").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(err.to_string(), "invalid Content-Length"); + } + + #[test] + fn body_larger_than_header_read_arrives_in_exact_sized_reads() { + let mut conn = machine(); + let body = vec![b'x'; 3 * HEADER_READ_LEN]; + let response = make_response(&body); + + let mut sent = 0; + while sent < response.len() { + assert!(conn.take_response().is_none()); + let space = conn.read_space(); + let n = space.len().min(response.len() - sent); + space[..n].copy_from_slice(&response[sent..sent + n]); + conn.commit_read(n).unwrap(); + sent += n; + } + assert_eq!(conn.take_response().unwrap(), body); + } + + #[test] + fn keep_alive_connection_serves_second_request() { + let mut conn = machine(); + for body in [br#"{"id":1}"#.as_ref(), br#"{"id":2}"#.as_ref()] { + frame_request(conn.begin_request(), "h", body, None, true); + while !conn.pending_write().is_empty() { + let n = conn.pending_write().len(); + conn.commit_write(n); + } + feed(&mut conn, &make_response(body)).unwrap(); + assert_eq!(conn.take_response().unwrap(), body); + } + } + + #[test] + fn two_connections_complete_out_of_order() { + let mut first = machine(); + let mut second = machine(); + frame_request(first.begin_request(), "h", br#"{"id":1}"#, None, true); + frame_request(second.begin_request(), "h", br#"{"id":2}"#, None, true); + + feed(&mut second, &make_response(br#"{"id":2,"result":"b"}"#)).unwrap(); + assert!(first.take_response().is_none()); + assert_eq!(second.take_response().unwrap(), br#"{"id":2,"result":"b"}"#.as_ref()); + + feed(&mut first, &make_response(br#"{"id":1,"result":"a"}"#)).unwrap(); + assert_eq!(first.take_response().unwrap(), br#"{"id":1,"result":"a"}"#.as_ref()); + } + + #[test] + fn reset_clears_partial_state_but_keeps_capacity() { + let mut conn = machine(); + frame_request(conn.begin_request(), "h", b"{}", None, true); + feed(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Le").unwrap(); + + conn.reset(); + assert!(conn.pending_write().is_empty()); + assert!(conn.take_response().is_none()); + + feed(&mut conn, &make_response(b"{}")).unwrap(); + assert_eq!(conn.take_response().unwrap(), b"{}"); + } +} diff --git a/crates/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs new file mode 100644 index 00000000..f37a4269 --- /dev/null +++ b/crates/httpcore/src/lib.rs @@ -0,0 +1,15 @@ +mod client; +mod query; +mod readiness; +mod server; +mod stream; +mod token_range; + +pub use client::{ClientConnection, frame_request}; +pub use query::Query; +pub use readiness::Readiness; +pub use server::{ + AfterResponse, ParsedRequest, ServerConnection, frame_response, frame_response_with_headers, +}; +pub use stream::{Bind, Listener, Stream}; +pub use token_range::TokenRange; diff --git a/crates/httpcore/src/query.rs b/crates/httpcore/src/query.rs new file mode 100644 index 00000000..ade6d952 --- /dev/null +++ b/crates/httpcore/src/query.rs @@ -0,0 +1,141 @@ +use std::borrow::Cow; + +pub struct Query<'a> { + rest: &'a str, +} + +impl<'a> Query<'a> { + pub fn new(raw: &'a str) -> Self { + Self { rest: raw } + } +} + +impl<'a> Iterator for Query<'a> { + type Item = (Cow<'a, str>, Cow<'a, str>); + + fn next(&mut self) -> Option { + while !self.rest.is_empty() { + let (pair, rest) = self.rest.split_once('&').unwrap_or((self.rest, "")); + self.rest = rest; + if pair.is_empty() { + continue; + } + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + return Some((percent_decode(key), percent_decode(value))); + } + None + } +} + +// `+` stays literal: the `+`-means-space rule is HTML form encoding, and no +// validator client sends a form body here — beacon-API query values are hex +// strings, validator statuses and graffiti, escaped per RFC 3986. +fn percent_decode(raw: &str) -> Cow<'_, str> { + let Some(first_escape) = raw.find('%') else { + return Cow::Borrowed(raw); + }; + let bytes = raw.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + out.extend_from_slice(&bytes[..first_escape]); + + let mut i = first_escape; + while i < bytes.len() { + match decode_escape(&bytes[i..]) { + Some(byte) => { + out.push(byte); + i += 3; + } + None => { + out.push(bytes[i]); + i += 1; + } + } + } + Cow::Owned(String::from_utf8_lossy(&out).into_owned()) +} + +fn decode_escape(bytes: &[u8]) -> Option { + let &[b'%', high, low, ..] = bytes else { return None }; + let digit = |byte: u8| (byte as char).to_digit(16); + Some((digit(high)? * 16 + digit(low)?) as u8) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pairs(raw: &str) -> Vec<(String, String)> { + Query::new(raw).map(|(k, v)| (k.into_owned(), v.into_owned())).collect() + } + + #[test] + fn plain_pairs_split_on_ampersand_and_equals() { + assert_eq!(pairs("id=1&status=active_ongoing"), [ + ("id".to_string(), "1".to_string()), + ("status".to_string(), "active_ongoing".to_string()), + ]); + } + + #[test] + fn escape_free_pairs_borrow_the_raw_query() { + let (key, value) = Query::new("status=active_ongoing").next().unwrap(); + assert!(matches!(key, Cow::Borrowed(_))); + assert!(matches!(value, Cow::Borrowed(_))); + } + + #[test] + fn percent_escapes_decoded_in_key_and_value() { + assert_eq!(pairs("a%20b=c%2Fd%20e"), [("a b".to_string(), "c/d e".to_string())]); + } + + #[test] + fn lowercase_hex_escape_decoded() { + assert_eq!(pairs("g=%2f%7e"), [("g".to_string(), "/~".to_string())]); + } + + #[test] + fn plus_stays_literal_rather_than_becoming_a_space() { + assert_eq!(pairs("graffiti=a+b"), [("graffiti".to_string(), "a+b".to_string())]); + } + + #[test] + fn malformed_escape_kept_literally() { + assert_eq!(pairs("a=%zz&b=%4&c=100%&d=%"), [ + ("a".to_string(), "%zz".to_string()), + ("b".to_string(), "%4".to_string()), + ("c".to_string(), "100%".to_string()), + ("d".to_string(), "%".to_string()), + ]); + } + + #[test] + fn escape_decoding_to_invalid_utf8_does_not_panic() { + assert_eq!(pairs("a=%ff%fe"), [("a".to_string(), "\u{fffd}\u{fffd}".to_string())]); + } + + #[test] + fn empty_query_yields_nothing() { + assert!(pairs("").is_empty()); + } + + #[test] + fn empty_segments_skipped() { + assert_eq!(pairs("&&a=1&&"), [("a".to_string(), "1".to_string())]); + } + + #[test] + fn key_without_equals_yields_empty_value() { + assert_eq!(pairs("skip_randao_verification&slot=7"), [ + ("skip_randao_verification".to_string(), String::new()), + ("slot".to_string(), "7".to_string()), + ]); + } + + #[test] + fn repeated_key_yields_every_occurrence() { + assert_eq!(pairs("id=1&id=2"), [ + ("id".to_string(), "1".to_string()), + ("id".to_string(), "2".to_string()), + ]); + } +} diff --git a/crates/httpcore/src/readiness.rs b/crates/httpcore/src/readiness.rs new file mode 100644 index 00000000..59720be2 --- /dev/null +++ b/crates/httpcore/src/readiness.rs @@ -0,0 +1,38 @@ +use std::{io::ErrorKind, time::Duration}; + +use mio::{Events, Poll, Registry}; + +/// One readiness loop for every HTTP machine sharing a thread: each tenant +/// registers its sockets through a `Registry` clone and reads the same event +/// batch, so an iteration waits once however many tenants there are. +pub struct Readiness { + poll: Poll, + events: Events, +} + +impl Readiness { + pub fn new(events_capacity: usize) -> Self { + Self { + poll: Poll::new().expect("mio Poll::new failed"), + events: Events::with_capacity(events_capacity), + } + } + + pub fn registry(&self) -> &Registry { + self.poll.registry() + } + + pub fn wait(&mut self, timeout: Duration) { + match self.poll.poll(&mut self.events, Some(timeout)) { + Ok(()) => {} + // A signal cut the wait short; the batch is empty and the next + // iteration waits again. + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(e) => panic!("mio poll failed: {e}"), + } + } + + pub fn events(&self) -> &Events { + &self.events + } +} diff --git a/crates/httpcore/src/server.rs b/crates/httpcore/src/server.rs new file mode 100644 index 00000000..4a49b390 --- /dev/null +++ b/crates/httpcore/src/server.rs @@ -0,0 +1,851 @@ +use std::io::{self, Write}; + +// Hard cap on the read buffer. Raw SSZ, uncompressed. 16 MiB matches observed +// production maximums (21 blobs × 128 KiB plus block fields). +const READ_BUF_MAX: usize = 16 << 20; +const READ_BUF_INIT: usize = 4096; +const WRITE_BUF_INIT: usize = 4096; + +pub struct ParsedRequest<'a> { + pub method: &'a str, + pub path: &'a str, + pub query: &'a str, + pub body: &'a [u8], + pub accept: Option<&'a str>, + pub content_type: Option<&'a str>, + pub eth_consensus_version: Option<&'a str>, + pub version: u8, + pub keep_alive: bool, +} + +/// `Incomplete` means "no verdict yet, feed me more bytes"; `Malformed` and +/// `TooLarge` mean the bytes can never become a request this connection +/// serves, so no amount of waiting helps. +enum ParseOutcome<'a> { + Complete { consumed: usize, request: ParsedRequest<'a> }, + Incomplete, + Malformed, + TooLarge, +} + +impl<'a> ParsedRequest<'a> { + fn parse(buf: &'a [u8]) -> ParseOutcome<'a> { + let mut headers = [httparse::EMPTY_HEADER; 64]; + let mut req = httparse::Request::new(&mut headers); + let headers_end = match req.parse(buf) { + Ok(httparse::Status::Complete(n)) => n, + Ok(httparse::Status::Partial) => return ParseOutcome::Incomplete, + Err(e) => { + tracing::warn!("unparseable request: {e}"); + return ParseOutcome::Malformed; + } + }; + let (Some(method), Some(raw_path), Some(version)) = (req.method, req.path, req.version) + else { + return ParseOutcome::Malformed; + }; + let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); + let keep_alive = version == 1 && + !headers.iter().any(|h| { + h.name.eq_ignore_ascii_case("connection") && h.value.eq_ignore_ascii_case(b"close") + }); + + let header = |name: &str| { + headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value) + }; + let content_length = match header("content-length") { + None => 0, + Some(value) => match trimmed_utf8(value).and_then(|v| v.parse().ok()) { + Some(length) => length, + None => { + tracing::warn!("unusable Content-Length: {:?}", String::from_utf8_lossy(value)); + return ParseOutcome::Malformed; + } + }, + }; + let Some(total) = headers_end.checked_add(content_length) else { + return ParseOutcome::Malformed; + }; + // The declared length settles this before a body byte arrives: filling + // the cap first would spend it and still leave nothing to answer with. + if total > READ_BUF_MAX { + return ParseOutcome::TooLarge; + } + if buf.len() < total { + return ParseOutcome::Incomplete; + } + + ParseOutcome::Complete { + consumed: total, + request: Self { + method, + path, + query, + body: &buf[headers_end..total], + accept: header("accept").and_then(trimmed_utf8), + content_type: header("content-type").and_then(trimmed_utf8), + eth_consensus_version: header("eth-consensus-version").and_then(trimmed_utf8), + version, + keep_alive, + }, + } + } +} + +fn trimmed_utf8(value: &[u8]) -> Option<&str> { + std::str::from_utf8(value).ok().map(str::trim) +} + +#[derive(Debug, PartialEq)] +#[must_use] +pub enum AfterResponse { + Close, + /// Half-close, then read and discard until the peer stops: the response + /// was framed while inbound bytes this connection will never read were + /// still arriving, and dropping the socket with them unread costs the peer + /// the very response it was just sent. + Linger, + ResponsePending, + AwaitRequest, +} + +enum Continuation { + KeepAlive, + Close, + Linger, +} + +pub struct ServerConnection { + read_buf: Vec, + read_pos: usize, + read_end: usize, + write_buf: Vec, + write_pos: usize, + continuation: Continuation, +} + +impl ServerConnection { + pub fn new() -> Self { + Self { + read_buf: vec![0u8; READ_BUF_INIT], + read_pos: 0, + read_end: 0, + write_buf: Vec::with_capacity(WRITE_BUF_INIT), + write_pos: 0, + continuation: Continuation::KeepAlive, + } + } + + pub fn read_space(&mut self) -> io::Result<&mut [u8]> { + // Compact the partial tail to the front: without this, a long-lived + // pipelined keep-alive connection whose buffer never fully drains + // creeps read_end toward the cap and spuriously rejects small requests. + if self.read_pos > 0 { + self.read_buf.copy_within(self.read_pos..self.read_end, 0); + self.read_end -= self.read_pos; + self.read_pos = 0; + } + if self.read_end == READ_BUF_MAX { + return Err(io::Error::new(io::ErrorKind::InvalidData, "request too large")); + } + if self.read_end == self.read_buf.len() { + self.read_buf.resize((self.read_buf.len() * 2).min(READ_BUF_MAX), 0); + } + Ok(&mut self.read_buf[self.read_end..]) + } + + pub fn commit_read(&mut self, n: usize) { + debug_assert!(self.read_end + n <= self.read_buf.len()); + self.read_end += n; + } + + /// Framing is lost — either never established, or declared past what the + /// read buffer holds so the body bytes are unreadable — and there is + /// nothing left to resynchronise on: answer, drop the whole buffer and + /// linger, since whatever the peer is still sending would otherwise cost + /// it the answer. + fn reject(&mut self, status: &str) -> bool { + self.continuation = Continuation::Linger; + frame_response_with_headers( + &mut self.write_buf, + status, + None, + &[("Connection", "close")], + b"", + ); + self.read_pos = 0; + self.read_end = 0; + true + } + + /// Scratch for a lingering connection's drain: the bytes are read only to + /// be dropped, so the buffer is reused as it stands and never grows. + pub fn discard_space(&mut self) -> &mut [u8] { + debug_assert!(matches!(self.continuation, Continuation::Linger)); + &mut self.read_buf + } + + pub fn dispatch, &mut Vec)>(&mut self, handler: &F) -> bool { + let (consumed, req) = + match ParsedRequest::parse(&self.read_buf[self.read_pos..self.read_end]) { + ParseOutcome::Complete { consumed, request } => (consumed, request), + ParseOutcome::Incomplete => return false, + ParseOutcome::Malformed => return self.reject("400 Bad Request"), + ParseOutcome::TooLarge => return self.reject("413 Payload Too Large"), + }; + if req.version != 1 { + tracing::warn!("rejecting HTTP/1.0 request"); + self.continuation = Continuation::Close; + frame_response(&mut self.write_buf, "505 HTTP Version Not Supported", None, b""); + } else { + self.continuation = + if req.keep_alive { Continuation::KeepAlive } else { Continuation::Close }; + handler(&req, &mut self.write_buf); + } + self.read_pos += consumed; + if self.read_pos == self.read_end { + self.read_pos = 0; + self.read_end = 0; + } + true + } + + pub fn pending_write(&self) -> &[u8] { + &self.write_buf[self.write_pos..] + } + + pub fn commit_write(&mut self, n: usize) { + debug_assert!(self.write_pos + n <= self.write_buf.len()); + self.write_pos += n; + } + + pub fn after_response, &mut Vec)>( + &mut self, + handler: &F, + ) -> AfterResponse { + debug_assert!(self.write_pos == self.write_buf.len()); + match self.continuation { + Continuation::Close => AfterResponse::Close, + Continuation::Linger => AfterResponse::Linger, + Continuation::KeepAlive => { + self.write_buf.clear(); + // A keep-alive connection lives for the idle timeout, refreshed + // by every request, so retaining the largest body it ever framed + // would pin that much per connection for as long as a client + // keeps polling. + self.write_buf.shrink_to(WRITE_BUF_INIT); + self.write_pos = 0; + // A request pipelined behind the one just answered is already in + // read_buf — the transport will never feed those bytes again, so + // it must be dispatched here or it never will be. + if self.dispatch(handler) { + AfterResponse::ResponsePending + } else { + AfterResponse::AwaitRequest + } + } + } + } +} + +impl Default for ServerConnection { + fn default() -> Self { + Self::new() + } +} + +pub fn frame_response(out: &mut Vec, status: &str, content_type: Option<&str>, body: &[u8]) { + frame_response_with_headers(out, status, content_type, &[], body); +} + +/// `headers` are emitted in the given order, after `Content-Type` and before +/// `Content-Length`. +pub fn frame_response_with_headers( + out: &mut Vec, + status: &str, + content_type: Option<&str>, + headers: &[(&str, &str)], + body: &[u8], +) { + write!(out, "HTTP/1.1 {status}\r\n").unwrap(); + if let Some(ct) = content_type { + write!(out, "Content-Type: {ct}\r\n").unwrap(); + } + for (name, value) in headers { + write!(out, "{name}: {value}\r\n").unwrap(); + } + write!(out, "Content-Length: {}\r\n\r\n", body.len()).unwrap(); + out.extend_from_slice(body); +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + + fn get_req(path: &str, version: &str) -> Vec { + format!("GET {path} {version}\r\nHost: localhost\r\n\r\n").into_bytes() + } + + /// One header more than `parse`'s fixed slot array holds. + fn overlong_header_req() -> Vec { + let mut req = b"GET /metrics HTTP/1.1\r\n".to_vec(); + for i in 0..65 { + req.extend_from_slice(format!("X-Pad-{i}: v\r\n").as_bytes()); + } + req.extend_from_slice(b"\r\n"); + req + } + + fn feed(conn: &mut ServerConnection, bytes: &[u8]) { + let space = conn.read_space().unwrap(); + space[..bytes.len()].copy_from_slice(bytes); + conn.commit_read(bytes.len()); + } + + fn feed_all(conn: &mut ServerConnection, mut bytes: &[u8]) { + while !bytes.is_empty() { + let space = conn.read_space().unwrap(); + let n = space.len().min(bytes.len()); + space[..n].copy_from_slice(&bytes[..n]); + conn.commit_read(n); + bytes = &bytes[n..]; + } + } + + fn fill_with_junk_until_reject(conn: &mut ServerConnection) -> io::Error { + loop { + match conn.read_space() { + Ok(space) => { + let n = space.len(); + space.fill(b'j'); + conn.commit_read(n); + } + Err(e) => return e, + } + assert!(!conn.dispatch(&|_, _: &mut Vec| { + panic!("incomplete request must not dispatch") + })); + } + } + + fn drain(conn: &mut ServerConnection) -> Vec { + let out = conn.pending_write().to_vec(); + conn.commit_write(out.len()); + out + } + + fn echo_path(req: &ParsedRequest<'_>, out: &mut Vec) { + frame_response(out, "200 OK", None, req.path.as_bytes()); + } + + fn parsed(buf: &[u8]) -> (usize, ParsedRequest<'_>) { + match ParsedRequest::parse(buf) { + ParseOutcome::Complete { consumed, request } => (consumed, request), + ParseOutcome::Incomplete => panic!("expected a complete request, got Incomplete"), + ParseOutcome::Malformed => panic!("expected a complete request, got Malformed"), + ParseOutcome::TooLarge => panic!("expected a complete request, got TooLarge"), + } + } + + fn oversized_post(declared: usize) -> Vec { + format!("POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: {declared}\r\n\r\n") + .into_bytes() + } + + fn reject_and_linger(request: &[u8], status: &str) -> ServerConnection { + let mut conn = ServerConnection::new(); + feed(&mut conn, request); + + assert!(conn.dispatch(&|_, _: &mut Vec| { + panic!("a rejected request must not reach the handler") + })); + assert_eq!( + conn.pending_write(), + format!("HTTP/1.1 {status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n") + .as_bytes() + ); + + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Linger); + conn + } + + #[test] + fn parse_http11_defaults_keep_alive() { + let req = get_req("/eth/v1/node/identity", "HTTP/1.1"); + let (_, r) = parsed(&req); + assert_eq!(r.path, "/eth/v1/node/identity"); + assert!(r.keep_alive); + } + + #[test] + fn parse_http11_connection_close() { + let req = b"GET /metrics HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + let (_, r) = parsed(req); + assert_eq!(r.path, "/metrics"); + assert!(!r.keep_alive); + } + + #[test] + fn parse_http10_defaults_close() { + let req = get_req("/", "HTTP/1.0"); + let (_, r) = parsed(&req); + assert!(!r.keep_alive); + } + + #[test] + fn parse_partial_is_incomplete() { + let outcome = ParsedRequest::parse(b"GET /eth/v1/node/identity HTTP/1.1\r\n"); + assert!(matches!(outcome, ParseOutcome::Incomplete)); + } + + #[test] + fn parse_query_string_split() { + let req = get_req("/eth/v1/beacon/states/head/validators?status=active", "HTTP/1.1"); + let (_, r) = parsed(&req); + assert_eq!(r.path, "/eth/v1/beacon/states/head/validators"); + assert_eq!(r.query, "status=active"); + } + + #[test] + fn parse_post_body_buffered() { + let body = b"{\"slot\":\"1\"}"; + let req = format!( + "POST /eth/v1/beacon/blocks HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let mut buf = req.into_bytes(); + assert!(matches!(ParsedRequest::parse(&buf), ParseOutcome::Incomplete), "body not arrived"); + buf.extend_from_slice(body); + let (consumed, r) = parsed(&buf); + assert_eq!(r.method, "POST"); + assert_eq!(r.body, body.as_ref()); + assert_eq!(consumed, buf.len()); + } + + #[test] + fn parse_returns_consumed_byte_count() { + let req1 = b"GET /metrics HTTP/1.1\r\nHost: localhost\r\n\r\n"; + let req2 = b"GET /eth/v1/node/identity HTTP/1.1\r\nHost: localhost\r\n\r\n"; + let mut buf = req1.to_vec(); + buf.extend_from_slice(req2); + let (consumed, r) = parsed(&buf); + assert_eq!(r.path, "/metrics"); + assert_eq!(consumed, req1.len()); + let (_, r2) = parsed(&buf[consumed..]); + assert_eq!(r2.path, "/eth/v1/node/identity"); + } + + #[test] + fn parse_negotiation_headers() { + let req = b"POST /eth/v2/beacon/blocks HTTP/1.1\r\nHost: x\r\nAccept: application/octet-stream;q=1.0,application/json;q=0.9\r\nContent-Type: application/octet-stream\r\nEth-Consensus-Version: fulu\r\n\r\n"; + let (_, r) = parsed(req); + assert_eq!(r.accept, Some("application/octet-stream;q=1.0,application/json;q=0.9")); + assert_eq!(r.content_type, Some("application/octet-stream")); + assert_eq!(r.eth_consensus_version, Some("fulu")); + } + + #[test] + fn parse_negotiation_headers_absent_are_none() { + let req = get_req("/metrics", "HTTP/1.1"); + let (_, r) = parsed(&req); + assert_eq!(r.accept, None); + assert_eq!(r.content_type, None); + assert_eq!(r.eth_consensus_version, None); + } + + #[test] + fn parse_negotiation_header_names_are_case_insensitive() { + let req = b"POST /p HTTP/1.1\r\nACCEPT: application/json\r\ncontent-type: application/json\r\neTh-CoNsEnSuS-vErSiOn: gloas\r\n\r\n"; + let (_, r) = parsed(req); + assert_eq!(r.accept, Some("application/json")); + assert_eq!(r.content_type, Some("application/json")); + assert_eq!(r.eth_consensus_version, Some("gloas")); + } + + #[test] + fn parse_unparseable_request_line_is_malformed() { + assert!(matches!( + ParsedRequest::parse(b"NOT A VALID REQUEST\r\n\r\n"), + ParseOutcome::Malformed + )); + } + + #[test] + fn parse_more_headers_than_fit_is_malformed() { + assert!(matches!(ParsedRequest::parse(&overlong_header_req()), ParseOutcome::Malformed)); + } + + #[test] + fn parse_invalid_content_length_is_malformed() { + let req = b"POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: abc\r\n\r\n"; + assert!(matches!(ParsedRequest::parse(req), ParseOutcome::Malformed)); + } + + #[test] + fn parse_content_length_beyond_usize_is_malformed() { + let req = b"POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 99999999999999999999\r\n\r\n"; + assert!(matches!(ParsedRequest::parse(req), ParseOutcome::Malformed)); + } + + #[test] + fn parse_content_length_past_the_read_cap_is_too_large() { + for declared in [READ_BUF_MAX, READ_BUF_MAX + 1] { + assert!(matches!( + ParsedRequest::parse(&oversized_post(declared)), + ParseOutcome::TooLarge + )); + } + } + + #[test] + fn parse_content_length_overflowing_the_header_end_is_malformed() { + let req = format!( + "POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n", + usize::MAX + ); + assert!(matches!(ParsedRequest::parse(req.as_bytes()), ParseOutcome::Malformed)); + } + + #[test] + fn dispatch_unparseable_request_line_writes_400_then_lingers() { + reject_and_linger(b"NOT A VALID REQUEST\r\n\r\n", "400 Bad Request"); + } + + #[test] + fn dispatch_more_headers_than_fit_writes_400_then_lingers() { + reject_and_linger(&overlong_header_req(), "400 Bad Request"); + } + + #[test] + fn dispatch_invalid_content_length_writes_400_then_lingers() { + reject_and_linger( + b"POST /foo HTTP/1.1\r\nHost: x\r\nContent-Length: abc\r\n\r\n", + "400 Bad Request", + ); + } + + #[test] + fn dispatch_content_length_beyond_usize_writes_400_then_lingers() { + reject_and_linger( + b"POST /foo HTTP/1.1\r\nHost: x\r\nContent-Length: 99999999999999999999\r\n\r\n", + "400 Bad Request", + ); + } + + /// A validator client posting its whole registry unchunked declares the + /// size up front, so the headers alone are enough to answer: the body + /// bytes that would not fit never have to arrive. + #[test] + fn dispatch_content_length_past_the_read_cap_writes_413_then_lingers() { + for declared in [READ_BUF_MAX, READ_BUF_MAX + 1, usize::MAX - 1024] { + reject_and_linger(&oversized_post(declared), "413 Payload Too Large"); + } + } + + /// The body the client is still sending is read for one reason only — to + /// keep the answer from being lost — so it must cost nothing to read. + #[test] + fn a_lingering_connection_discards_without_growing() { + let mut conn = reject_and_linger(&oversized_post(READ_BUF_MAX), "413 Payload Too Large"); + let scratch = conn.read_buf.len(); + + for round in 0..64 { + let space = conn.discard_space(); + assert_eq!(space.len(), scratch, "round {round} grew the scratch buffer"); + space.fill(b'b'); + } + assert!(conn.pending_write().is_empty(), "the answer stays sent, not re-framed"); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Linger, "nothing leaves Linger"); + } + + #[test] + fn dispatch_partial_request_writes_nothing() { + let mut conn = ServerConnection::new(); + feed(&mut conn, b"GET /eth/v1/node/identity HTTP/1.1\r\nHost: local"); + + assert!( + !conn.dispatch(&|_, _: &mut Vec| panic!("incomplete request must not dispatch")) + ); + assert!(conn.pending_write().is_empty(), "an unfinished request is not a bad one"); + + feed(&mut conn, b"host\r\n\r\n"); + assert!(conn.dispatch(&echo_path)); + assert_eq!( + conn.pending_write(), + b"HTTP/1.1 200 OK\r\nContent-Length: 21\r\n\r\n/eth/v1/node/identity" + ); + } + + #[test] + fn dispatch_partial_body_writes_nothing() { + let mut conn = ServerConnection::new(); + feed(&mut conn, b"POST /p HTTP/1.1\r\nHost: x\r\nContent-Length: 8\r\n\r\nhalf"); + + assert!(!conn.dispatch(&|_, _: &mut Vec| panic!("incomplete body must not dispatch"))); + assert!(conn.pending_write().is_empty()); + } + + #[test] + fn pipelined_garbage_after_valid_request_answers_first_then_rejects() { + let mut conn = ServerConnection::new(); + feed(&mut conn, b"GET /first HTTP/1.1\r\nHost: x\r\n\r\nNOT A VALID REQUEST\r\n\r\n"); + + assert!(conn.dispatch(&echo_path)); + assert_eq!(drain(&mut conn), b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\n/first"); + + assert_eq!(conn.after_response(&echo_path), AfterResponse::ResponsePending); + assert_eq!( + conn.pending_write(), + b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n" + ); + + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Linger); + } + + #[test] + fn frame_response_without_content_type_omits_header() { + let mut out = Vec::new(); + frame_response(&mut out, "404 Not Found", None, b""); + assert_eq!(out, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + } + + #[test] + fn frame_response_content_length_matches_body() { + let mut out = Vec::new(); + frame_response(&mut out, "200 OK", Some("application/json"), b"{\"data\":1}"); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 10\r\n\r\n{\"data\":1}" + ); + } + + #[test] + fn extra_headers_sit_between_content_type_and_content_length() { + let mut out = Vec::new(); + frame_response_with_headers( + &mut out, + "200 OK", + Some("application/octet-stream"), + &[("Eth-Consensus-Version", "fulu")], + b"\x01\x02", + ); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nEth-Consensus-Version: fulu\r\nContent-Length: 2\r\n\r\n\x01\x02" + ); + } + + #[test] + fn extra_headers_keep_their_given_order() { + let mut out = Vec::new(); + frame_response_with_headers( + &mut out, + "200 OK", + None, + &[("B-Header", "2"), ("A-Header", "1"), ("C-Header", "3")], + b"", + ); + assert_eq!( + out, + b"HTTP/1.1 200 OK\r\nB-Header: 2\r\nA-Header: 1\r\nC-Header: 3\r\nContent-Length: 0\r\n\r\n" + ); + } + + #[test] + fn no_extra_headers_frames_exactly_as_frame_response() { + let mut with_headers = Vec::new(); + frame_response_with_headers(&mut with_headers, "503 Service Unavailable", None, &[], b"x"); + let mut plain = Vec::new(); + frame_response(&mut plain, "503 Service Unavailable", None, b"x"); + assert_eq!(with_headers, plain); + } + + #[test] + fn dispatch_http10_writes_version_not_supported_then_closes() { + let mut conn = ServerConnection::new(); + feed(&mut conn, b"GET /metrics HTTP/1.0\r\nHost: localhost\r\n\r\n"); + + assert!(conn.dispatch(&|_, out: &mut Vec| { + out.extend_from_slice(b"should not appear"); + })); + assert_eq!( + conn.pending_write(), + b"HTTP/1.1 505 HTTP Version Not Supported\r\nContent-Length: 0\r\n\r\n" + ); + + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Close); + } + + #[test] + fn connection_close_request_closes_after_response() { + let mut conn = ServerConnection::new(); + feed(&mut conn, b"GET /metrics HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + + assert!(conn.dispatch(&echo_path)); + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Close); + } + + #[test] + fn request_fed_one_byte_at_a_time() { + let mut conn = ServerConnection::new(); + let req = get_req("/metrics", "HTTP/1.1"); + + for (i, byte) in req.iter().enumerate() { + feed(&mut conn, &[*byte]); + assert_eq!(conn.dispatch(&echo_path), i == req.len() - 1, "byte {i}"); + } + assert_eq!(conn.pending_write(), b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\n/metrics"); + + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::AwaitRequest); + } + + #[test] + fn pipelined_requests_split_across_feeds_respond_in_order() { + let mut conn = ServerConnection::new(); + + feed(&mut conn, b"GET /first HTTP/1.1\r\nHost: x\r\n\r\nGET /sec"); + assert!(conn.dispatch(&echo_path)); + assert_eq!(drain(&mut conn), b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\n/first"); + assert_eq!(conn.after_response(&echo_path), AfterResponse::AwaitRequest); + + feed(&mut conn, b"ond HTTP/1.1\r\nHost: x\r\n\r\n"); + assert!(conn.dispatch(&echo_path)); + assert_eq!(drain(&mut conn), b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\n/second"); + assert_eq!(conn.after_response(&echo_path), AfterResponse::AwaitRequest); + } + + #[test] + fn buffered_pipelined_request_dispatched_after_drain() { + let mut conn = ServerConnection::new(); + let calls = RefCell::new(Vec::new()); + let handler = |req: &ParsedRequest<'_>, out: &mut Vec| { + calls.borrow_mut().push(req.path.to_string()); + echo_path(req, out); + }; + + feed( + &mut conn, + b"GET /first HTTP/1.1\r\nHost: x\r\n\r\nGET /second HTTP/1.1\r\nHost: x\r\n\r\n", + ); + assert!(conn.dispatch(&handler)); + assert_eq!(*calls.borrow(), ["/first"]); + + let mut written = Vec::new(); + while !conn.pending_write().is_empty() { + let chunk_len = conn.pending_write().len().min(3); + written.extend_from_slice(&conn.pending_write()[..chunk_len]); + conn.commit_write(chunk_len); + assert_eq!(*calls.borrow(), ["/first"], "no dispatch mid-drain"); + } + assert_eq!(written, b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\n/first"); + + assert_eq!(conn.after_response(&handler), AfterResponse::ResponsePending); + assert_eq!(*calls.borrow(), ["/first", "/second"]); + assert_eq!(conn.pending_write(), b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\n/second"); + } + + /// A request that declares nothing and never ends has no verdict to be + /// answered with, so the cap is where the connection runs out. + #[test] + fn read_space_exhausted_rejects_request_too_large() { + let mut conn = ServerConnection::new(); + let err = fill_with_junk_until_reject(&mut conn); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(err.to_string(), "request too large"); + } + + #[test] + fn body_near_cap_dispatches() { + let mut conn = ServerConnection::new(); + let body_len = READ_BUF_MAX - 128; + let header = + format!("POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: {body_len}\r\n\r\n"); + feed_all(&mut conn, header.as_bytes()); + let chunk = vec![b'b'; 1 << 16]; + let mut remaining = body_len; + while remaining > 0 { + let n = remaining.min(chunk.len()); + feed_all(&mut conn, &chunk[..n]); + remaining -= n; + } + + let seen = RefCell::new(0usize); + assert!(conn.dispatch(&|req: &ParsedRequest<'_>, out: &mut Vec| { + *seen.borrow_mut() = req.body.len(); + assert!(req.body.iter().all(|&b| b == b'b')); + frame_response(out, "200 OK", None, b""); + })); + assert_eq!(*seen.borrow(), body_len); + } + + #[test] + fn pipelined_keep_alive_partial_tails_never_creep_into_cap() { + let mut conn = ServerConnection::new(); + let mut request = b"POST /r HTTP/1.1\r\nHost: x\r\nContent-Length: 65536\r\n\r\n".to_vec(); + request.extend_from_slice(&vec![b'p'; 65536]); + let split = 16; + + // Feed twice the cap in total; every dispatch leaves a partial + // successor in the buffer, so the pre-compaction offsets would reach + // READ_BUF_MAX about halfway through and reject with "request too + // large". + let rounds = 2 * READ_BUF_MAX / request.len(); + feed_all(&mut conn, &request[..split]); + for _ in 0..rounds { + feed_all(&mut conn, &request[split..]); + feed_all(&mut conn, &request[..split]); + assert!(conn.dispatch(&echo_path)); + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::AwaitRequest); + } + } + + #[test] + fn a_large_response_does_not_leave_the_connection_inflated() { + let mut conn = ServerConnection::new(); + let big = vec![b'x'; 4 << 20]; + feed(&mut conn, &get_req("/big", "HTTP/1.1")); + + assert!(conn.dispatch(&|_, out: &mut Vec| frame_response(out, "200 OK", None, &big))); + drain(&mut conn); + assert!(conn.write_buf.capacity() >= big.len(), "the body was framed whole"); + + assert_eq!(conn.after_response(&echo_path), AfterResponse::AwaitRequest); + assert!( + conn.write_buf.capacity() <= WRITE_BUF_INIT, + "{} bytes still held", + conn.write_buf.capacity() + ); + } + + #[test] + fn request_split_across_growth_boundary_not_corrupted() { + let mut conn = ServerConnection::new(); + let body: Vec = (0..6000u32).map(|i| (i % 251) as u8).collect(); + let mut request = + format!("POST /grow HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n", body.len()) + .into_bytes(); + let header_len = request.len(); + request.extend_from_slice(&body); + + feed_all(&mut conn, &request[..READ_BUF_INIT]); + assert!( + !conn.dispatch(&|_, _: &mut Vec| panic!("incomplete request must not dispatch")) + ); + feed_all(&mut conn, &request[READ_BUF_INIT..]); + + let seen = RefCell::new(Vec::new()); + assert!(conn.dispatch(&|req: &ParsedRequest<'_>, out: &mut Vec| { + seen.borrow_mut().extend_from_slice(req.body); + frame_response(out, "200 OK", None, b""); + })); + assert_eq!(*seen.borrow(), request[header_len..]); + } +} diff --git a/crates/httpcore/src/stream.rs b/crates/httpcore/src/stream.rs new file mode 100644 index 00000000..94e7c921 --- /dev/null +++ b/crates/httpcore/src/stream.rs @@ -0,0 +1,341 @@ +use std::{ + io::{self, Read, Write}, + net::{Shutdown, SocketAddr}, + path::{Path, PathBuf}, +}; + +use mio::{ + Interest, Registry, Token, + event::Source, + net::{TcpListener, TcpStream, UnixListener, UnixStream}, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Bind { + Tcp(SocketAddr), + Unix(PathBuf), +} + +impl Bind { + pub fn parse(text: &str) -> Self { + match text.parse() { + Ok(addr) => Self::Tcp(addr), + Err(_) => { + assert!( + !text.contains(':'), + "bind {text:?}: not a valid socket address (hostnames are not resolved), \ + and a unix socket path containing ':' is almost certainly a typo" + ); + Self::Unix(PathBuf::from(text)) + } + } + } +} + +pub enum Listener { + Tcp(TcpListener), + Unix(UnixListener), +} + +impl Listener { + pub fn bind(bind: &Bind) -> io::Result { + match bind { + Bind::Tcp(addr) => TcpListener::bind(*addr).map(Self::Tcp), + Bind::Unix(path) => UnixListener::bind(path).map(Self::Unix), + } + } + + pub fn accept(&self) -> io::Result { + match self { + Self::Tcp(listener) => { + let (stream, peer) = listener.accept()?; + tracing::info!("accepted connection from {peer}"); + Ok(Stream::Tcp(stream)) + } + Self::Unix(listener) => { + let (stream, _) = listener.accept()?; + tracing::info!("accepted connection on unix socket"); + Ok(Stream::Uds(stream)) + } + } + } + + /// The resolved bind: for TCP the actual listening address (a port-0 bind + /// reports the ephemeral port the OS assigned), for Unix the socket path. + pub fn local_addr(&self) -> Bind { + match self { + Self::Tcp(listener) => Bind::Tcp(listener.local_addr().expect("tcp local_addr")), + Self::Unix(listener) => Bind::Unix( + listener + .local_addr() + .ok() + .and_then(|addr| addr.as_pathname().map(Path::to_path_buf)) + .expect("unix listener bound to a path"), + ), + } + } +} + +impl Source for Listener { + fn register( + &mut self, + registry: &Registry, + token: Token, + interests: Interest, + ) -> io::Result<()> { + match self { + Self::Tcp(l) => l.register(registry, token, interests), + Self::Unix(l) => l.register(registry, token, interests), + } + } + + fn reregister( + &mut self, + registry: &Registry, + token: Token, + interests: Interest, + ) -> io::Result<()> { + match self { + Self::Tcp(l) => l.reregister(registry, token, interests), + Self::Unix(l) => l.reregister(registry, token, interests), + } + } + + fn deregister(&mut self, registry: &Registry) -> io::Result<()> { + match self { + Self::Tcp(l) => l.deregister(registry), + Self::Unix(l) => l.deregister(registry), + } + } +} + +pub enum Stream { + Tcp(TcpStream), + Uds(UnixStream), +} + +impl Stream { + pub fn connect_tcp(addr: SocketAddr) -> io::Result { + Ok(Self::Tcp(TcpStream::connect(addr)?)) + } + + pub fn connect_uds(path: &Path) -> io::Result { + Ok(Self::Uds(UnixStream::connect(path)?)) + } + + /// After the writable event that ends a non-blocking connect, distinguishes + /// success from failure: TCP has a peer address only once connected; a Unix + /// socket reports connect failure through SO_ERROR (mio's readiness flags + /// are not reliable for it). + pub fn connect_complete(&self) -> io::Result<()> { + match self { + Self::Tcp(s) => s.peer_addr().map(|_| ()), + Self::Uds(s) => match s.take_error()? { + Some(e) => Err(e), + None => Ok(()), + }, + } + } + + /// Ends this side's stream while leaving the peer's readable: everything + /// written so far is delivered, terminated by the peer's end-of-file. + pub fn shutdown_write(&self) -> io::Result<()> { + match self { + Self::Tcp(s) => s.shutdown(Shutdown::Write), + Self::Uds(s) => s.shutdown(Shutdown::Write), + } + } +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + Self::Tcp(s) => s.read(buf), + Self::Uds(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Self::Tcp(s) => s.write(buf), + Self::Uds(s) => s.write(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Self::Tcp(s) => s.flush(), + Self::Uds(s) => s.flush(), + } + } +} + +impl Source for Stream { + fn register( + &mut self, + registry: &Registry, + token: Token, + interests: Interest, + ) -> io::Result<()> { + match self { + Self::Tcp(s) => s.register(registry, token, interests), + Self::Uds(s) => s.register(registry, token, interests), + } + } + + fn reregister( + &mut self, + registry: &Registry, + token: Token, + interests: Interest, + ) -> io::Result<()> { + match self { + Self::Tcp(s) => s.reregister(registry, token, interests), + Self::Uds(s) => s.reregister(registry, token, interests), + } + } + + fn deregister(&mut self, registry: &Registry) -> io::Result<()> { + match self { + Self::Tcp(s) => s.deregister(registry), + Self::Uds(s) => s.deregister(registry), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::{ClientConnection, frame_request}; + + #[test] + fn parse_socket_addr_is_tcp() { + assert_eq!(Bind::parse("0.0.0.0:5051"), Bind::Tcp("0.0.0.0:5051".parse().unwrap())); + assert_eq!(Bind::parse("127.0.0.1:0"), Bind::Tcp("127.0.0.1:0".parse().unwrap())); + assert_eq!(Bind::parse("[::1]:5051"), Bind::Tcp("[::1]:5051".parse().unwrap())); + } + + #[test] + fn parse_non_addr_is_unix_path() { + assert_eq!(Bind::parse("/run/beacon.sock"), Bind::Unix("/run/beacon.sock".into())); + assert_eq!(Bind::parse("beacon.sock"), Bind::Unix("beacon.sock".into())); + } + + #[test] + #[should_panic(expected = "not a valid socket address")] + fn parse_rejects_hostname_with_port() { + Bind::parse("localhost:5051"); + } + + #[test] + #[should_panic(expected = "not a valid socket address")] + fn parse_rejects_typoed_socket_addr() { + Bind::parse("127.0.0.1:505x"); + } + + #[test] + fn tcp_listener_reports_ephemeral_port() { + let listener = Listener::bind(&Bind::parse("127.0.0.1:0")).unwrap(); + let Bind::Tcp(addr) = listener.local_addr() else { panic!("tcp bind") }; + assert_ne!(addr.port(), 0); + } + + #[test] + fn unix_listener_reports_bound_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("api.sock"); + let listener = Listener::bind(&Bind::Unix(path.clone())).unwrap(); + assert_eq!(listener.local_addr(), Bind::Unix(path)); + } + + #[test] + fn uds_pair_round_trip_through_client_connection() { + let (client_half, mut server_half) = UnixStream::pair().unwrap(); + let mut stream = Stream::Uds(client_half); + let mut conn = ClientConnection::with_capacity(4096, 4096); + + let body = br#"{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":7}"#; + frame_request(conn.begin_request(), "localhost", body, Some("Bearer t.t.t"), true); + while !conn.pending_write().is_empty() { + match stream.write(conn.pending_write()) { + Ok(n) => conn.commit_write(n), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => panic!("write: {e}"), + } + } + + let mut request = vec![0u8; 4096]; + let n = blocking_read(&mut server_half, &mut request); + let request = String::from_utf8(request[..n].to_vec()).unwrap(); + assert!(request.starts_with("POST / HTTP/1.1\r\n")); + assert!(request.contains("Authorization: Bearer t.t.t\r\n")); + assert!(request.ends_with(std::str::from_utf8(body).unwrap())); + + let response_body = br#"{"jsonrpc":"2.0","id":7,"result":false}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", + response_body.len(), + std::str::from_utf8(response_body).unwrap() + ); + blocking_write(&mut server_half, response.as_bytes()); + + loop { + if let Some(got) = conn.take_response() { + assert_eq!(got, response_body); + break; + } + match stream.read(conn.read_space()) { + Ok(n) => conn.commit_read(n).unwrap(), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => panic!("read: {e}"), + } + } + } + + /// Unix sockets carry the half-close the linger path needs: the peer reads + /// what was written before it and then sees end-of-file, so the answer + /// survives a shutdown taken while the peer is still sending. + #[test] + fn uds_shutdown_write_delivers_the_answer_then_eof() { + let (mut client, server_half) = UnixStream::pair().unwrap(); + let mut server = Stream::Uds(server_half); + + server.write_all(b"HTTP/1.1 413 Payload Too Large\r\n\r\n").unwrap(); + server.shutdown_write().unwrap(); + + let mut answer = vec![0u8; 64]; + let n = blocking_read(&mut client, &mut answer); + assert_eq!(&answer[..n], b"HTTP/1.1 413 Payload Too Large\r\n\r\n"); + assert_eq!(blocking_read(&mut client, &mut answer), 0, "the half-close reads as eof"); + + assert_eq!( + server.read(&mut answer).unwrap_err().kind(), + io::ErrorKind::WouldBlock, + "the read half outlives the write half" + ); + } + + fn blocking_read(stream: &mut UnixStream, buf: &mut [u8]) -> usize { + use std::io::Read as _; + loop { + match stream.read(buf) { + Ok(n) => return n, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => panic!("read: {e}"), + } + } + } + + fn blocking_write(stream: &mut UnixStream, mut bytes: &[u8]) { + use std::io::Write as _; + while !bytes.is_empty() { + match stream.write(bytes) { + Ok(n) => bytes = &bytes[n..], + Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(e) => panic!("write: {e}"), + } + } + } +} diff --git a/crates/httpcore/src/token_range.rs b/crates/httpcore/src/token_range.rs new file mode 100644 index 00000000..f225159f --- /dev/null +++ b/crates/httpcore/src/token_range.rs @@ -0,0 +1,137 @@ +use mio::Token; + +/// One tenant's share of a shared [`Readiness`](crate::Readiness) token space: +/// a token two tenants could both allocate would deliver one's socket +/// readiness into the other's dispatch, so each takes a disjoint range. +#[derive(Clone, Copy)] +pub struct TokenRange { + base: usize, + span: usize, +} + +impl TokenRange { + pub const fn new(base: usize, span: usize) -> Self { + Self { base, span } + } + + /// The token space of a loop with a sole tenant, bar `Token(usize::MAX)` + /// which no span based at zero reaches. + pub const fn whole() -> Self { + Self::new(0, usize::MAX) + } + + /// One of `count` equal shares, for a loop with that many tenants. + /// Distinct indices cannot alias, at the price of the tokens above the + /// last share: integer division leaves those owned by nobody. + pub const fn share(index: usize, count: usize) -> Self { + assert!(index < count, "share index outside the tenant count"); + let span = usize::MAX / count; + Self::new(index * span, span) + } + + pub const fn span(&self) -> usize { + self.span + } + + #[cfg(test)] + const fn overlaps(self, other: Self) -> bool { + let (lower, upper) = if self.base <= other.base { (self, other) } else { (other, self) }; + upper.base - lower.base < lower.span + } + + pub fn at(&self, offset: usize) -> Token { + assert!(offset < self.span, "offset {offset} outside a span of {}", self.span); + Token(self.base + offset) + } + + pub fn offset_of(&self, token: Token) -> Option { + token.0.checked_sub(self.base).filter(|offset| *offset < self.span) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The partition a tile with two tenants hands out: neither half may claim + /// a token the other allocates. + const HALF: usize = 1 << (usize::BITS - 1); + + #[test] + fn offsets_map_to_tokens_above_the_base() { + let range = TokenRange::new(HALF, HALF); + assert_eq!(range.at(0), Token(HALF)); + assert_eq!(range.at(7), Token(HALF + 7)); + assert_eq!(range.offset_of(Token(HALF + 7)), Some(7)); + } + + #[test] + fn a_token_outside_the_range_has_no_offset() { + let low = TokenRange::new(0, HALF); + let high = TokenRange::new(HALF, HALF); + + assert_eq!(low.offset_of(Token(HALF)), None, "the high half is not the low half's"); + assert_eq!(high.offset_of(Token(0)), None, "the low half is not the high half's"); + assert_eq!(high.offset_of(Token(HALF - 1)), None); + assert_eq!(low.offset_of(Token(HALF - 1)), Some(HALF - 1)); + } + + #[test] + fn every_token_belongs_to_exactly_one_half() { + let low = TokenRange::new(0, HALF); + let high = TokenRange::new(HALF, HALF); + for token in [Token(0), Token(1), Token(HALF - 1), Token(HALF), Token(usize::MAX)] { + assert!( + low.offset_of(token).is_some() != high.offset_of(token).is_some(), + "{token:?} must belong to one half only" + ); + } + } + + /// The property the partition rests on, over more tenants than the two a + /// tile splits the loop between today. + #[test] + fn no_two_shares_of_a_count_overlap() { + for count in 1..=8 { + let shares = (0..count).map(|i| TokenRange::share(i, count)).collect::>(); + for (i, share) in shares.iter().enumerate() { + assert!(share.span() > 0, "share {i} of {count} is empty"); + for other in &shares[i + 1..] { + assert!(!share.overlaps(*other), "share {i} of {count} overlaps a later one"); + } + } + } + } + + #[test] + fn a_share_index_at_or_past_the_count_is_refused() { + assert!(std::panic::catch_unwind(|| TokenRange::share(2, 2)).is_err()); + } + + #[test] + fn halves_do_not_overlap_but_anything_sharing_a_base_does() { + let low = TokenRange::new(0, HALF); + let high = TokenRange::new(HALF, HALF); + + assert!(!low.overlaps(high)); + assert!(!high.overlaps(low)); + assert!(low.overlaps(low)); + assert!(low.overlaps(TokenRange::new(HALF - 1, 4)), "one shared token is an overlap"); + assert!(TokenRange::whole().overlaps(high)); + } + + #[test] + fn the_whole_space_claims_every_token_a_sole_tenant_can_allocate() { + let whole = TokenRange::whole(); + assert_eq!(whole.offset_of(Token(0)), Some(0)); + assert_eq!(whole.offset_of(Token(HALF)), Some(HALF)); + assert_eq!(whole.span(), usize::MAX); + assert_eq!(whole.offset_of(Token(usize::MAX)), None, "the last token is nobody's"); + } + + #[test] + #[should_panic(expected = "outside a span")] + fn allocating_past_the_span_is_a_bug() { + TokenRange::new(0, 4).at(4); + } +} diff --git a/crates/peer/src/lib.rs b/crates/peer/src/lib.rs index 035c4886..ae95f52b 100644 --- a/crates/peer/src/lib.rs +++ b/crates/peer/src/lib.rs @@ -26,5 +26,7 @@ silver_common::declare_counters! { GossipInvalidFrame, GossipInvalidControl, GossipInvalidMsg, + // Live in-flight dial count (set each tick). + PeersConnecting, } } diff --git a/crates/peer/src/manager.rs b/crates/peer/src/manager.rs index ffefdf31..31e4478c 100644 --- a/crates/peer/src/manager.rs +++ b/crates/peer/src/manager.rs @@ -258,6 +258,11 @@ impl PeerManager { custody_columns: u128, awaiting_local_replay: bool, ) -> Self { + // The counters file is mapped, never truncated: until the first tick + // these gauges still read the last run's peer counts. + crate::PeerCounters::PeersConnected.set(0); + crate::PeerCounters::PeersConnecting.set(0); + let now = Instant::now(); let mesh = our_topics.iter().map(|t| (*t, Vec::with_capacity(params.d_high as usize))).collect(); @@ -818,6 +823,7 @@ impl PeerManager { }); crate::PeerCounters::PeersConnected.set(self.peers.len() as u64); + crate::PeerCounters::PeersConnecting.set(self.dialing.len() as u64); } // ── Lifecycle ─────────────────────────────────────────────────────── @@ -2402,6 +2408,15 @@ mod tests { params: ScoreParams, awaiting_replay: bool, ) -> (PeerManager, Captured) { + // `PeerManager::new` and `tick` write the peer gauges; left at the + // default base that is the counter file a node running on this + // machine is serving from. + crate::PeerCounters::init_with_base( + std::env::temp_dir().join(format!("silver_peer_test_{}", std::process::id())), + "silver", + ) + .unwrap(); + ( PeerManager::new( peer_id(99), @@ -3682,9 +3697,13 @@ mod tests { mgr.redial_known_peers(t_drop, &mut |c| cap.0.push(c)); assert_eq!(dials(&cap), 0, "in-flight dial must not repeat"); + mgr.tick(t_drop, &mut |c| cap.0.push(c)); + assert_eq!(crate::PeerCounters::PeersConnecting.get(), 1, "the dial is in flight"); + // Dial times out via the stale-dial sweep -> 1h backoff. let after_sweep = t_drop + Duration::from_secs(16); mgr.tick(after_sweep, &mut |c| cap.0.push(c)); + assert_eq!(crate::PeerCounters::PeersConnecting.get(), 0, "the sweep dropped the dial"); cap.0.clear(); mgr.redial_known_peers(after_sweep, &mut |c| cap.0.push(c)); assert_eq!(dials(&cap), 0, "failed dial must back off"); diff --git a/docs/adr/0001-single-api-tile.md b/docs/adr/0001-single-api-tile.md new file mode 100644 index 00000000..1cf11b6d --- /dev/null +++ b/docs/adr/0001-single-api-tile.md @@ -0,0 +1,24 @@ +--- +status: accepted +--- + +# One tile hosts all API access + +Every tile is an OS thread pinned to a dedicated CPU core, and API traffic — +serving the beacon API, calling the engine API — is latency-tolerant work +dominated by network round-trips that cannot justify two pinned cores. All API +access is consolidated into a single `application_boundary` tile hosting two +transport-free crates: `beacon_api` (HTTP server) and `engine_api` (HTTP +client, renamed from `engine`). Hosted crates are hardcoded and composed by +plain function calls in the tile's `loop_body` — no plugin registry, no +hosting trait; adding a future hosted crate (e.g. a builder-API client or a +`health`/`log_tail` endpoint family) edits the tile, which is a deliberate, +cheap cost. The spine contract is unchanged: producers and consumers of +`engine_reqs`/`engine_resps`/`engine_health` see no difference. + +## Considered options + +Separate tiles per API surface (status quo — wastes a core per surface); a +`Hosted` trait + registry (speculative generality for exactly two crates); +per-crate transport ownership behind a port trait (generics leak into every +hosted crate's signatures). diff --git a/docs/adr/0002-hand-rolled-http.md b/docs/adr/0002-hand-rolled-http.md new file mode 100644 index 00000000..8f3e34fd --- /dev/null +++ b/docs/adr/0002-hand-rolled-http.md @@ -0,0 +1,24 @@ +--- +status: accepted +--- + +# Hand-rolled HTTP over mio; no async runtime, no TLS + +API I/O uses the same idiom as the rest of the node: non-blocking mio polled +from a busy-poll loop with `httparse` framing — one shared connection state +machine (crate `httpcore`) serving both roles, server and client — rather +than hyper/axum/reqwest and the tokio runtime they drag in. The node has no +async runtime and will not grow one for its coldest path; the machine already +existed twice (engine `http.rs` and the beacon_api prototype, plus a dead +474-line UDS copy) and, once shared, is small and testable at the byte level. + +Transports are a closed set we control, so they are an enum +(`Tcp | Uds`), not a trait. Unix sockets are supported on both sides: the +beacon_api server bind and the execution endpoint. TLS is a non-goal — all +API connections run over trusted local LAN or VPN. That transitively rules +out QUIC/HTTP-3 for API surfaces (considered and rejected 2026-08-18): QUIC +mandates TLS 1.3 (RFC 9001), and no validator client speaks HTTP/3, so +there would be no consumers even if the TLS stance changed. Auth is protocol-layer, +not transport-layer: `engine_api` owns the JWT Authorization header; UDS +relies on socket path permissions, and JWT-over-UDS can be added later as an +`engine_api` config flag without touching the transport layer. diff --git a/docs/adr/0003-dispatch-asymmetry.md b/docs/adr/0003-dispatch-asymmetry.md new file mode 100644 index 00000000..2286253b --- /dev/null +++ b/docs/adr/0003-dispatch-asymmetry.md @@ -0,0 +1,21 @@ +--- +status: accepted +--- + +# Dispatch: table for server routes, enum match for client methods + +Beacon-api request routing is a const data table — (method, parameterised +path pattern) → handler function, compiled to segments at init and linearly +scanned. Engine-api call dispatch stays a Rust `match` on closed enums +(`EngineReq` inbound, `ReqKind` on completion). The asymmetry is deliberate: +the server-side endpoint set is open and keyed by runtime wire strings, so a +table earns its keep; the client-side protocol set is closed and minted by +us, where a match is already a compile-time-exhaustive jump table, and a +runtime table would force type erasure over encoders with genuinely +different shapes (TCache handles, the hand-written newPayload envelope), +trading compile errors for runtime failures. + +Do not "fix" this inconsistency by making the client side table-driven: four +independently-produced designs each converged on exactly this split. The +governing principle, which also chose the transport enum in ADR-0002: +**closed set we control → enum; open set from the wire → table.** diff --git a/docs/adr/0004-sync-materialized-api.md b/docs/adr/0004-sync-materialized-api.md new file mode 100644 index 00000000..b5fb1c22 --- /dev/null +++ b/docs/adr/0004-sync-materialized-api.md @@ -0,0 +1,54 @@ +--- +status: accepted +--- + +# Synchronous handlers, materialized responses, no streaming + +Beacon-api handlers are synchronous compute — no I/O, no blocking — invoked +only once a request has fully arrived; responses are materialized in the +connection's write buffer and drained incrementally. All transport pumps are +non-blocking (`poll(Duration::ZERO)`), so serving and engine traffic +interleave per readiness event: a slow API consumer never stalls engine +calls, and vice versa. + +This holds for every request/response endpoint in the targeted surface: +verified against the beacon-APIs spec and five validator clients (Teku, +Lighthouse, Nimbus, Prysm, Vouch), nothing a validator client +requires streams or long-polls except the `/eth/v1/events` SSE stream. + +Amended 2026-08-18: SSE is in scope — validator clients will not be asked +to poll. It will be served in-process as an explicit subscription-mode +carve-out on the server connection machine (a long-lived, mostly idle +connection with small appended writes — deliberately outside this ADR's +bounded-buffer model), fed from a spine events queue produced by the +beacon-state tile. Implementation is scheduled after the initial endpoint +surface; the 404 served for `/eth/v1/events` today is interim behavior, +not the decision, and the previously-floated out-of-process serving option +is no longer the plan of record. Everything else stays materialized in a +bounded buffer by construction — the SSE carve-out is the single +sanctioned exception, and its design round amends this ADR with the +concrete mechanism. + +Amended 2026-08-20: the bounded-buffer claim is not universal. A validator +registry response is bounded only by the registry — ~1GiB at mainnet scale — +because the beacon-APIs schema requires an empty filter to return every +validator, and refusing that answer is a compatibility wall: validator +clients submit their whole key set in one request, and go-eth2-client +deactivates a beacon node that answers 5xx. Serving it costs ~0.9s of +synchronous render on the tile, so the interleaving guarantee above holds +for I/O but not for compute: a handler that materializes a large body does +delay engine traffic, however non-blocking the transport beneath it. Both +follow from serving a request/response API on the thread that drives the +execution client, not from any one endpoint, and neither is bounded by the +connection write buffer, which releases its capacity after each response. + +Amended 2026-08-21: `poll(Duration::ZERO)` is the busy-spin build's mechanism, not +the decision. Under `flux/park` a tile that reports no work parks unless it has +registered an `mio::Waker` with the flux work signal, and that signal fires on +spine publishes alone, so a parked tile would sleep through an inbound request. A +park build therefore needs the waker and a non-zero timeout, and both need one +readiness loop, since blocking in either of two would starve the other. The tile +serves the beacon-api server and the engine-api client from a single `Poll`, each +registering through its own share of the token space, so the interleaving above +follows from that loop rather than from the timeout being zero. The waker and the +timeout are what remain. diff --git a/docs/spine-message-flow.md b/docs/spine-message-flow.md index 6c47a5d1..596173bd 100644 --- a/docs/spine-message-flow.md +++ b/docs/spine-message-flow.md @@ -8,8 +8,9 @@ them (see [tcaches](#tcaches)). The tiles: **Network** (QUIC + discv5), **Control** (`PeerManager` + `SyncEngine` + `GossipHandler` — gossipsub decode/encode runs in-tile, not as its own tile), **BeaconState** (state transition + fork choice), **Storage** (disk + backfill), -**Engine** (EL / engine API), **DataColumns** (column validation, DA tracking, -EL blob fetch — split out of Storage). +**ApplicationBoundary** (hosting the `engine_api` client and the `beacon_api` server; the +server side consumes `beacon_events` and `sync_target` to report node status), +**DataColumns** (column validation, DA tracking, EL blob fetch — split out of Storage). ```mermaid flowchart LR @@ -17,7 +18,7 @@ flowchart LR CTL["Control
PeerManager + SyncEngine + GossipHandler"] BS["BeaconState
state · fork choice"] ST["Storage
disk · backfill"] - EN["Engine
EL / engine API"] + EN["ApplicationBoundary
engine_api client · beacon_api server"] DC["DataColumns
column validation · DA · EL blobs"] %% ---- inbound ---- @@ -45,6 +46,7 @@ flowchart LR BS -->|beacon_events : BeaconStateEvent| CTL BS -->|beacon_events : BeaconStateEvent| ST BS -->|beacon_events : BeaconStateEvent| DC + BS -->|beacon_events : BeaconStateEvent| EN DC -->|"data_columns : DataColumnsEvent (Available)"| BS DC -->|"data_columns : DataColumnsEvent (Persist)"| ST ST -->|replay_blocks : ReplayBlock| BS @@ -53,6 +55,7 @@ flowchart LR CTL -->|sync_target : SyncUpdate| BS CTL -->|sync_target : SyncUpdate| ST CTL -->|sync_target : SyncUpdate| DC + CTL -->|sync_target : SyncUpdate| EN CTL -->|syncing_strategy : SyncingStrategy| ST CTL -->|syncing_strategy : SyncingStrategy| DC @@ -75,12 +78,13 @@ Solid arrows are spine queues (`queue : MessageType`), one per consumer since qu SPMC. The gossip handler's other traffic is in-tile, not on the spine: its `PeerEvent`s (gossipsub scoring/misbehaviour) go straight to the `PeerManager`, `PeerControl` is forwarded to the handler directly, and its fork digest is set from the `Status` Control -already consumes. Two queues are omitted from the diagram: `engine_health` (Engine +already consumes. Two queues are omitted from the diagram: `engine_health` (ApplicationBoundary produces it, no tile consumes it) and `peer_stats` (Network produces connection stats, Control produces score breakdowns; consumed out-of-process by surfer's Peers tab, which joins the spine as a broadcast reader the same way its Events pane does). The -DataColumns↔Engine edges carry only the `GetBlobs` variants (EL-mempool blob fetch); the -queues are broadcast, so DataColumns sees every `EngineResp` and ignores the rest. +DataColumns↔ApplicationBoundary edges carry only the `GetBlobs` variants (EL-mempool blob +fetch); the queues are broadcast, so DataColumns sees every `EngineResp` and ignores the +rest. ## Spine queues @@ -92,14 +96,14 @@ queues are broadcast, so DataColumns sees every `EngineResp` and ignores the res | `rpc_inbound` | `RpcInbound` | Network | Control, BeaconState, Storage, DataColumns | ref → `incoming_rpc` | | `peer_events` | `PeerEvent` | Network, BeaconState, Storage, DataColumns | Control | mostly inline; `SendGossip` ref → `outgoing_gossip`, `PublishDataColumn` ref → `incoming_rpc` | | `peer_control` | `PeerControl` | Control | Network, Storage | inline | -| `beacon_events` | `BeaconStateEvent` | BeaconState | Control, Storage, DataColumns | mostly inline; `PersistBlock`/`PersistEnvelope` refs → `ssz_gossip` / `incoming_rpc` (by source) | +| `beacon_events` | `BeaconStateEvent` | BeaconState | Control, Storage, DataColumns, ApplicationBoundary | mostly inline; `PersistBlock`/`PersistEnvelope` refs → `ssz_gossip` / `incoming_rpc` (by source) | | `data_columns` | `DataColumnsEvent` | DataColumns | BeaconState _(Available)_, Storage _(Persist)_ | `Available` inline; `Persist` ref → `ssz_gossip` / `incoming_rpc` / `el_data_columns` (by `ColumnSource`) | -| `sync_target` | `SyncUpdate` | Control | BeaconState, Storage, DataColumns | inline | +| `sync_target` | `SyncUpdate` | Control | BeaconState, Storage, DataColumns, ApplicationBoundary | inline | | `replay_blocks` | `ReplayBlock` | Storage | BeaconState | ref → `replay_blocks` tcache | | `syncing_strategy` | `SyncingStrategy` | Control | Storage, DataColumns | inline | -| `engine_reqs` | `EngineReq` | BeaconState, DataColumns _(GetBlobs)_ | Engine | refs → `ssz_gossip` / `incoming_rpc`; GetBlobs inline | -| `engine_resps` | `EngineResp` | Engine | BeaconState, DataColumns _(GetBlobs)_ | ref → `incoming_engine_resp` | -| `engine_health` | `EngineHealthEvent` | Engine | _none (currently unconsumed)_ | inline | +| `engine_reqs` | `EngineReq` | BeaconState, DataColumns _(GetBlobs)_ | ApplicationBoundary | refs → `ssz_gossip` / `incoming_rpc`; GetBlobs inline | +| `engine_resps` | `EngineResp` | ApplicationBoundary | BeaconState, DataColumns _(GetBlobs)_ | ref → `incoming_engine_resp` | +| `engine_health` | `EngineHealthEvent` | ApplicationBoundary | _none (currently unconsumed)_ | inline | | `peer_stats` | `PeerStats` | Network _(P2p)_, Control _(Scores, Topic)_ | _none in-process (surfer)_ | inline | ## TCaches @@ -109,12 +113,12 @@ Bulk-byte rings that the queue messages reference, so payloads cross tiles witho | TCache | Producer | Consumer(s) | Payload | |--------|----------|-------------|---------| | `incoming_gossip` | Network | Control _(gossip, random access)_ | raw gossipsub protobuf from the wire | -| `ssz_gossip` | Control _(gossip)_ | BeaconState, DataColumns (live + persist), Storage (persist), Engine | decompressed gossip SSZ | +| `ssz_gossip` | Control _(gossip)_ | BeaconState, DataColumns (live + persist), Storage (persist), ApplicationBoundary | decompressed gossip SSZ | | `outgoing_gossip` | Control _(gossip)_ | Network | gossip protobuf: mcache copies of incoming messages, local publishes, IDONTWANT/IWANT control frames | -| `incoming_rpc` | Network | BeaconState, DataColumns (live + persist), Storage (live + persist), Engine, Control (column republish) | RPC response bodies (BeaconBlock / DataColumnSidecar) | +| `incoming_rpc` | Network | BeaconState, DataColumns (live + persist), Storage (live + persist), ApplicationBoundary, Control (column republish) | RPC response bodies (BeaconBlock / DataColumnSidecar) | | `outgoing_rpc` _(multi-producer)_ | Control, Storage | Network | RPC request bodies (we ask) + served response bodies (we answer) | | `replay_blocks` | Storage | BeaconState | persisted block SSZ replayed at startup | -| `incoming_engine_resp` | Engine | BeaconState, DataColumns (GetBlobs) | EL responses (payloads, blobs, bodies) | +| `incoming_engine_resp` | ApplicationBoundary | BeaconState, DataColumns (GetBlobs) | EL responses (payloads, blobs, bodies) | | `el_data_columns` | DataColumns | Storage | column sidecars reconstructed from EL-mempool blobs | ---