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..5119ba51 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,46 @@ 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", + "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 +4524,7 @@ version = "0.0.1" dependencies = [ "hex", "serde", + "toml", ] [[package]] @@ -4559,6 +4593,7 @@ dependencies = [ "silver_chain_spec", "silver_common", "toml", + "tracing", ] [[package]] @@ -4631,7 +4666,7 @@ dependencies = [ ] [[package]] -name = "silver_engine" +name = "silver_engine_api" version = "0.0.1" dependencies = [ "base64 0.22.1", @@ -4645,7 +4680,10 @@ dependencies = [ "sha2", "silver_common", "silver_config", + "silver_engine_api", + "silver_httpcore", "simd-json", + "tempfile", "thiserror 1.0.69", "tracing", "tracing-subscriber", @@ -4671,6 +4709,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..d123bea8 --- /dev/null +++ b/crates/application_boundary/Cargo.toml @@ -0,0 +1,24 @@ +[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 + +[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..9e368b69 --- /dev/null +++ b/crates/application_boundary/src/lib.rs @@ -0,0 +1,104 @@ +use std::time::Duration; + +use flux::{spine::SpineAdapter, tile::Tile}; +use silver_beacon_api::{BeaconApi, SlotStatus}; +use silver_beacon_state_data::{BeaconStateReader, SpecConfig}; +use silver_common::{ + BeaconStateEvent, Enr, Identify, Keypair, SilverSpine, SyncUpdate, TProducer, TRandomAccess, +}; +use silver_config::EngineConfig; +use silver_engine_api::EngineApi; +use silver_httpcore::{Bind, Readiness, TokenRange}; + +/// 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 { + latest_block_slot, wall_slot, head_optimistic, .. + } = event + { + status.slots = + Some(SlotStatus { head_slot: latest_block_slot, wall_slot, head_optimistic }); + } + }); + adapter.consume(|update: SyncUpdate, _| { + status.syncing = !matches!(update, SyncUpdate::Following); + }); + + status.el = self.engine.sync_status(); + } +} diff --git a/crates/application_boundary/tests/tile.rs b/crates/application_boundary/tests/tile.rs new file mode 100644 index 00000000..63b513cc --- /dev/null +++ b/crates/application_boundary/tests/tile.rs @@ -0,0 +1,672 @@ +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::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, +}; +use silver_config::EngineConfig; +use silver_engine_api::test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}; +use silver_httpcore::Bind; +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 { + 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)); + } + }); +} + +fn status_event(head_slot: u64, wall_slot: u64, head_optimistic: bool) -> BeaconStateEvent { + BeaconStateEvent::Status { + ssz: [0u8; STATUS_V2_SIZE], + head_optimistic, + latest_block_slot: head_slot, + 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(1, 1, 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" + ); + + inj.produce(status_event(7, 9, true)); + 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(SlotStatus { head_slot: 7, wall_slot: 9, head_optimistic: true }) + ); + assert_eq!(status.slots.unwrap().sync_distance(), 2); + assert!(status.syncing); + + inj.produce(status_event(9, 9, false)); + inj.produce(SyncUpdate::Following); + tile.loop_body(&mut adapter); + let status = *tile.beacon.node_status_mut(); + assert_eq!( + status.slots, + Some(SlotStatus { head_slot: 9, wall_slot: 9, head_optimistic: false }), + "each status replaces the last, 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"); + } + + inj.produce(status_event(7, 9, false)); + 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(SlotStatus { head_slot: 7, wall_slot: 9, head_optimistic: false }) + ); + assert!(!status.syncing); + assert_eq!(status.el, ELSyncStatus::Synced); +} + +/// 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/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/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..97613d12 --- /dev/null +++ b/crates/beacon_api/src/ids.rs @@ -0,0 +1,33 @@ +//! The forms an identifier arrives in. A bare `Uint64` is a slot for the +//! `state_id`/`block_id` of `params/index.yaml`; 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()) + }) +} diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs new file mode 100644 index 00000000..7b7cbb39 --- /dev/null +++ b/crates/beacon_api/src/json.rs @@ -0,0 +1,467 @@ +//! 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, Checkpoint, Fork, Version}; + +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, +} + +/// 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(); + } + + 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(); + } + + 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(); + } +} + +/// 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::FAR_FUTURE_EPOCH; + + use super::*; + + 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"); + } + + #[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..06eea89a --- /dev/null +++ b/crates/beacon_api/src/lib.rs @@ -0,0 +1,14 @@ +mod config; +mod identity; +mod ids; +mod json; +mod node_status; +mod receipts; +mod response; +mod router; +mod routes; +mod server; +mod statics; + +pub use node_status::{NodeStatus, SlotStatus}; +pub use server::BeaconApi; diff --git a/crates/beacon_api/src/node_status.rs b/crates/beacon_api/src/node_status.rs new file mode 100644 index 00000000..5410e490 --- /dev/null +++ b/crates/beacon_api/src/node_status.rs @@ -0,0 +1,104 @@ +use silver_common::ELSyncStatus; + +use crate::json::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, +} + +/// 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) + } + + /// 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.head_slot), + sync_distance: self.sync_distance(), + is_syncing: self.is_syncing(), + is_optimistic: self.execution_optimistic(), + el_offline: self.el_offline(), + } + } + + /// `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) + } +} + +/// `head_slot` is the highest imported block's slot, so a `sync_distance` of +/// one is ordinary on a synced node — the current slot's block lands partway +/// into it, and an empty slot never produces one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SlotStatus { + pub head_slot: u64, + pub wall_slot: u64, + pub head_optimistic: bool, +} + +impl SlotStatus { + /// Saturating: a head ahead of the wall clock (a peer's block accepted + /// early in the slot) is zero distance, not an underflow. + pub fn sync_distance(&self) -> u64 { + self.wall_slot.saturating_sub(self.head_slot) + } +} 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..b74bfb86 --- /dev/null +++ b/crates/beacon_api/src/routes.rs @@ -0,0 +1,859 @@ +#[cfg(test)] +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +#[cfg(test)] +use silver_beacon_state_data::BeaconStateOwner; +use silver_beacon_state_data::{BeaconStateReader, SpecConfig, StateReadView}; +use silver_common::{Enr, Identify, Keypair}; +use silver_httpcore::Query; + +use crate::{ + NodeStatus, + ids::{parse_root, parse_uint64}, + json::{FinalityCheckpoints, GenesisData, Json, ReadFlags}, + 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, +}; + +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", not_implemented), + (Method::Get, "/eth/v1/beacon/genesis", genesis), + (Method::Get, "/eth/v1/beacon/headers/{block_id}", not_implemented), + ( + 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", not_implemented), + (Method::Post, "/eth/v1/beacon/states/{state_id}/validators", not_implemented), + (Method::Get, "/eth/v1/beacon/states/{state_id}/validators/{validator_id}", not_implemented), + (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", not_implemented), + (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}", not_implemented), + (Method::Post, "/eth/v1/validator/duties/sync/{epoch}", not_implemented), + (Method::Post, "/eth/v1/validator/liveness/{epoch}", not_implemented), + (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}", not_implemented), + (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. Which code that is belongs to the endpoint. + 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() +} + +/// The surface a request can name ahead of what silver serves: each of these +/// routes needs data the node does not yet keep (a block store, the validator +/// registry, duty shuffling, liveness tracking, in-process peer counts), so +/// the honest answer is the 501 that tells the client to look elsewhere, +/// rather than a partial answer assembled from the wrong data. +fn not_implemented(_req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.error(501, "endpoint not implemented by this beacon node"); +} + +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 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()) +} + +#[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, ELSyncStatus}; + use silver_httpcore::ParsedRequest; + + use super::*; + use crate::{SlotStatus, 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 { + NodeStatus { + slots: Some(SlotStatus { head_slot: 100, wall_slot: 100, head_optimistic: false }), + syncing: false, + el: ELSyncStatus::Synced, + } + } + + fn head_at(head_slot: u64, wall_slot: u64) -> NodeStatus { + NodeStatus { + slots: Some(SlotStatus { head_slot, wall_slot, head_optimistic: false }), + ..ready() + } + } + + 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"); + } + + /// Every stubbed route answers 501 whatever the node's state: routed, so + /// a client can tell "this node does not serve it" (501) from "no such + /// endpoint exists" (404). + #[test] + fn stubbed_routes_answer_501_not_404() { + let router = Router::new(ROUTES); + let ctx = preboot_ctx(); + for (method, path) in [ + ("GET", "/eth/v1/beacon/blocks/head/root"), + ("GET", "/eth/v1/beacon/headers/head"), + ("GET", "/eth/v1/beacon/states/head/validators"), + ("POST", "/eth/v1/beacon/states/head/validators"), + ("GET", "/eth/v1/beacon/states/head/validators/0"), + ("GET", "/eth/v1/node/peer_count"), + ("GET", "/eth/v1/validator/duties/proposer/0"), + ("POST", "/eth/v1/validator/duties/sync/0"), + ("POST", "/eth/v1/validator/liveness/0"), + ("GET", "/eth/v2/validator/duties/proposer/0"), + ] { + let mut out = Vec::new(); + let req = ParsedRequest { + method, + path, + query: "", + body: b"[]", + accept: None, + content_type: Some("application/json"), + eth_consensus_version: None, + version: 1, + keep_alive: true, + }; + router.dispatch(&req, &ctx, &mut out); + assert!(out.starts_with(b"HTTP/1.1 501 Not Implemented\r\n"), "{method} {path}"); + assert_eq!( + body(&out), + br#"{"code":501,"message":"endpoint not implemented by this beacon node"}"#, + "{method} {path}" + ); + } + } + + #[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_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 24782777..df4f58ef 100644 --- a/crates/beacon_state/data/src/types.rs +++ b/crates/beacon_state/data/src/types.rs @@ -58,6 +58,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 9e677f5e..542af356 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, @@ -437,11 +437,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) @@ -480,10 +479,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/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index 19277e66..16201205 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -11,7 +11,7 @@ use silver_common::{ ssz_view::{ ATTESTATION_DATA_SIZE, AttestationView, PROPOSER_SLASHING_SIZE, SIGNED_AGG_PROOF_MIN, SIGNED_BLS_CHANGE_SIZE, SIGNED_VOLUNTARY_EXIT_SIZE, SINGLE_ATT_SIZE, - SignedAggregateAndProofView, SingleAttestationView, + SignedAggregateAndProofView, SingleAttestationView, StatusView, }, }; @@ -314,6 +314,48 @@ 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)); +} + #[test] fn block_unknown_parent_rejected() { let mut tile = make_tile(); 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 a524c6a0..275e8656 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}; @@ -135,6 +136,7 @@ fn main() -> Result<(), Box> { None, ), ); + let identify = config.identify()?; let p2p_context = Context { gossip_producer: incoming_gossip_producer, gossip_consumer: outgoing_gossip_producer @@ -142,7 +144,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(); @@ -261,7 +263,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, @@ -280,7 +292,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, @@ -327,12 +343,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 { @@ -367,3 +395,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 c1f0a6f8..ea8698e5 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; @@ -34,6 +37,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| { @@ -125,6 +132,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, } @@ -160,6 +177,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, } } @@ -169,7 +189,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 { @@ -212,6 +244,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) } @@ -340,6 +387,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 } @@ -374,6 +433,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/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..08dfeabc --- /dev/null +++ b/docs/adr/0004-sync-materialized-api.md @@ -0,0 +1,62 @@ +--- +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. + +Amended 2026-08-24: the endpoints the 2026-08-20 amendment measured are not +served for now. The validator registry, duties, liveness, per-block reads and +peer counts answer 501: each needs data the node does not yet keep, or a +render that outruns the synchronous model above, and each is deferred to its +own PR rather than served from the wrong data. The ~1GiB/~0.9s registry +figures stand as the recorded cost a bounded-render design has to answer +before that endpoint returns. 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 | ---