From 578f8a9ed8badad70404f1ae53061c0e094ca1fc Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 26 May 2026 11:55:44 +0100 Subject: [PATCH 01/33] adds beacon api --- Cargo.lock | 15 + Cargo.toml | 2 + crates/beacon_api/Cargo.toml | 19 + crates/beacon_api/examples/srv.rs | 20 ++ crates/beacon_api/src/lib.rs | 2 + crates/beacon_api/src/tile.rs | 577 ++++++++++++++++++++++++++++++ crates/bin/Cargo.toml | 1 + crates/bin/src/main.rs | 6 +- 8 files changed, 641 insertions(+), 1 deletion(-) create mode 100644 crates/beacon_api/Cargo.toml create mode 100644 crates/beacon_api/examples/srv.rs create mode 100644 crates/beacon_api/src/lib.rs create mode 100644 crates/beacon_api/src/tile.rs diff --git a/Cargo.lock b/Cargo.lock index 150b6377..53cc5f77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4323,6 +4323,7 @@ dependencies = [ "mimalloc", "quinn-proto", "rand 0.8.6", + "silver_beacon_api", "silver_beacon_state", "silver_beacon_state_data", "silver_common", @@ -4337,6 +4338,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "silver_beacon_api" +version = "0.0.1" +dependencies = [ + "flux", + "hex", + "httparse", + "mio", + "serde", + "serde_json", + "silver_common", + "tracing", +] + [[package]] name = "silver_beacon_state" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 22977a35..3483b726 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/beacon_api", "crates/beacon_state/data", "crates/beacon_state/tile", "crates/bin", @@ -60,6 +61,7 @@ inherits = "dev" opt-level = 3 [workspace.dependencies] +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" } diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml new file mode 100644 index 00000000..d655e1f0 --- /dev/null +++ b/crates/beacon_api/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "silver_beacon_api" +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +flux.workspace = true +hex.workspace = true +mio.workspace = true +silver_common.workspace = true +serde.workspace = true +tracing.workspace = true +serde_json = "1.0.149" +httparse = "1.10.1" + +[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..5cdaaa74 --- /dev/null +++ b/crates/beacon_api/examples/srv.rs @@ -0,0 +1,20 @@ +use flux::{ + tile::{TileConfig, attach_tile}, + utils::ThreadPriority, +}; +use silver_beacon_api::BeaconApiTile; +use silver_common::{Enr, Identify, Keypair, SilverSpine}; + +fn main() { + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + let identify = Identify::default(); + let spine = SilverSpine::new(None); + spine.start(None, None, |scoped_spine| { + attach_tile( + BeaconApiTile::new(&keypair, local_enr, &identify), + scoped_spine, + TileConfig::new(1, ThreadPriority::OSDefault), + ); + }); +} diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs new file mode 100644 index 00000000..500cb6dc --- /dev/null +++ b/crates/beacon_api/src/lib.rs @@ -0,0 +1,2 @@ +mod tile; +pub use tile::BeaconApiTile; diff --git a/crates/beacon_api/src/tile.rs b/crates/beacon_api/src/tile.rs new file mode 100644 index 00000000..c6923e68 --- /dev/null +++ b/crates/beacon_api/src/tile.rs @@ -0,0 +1,577 @@ +use std::{ + collections::HashMap, + io::{self, Read, Write}, + time::Duration, +}; + +use flux::{spine::SpineAdapter, tile::Tile}; +use mio::{ + Events, Interest, Poll, Token, + net::{TcpListener, TcpStream}, +}; +use serde::{Deserialize, Serialize}; +use silver_common::{Enr, Eth2Addr, Identify, Keypair, SilverSpine}; + +const LISTENER: Token = Token(0); +const IDENTITY_PATH: &str = "/eth/v1/node/identity"; +const METRICS_PATH: &str = "/metrics"; +const NOT_FOUND: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; +const VERSION_NOT_SUPPORTED: &[u8] = + b"HTTP/1.1 505 HTTP Version Not Supported\r\nContent-Length: 0\r\n\r\n"; +const METRICS_EMPTY: &[u8] = + b"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4; charset=utf-8\r\nContent-Length: 0\r\n\r\n"; +// 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 WRITE_BUF_INIT: usize = 4096; + +#[allow(dead_code)] +struct ParsedRequest<'a> { + method: &'a str, + path: &'a str, + query: &'a str, + body: &'a [u8], + version: u8, + keep_alive: bool, +} + +#[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, +} + +struct HttpConnection { + stream: TcpStream, + read_buf: Box<[u8; READ_BUF_MAX]>, + read_pos: usize, + read_end: usize, + write_buf: Vec, + write_pos: usize, + keep_alive: bool, +} + +impl HttpConnection { + fn new(stream: TcpStream) -> Self { + Self { + stream, + read_buf: Box::new([0u8; READ_BUF_MAX]), + read_pos: 0, + read_end: 0, + write_buf: Vec::with_capacity(WRITE_BUF_INIT), + write_pos: 0, + keep_alive: true, + } + } + + fn reset(&mut self) { + self.write_buf.clear(); + self.write_pos = 0; + } +} + +pub struct BeaconApiTile { + poll: Poll, + events: Events, + listener: TcpListener, + current_token: Token, + connections: HashMap, + identity_response: Vec, +} + +impl BeaconApiTile { + pub fn new(keypair: &Keypair, local_enr: Enr, identify: &Identify) -> Self { + let poll = Poll::new().unwrap(); + let addr = "0.0.0.0:5051".parse().unwrap(); + let mut listener = TcpListener::bind(addr).unwrap(); + poll.registry().register(&mut listener, LISTENER, Interest::READABLE).unwrap(); + + let identity_response = build_identity_response(keypair, &local_enr, identify); + + Self { + poll, + events: Events::with_capacity(1024), + listener, + current_token: Token(LISTENER.0 + 1), + connections: HashMap::new(), + identity_response, + } + } +} + +impl Tile for BeaconApiTile { + fn loop_body(&mut self, _adapter: &mut SpineAdapter) { + self.poll.poll(&mut self.events, Some(Duration::from_millis(100))).unwrap(); + + for event in &self.events { + match event.token() { + LISTENER => { + let (mut stream, address) = match self.listener.accept() { + Ok(conn) => conn, + Err(e) => { + tracing::warn!("accept failed: {e}"); + continue; + } + }; + + tracing::info!("accepted connection from {address}"); + let token = next(&mut self.current_token); + self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); + self.connections.insert(token, HttpConnection::new(stream)); + } + token => { + if let Some(conn) = self.connections.get_mut(&token) { + match handle_event(self.poll.registry(), conn, event, &|req, out| match req + .path + { + IDENTITY_PATH => handle_identity(&self.identity_response, out), + METRICS_PATH => handle_metrics(out), + _ => handle_unknown(req.path, out), + }) { + Ok(true) => { + let _ = self.poll.registry().deregister(&mut conn.stream); + self.connections.remove(&token); + } + Ok(false) => {} + Err(e) => { + tracing::warn!("connection error: {e}"); + let _ = self.poll.registry().deregister(&mut conn.stream); + self.connections.remove(&token); + } + }; + } + } + } + } + } +} + +fn handle_identity(response: &[u8], out: &mut Vec) { + out.extend_from_slice(response); +} + +fn handle_metrics(out: &mut Vec) { + out.extend_from_slice(METRICS_EMPTY); +} + +fn handle_unknown(path: &str, out: &mut Vec) { + tracing::warn!("unknown path: {path}"); + out.extend_from_slice(NOT_FOUND); +} + +// TODO: write_buf materialises the full response in heap memory. For large +// payloads (beacon states >200 MiB, blocks, blobs) replace with scatter-gather +// streaming: hold a tcache snapshot reference and write headers + body via +// write_vectored without copying. The write loop already drains by position so +// the structure supports a multi-part write state without changes to the outer +// logic. +// +// TODO: path routing here is exact-match only. Most beacon API paths are +// parameterised (/eth/v1/beacon/states/{state_id}/...). Add prefix/pattern +// matching before implementing any parameterised routes. +fn handle_event, &mut Vec)>( + registry: &mio::Registry, + conn: &mut HttpConnection, + event: &mio::event::Event, + request_handler: &F, +) -> io::Result { + if event.is_readable() { + loop { + if conn.read_end == READ_BUF_MAX { + return Err(io::Error::new(io::ErrorKind::InvalidData, "request too large")); + } + match conn.stream.read(&mut conn.read_buf[conn.read_end..]) { + Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), + Ok(n) => conn.read_end += n, + Err(e) if would_block(&e) => break, + Err(e) if interrupted(&e) => continue, + Err(e) => return Err(e), + } + } + + dispatch(registry, conn, event.token(), request_handler)?; + return Ok(false); + } + + if event.is_writable() { + if conn.write_pos < conn.write_buf.len() { + loop { + match conn.stream.write(&conn.write_buf[conn.write_pos..]) { + Ok(0) => { + return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")) + } + Ok(n) => { + conn.write_pos += n; + if conn.write_pos == conn.write_buf.len() { + break; + } + } + Err(e) if would_block(&e) => return Ok(false), + Err(e) if interrupted(&e) => continue, + Err(e) => return Err(e), + } + } + if conn.keep_alive { + conn.reset(); + // Serve any pipelined request buffered while we were writing. + // Without this, edge-triggered epoll won't re-fire for data + // that's already in read_buf. + if !dispatch(registry, conn, event.token(), request_handler)? { + registry.reregister(&mut conn.stream, event.token(), Interest::READABLE)?; + } + } else { + return Ok(true); + } + } + return Ok(false); + } + + Ok(false) +} + +fn dispatch, &mut Vec)>( + registry: &mio::Registry, + conn: &mut HttpConnection, + token: Token, + handler: &F, +) -> io::Result { + let Some((consumed, req)) = try_parse_request(&conn.read_buf[conn.read_pos..conn.read_end]) + else { + return Ok(false); + }; + if req.version != 1 { + tracing::warn!("rejecting HTTP/1.0 request"); + conn.keep_alive = false; + conn.write_buf.extend_from_slice(VERSION_NOT_SUPPORTED); + } else { + conn.keep_alive = req.keep_alive; + handler(&req, &mut conn.write_buf); + } + conn.read_pos += consumed; + //TODO: check if we actually need to support pipeling. If not, we can simplify + // this. + if conn.read_pos == conn.read_end { + conn.read_pos = 0; + conn.read_end = 0; + } + registry.reregister(&mut conn.stream, token, Interest::WRITABLE)?; + Ok(true) +} + +fn try_parse_request(buf: &[u8]) -> Option<(usize, ParsedRequest<'_>)> { + 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, + _ => return None, + }; + let method = req.method?; + let raw_path = req.path?; + let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); + let version = req.version?; + 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 content_length: usize = + match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { + None => 0, + Some(h) => std::str::from_utf8(h.value).ok().and_then(|v| v.trim().parse().ok())?, + }; + let total = headers_end + content_length; + if buf.len() < total { + return None; + } + Some((total, ParsedRequest { + method, + path, + query, + body: &buf[headers_end..total], + version, + keep_alive, + })) +} + +fn build_identity_response(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(), + }, + }; + + let body = serde_json::to_string(&IdentityResponse { data: &identity }).unwrap(); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ) + .into_bytes() +} + +fn next(current: &mut Token) -> Token { + let tok = Token(current.0); + let n = current.0.wrapping_add(1); + // Skip Token(0) == LISTENER on wrap to avoid aliasing the accept socket. + current.0 = if n == LISTENER.0 { LISTENER.0 + 1 } else { n }; + tok +} + +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 silver_common::{Enr, Identify, Keypair}; + + use super::*; + + fn get_req(path: &str, version: &str) -> Vec { + format!("GET {path} {version}\r\nHost: localhost\r\n\r\n").into_bytes() + } + + #[test] + fn parse_http11_defaults_keep_alive() { + let req = get_req("/eth/v1/node/identity", "HTTP/1.1"); + let (_, r) = try_parse_request(&req).unwrap(); + 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) = try_parse_request(req).unwrap(); + assert_eq!(r.path, "/metrics"); + assert!(!r.keep_alive); + } + + #[test] + fn parse_http10_defaults_close() { + let req = get_req("/", "HTTP/1.0"); + let (_, r) = try_parse_request(&req).unwrap(); + assert!(!r.keep_alive); + } + + #[test] + fn parse_partial_returns_none() { + assert!(try_parse_request(b"GET /eth/v1/node/identity HTTP/1.1\r\n").is_none()); + } + + #[test] + fn parse_query_string_split() { + let req = get_req("/eth/v1/beacon/states/head/validators?status=active", "HTTP/1.1"); + let (_, r) = try_parse_request(&req).unwrap(); + 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(); + // incomplete — body not yet arrived + assert!(try_parse_request(&buf).is_none()); + buf.extend_from_slice(body); + let (consumed, r) = try_parse_request(&buf).unwrap(); + 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) = try_parse_request(&buf).unwrap(); + assert_eq!(r.path, "/metrics"); + assert_eq!(consumed, req1.len()); + let (_, r2) = try_parse_request(&buf[consumed..]).unwrap(); + assert_eq!(r2.path, "/eth/v1/node/identity"); + } + + #[test] + fn metrics_response_valid_prometheus_format() { + let mut out = Vec::new(); + handle_metrics(&mut out); + let s = std::str::from_utf8(&out).unwrap(); + assert!(s.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(s.contains("text/plain; version=0.0.4; charset=utf-8")); + let body_start = s.find("\r\n\r\n").unwrap() + 4; + assert_eq!(&s[body_start..], ""); + } + + #[test] + fn unknown_path_returns_404() { + let mut out = Vec::new(); + handle_unknown("/not/real", &mut out); + assert!(out.starts_with(b"HTTP/1.1 404")); + } + + #[test] + fn identity_response_content_length_matches_body() { + let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(kp.secret_key()).unwrap(); + let resp = build_identity_response(&kp, &enr, &Identify::default()); + let s = std::str::from_utf8(&resp).unwrap(); + let header_end = s.find("\r\n\r\n").unwrap(); + let body = &s[header_end + 4..]; + 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, body.len()); + } + + #[test] + fn identity_response_json_fields_present() { + let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); + let enr = Enr::builder().build(kp.secret_key()).unwrap(); + let resp = build_identity_response(&kp, &enr, &Identify::default()); + let s = std::str::from_utf8(&resp).unwrap(); + let body = &s[s.find("\r\n\r\n").unwrap() + 4..]; + let v: serde_json::Value = serde_json::from_str(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_response_p2p_address_format() { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + 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 resp = build_identity_response(&kp, &enr, &identify); + let s = std::str::from_utf8(&resp).unwrap(); + let body = &s[s.find("\r\n\r\n").unwrap() + 4..]; + let v: serde_json::Value = serde_json::from_str(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}"); + } + + #[test] + fn parse_invalid_content_length_returns_none() { + let req = b"POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: abc\r\n\r\n"; + assert!(try_parse_request(req).is_none()); + } + + #[test] + fn dispatch_http10_writes_version_not_supported() { + let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = std_listener.local_addr().unwrap(); + let _client = std::net::TcpStream::connect(addr).unwrap(); + let (server, _) = std_listener.accept().unwrap(); + server.set_nonblocking(true).unwrap(); + + let poll = Poll::new().unwrap(); + let token = Token(1); + let mut stream = mio::net::TcpStream::from_std(server); + poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); + + let mut conn = HttpConnection::new(stream); + let req = b"GET /metrics HTTP/1.0\r\nHost: localhost\r\n\r\n"; + conn.read_buf[..req.len()].copy_from_slice(req); + conn.read_end = req.len(); + + dispatch(poll.registry(), &mut conn, token, &|_, out| { + out.extend_from_slice(b"should not appear"); + }) + .unwrap(); + + assert!( + conn.write_buf.starts_with(b"HTTP/1.1 505"), + "expected 505, got: {:?}", + String::from_utf8_lossy(&conn.write_buf) + ); + } + + #[test] + fn token_wrap_skips_listener() { + let mut cur = Token(usize::MAX); + let assigned = next(&mut cur); + assert_ne!(assigned, LISTENER, "returned token must not alias LISTENER"); + assert_ne!(cur, LISTENER, "next token must not alias LISTENER after wrap"); + assert_eq!(cur.0, LISTENER.0 + 1); + } +} diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index 65e93075..85442782 100644 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true version.workspace = true [dependencies] +silver_beacon_api.workspace = true silver_beacon_state.workspace = true silver_beacon_state_data.workspace = true silver_common.workspace = true diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 98b412b3..dac5703f 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -6,6 +6,7 @@ use flux::{ }; use quinn_proto::{Endpoint, EndpointConfig}; use rand::RngCore; +use silver_beacon_api::BeaconApiTile; use silver_beacon_state::{BeaconStateTile, SlotTicker}; use silver_beacon_state_data::{BeaconState, BeaconStateOwner}; use silver_common::{Enr, ProtoIdentify, SilverSpine, TCache, TCacheProducer}; @@ -114,6 +115,7 @@ fn main() -> Result<(), Box> { None, ), ); + let identify = config.identify()?; let p2p_context = Context { gossip_producer: incoming_gossip_producer, gossip_consumer: outgoing_gossip_producer @@ -121,7 +123,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(); @@ -146,6 +148,7 @@ fn main() -> Result<(), Box> { discv5.add_enr(enr, now); } + let beacon_api_tile = BeaconApiTile::new(&keypair, local_enr, &identify); let network_tile = NetworkTile::new(discv5_addr, discv5, p2p_addr, p2p_endpoint, p2p_context)?; let gossip_tile = GossipHandler::new( incoming_gossip_consumer, @@ -215,6 +218,7 @@ fn main() -> Result<(), Box> { attach_tile(network_tile, scoped_spine, TileConfig::new(3, ThreadPriority::OSDefault)); attach_tile(beacon_state_tile, scoped_spine, TileConfig::new(4, ThreadPriority::OSDefault)); attach_tile(storage_tile, scoped_spine, TileConfig::new(5, ThreadPriority::OSDefault)); + attach_tile(beacon_api_tile, scoped_spine, TileConfig::new(6, ThreadPriority::OSDefault)); }); Ok(()) From d096b51473fb1c469ac9bc0dc9f44c7d694e9575 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 09:25:38 +0100 Subject: [PATCH 02/33] Documentation of planned Beacon API work --- CONTEXT.md | 38 ++++++++++++++++++++++++++ docs/adr/0001-single-api-tile.md | 26 ++++++++++++++++++ docs/adr/0002-hand-rolled-http.md | 21 ++++++++++++++ docs/adr/0003-dispatch-asymmetry.md | 21 ++++++++++++++ docs/adr/0004-sync-materialized-api.md | 23 ++++++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-single-api-tile.md create mode 100644 docs/adr/0002-hand-rolled-http.md create mode 100644 docs/adr/0003-dispatch-asymmetry.md create mode 100644 docs/adr/0004-sync-materialized-api.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..7da5e644 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,38 @@ +# Silver + +A from-scratch Ethereum beacon node, organised as tiles — independent +pinned-thread components communicating over a typed message spine. + +## Language + +**Tile**: +A component with its own OS thread pinned to a dedicated CPU core, +implementing `loop_body` and attached to the spine. +_Avoid_: service, actor, worker. + +**Spine**: +The process-wide typed message fabric connecting tiles. +_Avoid_: bus, broker. + +**Spine queue**: +A fixed-size lock-free ring on the spine carrying `Copy` messages, broadcast +to consumers. +_Avoid_: channel. + +**TCache**: +The shared-memory bulk store; spine messages carry handles into it instead of +payloads. + +**Hosted crate**: +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/docs/adr/0001-single-api-tile.md b/docs/adr/0001-single-api-tile.md new file mode 100644 index 00000000..425964a6 --- /dev/null +++ b/docs/adr/0001-single-api-tile.md @@ -0,0 +1,26 @@ +--- +status: proposed +--- + +# 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 `client_server` 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). Four independent designs were produced and +compared; see `.local/client-server-design.md` (untracked design notes) for +the full comparison. diff --git a/docs/adr/0002-hand-rolled-http.md b/docs/adr/0002-hand-rolled-http.md new file mode 100644 index 00000000..a3593756 --- /dev/null +++ b/docs/adr/0002-hand-rolled-http.md @@ -0,0 +1,21 @@ +--- +status: proposed +--- + +# 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. 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..39b87de5 --- /dev/null +++ b/docs/adr/0003-dispatch-asymmetry.md @@ -0,0 +1,21 @@ +--- +status: proposed +--- + +# 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..63e1f0c1 --- /dev/null +++ b/docs/adr/0004-sync-materialized-api.md @@ -0,0 +1,23 @@ +--- +status: proposed +--- + +# 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 the whole surface v1 targets: verified against the +beacon-APIs spec and five validator clients (see +`.local/beacon-api-vc-surface.md`, untracked), nothing a validator client +requires streams or long-polls except the optional `/eth/v1/events` SSE +stream, which every surveyed client can replace with polling. v1 answers it +with a clean 404 and tolerates client reconnect retries. If subscriptions +are ever wanted, they may be served out-of-process (e.g. a circular-buffer +export read by a separate serving process) rather than by adding streaming +here. Endpoints whose response cannot be materialized in a bounded buffer +are out of scope by construction; revisit this ADR before accepting one. From b977f93b371521afb46683f1545793083fa5a998 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 10:46:14 +0100 Subject: [PATCH 03/33] Extract HTTP server byte machine into silver_httpcore First step of the client_server tile consolidation (docs/adr/0001): the HTTP/1.1 connection state machine (parse, keep-alive, pipelining, response framing) moves out of beacon_api into a new transport-free crate with bytes-only interfaces, so it can be tested without sockets and shared with the client role next. beacon_api keeps its tile, poll, and endpoints unchanged; behavior is byte-identical. Framing tests move with the machine and gain deterministic chunking coverage (single-byte feeds, pipelined requests split across feeds, oversize rejection, dispatch-after-drain). Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 10 +- Cargo.toml | 2 + crates/beacon_api/Cargo.toml | 2 +- crates/beacon_api/src/tile.rs | 289 ++++--------------------- crates/httpcore/Cargo.toml | 13 ++ crates/httpcore/src/lib.rs | 3 + crates/httpcore/src/server.rs | 383 ++++++++++++++++++++++++++++++++++ 7 files changed, 446 insertions(+), 256 deletions(-) create mode 100644 crates/httpcore/Cargo.toml create mode 100644 crates/httpcore/src/lib.rs create mode 100644 crates/httpcore/src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 36ccc6e9..4ab273dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4449,11 +4449,11 @@ version = "0.0.1" dependencies = [ "flux", "hex", - "httparse", "mio", "serde", "serde_json", "silver_common", + "silver_httpcore", "tracing", ] @@ -4686,6 +4686,14 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "silver_httpcore" +version = "0.0.1" +dependencies = [ + "httparse", + "tracing", +] + [[package]] name = "silver_metrics" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index b0d71d02..43387908 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/discovery", "crates/e2e", "crates/gossip", + "crates/httpcore", "crates/engine", "crates/metrics", "crates/network", @@ -73,6 +74,7 @@ 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" } diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index d655e1f0..ad6cda2c 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -10,10 +10,10 @@ flux.workspace = true hex.workspace = true mio.workspace = true silver_common.workspace = true +silver_httpcore.workspace = true serde.workspace = true tracing.workspace = true serde_json = "1.0.149" -httparse = "1.10.1" [lints] workspace = true diff --git a/crates/beacon_api/src/tile.rs b/crates/beacon_api/src/tile.rs index c6923e68..19ad2ebe 100644 --- a/crates/beacon_api/src/tile.rs +++ b/crates/beacon_api/src/tile.rs @@ -11,29 +11,12 @@ use mio::{ }; use serde::{Deserialize, Serialize}; use silver_common::{Enr, Eth2Addr, Identify, Keypair, SilverSpine}; +use silver_httpcore::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; const LISTENER: Token = Token(0); const IDENTITY_PATH: &str = "/eth/v1/node/identity"; const METRICS_PATH: &str = "/metrics"; -const NOT_FOUND: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; -const VERSION_NOT_SUPPORTED: &[u8] = - b"HTTP/1.1 505 HTTP Version Not Supported\r\nContent-Length: 0\r\n\r\n"; -const METRICS_EMPTY: &[u8] = - b"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4; charset=utf-8\r\nContent-Length: 0\r\n\r\n"; -// 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 WRITE_BUF_INIT: usize = 4096; - -#[allow(dead_code)] -struct ParsedRequest<'a> { - method: &'a str, - path: &'a str, - query: &'a str, - body: &'a [u8], - version: u8, - keep_alive: bool, -} +const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; #[derive(Debug, Serialize)] struct IdentityResponse<'a> { @@ -57,33 +40,9 @@ struct Metadata { custody_group_count: String, } -struct HttpConnection { +struct Connection { stream: TcpStream, - read_buf: Box<[u8; READ_BUF_MAX]>, - read_pos: usize, - read_end: usize, - write_buf: Vec, - write_pos: usize, - keep_alive: bool, -} - -impl HttpConnection { - fn new(stream: TcpStream) -> Self { - Self { - stream, - read_buf: Box::new([0u8; READ_BUF_MAX]), - read_pos: 0, - read_end: 0, - write_buf: Vec::with_capacity(WRITE_BUF_INIT), - write_pos: 0, - keep_alive: true, - } - } - - fn reset(&mut self) { - self.write_buf.clear(); - self.write_pos = 0; - } + http: ServerConnection, } pub struct BeaconApiTile { @@ -91,7 +50,7 @@ pub struct BeaconApiTile { events: Events, listener: TcpListener, current_token: Token, - connections: HashMap, + connections: HashMap, identity_response: Vec, } @@ -133,9 +92,15 @@ impl Tile for BeaconApiTile { tracing::info!("accepted connection from {address}"); let token = next(&mut self.current_token); self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); - self.connections.insert(token, HttpConnection::new(stream)); + self.connections + .insert(token, Connection { stream, http: ServerConnection::new() }); } token => { + // TODO: path routing here is exact-match only. Most beacon + // API paths are parameterised + // (/eth/v1/beacon/states/{state_id}/...). Add + // prefix/pattern matching before implementing any + // parameterised routes. if let Some(conn) = self.connections.get_mut(&token) { match handle_event(self.poll.registry(), conn, event, &|req, out| match req .path @@ -167,58 +132,48 @@ fn handle_identity(response: &[u8], out: &mut Vec) { } fn handle_metrics(out: &mut Vec) { - out.extend_from_slice(METRICS_EMPTY); + frame_response(out, "200 OK", Some(METRICS_CONTENT_TYPE), b""); } fn handle_unknown(path: &str, out: &mut Vec) { tracing::warn!("unknown path: {path}"); - out.extend_from_slice(NOT_FOUND); + frame_response(out, "404 Not Found", None, b""); } -// TODO: write_buf materialises the full response in heap memory. For large -// payloads (beacon states >200 MiB, blocks, blobs) replace with scatter-gather -// streaming: hold a tcache snapshot reference and write headers + body via -// write_vectored without copying. The write loop already drains by position so -// the structure supports a multi-part write state without changes to the outer -// logic. -// -// TODO: path routing here is exact-match only. Most beacon API paths are -// parameterised (/eth/v1/beacon/states/{state_id}/...). Add prefix/pattern -// matching before implementing any parameterised routes. fn handle_event, &mut Vec)>( registry: &mio::Registry, - conn: &mut HttpConnection, + conn: &mut Connection, event: &mio::event::Event, request_handler: &F, ) -> io::Result { if event.is_readable() { loop { - if conn.read_end == READ_BUF_MAX { - return Err(io::Error::new(io::ErrorKind::InvalidData, "request too large")); - } - match conn.stream.read(&mut conn.read_buf[conn.read_end..]) { + let space = conn.http.read_space()?; + match conn.stream.read(space) { Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), - Ok(n) => conn.read_end += n, + Ok(n) => conn.http.commit_read(n), Err(e) if would_block(&e) => break, Err(e) if interrupted(&e) => continue, Err(e) => return Err(e), } } - dispatch(registry, conn, event.token(), request_handler)?; + if conn.http.dispatch(request_handler) { + registry.reregister(&mut conn.stream, event.token(), Interest::WRITABLE)?; + } return Ok(false); } if event.is_writable() { - if conn.write_pos < conn.write_buf.len() { + if !conn.http.pending_write().is_empty() { loop { - match conn.stream.write(&conn.write_buf[conn.write_pos..]) { + match conn.stream.write(conn.http.pending_write()) { Ok(0) => { return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")) } Ok(n) => { - conn.write_pos += n; - if conn.write_pos == conn.write_buf.len() { + conn.http.commit_write(n); + if conn.http.pending_write().is_empty() { break; } } @@ -227,16 +182,14 @@ fn handle_event, &mut Vec)>( Err(e) => return Err(e), } } - if conn.keep_alive { - conn.reset(); - // Serve any pipelined request buffered while we were writing. - // Without this, edge-triggered epoll won't re-fire for data - // that's already in read_buf. - if !dispatch(registry, conn, event.token(), request_handler)? { - registry.reregister(&mut conn.stream, event.token(), Interest::READABLE)?; + match conn.http.after_response(request_handler) { + AfterResponse::Close => return Ok(true), + AfterResponse::ResponsePending => { + registry.reregister(&mut conn.stream, event.token(), Interest::WRITABLE)? + } + AfterResponse::AwaitRequest => { + registry.reregister(&mut conn.stream, event.token(), Interest::READABLE)? } - } else { - return Ok(true); } } return Ok(false); @@ -245,69 +198,6 @@ fn handle_event, &mut Vec)>( Ok(false) } -fn dispatch, &mut Vec)>( - registry: &mio::Registry, - conn: &mut HttpConnection, - token: Token, - handler: &F, -) -> io::Result { - let Some((consumed, req)) = try_parse_request(&conn.read_buf[conn.read_pos..conn.read_end]) - else { - return Ok(false); - }; - if req.version != 1 { - tracing::warn!("rejecting HTTP/1.0 request"); - conn.keep_alive = false; - conn.write_buf.extend_from_slice(VERSION_NOT_SUPPORTED); - } else { - conn.keep_alive = req.keep_alive; - handler(&req, &mut conn.write_buf); - } - conn.read_pos += consumed; - //TODO: check if we actually need to support pipeling. If not, we can simplify - // this. - if conn.read_pos == conn.read_end { - conn.read_pos = 0; - conn.read_end = 0; - } - registry.reregister(&mut conn.stream, token, Interest::WRITABLE)?; - Ok(true) -} - -fn try_parse_request(buf: &[u8]) -> Option<(usize, ParsedRequest<'_>)> { - 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, - _ => return None, - }; - let method = req.method?; - let raw_path = req.path?; - let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); - let version = req.version?; - 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 content_length: usize = - match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { - None => 0, - Some(h) => std::str::from_utf8(h.value).ok().and_then(|v| v.trim().parse().ok())?, - }; - let total = headers_end + content_length; - if buf.len() < total { - return None; - } - Some((total, ParsedRequest { - method, - path, - query, - body: &buf[headers_end..total], - version, - keep_alive, - })) -} - fn build_identity_response(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); @@ -358,12 +248,9 @@ fn build_identity_response(keypair: &Keypair, local_enr: &Enr, identify: &Identi }; let body = serde_json::to_string(&IdentityResponse { data: &identity }).unwrap(); - format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", - body.len(), - body - ) - .into_bytes() + let mut response = Vec::new(); + frame_response(&mut response, "200 OK", Some("application/json"), body.as_bytes()); + response } fn next(current: &mut Token) -> Token { @@ -388,76 +275,6 @@ mod tests { use super::*; - fn get_req(path: &str, version: &str) -> Vec { - format!("GET {path} {version}\r\nHost: localhost\r\n\r\n").into_bytes() - } - - #[test] - fn parse_http11_defaults_keep_alive() { - let req = get_req("/eth/v1/node/identity", "HTTP/1.1"); - let (_, r) = try_parse_request(&req).unwrap(); - 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) = try_parse_request(req).unwrap(); - assert_eq!(r.path, "/metrics"); - assert!(!r.keep_alive); - } - - #[test] - fn parse_http10_defaults_close() { - let req = get_req("/", "HTTP/1.0"); - let (_, r) = try_parse_request(&req).unwrap(); - assert!(!r.keep_alive); - } - - #[test] - fn parse_partial_returns_none() { - assert!(try_parse_request(b"GET /eth/v1/node/identity HTTP/1.1\r\n").is_none()); - } - - #[test] - fn parse_query_string_split() { - let req = get_req("/eth/v1/beacon/states/head/validators?status=active", "HTTP/1.1"); - let (_, r) = try_parse_request(&req).unwrap(); - 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(); - // incomplete — body not yet arrived - assert!(try_parse_request(&buf).is_none()); - buf.extend_from_slice(body); - let (consumed, r) = try_parse_request(&buf).unwrap(); - 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) = try_parse_request(&buf).unwrap(); - assert_eq!(r.path, "/metrics"); - assert_eq!(consumed, req1.len()); - let (_, r2) = try_parse_request(&buf[consumed..]).unwrap(); - assert_eq!(r2.path, "/eth/v1/node/identity"); - } - #[test] fn metrics_response_valid_prometheus_format() { let mut out = Vec::new(); @@ -530,42 +347,6 @@ mod tests { assert!(addr.starts_with("/ip4/1.2.3.4/tcp/9000/p2p/"), "bad format: {addr}"); } - #[test] - fn parse_invalid_content_length_returns_none() { - let req = b"POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: abc\r\n\r\n"; - assert!(try_parse_request(req).is_none()); - } - - #[test] - fn dispatch_http10_writes_version_not_supported() { - let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = std_listener.local_addr().unwrap(); - let _client = std::net::TcpStream::connect(addr).unwrap(); - let (server, _) = std_listener.accept().unwrap(); - server.set_nonblocking(true).unwrap(); - - let poll = Poll::new().unwrap(); - let token = Token(1); - let mut stream = mio::net::TcpStream::from_std(server); - poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); - - let mut conn = HttpConnection::new(stream); - let req = b"GET /metrics HTTP/1.0\r\nHost: localhost\r\n\r\n"; - conn.read_buf[..req.len()].copy_from_slice(req); - conn.read_end = req.len(); - - dispatch(poll.registry(), &mut conn, token, &|_, out| { - out.extend_from_slice(b"should not appear"); - }) - .unwrap(); - - assert!( - conn.write_buf.starts_with(b"HTTP/1.1 505"), - "expected 505, got: {:?}", - String::from_utf8_lossy(&conn.write_buf) - ); - } - #[test] fn token_wrap_skips_listener() { let mut cur = Token(usize::MAX); diff --git a/crates/httpcore/Cargo.toml b/crates/httpcore/Cargo.toml new file mode 100644 index 00000000..6a8e247b --- /dev/null +++ b/crates/httpcore/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "silver_httpcore" +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +httparse.workspace = true +tracing.workspace = true + +[lints] +workspace = true diff --git a/crates/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs new file mode 100644 index 00000000..c15ea426 --- /dev/null +++ b/crates/httpcore/src/lib.rs @@ -0,0 +1,3 @@ +mod server; + +pub use server::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; diff --git a/crates/httpcore/src/server.rs b/crates/httpcore/src/server.rs new file mode 100644 index 00000000..4371a46c --- /dev/null +++ b/crates/httpcore/src/server.rs @@ -0,0 +1,383 @@ +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 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 version: u8, + pub keep_alive: bool, +} + +impl<'a> ParsedRequest<'a> { + fn parse(buf: &'a [u8]) -> Option<(usize, Self)> { + 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, + _ => return None, + }; + let method = req.method?; + let raw_path = req.path?; + let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); + let version = req.version?; + 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 content_length: usize = + match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { + None => 0, + Some(h) => std::str::from_utf8(h.value).ok().and_then(|v| v.trim().parse().ok())?, + }; + let total = headers_end + content_length; + if buf.len() < total { + return None; + } + Some((total, Self { + method, + path, + query, + body: &buf[headers_end..total], + version, + keep_alive, + })) + } +} + +#[derive(Debug, PartialEq)] +#[must_use] +pub enum AfterResponse { + Close, + ResponsePending, + AwaitRequest, +} + +pub struct ServerConnection { + read_buf: Box<[u8; READ_BUF_MAX]>, + read_pos: usize, + read_end: usize, + write_buf: Vec, + write_pos: usize, + keep_alive: bool, +} + +impl ServerConnection { + pub fn new() -> Self { + Self { + read_buf: Box::new([0u8; READ_BUF_MAX]), + read_pos: 0, + read_end: 0, + write_buf: Vec::with_capacity(WRITE_BUF_INIT), + write_pos: 0, + keep_alive: true, + } + } + + pub fn read_space(&mut self) -> io::Result<&mut [u8]> { + if self.read_end == READ_BUF_MAX { + return Err(io::Error::new(io::ErrorKind::InvalidData, "request too large")); + } + Ok(&mut self.read_buf[self.read_end..]) + } + + pub fn commit_read(&mut self, n: usize) { + debug_assert!(self.read_end + n <= READ_BUF_MAX); + self.read_end += n; + } + + pub fn dispatch, &mut Vec)>(&mut self, handler: &F) -> bool { + let Some((consumed, req)) = + ParsedRequest::parse(&self.read_buf[self.read_pos..self.read_end]) + else { + return false; + }; + if req.version != 1 { + tracing::warn!("rejecting HTTP/1.0 request"); + self.keep_alive = false; + frame_response(&mut self.write_buf, "505 HTTP Version Not Supported", None, b""); + } else { + self.keep_alive = req.keep_alive; + 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()); + if !self.keep_alive { + return AfterResponse::Close; + } + self.write_buf.clear(); + 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]) { + match content_type { + Some(ct) => write!( + out, + "HTTP/1.1 {status}\r\nContent-Type: {ct}\r\nContent-Length: {}\r\n\r\n", + body.len() + ), + None => write!(out, "HTTP/1.1 {status}\r\nContent-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() + } + + 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 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()); + } + + #[test] + fn parse_http11_defaults_keep_alive() { + let req = get_req("/eth/v1/node/identity", "HTTP/1.1"); + let (_, r) = ParsedRequest::parse(&req).unwrap(); + 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) = ParsedRequest::parse(req).unwrap(); + assert_eq!(r.path, "/metrics"); + assert!(!r.keep_alive); + } + + #[test] + fn parse_http10_defaults_close() { + let req = get_req("/", "HTTP/1.0"); + let (_, r) = ParsedRequest::parse(&req).unwrap(); + assert!(!r.keep_alive); + } + + #[test] + fn parse_partial_returns_none() { + assert!(ParsedRequest::parse(b"GET /eth/v1/node/identity HTTP/1.1\r\n").is_none()); + } + + #[test] + fn parse_query_string_split() { + let req = get_req("/eth/v1/beacon/states/head/validators?status=active", "HTTP/1.1"); + let (_, r) = ParsedRequest::parse(&req).unwrap(); + 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(); + // incomplete — body not yet arrived + assert!(ParsedRequest::parse(&buf).is_none()); + buf.extend_from_slice(body); + let (consumed, r) = ParsedRequest::parse(&buf).unwrap(); + 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) = ParsedRequest::parse(&buf).unwrap(); + assert_eq!(r.path, "/metrics"); + assert_eq!(consumed, req1.len()); + let (_, r2) = ParsedRequest::parse(&buf[consumed..]).unwrap(); + assert_eq!(r2.path, "/eth/v1/node/identity"); + } + + #[test] + fn parse_invalid_content_length_returns_none() { + let req = b"POST /foo HTTP/1.1\r\nHost: localhost\r\nContent-Length: abc\r\n\r\n"; + assert!(ParsedRequest::parse(req).is_none()); + } + + #[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 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"); + } + + #[test] + fn read_space_exhausted_rejects_request_too_large() { + let mut conn = ServerConnection::new(); + let space = conn.read_space().unwrap(); + let header = b"POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: 33554432\r\n\r\n"; + space[..header.len()].copy_from_slice(header); + let n = space.len(); + conn.commit_read(n); + + assert!( + !conn.dispatch(&|_, _: &mut Vec| panic!("incomplete request must not dispatch")) + ); + let err = conn.read_space().unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(err.to_string(), "request too large"); + } +} From 42d86da0cb41e965cf5ca804eca5c7f3a831be37 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 11:33:59 +0100 Subject: [PATCH 04/33] Move HTTP client machine into silver_httpcore; delete dead IPC transport Second step of the client_server consolidation (docs/adr/0001, 0002): the engine's connection byte machine (request framing, Content-Length response parsing, partial-I/O resumption) moves to silver_httpcore as the client-role sibling of the server machine, and the transport becomes the closed-set Stream enum (Tcp | Uds). The newline-framed ipc.rs (dead code) is deleted; Unix-socket support is now the same HTTP pool over Stream::Uds, proven by a real UDS round-trip test asserting the JWT bearer header on the wire. Engine keeps all protocol: pool policy, JSON-RPC, JWT, correlation, ReqKind dispatch. The newPayload transcode path is untouched (verified: one body copy before and after). Request framing is pinned by golden-byte tests captured from the previous implementation. New: EngineConfig::max_connections (default 32) bounds the previously unbounded pool; spine intake gates on pool capacity via consume_one, so excess requests wait on the queue. Healthcheck issuance gates on capacity too. A connect that cannot start (resolve/connect/register error) now fails the rpc through the normal error path instead of stranding it forever -- previously masked by unbounded pool growth, fatal under a cap. Behavior notes: an empty Content-Length value is now rejected instead of read as zero; the Connecting-state error checks for UDS follow the TCP shape (the old distrusting variant was unreachable dead code). Known limitation (follow-up tracked in Linear): no per-request deadline, so an EL that accepts requests but never responds can gate intake while the engine_reqs ring (1024 slots) overwrites oldest entries. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 3 + crates/config/src/engine_config.rs | 7 + crates/engine/Cargo.toml | 4 +- crates/engine/src/client.rs | 68 ++--- crates/engine/src/error.rs | 2 - crates/engine/src/http.rs | 474 ----------------------------- crates/engine/src/ipc.rs | 267 ---------------- crates/engine/src/lib.rs | 5 +- crates/engine/src/pool.rs | 451 +++++++++++++++++++++++++++ crates/engine/src/test_el.rs | 186 +++++++++++ crates/engine/src/tile.rs | 152 ++++++++- crates/httpcore/Cargo.toml | 1 + crates/httpcore/src/client.rs | 343 +++++++++++++++++++++ crates/httpcore/src/lib.rs | 4 + crates/httpcore/src/stream.rs | 170 +++++++++++ 15 files changed, 1336 insertions(+), 801 deletions(-) delete mode 100644 crates/engine/src/http.rs delete mode 100644 crates/engine/src/ipc.rs create mode 100644 crates/engine/src/pool.rs create mode 100644 crates/engine/src/test_el.rs create mode 100644 crates/httpcore/src/client.rs create mode 100644 crates/httpcore/src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index 4ab273dc..6198339b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4660,7 +4660,9 @@ dependencies = [ "sha2", "silver_common", "silver_config", + "silver_httpcore", "simd-json", + "tempfile", "thiserror 1.0.69", "tracing", "tracing-subscriber", @@ -4691,6 +4693,7 @@ name = "silver_httpcore" version = "0.0.1" dependencies = [ "httparse", + "mio", "tracing", ] diff --git a/crates/config/src/engine_config.rs b/crates/config/src/engine_config.rs index 718cf84a..f48d9405 100644 --- a/crates/config/src/engine_config.rs +++ b/crates/config/src/engine_config.rs @@ -4,6 +4,10 @@ fn default_tcache_size() -> usize { 2 << 24 } +fn default_max_connections() -> usize { + 32 +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct EngineConfig { pub execution_endpoint: String, @@ -11,6 +15,8 @@ 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, /// 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 +30,7 @@ impl Default for EngineConfig { execution_endpoint: "http://localhost:8551".into(), jwt_secret: "0".into(), incoming_engine_resp_tcache_size: 2 << 24, + max_connections: 32, unsafe_no_el: false, } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c733a4e1..2f6d0b68 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -11,17 +11,19 @@ base64.workspace = true flux.workspace = true hex.workspace = true hmac.workspace = true -httparse.workspace = 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 [dev-dependencies] +httparse.workspace = true +tempfile = "3" tracing-subscriber.workspace = true [lints] diff --git a/crates/engine/src/client.rs b/crates/engine/src/client.rs index 737e2849..2cadb460 100644 --- a/crates/engine/src/client.rs +++ b/crates/engine/src/client.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{path::PathBuf, time::Duration}; use mio::{Events, Poll}; use rustc_hash::FxHashMap; @@ -6,8 +6,7 @@ use silver_common::merkle::B256; 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, @@ -45,13 +44,8 @@ pub enum ReqKind { GetPayloadBodiesByRange(u64), } -enum Transport { - Http(HttpPool), - Ipc(IpcPool), -} - pub struct EngineClient { - transport: Transport, + pool: HttpPool, poll: Poll, events: Events, id: u64, @@ -61,10 +55,18 @@ pub struct EngineClient { } impl EngineClient { - pub fn new(endpoint: impl Into, jwt: &str) -> Self { + pub fn new(endpoint: impl Into, jwt: &str, max_connections: usize) -> Self { + Self::with_endpoint(Endpoint::Http(endpoint.into()), jwt, max_connections) + } + + pub fn new_uds(path: impl Into, jwt: &str, max_connections: usize) -> Self { + Self::with_endpoint(Endpoint::Uds(path.into()), jwt, max_connections) + } + + fn with_endpoint(endpoint: Endpoint, jwt: &str, max_connections: usize) -> 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)), + pool: HttpPool::new(endpoint, jwt, max_connections), poll: Poll::new().expect("mio Poll::new failed"), events: Events::with_capacity(EVENTS_CAPACITY), id: 1, @@ -74,16 +76,8 @@ 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() } } @@ -114,10 +108,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, &mut c.poll); } pub fn send_fcu( @@ -168,10 +159,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, &mut c.poll); c.pending_requests.insert(rpc_id, ReqKind::NewPayload(block_root)); Ok(()) } @@ -256,26 +244,18 @@ pub fn get_client_version(c: &mut EngineClient) { } /// 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. +/// Raw bytes are the full HTTP 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); - } - }), - } + let EngineClient { pool, events, poll, pending_requests, .. } = c; + pool.poll_events(events, poll, &mut |rpc_id, res| { + if let Some(req_kind) = pending_requests.remove(&rpc_id) { + on_complete(req_kind, res); + } + }); } #[cfg(test)] diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index bc6fae82..237ab410 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/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/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 index b0e36fea..0d982f7f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,10 +1,11 @@ mod client; mod error; -mod http; -mod ipc; mod jwt; +mod pool; mod req_handlers; mod resp_handlers; +#[cfg(test)] +mod test_el; pub mod tile; mod types; diff --git a/crates/engine/src/pool.rs b/crates/engine/src/pool.rs new file mode 100644 index 00000000..2d06ba03 --- /dev/null +++ b/crates/engine/src/pool.rs @@ -0,0 +1,451 @@ +use std::{ + io::{self, Read, Write}, + net::{SocketAddr, ToSocketAddrs}, + path::PathBuf, +}; + +use mio::{Events, Interest, Poll, Token}; +use silver_httpcore::{ClientConnection, Stream, 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; + +#[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, +} + +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, + } + } + + fn is_free(&self) -> bool { + self.in_flight.is_none() && self.pending_id.is_none() + } + + fn enqueue(&mut self, rpc_id: u64, body: &[u8], poll: &mut Poll) { + 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); + + match self.conn { + Conn::Disconnected => self.connect(poll), + Conn::Connected(_) => self.update_interest(poll), + Conn::Connecting(_) => {} + } + } + + fn handle_events(&mut self, 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() != self.token { + continue; + } + match &self.conn { + Conn::Disconnected => {} + Conn::Connecting(stream) => { + if event.is_error() || event.is_read_closed() || event.is_write_closed() { + self.fail(poll, on_complete, "connect failed"); + break; + } + 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(poll); + } else { + self.fail(poll, on_complete, "connect failed"); + break; + } + } + } + Conn::Connected(_) => { + if event.is_error() { + self.fail(poll, on_complete, "connection error"); + break; + } + if event.is_writable() { + if let Err(e) = self.do_write() { + let msg = e.to_string(); + self.fail(poll, on_complete, &msg); + break; + } + self.update_interest(poll); + } + 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 break + // below covers that close path too. + if let Err(e) = self.do_read(on_complete) { + let msg = e.to_string(); + self.fail(poll, on_complete, &msg); + break; + } + } + if event.is_read_closed() { + // Remote closed with no (more) data — in_flight will + // never get a response. + self.fail(poll, on_complete, "connection closed"); + break; + } + } + } + } + } + + fn connect(&mut self, poll: &mut Poll) { + 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 poll.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, .. } = 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() { + 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, 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) = 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.machine.reset(); + let old = std::mem::replace(&mut self.conn, Conn::Disconnected); + if let Conn::Connecting(mut stream) | Conn::Connected(mut stream) = old { + let _ = poll.registry().deregister(&mut stream); + } + } + + fn update_interest(&mut self, poll: &mut Poll) { + 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 _ = poll.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, + max_connections: usize, +} + +impl HttpPool { + pub(crate) fn new(endpoint: Endpoint, jwt: JwtSecret, max_connections: usize) -> Self { + let connections = vec![PooledConnection::new(endpoint.clone(), jwt.clone(), Token(0))]; + Self { connections, endpoint, jwt, max_connections } + } + + /// `enqueue` never refuses work; every caller gates on this before + /// submitting. The first-run healthcheck trio issues three requests + /// against one gate check, so the pool can overshoot `max_connections` + /// by at most two connections, once. + 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], poll: &mut Poll) { + if let Some(conn) = self.connections.iter_mut().find(|c| c.is_free()) { + conn.enqueue(rpc_id, body, poll); + } else { + let mut new_conn = PooledConnection::new( + self.endpoint.clone(), + self.jwt.clone(), + Token(self.connections.len()), + ); + new_conn.enqueue(rpc_id, body, poll); + self.connections.push(new_conn); + } + } + + pub(crate) fn poll_events(&mut self, events: &Events, poll: &mut Poll, on_complete: &mut F) + where + F: FnMut(u64, Result<&mut [u8], EngineError>), + { + 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(poll, on_complete, "connect failed to start"); + } + conn.handle_events(events, poll, on_complete); + } + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use tempfile::TempDir; + + use crate::{ + EngineClient, + client::{ReqKind, poll, send_fcu}, + test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}, + types::ForkchoiceState, + }; + + 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 = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32); + let block_root = [7u8; 32]; + send_fcu(&mut client, 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", || { + poll(&mut client, |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 = EngineClient::new_uds(&missing_socket, jwt_path.to_str().unwrap(), 1); + let block_root = [3u8; 32]; + send_fcu(&mut client, block_root, fcu_state(3), None); + assert!(!client.has_capacity(), "request occupies the only connection"); + + let mut failed: Option<[u8; 32]> = None; + spin_until("connect failure surfaces as rpc error", || { + poll(&mut client, |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.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 = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32); + let block_root = [9u8; 32]; + send_fcu(&mut client, 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", || { + poll(&mut client, |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); + } +} diff --git a/crates/engine/src/test_el.rs b/crates/engine/src/test_el.rs new file mode 100644 index 00000000..f7bdfee1 --- /dev/null +++ b/crates/engine/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(crate) const FCU_VALID_RESULT: &str = r#"{"payloadStatus":{"status":"VALID","latestValidHash":null,"validationError":null},"payloadId":null}"#; + +pub(crate) 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(crate) struct ElRequest { + conn: usize, + pub(crate) id: u64, + pub(crate) method: String, + pub(crate) authorization: Option, + pub(crate) body: String, +} + +/// Deterministic single-threaded fake execution client: accepts connections +/// and buffers requests on `pump`, answers only when the test says so. +pub(crate) struct FakeEl { + listener: ElListener, + conns: Vec>, + read_bufs: Vec>, + pub(crate) requests: Vec, +} + +impl FakeEl { + pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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/tile.rs b/crates/engine/src/tile.rs index 4d4e6470..55cb423d 100644 --- a/crates/engine/src/tile.rs +++ b/crates/engine/src/tile.rs @@ -50,15 +50,23 @@ impl Tile for EngineTile { }); return; } - adapter.consume(|req: EngineReq, producers| { - handle_request( - self.client.as_mut().unwrap(), - &mut self.gossip_consumer, - &mut self.rpc_consumer, - &req, - producers, - ); - }); + // 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; + } + } self.spin(adapter); } } @@ -76,7 +84,11 @@ impl EngineTile { ); None } else { - Some(EngineClient::new(&config.execution_endpoint, &config.jwt_secret)) + Some(EngineClient::new( + &config.execution_endpoint, + &config.jwt_secret, + config.max_connections, + )) }; Self { client, @@ -109,7 +121,10 @@ impl EngineTile { // Only reached in EL mode; loop_body returns early otherwise. let client = client.as_mut().expect("spin without EL client"); - 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); } @@ -168,3 +183,118 @@ fn run_healthcheck( *healthcheck_deadline = Instant::now() + HEALTHCHECK_INTERVAL; *healthcheck_pending = true; } + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use flux::{spine::SpineAdapter, tile::Tile}; + use silver_common::{EngineFcuReq, EngineReq, EngineResp, SilverSpine, TCache, TCacheProducer}; + use silver_config::EngineConfig; + use tempfile::TempDir; + + use super::EngineTile; + use crate::test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}; + + struct Injector; + impl Tile for Injector { + fn loop_body(&mut self, _: &mut SpineAdapter) {} + } + + 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])) + } + + /// (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 gossip_p = TCache::producer("engine_cap_test_gossip", 1 << 12); + let rpc_p = TCache::producer("engine_cap_test_rpc", 1 << 12); + let resp_p = TCache::producer("engine_cap_test_resp", 1 << 12); + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + max_connections: 3, + ..EngineConfig::default() + }; + let mut tile = EngineTile::new( + config, + gossip_p.cache_ref().random_access("t", true).unwrap(), + rpc_p.cache_ref().random_access("t", true).unwrap(), + resp_p, + ); + 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 EngineTile, 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"); + } +} diff --git a/crates/httpcore/Cargo.toml b/crates/httpcore/Cargo.toml index 6a8e247b..6f13244e 100644 --- a/crates/httpcore/Cargo.toml +++ b/crates/httpcore/Cargo.toml @@ -7,6 +7,7 @@ version.workspace = true [dependencies] httparse.workspace = true +mio.workspace = true tracing.workspace = true [lints] diff --git a/crates/httpcore/src/client.rs b/crates/httpcore/src/client.rs new file mode 100644 index 00000000..a07a7a88 --- /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 silver_engine'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 index c15ea426..7198254f 100644 --- a/crates/httpcore/src/lib.rs +++ b/crates/httpcore/src/lib.rs @@ -1,3 +1,7 @@ +mod client; mod server; +mod stream; +pub use client::{ClientConnection, frame_request}; pub use server::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; +pub use stream::Stream; diff --git a/crates/httpcore/src/stream.rs b/crates/httpcore/src/stream.rs new file mode 100644 index 00000000..aa0e6766 --- /dev/null +++ b/crates/httpcore/src/stream.rs @@ -0,0 +1,170 @@ +use std::{ + io::{self, Read, Write}, + net::SocketAddr, + path::Path, +}; + +use mio::{ + Interest, Registry, Token, + event::Source, + net::{TcpStream, UnixStream}, +}; + +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(()), + }, + } + } +} + +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 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}"), + } + } + } + + 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}"), + } + } + } +} From e7cbd99b3f56173d2316d13cf59746f426e3901a Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 12:34:39 +0100 Subject: [PATCH 05/33] Table-based dispatch for beacon_api routes Third step of the client_server consolidation (docs/adr/0003): the inline exact-match path closure becomes a const route table -- (method, pattern, handler fn) compiled once at init into literal/param segments, linearly scanned, with zero-alloc borrowed params (inline capacity 4). The router owns 404 (byte-identical to before) and the new 405 for known-path/wrong- method -- previously the HTTP method was ignored entirely. Handlers own 400, and ApiCtx::read_state_or_503 pins the pre-bootstrap contract: a BeaconStateReader (now threaded from the beacon-state tile) answering None yields 503 with the beacon-api error JSON shape. Identity moves to body-bytes-plus-per-request framing; wire bytes are byte-identical, pinned by a golden test captured from the previous implementation. Duplicate patterns (modulo param names) and >4 params panic at init. Adding an endpoint is now one table row + one handler + one socket-free test through the table. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 1 + crates/beacon_api/Cargo.toml | 1 + crates/beacon_api/examples/srv.rs | 5 +- crates/beacon_api/src/identity.rs | 115 +++++++++++ crates/beacon_api/src/lib.rs | 5 + crates/beacon_api/src/response.rs | 65 ++++++ crates/beacon_api/src/router.rs | 328 ++++++++++++++++++++++++++++++ crates/beacon_api/src/routes.rs | 173 ++++++++++++++++ crates/beacon_api/src/tile.rs | 206 ++----------------- crates/bin/src/main.rs | 4 +- 10 files changed, 715 insertions(+), 188 deletions(-) create mode 100644 crates/beacon_api/src/identity.rs create mode 100644 crates/beacon_api/src/response.rs create mode 100644 crates/beacon_api/src/router.rs create mode 100644 crates/beacon_api/src/routes.rs diff --git a/Cargo.lock b/Cargo.lock index 6198339b..37d87515 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4452,6 +4452,7 @@ dependencies = [ "mio", "serde", "serde_json", + "silver_beacon_state_data", "silver_common", "silver_httpcore", "tracing", diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index ad6cda2c..51d08ff2 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -9,6 +9,7 @@ version.workspace = true flux.workspace = true hex.workspace = true mio.workspace = true +silver_beacon_state_data.workspace = true silver_common.workspace = true silver_httpcore.workspace = true serde.workspace = true diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index e72e00ef..4f6d8627 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -1,15 +1,18 @@ use flux::tile::{TileConfig, attach_tile}; use silver_beacon_api::BeaconApiTile; +use silver_beacon_state_data::BeaconStateOwner; use silver_common::{Enr, Identify, Keypair, SilverSpine}; fn main() { let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); let identify = Identify::default(); + // Never-published reader: state endpoints answer 503, as pre-bootstrap. + let state = BeaconStateOwner::empty_test(0).reader(); let spine = SilverSpine::new(None); spine.start(None, None, |scoped_spine| { attach_tile( - BeaconApiTile::new(&keypair, local_enr, &identify), + BeaconApiTile::new(&keypair, local_enr, &identify, state), scoped_spine, TileConfig::new(1, None), ); 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/lib.rs b/crates/beacon_api/src/lib.rs index 500cb6dc..e7bb308d 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -1,2 +1,7 @@ +mod identity; +mod response; +mod router; +mod routes; mod tile; + pub use tile::BeaconApiTile; diff --git a/crates/beacon_api/src/response.rs b/crates/beacon_api/src/response.rs new file mode 100644 index 00000000..732aa23c --- /dev/null +++ b/crates/beacon_api/src/response.rs @@ -0,0 +1,65 @@ +use silver_httpcore::frame_response; + +pub(crate) struct Response<'a> { + out: &'a mut Vec, +} + +impl<'a> Response<'a> { + pub(crate) fn new(out: &'a mut Vec) -> Self { + Self { out } + } + + pub(crate) fn json(&mut self, body: &[u8]) { + frame_response(self.out, "200 OK", Some("application/json"), body); + } + + pub(crate) fn empty(&mut self, content_type: &str) { + frame_response(self.out, "200 OK", Some(content_type), b""); + } + + /// Beacon-API error shape: `{"code":,"message":"..."}`. + pub(crate) fn error(&mut self, code: u16, message: &str) { + debug_assert!(!message.contains(['"', '\\']), "message goes into JSON unescaped"); + let status = match code { + 400 => "400 Bad Request", + 405 => "405 Method Not Allowed", + 503 => "503 Service Unavailable", + _ => unreachable!("unmapped error code {code}"), + }; + let body = format!("{{\"code\":{code},\"message\":\"{message}\"}}"); + frame_response(self.out, status, Some("application/json"), body.as_bytes()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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" + ); + } +} diff --git a/crates/beacon_api/src/router.rs b/crates/beacon_api/src/router.rs new file mode 100644 index 00000000..f0d258df --- /dev/null +++ b/crates/beacon_api/src/router.rs @@ -0,0 +1,328 @@ +use silver_httpcore::{ParsedRequest, frame_response}; + +use crate::{response::Response, routes::ApiCtx}; + +const MAX_PARAMS: usize = 4; + +#[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<'_>); + +// Fields become live with the first parameterised endpoints; 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) body: &'a [u8], +} + +pub(crate) struct Params<'a> { + entries: [(&'static str, &'a str); MAX_PARAMS], + len: usize, +} + +impl<'a> Params<'a> { + #[allow(dead_code)] + 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, + 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"", 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); + } + + #[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", + 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..d8da5ccf --- /dev/null +++ b/crates/beacon_api/src/routes.rs @@ -0,0 +1,173 @@ +#[cfg(test)] +use silver_beacon_state_data::BeaconStateOwner; +use silver_beacon_state_data::{BeaconStateReader, StateReadView}; +use silver_common::{Enr, Identify, Keypair}; + +use crate::{ + identity::build_identity_json, + response::Response, + router::{Handler, Method, Request}, +}; + +const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; + +pub(crate) const ROUTES: &[(Method, &str, Handler)] = + &[(Method::Get, "/eth/v1/node/identity", identity), (Method::Get, "/metrics", metrics)]; + +pub(crate) struct ApiCtx { + pub(crate) identity_json: Vec, + pub(crate) state: BeaconStateReader, +} + +impl ApiCtx { + pub(crate) fn new( + keypair: &Keypair, + local_enr: &Enr, + identify: &Identify, + state: BeaconStateReader, + ) -> Self { + Self { identity_json: build_identity_json(keypair, local_enr, identify), state } + } + + #[allow(dead_code)] + pub(crate) fn read_state_or_503( + &self, + resp: &mut Response<'_>, + read: impl Fn(StateReadView<'_>) -> R, + ) -> Option { + let result = self.state.read(&read); + if result.is_none() { + resp.error(503, "beacon node not initialized"); + } + result + } +} + +fn identity(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + resp.json(&ctx.identity_json); +} + +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 { + ApiCtx { identity_json: Vec::new(), state: BeaconStateOwner::empty_test(0).reader() } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use silver_beacon_state_data::BeaconState; + use silver_httpcore::ParsedRequest; + + use super::*; + use crate::router::Router; + + /// Wire bytes the pre-table implementation produced for these exact + /// inputs (captured before the table dispatch landed). + const GOLDEN_IDENTITY: &str = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 478\r\n\r\n{\"data\":{\"peer_id\":\"16Uiu2HAmEWQnHq2jLKJypwVnVoQeFCULuyop6atvq2eWjYSUjzNi\",\"enr\":\"enr:-HW4QFVim6voTojjE-JbeUF0GPFRcqmWxgqgJ8-tXE5hh9PFTQSCwUJPHY_61U3Wvzi6OGrvJfb6KNjNpw4Q18sNL_sBgmlkgnY0iXNlY3AyNTZrMaEDG4TFVnsSZECZXT7VqroFZdceGDRgSBn_nBf16dXdB48\",\"p2p_addresses\":[\"/ip4/1.2.3.4/tcp/9000/p2p/16Uiu2HAmEWQnHq2jLKJypwVnVoQeFCULuyop6atvq2eWjYSUjzNi\"],\"discovery_addresses\":[],\"metadata\":{\"seq_number\":\"1\",\"attnets\":\"0x0000000000000000\",\"syncnets\":\"0x00\",\"custody_group_count\":\"4\"}}}"; + + fn fixture_ctx() -> ApiCtx { + 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)); + ApiCtx::new(&kp, &enr, &identify, BeaconStateOwner::empty_test(0).reader()) + } + + fn get(router: &Router, ctx: &ApiCtx, path: &str) -> Vec { + let mut out = Vec::new(); + let req = ParsedRequest { + method: "GET", + path, + query: "", + body: b"", + 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, &fixture_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, &fixture_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 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"); + } + + fn genesis_root(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(root) = ctx.read_state_or_503(resp, |view| view.imm.genesis_validators_root) + else { + return; + }; + resp.json(hex::encode(root).as_bytes()); + } + + #[test] + fn state_route_503_before_bootstrap() { + let router = Router::new(&[(Method::Get, "/test/genesis_root", genesis_root)]); + let resp = get(&router, &preboot_ctx(), "/test/genesis_root"); + assert!(resp.starts_with(b"HTTP/1.1 503 Service Unavailable\r\n")); + assert_eq!(body(&resp), br#"{"code":503,"message":"beacon node not initialized"}"#); + } + + #[test] + fn state_route_reads_published_state() { + let mut owner = BeaconStateOwner::new(BeaconState::empty_test(0)); + let anchor = owner.roll_fresh(); + owner.publish_state_id(anchor); + let ctx = ApiCtx { identity_json: Vec::new(), state: owner.reader() }; + + let router = Router::new(&[(Method::Get, "/test/genesis_root", genesis_root)]); + let resp = get(&router, &ctx, "/test/genesis_root"); + assert!(resp.starts_with(b"HTTP/1.1 200 OK\r\n")); + assert_eq!(body(&resp), hex::encode([0u8; 32]).as_bytes()); + } +} diff --git a/crates/beacon_api/src/tile.rs b/crates/beacon_api/src/tile.rs index 19ad2ebe..09b99076 100644 --- a/crates/beacon_api/src/tile.rs +++ b/crates/beacon_api/src/tile.rs @@ -9,36 +9,16 @@ use mio::{ Events, Interest, Poll, Token, net::{TcpListener, TcpStream}, }; -use serde::{Deserialize, Serialize}; -use silver_common::{Enr, Eth2Addr, Identify, Keypair, SilverSpine}; -use silver_httpcore::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; +use silver_beacon_state_data::BeaconStateReader; +use silver_common::{Enr, Identify, Keypair, SilverSpine}; +use silver_httpcore::{AfterResponse, ParsedRequest, ServerConnection}; -const LISTENER: Token = Token(0); -const IDENTITY_PATH: &str = "/eth/v1/node/identity"; -const METRICS_PATH: &str = "/metrics"; -const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; - -#[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, -} +use crate::{ + router::Router, + routes::{ApiCtx, ROUTES}, +}; -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Metadata { - seq_number: String, - attnets: String, - syncnets: String, - custody_group_count: String, -} +const LISTENER: Token = Token(0); struct Connection { stream: TcpStream, @@ -51,25 +31,30 @@ pub struct BeaconApiTile { listener: TcpListener, current_token: Token, connections: HashMap, - identity_response: Vec, + router: Router, + ctx: ApiCtx, } impl BeaconApiTile { - pub fn new(keypair: &Keypair, local_enr: Enr, identify: &Identify) -> Self { + pub fn new( + keypair: &Keypair, + local_enr: Enr, + identify: &Identify, + state: BeaconStateReader, + ) -> Self { let poll = Poll::new().unwrap(); let addr = "0.0.0.0:5051".parse().unwrap(); let mut listener = TcpListener::bind(addr).unwrap(); poll.registry().register(&mut listener, LISTENER, Interest::READABLE).unwrap(); - let identity_response = build_identity_response(keypair, &local_enr, identify); - Self { poll, events: Events::with_capacity(1024), listener, current_token: Token(LISTENER.0 + 1), connections: HashMap::new(), - identity_response, + router: Router::new(ROUTES), + ctx: ApiCtx::new(keypair, &local_enr, identify, state), } } } @@ -96,18 +81,9 @@ impl Tile for BeaconApiTile { .insert(token, Connection { stream, http: ServerConnection::new() }); } token => { - // TODO: path routing here is exact-match only. Most beacon - // API paths are parameterised - // (/eth/v1/beacon/states/{state_id}/...). Add - // prefix/pattern matching before implementing any - // parameterised routes. if let Some(conn) = self.connections.get_mut(&token) { - match handle_event(self.poll.registry(), conn, event, &|req, out| match req - .path - { - IDENTITY_PATH => handle_identity(&self.identity_response, out), - METRICS_PATH => handle_metrics(out), - _ => handle_unknown(req.path, out), + match handle_event(self.poll.registry(), conn, event, &|req, out| { + self.router.dispatch(req, &self.ctx, out) }) { Ok(true) => { let _ = self.poll.registry().deregister(&mut conn.stream); @@ -127,19 +103,6 @@ impl Tile for BeaconApiTile { } } -fn handle_identity(response: &[u8], out: &mut Vec) { - out.extend_from_slice(response); -} - -fn handle_metrics(out: &mut Vec) { - frame_response(out, "200 OK", Some(METRICS_CONTENT_TYPE), b""); -} - -fn handle_unknown(path: &str, out: &mut Vec) { - tracing::warn!("unknown path: {path}"); - frame_response(out, "404 Not Found", None, b""); -} - fn handle_event, &mut Vec)>( registry: &mio::Registry, conn: &mut Connection, @@ -198,61 +161,6 @@ fn handle_event, &mut Vec)>( Ok(false) } -fn build_identity_response(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(), - }, - }; - - let body = serde_json::to_string(&IdentityResponse { data: &identity }).unwrap(); - let mut response = Vec::new(); - frame_response(&mut response, "200 OK", Some("application/json"), body.as_bytes()); - response -} - fn next(current: &mut Token) -> Token { let tok = Token(current.0); let n = current.0.wrapping_add(1); @@ -271,82 +179,8 @@ fn interrupted(err: &io::Error) -> bool { #[cfg(test)] mod tests { - use silver_common::{Enr, Identify, Keypair}; - use super::*; - #[test] - fn metrics_response_valid_prometheus_format() { - let mut out = Vec::new(); - handle_metrics(&mut out); - let s = std::str::from_utf8(&out).unwrap(); - assert!(s.starts_with("HTTP/1.1 200 OK\r\n")); - assert!(s.contains("text/plain; version=0.0.4; charset=utf-8")); - let body_start = s.find("\r\n\r\n").unwrap() + 4; - assert_eq!(&s[body_start..], ""); - } - - #[test] - fn unknown_path_returns_404() { - let mut out = Vec::new(); - handle_unknown("/not/real", &mut out); - assert!(out.starts_with(b"HTTP/1.1 404")); - } - - #[test] - fn identity_response_content_length_matches_body() { - let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); - let enr = Enr::builder().build(kp.secret_key()).unwrap(); - let resp = build_identity_response(&kp, &enr, &Identify::default()); - let s = std::str::from_utf8(&resp).unwrap(); - let header_end = s.find("\r\n\r\n").unwrap(); - let body = &s[header_end + 4..]; - 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, body.len()); - } - - #[test] - fn identity_response_json_fields_present() { - let kp = Keypair::from_secret(&[1u8; 32]).unwrap(); - let enr = Enr::builder().build(kp.secret_key()).unwrap(); - let resp = build_identity_response(&kp, &enr, &Identify::default()); - let s = std::str::from_utf8(&resp).unwrap(); - let body = &s[s.find("\r\n\r\n").unwrap() + 4..]; - let v: serde_json::Value = serde_json::from_str(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_response_p2p_address_format() { - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - 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 resp = build_identity_response(&kp, &enr, &identify); - let s = std::str::from_utf8(&resp).unwrap(); - let body = &s[s.find("\r\n\r\n").unwrap() + 4..]; - let v: serde_json::Value = serde_json::from_str(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}"); - } - #[test] fn token_wrap_skips_listener() { let mut cur = Token(usize::MAX); diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index b733a756..35283a39 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -179,7 +179,6 @@ fn main() -> Result<(), Box> { } } - let beacon_api_tile = BeaconApiTile::new(&keypair, local_enr, &identify); let network_tile = NetworkTile::new(discv5_addr, discv5, p2p_addr, p2p_endpoint, p2p_context)?; let (checkpoint, checkpoint_pubkeys) = load_checkpoint(&config)?; @@ -231,6 +230,9 @@ fn main() -> Result<(), Box> { !config.disable_weak_subjectivity_check(), state, ); + let beacon_api_tile = + BeaconApiTile::new(&keypair, local_enr, &identify, beacon_state_tile.reader()); + let state_reader = beacon_state_tile.reader(); let storage_tile = StorageTile::new( From 96a900cedb21d21216a1f25974fc43a426f339b8 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 13:12:19 +0100 Subject: [PATCH 06/33] Rename crates/engine to crates/engine_api Names track current reality: since C2 the crate is a pure engine-API protocol client (JSON-RPC, JWT, correlation, ReqKind dispatch) over the shared silver_httpcore transport, and "Engine API" is the established name for the EL protocol it speaks. Package silver_engine becomes silver_engine_api. Purely mechanical; no logic changes. The EngineTile type and the spine-flow doc's "Engine" tile naming are untouched -- the tile itself dissolves in the upcoming consolidation commit, which owns that doc update. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- crates/bin/Cargo.toml | 2 +- crates/bin/src/main.rs | 2 +- crates/{engine => engine_api}/Cargo.toml | 2 +- crates/{engine => engine_api}/src/client.rs | 0 crates/{engine => engine_api}/src/error.rs | 0 crates/{engine => engine_api}/src/jwt.rs | 0 crates/{engine => engine_api}/src/lib.rs | 0 crates/{engine => engine_api}/src/pool.rs | 0 crates/{engine => engine_api}/src/req_handlers.rs | 0 crates/{engine => engine_api}/src/resp_handlers.rs | 0 crates/{engine => engine_api}/src/test_el.rs | 0 crates/{engine => engine_api}/src/tile.rs | 0 crates/{engine => engine_api}/src/types.rs | 0 .../testdata/empty_var_payload.ssz | Bin .../testdata/get_payload_tcache.bin | Bin .../testdata/large_extra_payload.ssz | Bin .../testdata/many_tx_payload.ssz | Bin .../testdata/sample_payload.ssz | Bin .../testdata/signed_block.ssz | Bin .../testdata/signed_block_params.json | 0 crates/{engine => engine_api}/testdata/tx_multi.bin | Bin .../{engine => engine_api}/testdata/tx_single.bin | Bin .../{engine => engine_api}/testdata/withdrawals.bin | Bin crates/httpcore/src/client.rs | 2 +- 26 files changed, 8 insertions(+), 8 deletions(-) rename crates/{engine => engine_api}/Cargo.toml (95%) rename crates/{engine => engine_api}/src/client.rs (100%) rename crates/{engine => engine_api}/src/error.rs (100%) rename crates/{engine => engine_api}/src/jwt.rs (100%) rename crates/{engine => engine_api}/src/lib.rs (100%) rename crates/{engine => engine_api}/src/pool.rs (100%) rename crates/{engine => engine_api}/src/req_handlers.rs (100%) rename crates/{engine => engine_api}/src/resp_handlers.rs (100%) rename crates/{engine => engine_api}/src/test_el.rs (100%) rename crates/{engine => engine_api}/src/tile.rs (100%) rename crates/{engine => engine_api}/src/types.rs (100%) rename crates/{engine => engine_api}/testdata/empty_var_payload.ssz (100%) rename crates/{engine => engine_api}/testdata/get_payload_tcache.bin (100%) rename crates/{engine => engine_api}/testdata/large_extra_payload.ssz (100%) rename crates/{engine => engine_api}/testdata/many_tx_payload.ssz (100%) rename crates/{engine => engine_api}/testdata/sample_payload.ssz (100%) rename crates/{engine => engine_api}/testdata/signed_block.ssz (100%) rename crates/{engine => engine_api}/testdata/signed_block_params.json (100%) rename crates/{engine => engine_api}/testdata/tx_multi.bin (100%) rename crates/{engine => engine_api}/testdata/tx_single.bin (100%) rename crates/{engine => engine_api}/testdata/withdrawals.bin (100%) diff --git a/Cargo.lock b/Cargo.lock index 37d87515..b61065e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4435,7 +4435,7 @@ dependencies = [ "silver_config", "silver_control", "silver_discovery", - "silver_engine", + "silver_engine_api", "silver_gossip", "silver_network", "silver_peer", @@ -4647,7 +4647,7 @@ dependencies = [ ] [[package]] -name = "silver_engine" +name = "silver_engine_api" version = "0.0.1" dependencies = [ "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 43387908..2d0fa574 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ "crates/e2e", "crates/gossip", "crates/httpcore", - "crates/engine", + "crates/engine_api", "crates/metrics", "crates/network", "crates/peer", @@ -78,7 +78,7 @@ 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 = "d6785f1af35336002476c3d97721fcc67fe76dfd"} flux-utils = { git = "https://github.com/gattaca-com/flux", rev = "d6785f1af35336002476c3d97721fcc67fe76dfd", features = ["bytes"]} flux-profiler = { git = "https://github.com/gattaca-com/flux", rev = "d6785f1af35336002476c3d97721fcc67fe76dfd"} diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index 8fb3e1ac..d666283e 100644 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -18,7 +18,7 @@ silver_gossip.workspace = true silver_network.workspace = true silver_peer.workspace = true silver_storage.workspace = true -silver_engine.workspace = true +silver_engine_api.workspace = true clap.workspace = true flux.workspace = true diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 35283a39..e2de9a5c 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -16,7 +16,7 @@ use silver_common::{ use silver_config::Config; use silver_control::Controller; use silver_discovery::{DiscV5, Discovery}; -use silver_engine::EngineTile; +use silver_engine_api::EngineTile; use silver_gossip::GossipHandler; use silver_network::{Context, NetworkTile, P2p}; use silver_peer::PeerManager; diff --git a/crates/engine/Cargo.toml b/crates/engine_api/Cargo.toml similarity index 95% rename from crates/engine/Cargo.toml rename to crates/engine_api/Cargo.toml index 2f6d0b68..0ada4f80 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 diff --git a/crates/engine/src/client.rs b/crates/engine_api/src/client.rs similarity index 100% rename from crates/engine/src/client.rs rename to crates/engine_api/src/client.rs diff --git a/crates/engine/src/error.rs b/crates/engine_api/src/error.rs similarity index 100% rename from crates/engine/src/error.rs rename to crates/engine_api/src/error.rs 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/src/lib.rs b/crates/engine_api/src/lib.rs similarity index 100% rename from crates/engine/src/lib.rs rename to crates/engine_api/src/lib.rs diff --git a/crates/engine/src/pool.rs b/crates/engine_api/src/pool.rs similarity index 100% rename from crates/engine/src/pool.rs rename to crates/engine_api/src/pool.rs 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/src/test_el.rs b/crates/engine_api/src/test_el.rs similarity index 100% rename from crates/engine/src/test_el.rs rename to crates/engine_api/src/test_el.rs diff --git a/crates/engine/src/tile.rs b/crates/engine_api/src/tile.rs similarity index 100% rename from crates/engine/src/tile.rs rename to crates/engine_api/src/tile.rs 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/httpcore/src/client.rs b/crates/httpcore/src/client.rs index a07a7a88..293c6bf3 100644 --- a/crates/httpcore/src/client.rs +++ b/crates/httpcore/src/client.rs @@ -175,7 +175,7 @@ mod tests { conn.commit_read(n) } - // Captured verbatim from silver_engine's `build_request_into` before the + // 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() { From 36f291ec66f4d5d924203127a3cec2d51ac776b3 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 13:47:05 +0100 Subject: [PATCH 07/33] Consolidate API access into the client_server tile Realizes docs/adr/0001: one spine-attached tile now hosts all API access. BeaconApiTile and EngineTile dissolve into transport-free-of-flux components -- BeaconApi (own mio Poll, now polled with Duration::ZERO: the 100 ms blocking poll is gone) and EngineApi (EngineTile's intake/spin logic verbatim; C2's pool and event paths untouched) -- composed by plain function calls in ClientServerTile::loop_body. The tile attaches at core 5; core 7 is freed. Server activity now feeds flux work-tracking (the old beacon tile ignored its adapter). Config: beacon_api_bind (default 0.0.0.0:5051, preserving today's behavior) via config file, builder, and --beacon-api-bind; binds parse as TCP addr or unix socket path (httpcore Bind/Listener, UDS serving included); execution_endpoint accepts http:// or a socket path, panicking on any other scheme. BeaconApi::local_addr exposes the resolved bind so tests bind port 0 and discover the ephemeral port. New integration tests drive the real tile over a real spine (SpineAdapter::connect_tile) with hand-cranked loop_body: identity served over real TCP and UDS sockets, and the merged-loop invariant from ADR 0004 gets its first test -- a beacon-api request served while four engine calls sit unanswered on a fake EL, with the FCU completion still correlating afterwards. The pool-cap test migrates to the merged tile intact. Accept now drains until WouldBlock (single-accept could strand a simultaneous second connection under edge-triggered registration), and EngineApi::spin no-ops without an EL client instead of panicking, since the merged loop calls it unconditionally in unsafe_no_el mode. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 20 +- Cargo.toml | 2 + crates/beacon_api/Cargo.toml | 1 - crates/beacon_api/examples/srv.rs | 25 +- crates/beacon_api/src/lib.rs | 4 +- crates/beacon_api/src/{tile.rs => server.rs} | 50 ++-- crates/bin/Cargo.toml | 2 + crates/bin/src/main.rs | 26 +- crates/client_server/Cargo.toml | 24 ++ crates/client_server/src/lib.rs | 19 ++ crates/client_server/tests/tile.rs | 299 +++++++++++++++++++ crates/config/src/lib.rs | 26 ++ crates/engine_api/Cargo.toml | 5 + crates/engine_api/src/{tile.rs => api.rs} | 193 +++--------- crates/engine_api/src/client.rs | 39 ++- crates/engine_api/src/lib.rs | 8 +- crates/engine_api/src/test_el.rs | 28 +- crates/httpcore/Cargo.toml | 3 + crates/httpcore/src/lib.rs | 2 +- crates/httpcore/src/stream.rs | 127 +++++++- docs/spine-message-flow.md | 21 +- 21 files changed, 688 insertions(+), 236 deletions(-) rename crates/beacon_api/src/{tile.rs => server.rs} (84%) create mode 100644 crates/client_server/Cargo.toml create mode 100644 crates/client_server/src/lib.rs create mode 100644 crates/client_server/tests/tile.rs rename crates/engine_api/src/{tile.rs => api.rs} (54%) diff --git a/Cargo.lock b/Cargo.lock index b61065e6..58b6806f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4430,6 +4430,7 @@ dependencies = [ "silver_beacon_api", "silver_beacon_state", "silver_beacon_state_data", + "silver_client_server", "silver_columns", "silver_common", "silver_config", @@ -4437,6 +4438,7 @@ dependencies = [ "silver_discovery", "silver_engine_api", "silver_gossip", + "silver_httpcore", "silver_network", "silver_peer", "silver_storage", @@ -4447,7 +4449,6 @@ dependencies = [ name = "silver_beacon_api" version = "0.0.1" dependencies = [ - "flux", "hex", "mio", "serde", @@ -4509,6 +4510,22 @@ dependencies = [ "serde", ] +[[package]] +name = "silver_client_server" +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_columns" version = "0.0.1" @@ -4695,6 +4712,7 @@ version = "0.0.1" dependencies = [ "httparse", "mio", + "tempfile", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index 2d0fa574..1e1bbbe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/beacon_state/data", "crates/beacon_state/tile", "crates/bin", + "crates/client_server", "crates/common", "crates/config", "crates/config/chain_spec", @@ -66,6 +67,7 @@ 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" } +silver_client_server = { path = "crates/client_server" } silver_columns = { path = "crates/columns" } silver_common = { path = "crates/common" } silver_config = { path = "crates/config" } diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index 51d08ff2..e62d76bf 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -6,7 +6,6 @@ rust-version.workspace = true version.workspace = true [dependencies] -flux.workspace = true hex.workspace = true mio.workspace = true silver_beacon_state_data.workspace = true diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index 4f6d8627..87316c0b 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -1,20 +1,21 @@ -use flux::tile::{TileConfig, attach_tile}; -use silver_beacon_api::BeaconApiTile; +use std::time::Duration; + +use silver_beacon_api::BeaconApi; use silver_beacon_state_data::BeaconStateOwner; -use silver_common::{Enr, Identify, Keypair, SilverSpine}; +use silver_common::{Enr, Identify, Keypair}; +use silver_httpcore::Bind; fn main() { + let bind = Bind::parse(&std::env::args().nth(1).unwrap_or_else(|| "0.0.0.0:5051".into())); let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); - let identify = Identify::default(); // Never-published reader: state endpoints answer 503, as pre-bootstrap. let state = BeaconStateOwner::empty_test(0).reader(); - let spine = SilverSpine::new(None); - spine.start(None, None, |scoped_spine| { - attach_tile( - BeaconApiTile::new(&keypair, local_enr, &identify, state), - scoped_spine, - TileConfig::new(1, None), - ); - }); + + let mut api = BeaconApi::new(&bind, &keypair, local_enr, &Identify::default(), state); + println!("serving on {:?}", api.local_addr()); + loop { + api.pump(); + std::thread::sleep(Duration::from_millis(1)); + } } diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index e7bb308d..56e01769 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -2,6 +2,6 @@ mod identity; mod response; mod router; mod routes; -mod tile; +mod server; -pub use tile::BeaconApiTile; +pub use server::BeaconApi; diff --git a/crates/beacon_api/src/tile.rs b/crates/beacon_api/src/server.rs similarity index 84% rename from crates/beacon_api/src/tile.rs rename to crates/beacon_api/src/server.rs index 09b99076..82049a6e 100644 --- a/crates/beacon_api/src/tile.rs +++ b/crates/beacon_api/src/server.rs @@ -4,14 +4,10 @@ use std::{ time::Duration, }; -use flux::{spine::SpineAdapter, tile::Tile}; -use mio::{ - Events, Interest, Poll, Token, - net::{TcpListener, TcpStream}, -}; +use mio::{Events, Interest, Poll, Token}; use silver_beacon_state_data::BeaconStateReader; -use silver_common::{Enr, Identify, Keypair, SilverSpine}; -use silver_httpcore::{AfterResponse, ParsedRequest, ServerConnection}; +use silver_common::{Enr, Identify, Keypair}; +use silver_httpcore::{AfterResponse, Bind, Listener, ParsedRequest, ServerConnection, Stream}; use crate::{ router::Router, @@ -21,30 +17,31 @@ use crate::{ const LISTENER: Token = Token(0); struct Connection { - stream: TcpStream, + stream: Stream, http: ServerConnection, } -pub struct BeaconApiTile { +pub struct BeaconApi { poll: Poll, events: Events, - listener: TcpListener, + listener: Listener, current_token: Token, connections: HashMap, router: Router, ctx: ApiCtx, } -impl BeaconApiTile { +impl BeaconApi { pub fn new( + bind: &Bind, keypair: &Keypair, local_enr: Enr, identify: &Identify, state: BeaconStateReader, ) -> Self { let poll = Poll::new().unwrap(); - let addr = "0.0.0.0:5051".parse().unwrap(); - let mut listener = TcpListener::bind(addr).unwrap(); + let mut listener = + Listener::bind(bind).unwrap_or_else(|e| panic!("beacon api bind {bind:?}: {e}")); poll.registry().register(&mut listener, LISTENER, Interest::READABLE).unwrap(); Self { @@ -57,31 +54,36 @@ impl BeaconApiTile { ctx: ApiCtx::new(keypair, &local_enr, identify, state), } } -} -impl Tile for BeaconApiTile { - fn loop_body(&mut self, _adapter: &mut SpineAdapter) { - self.poll.poll(&mut self.events, Some(Duration::from_millis(100))).unwrap(); + pub fn local_addr(&self) -> Bind { + self.listener.local_addr() + } + + pub fn pump(&mut self) -> bool { + self.poll.poll(&mut self.events, Some(Duration::ZERO)).unwrap(); + let mut did_work = false; for event in &self.events { match event.token() { - LISTENER => { - let (mut stream, address) = match self.listener.accept() { - Ok(conn) => conn, + LISTENER => loop { + let mut stream = match self.listener.accept() { + Ok(stream) => stream, + Err(e) if would_block(&e) => break, Err(e) => { tracing::warn!("accept failed: {e}"); - continue; + break; } }; - tracing::info!("accepted connection from {address}"); + did_work = true; let token = next(&mut self.current_token); self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); self.connections .insert(token, Connection { stream, http: ServerConnection::new() }); - } + }, token => { if let Some(conn) = self.connections.get_mut(&token) { + did_work = true; match handle_event(self.poll.registry(), conn, event, &|req, out| { self.router.dispatch(req, &self.ctx, out) }) { @@ -100,6 +102,8 @@ impl Tile for BeaconApiTile { } } } + + did_work } } diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index d666283e..11a264d0 100644 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -9,6 +9,7 @@ version.workspace = true silver_beacon_api.workspace = true silver_beacon_state.workspace = true silver_beacon_state_data.workspace = true +silver_client_server.workspace = true silver_columns.workspace = true silver_common.workspace = true silver_config.workspace = true @@ -19,6 +20,7 @@ silver_network.workspace = true silver_peer.workspace = true silver_storage.workspace = true silver_engine_api.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 e2de9a5c..00fd7290 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -4,9 +4,10 @@ use flux::tile::{TileConfig, attach_tile}; use mimalloc::MiMalloc; use quinn_proto::{Endpoint, EndpointConfig}; use rand::RngCore; -use silver_beacon_api::BeaconApiTile; +use silver_beacon_api::BeaconApi; use silver_beacon_state::{BeaconStateTile, SlotTicker}; use silver_beacon_state_data::{BeaconState, SLOTS_PER_EPOCH}; +use silver_client_server::ClientServerTile; use silver_columns::tile::DataColumnsTile; #[cfg(feature = "alloc-profile")] use silver_common::metrics::CountingAllocator; @@ -16,8 +17,9 @@ use silver_common::{ use silver_config::Config; use silver_control::Controller; use silver_discovery::{DiscV5, Discovery}; -use silver_engine_api::EngineTile; +use silver_engine_api::EngineApi; 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}; @@ -230,8 +232,13 @@ fn main() -> Result<(), Box> { !config.disable_weak_subjectivity_check(), state, ); - let beacon_api_tile = - BeaconApiTile::new(&keypair, local_enr, &identify, beacon_state_tile.reader()); + let beacon_api = BeaconApi::new( + &Bind::parse(config.beacon_api_bind()), + &keypair, + local_enr, + &identify, + beacon_state_tile.reader(), + ); let state_reader = beacon_state_tile.reader(); @@ -262,12 +269,13 @@ fn main() -> Result<(), Box> { el_producer, ); - let engine_tile = EngineTile::new( + let engine_api = EngineApi::new( config.engine_config(), ssz_gossip_consumer_eng, incoming_rpc_consumer_eng, incoming_engine_resp_producer, ); + let client_server_tile = ClientServerTile { beacon: beacon_api, engine: engine_api }; // Spine let spine = SilverSpine::new(None); @@ -278,9 +286,8 @@ fn main() -> Result<(), Box> { attach_tile(network_tile, scoped_spine, TileConfig::new(2, None)); attach_tile(beacon_state_tile, scoped_spine, TileConfig::new(3, None)); attach_tile(storage_tile, scoped_spine, TileConfig::new(4, None)); - attach_tile(engine_tile, scoped_spine, TileConfig::new(5, None)); + attach_tile(client_server_tile, scoped_spine, TileConfig::new(5, None)); attach_tile(data_columns_tile, scoped_spine, TileConfig::new(6, None)); - attach_tile(beacon_api_tile, scoped_spine, TileConfig::new(7, None)); }); Ok(()) @@ -322,6 +329,11 @@ fn load_config() -> Result { if args.iter().any(|a| a == "--unsafe-no-el") { config = config.with_unsafe_no_el(true); } + if let Some(bind) = + args.iter().position(|a| a == "--beacon-api-bind").and_then(|i| args.get(i + 1)) + { + config = config.with_beacon_api_bind(bind.clone()); + } tracing::info!("loaded config: {config:#?}"); diff --git a/crates/client_server/Cargo.toml b/crates/client_server/Cargo.toml new file mode 100644 index 00000000..29660382 --- /dev/null +++ b/crates/client_server/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "silver_client_server" +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +flux.workspace = true +silver_beacon_api.workspace = true +silver_common.workspace = true +silver_engine_api.workspace = true + +[dev-dependencies] +hex.workspace = true +serde_json.workspace = true +silver_beacon_state_data.workspace = true +silver_config.workspace = true +silver_engine_api = { workspace = true, features = ["test-el"] } +silver_httpcore.workspace = true +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/client_server/src/lib.rs b/crates/client_server/src/lib.rs new file mode 100644 index 00000000..c07638e9 --- /dev/null +++ b/crates/client_server/src/lib.rs @@ -0,0 +1,19 @@ +use flux::{spine::SpineAdapter, tile::Tile}; +use silver_beacon_api::BeaconApi; +use silver_common::SilverSpine; +use silver_engine_api::EngineApi; + +pub struct ClientServerTile { + pub beacon: BeaconApi, + pub engine: EngineApi, +} + +impl Tile for ClientServerTile { + fn loop_body(&mut self, adapter: &mut SpineAdapter) { + self.engine.intake(adapter); + self.engine.spin(adapter); + if self.beacon.pump() { + adapter.mark_work(); + } + } +} diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs new file mode 100644 index 00000000..0e9110d4 --- /dev/null +++ b/crates/client_server/tests/tile.rs @@ -0,0 +1,299 @@ +use std::{ + io::{Read, Write}, + net::TcpStream, + os::unix::net::UnixStream, + time::{Duration, Instant}, +}; + +use flux::{spine::SpineAdapter, tile::Tile}; +use silver_beacon_api::BeaconApi; +use silver_beacon_state_data::BeaconStateOwner; +use silver_client_server::ClientServerTile; +use silver_common::{ + EngineFcuReq, EngineReq, EngineResp, Enr, Identify, Keypair, SilverSpine, TCache, + TCacheProducer, +}; +use silver_config::EngineConfig; +use silver_engine_api::{ + EngineApi, + 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 beacon(bind: &Bind) -> BeaconApi { + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + BeaconApi::new( + bind, + &keypair, + local_enr, + &Identify::default(), + BeaconStateOwner::empty_test(0).reader(), + ) +} + +fn engine(config: EngineConfig, tcache_names: [&'static str; 3]) -> EngineApi { + 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); + EngineApi::new( + config, + gossip_p.cache_ref().random_access("t", true).unwrap(), + rpc_p.cache_ref().random_access("t", true).unwrap(), + resp_p, + ) +} + +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])) +} + +#[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 = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(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_addr() else { panic!("expected tcp bind") }; + assert_ne!(addr.port(), 0, "port-0 bind must resolve to an ephemeral port"); + + let client = 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") + }); + + 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 = ClientServerTile { + beacon: beacon(&Bind::Unix(socket.clone())), + engine: engine(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_addr(), 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 = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(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 ClientServerTile, 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_addr() else { panic!("expected tcp bind") }; + let client = 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") + }); + 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 = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(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 ClientServerTile, 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"); +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 4e965e5a..734b4209 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -33,6 +33,10 @@ const fn default_u64() -> u64 { V } +fn default_beacon_api_bind() -> String { + "0.0.0.0:5051".into() +} + fn default_data_dir() -> String { std::env::home_dir() .and_then(|mut b| { @@ -122,6 +126,9 @@ pub struct Config { data_storage_dir: String, #[serde(default)] engine_config: EngineConfig, + /// TCP `addr:port` or a unix socket path. + #[serde(default = "default_beacon_api_bind")] + beacon_api_bind: String, #[serde(default)] disable_weak_subjectivity_check: bool, } @@ -156,6 +163,7 @@ 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(), disable_weak_subjectivity_check: false, } } @@ -208,6 +216,11 @@ impl Config { self } + pub fn with_beacon_api_bind(mut self, bind: String) -> Self { + self.beacon_api_bind = bind; + self + } + pub fn keypair(&self) -> Result { Keypair::from_secret(&self.secret_key) } @@ -336,6 +349,10 @@ impl Config { self.engine_config.clone() } + pub fn beacon_api_bind(&self) -> &str { + &self.beacon_api_bind + } + pub fn disable_weak_subjectivity_check(&self) -> bool { self.disable_weak_subjectivity_check } @@ -366,6 +383,15 @@ 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"); + } + + #[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("/run/beacon.sock".into()); + assert_eq!(cfg.beacon_api_bind(), "/run/beacon.sock"); } #[test] diff --git a/crates/engine_api/Cargo.toml b/crates/engine_api/Cargo.toml index 0ada4f80..2ec66152 100644 --- a/crates/engine_api/Cargo.toml +++ b/crates/engine_api/Cargo.toml @@ -11,6 +11,7 @@ base64.workspace = true flux.workspace = true hex.workspace = true hmac.workspace = true +httparse = { workspace = true, optional = true } mio.workspace = true rustc-hash.workspace = true serde.workspace = true @@ -21,6 +22,10 @@ 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 tempfile = "3" diff --git a/crates/engine_api/src/tile.rs b/crates/engine_api/src/api.rs similarity index 54% rename from crates/engine_api/src/tile.rs rename to crates/engine_api/src/api.rs index 55cb423d..42bed05f 100644 --- a/crates/engine_api/src/tile.rs +++ b/crates/engine_api/src/api.rs @@ -1,6 +1,6 @@ use std::time::{Duration, Instant}; -use flux::{spine::SpineAdapter, tile::Tile}; +use flux::spine::SpineAdapter; use silver_common::{ ELSyncStatus, EngineHealthEvent, EngineReq, SilverSpine, TProducer, TRandomAccess, }; @@ -15,7 +15,7 @@ use crate::{ 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,8 +32,38 @@ pub struct EngineTile { scratch: Vec, } -impl Tile for EngineTile { - fn loop_body(&mut self, adapter: &mut SpineAdapter) { +impl EngineApi { + pub fn new( + config: EngineConfig, + gossip_consumer: TRandomAccess, + rpc_consumer: TRandomAccess, + resp_producer: TProducer, + ) -> Self { + let client = if config.unsafe_no_el { + 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, + config.max_connections, + )) + }; + Self { + client, + gossip_consumer, + rpc_consumer, + resp_producer, + + first_run: true, + healthcheck_pending: false, + healthcheck_deadline: Instant::now(), + sync_status: ELSyncStatus::Unknown, + scratch: Vec::new(), + } + } + + pub fn intake(&mut self, adapter: &mut SpineAdapter) { self.rpc_consumer.free(); self.gossip_consumer.free(); @@ -67,44 +97,9 @@ impl Tile for EngineTile { break; } } - self.spin(adapter); } -} -impl EngineTile { - pub fn new( - 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" - ); - None - } else { - Some(EngineClient::new( - &config.execution_endpoint, - &config.jwt_secret, - config.max_connections, - )) - }; - Self { - client, - gossip_consumer, - rpc_consumer, - resp_producer, - - first_run: true, - healthcheck_pending: false, - healthcheck_deadline: Instant::now(), - sync_status: ELSyncStatus::Unknown, - scratch: Vec::new(), - } - } - - fn spin(&mut self, adapter: &mut SpineAdapter) { + pub fn spin(&mut self, adapter: &mut SpineAdapter) { let mut negotiated_get_payload_method: Option<&'static str> = None; { @@ -118,8 +113,7 @@ 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 && @@ -183,118 +177,3 @@ fn run_healthcheck( *healthcheck_deadline = Instant::now() + HEALTHCHECK_INTERVAL; *healthcheck_pending = true; } - -#[cfg(test)] -mod tests { - use std::time::{Duration, Instant}; - - use flux::{spine::SpineAdapter, tile::Tile}; - use silver_common::{EngineFcuReq, EngineReq, EngineResp, SilverSpine, TCache, TCacheProducer}; - use silver_config::EngineConfig; - use tempfile::TempDir; - - use super::EngineTile; - use crate::test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}; - - struct Injector; - impl Tile for Injector { - fn loop_body(&mut self, _: &mut SpineAdapter) {} - } - - 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])) - } - - /// (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 gossip_p = TCache::producer("engine_cap_test_gossip", 1 << 12); - let rpc_p = TCache::producer("engine_cap_test_rpc", 1 << 12); - let resp_p = TCache::producer("engine_cap_test_resp", 1 << 12); - let config = EngineConfig { - execution_endpoint: endpoint, - jwt_secret: jwt_path.to_str().unwrap().to_string(), - max_connections: 3, - ..EngineConfig::default() - }; - let mut tile = EngineTile::new( - config, - gossip_p.cache_ref().random_access("t", true).unwrap(), - rpc_p.cache_ref().random_access("t", true).unwrap(), - resp_p, - ); - 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 EngineTile, 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"); - } -} diff --git a/crates/engine_api/src/client.rs b/crates/engine_api/src/client.rs index 2cadb460..48b6ae34 100644 --- a/crates/engine_api/src/client.rs +++ b/crates/engine_api/src/client.rs @@ -55,8 +55,8 @@ pub struct EngineClient { } impl EngineClient { - pub fn new(endpoint: impl Into, jwt: &str, max_connections: usize) -> Self { - Self::with_endpoint(Endpoint::Http(endpoint.into()), jwt, max_connections) + pub fn new(endpoint: &str, jwt: &str, max_connections: usize) -> Self { + Self::with_endpoint(parse_endpoint(endpoint), jwt, max_connections) } pub fn new_uds(path: impl Into, jwt: &str, max_connections: usize) -> Self { @@ -81,6 +81,19 @@ impl EngineClient { } } +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)) + } +} + fn next_id(id: &mut u64) -> u64 { let v = *id; *id += 1; @@ -264,6 +277,28 @@ mod tests { 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_api/src/lib.rs b/crates/engine_api/src/lib.rs index 0d982f7f..fb9ea03f 100644 --- a/crates/engine_api/src/lib.rs +++ b/crates/engine_api/src/lib.rs @@ -1,15 +1,15 @@ +mod api; mod client; mod error; mod jwt; mod pool; mod req_handlers; mod resp_handlers; -#[cfg(test)] -mod test_el; -pub mod tile; +#[cfg(any(test, feature = "test-el"))] +pub mod test_el; mod types; +pub use api::EngineApi; pub use client::EngineClient; pub use error::EngineError; pub use jwt::JwtSecret; -pub use tile::EngineTile; diff --git a/crates/engine_api/src/test_el.rs b/crates/engine_api/src/test_el.rs index f7bdfee1..8fbbb9d2 100644 --- a/crates/engine_api/src/test_el.rs +++ b/crates/engine_api/src/test_el.rs @@ -7,9 +7,9 @@ use std::{ use simd_json::prelude::{ValueAsScalar, ValueObjectAccess}; -pub(crate) const FCU_VALID_RESULT: &str = r#"{"payloadStatus":{"status":"VALID","latestValidHash":null,"validationError":null},"payloadId":null}"#; +pub const FCU_VALID_RESULT: &str = r#"{"payloadStatus":{"status":"VALID","latestValidHash":null,"validationError":null},"payloadId":null}"#; -pub(crate) fn write_jwt(dir: &Path) -> PathBuf { +pub fn write_jwt(dir: &Path) -> PathBuf { let path = dir.join("jwt.hex"); std::fs::write(&path, "0000000000000000000000000000000000000000000000000000000000000000") .unwrap(); @@ -51,32 +51,32 @@ impl Write for ElStream { } } -pub(crate) struct ElRequest { +pub struct ElRequest { conn: usize, - pub(crate) id: u64, - pub(crate) method: String, - pub(crate) authorization: Option, - pub(crate) body: String, + 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(crate) struct FakeEl { +pub struct FakeEl { listener: ElListener, conns: Vec>, read_bufs: Vec>, - pub(crate) requests: Vec, + pub requests: Vec, } impl FakeEl { - pub(crate) fn tcp() -> (Self, String) { + 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(crate) fn uds(path: &Path) -> Self { + pub fn uds(path: &Path) -> Self { let listener = UnixListener::bind(path).unwrap(); listener.set_nonblocking(true).unwrap(); Self::new(ElListener::Uds(listener)) @@ -86,7 +86,7 @@ impl FakeEl { Self { listener, conns: Vec::new(), read_bufs: Vec::new(), requests: Vec::new() } } - pub(crate) fn pump(&mut self) { + pub fn pump(&mut self) { loop { let accepted = match &self.listener { ElListener::Tcp(l) => l.accept().map(|(s, _)| { @@ -133,7 +133,7 @@ impl FakeEl { } } - pub(crate) fn respond(&mut self, request_index: usize, result_json: &str) { + 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!( @@ -151,7 +151,7 @@ impl FakeEl { } } - pub(crate) fn close_connection_of(&mut self, request_index: usize) { + pub fn close_connection_of(&mut self, request_index: usize) { self.conns[self.requests[request_index].conn] = None; } } diff --git a/crates/httpcore/Cargo.toml b/crates/httpcore/Cargo.toml index 6f13244e..237e1c7a 100644 --- a/crates/httpcore/Cargo.toml +++ b/crates/httpcore/Cargo.toml @@ -10,5 +10,8 @@ httparse.workspace = true mio.workspace = true tracing.workspace = true +[dev-dependencies] +tempfile = "3" + [lints] workspace = true diff --git a/crates/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs index 7198254f..7efa53d4 100644 --- a/crates/httpcore/src/lib.rs +++ b/crates/httpcore/src/lib.rs @@ -4,4 +4,4 @@ mod stream; pub use client::{ClientConnection, frame_request}; pub use server::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; -pub use stream::Stream; +pub use stream::{Bind, Listener, Stream}; diff --git a/crates/httpcore/src/stream.rs b/crates/httpcore/src/stream.rs index aa0e6766..e63eda53 100644 --- a/crates/httpcore/src/stream.rs +++ b/crates/httpcore/src/stream.rs @@ -1,15 +1,107 @@ use std::{ io::{self, Read, Write}, net::SocketAddr, - path::Path, + path::{Path, PathBuf}, }; use mio::{ Interest, Registry, Token, event::Source, - net::{TcpStream, UnixStream}, + 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(_) => 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), @@ -102,6 +194,37 @@ 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())); + // Hostnames don't parse as SocketAddr (no resolution here), so they + // fall through to a path. + assert_eq!(Bind::parse("localhost:5051"), Bind::Unix("localhost:5051".into())); + } + + #[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(); diff --git a/docs/spine-message-flow.md b/docs/spine-message-flow.md index 25b9eb4d..a86ba26b 100644 --- a/docs/spine-message-flow.md +++ b/docs/spine-message-flow.md @@ -8,7 +8,8 @@ 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). +**ClientServer** (hosting the `engine_api` client and the `beacon_api` server; the +server talks HTTP only, so it has no spine edges of its own). ```mermaid flowchart LR @@ -16,7 +17,7 @@ flowchart LR CTL["Control
PeerManager + SyncEngine + GossipHandler"] BS["BeaconState
state · fork choice"] ST["Storage
disk · backfill"] - EN["Engine
EL / engine API"] + EN["ClientServer
engine_api client · beacon_api server"] %% ---- inbound ---- NET -.->|"incoming_gossip (tcache)"| CTL @@ -69,8 +70,8 @@ output is `new_gossip`). The gossip handler's other traffic is in-tile, not on t 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. `engine_health` is omitted -from the diagram: Engine produces it but no tile currently consumes it. Both -Storage↔Engine edges carry only the `GetBlobs` variants (EL-mempool blob fetch); the +from the diagram: ClientServer produces it but no tile currently consumes it. Both +Storage↔ClientServer edges carry only the `GetBlobs` variants (EL-mempool blob fetch); the queues are broadcast, so Storage sees every `EngineResp` and ignores the rest. ## Spine queues @@ -87,9 +88,9 @@ queues are broadcast, so Storage sees every `EngineResp` and ignores the rest. | `sync_target` | `SyncUpdate` | Control | BeaconState, Storage | inline | | `replay_blocks` | `ReplayBlock` | Storage | BeaconState | ref → `replay_blocks` tcache | | `syncing_strategy` | `SyncingStrategy` | Control | Storage | inline | -| `engine_reqs` | `EngineReq` | BeaconState, Storage _(GetBlobs)_ | Engine | refs → `ssz_gossip` / `incoming_rpc`; GetBlobs inline | -| `engine_resps` | `EngineResp` | Engine | BeaconState, Storage _(GetBlobs)_ | ref → `incoming_engine_resp` | -| `engine_health` | `EngineHealthEvent` | Engine | _none (currently unconsumed)_ | inline | +| `engine_reqs` | `EngineReq` | BeaconState, Storage _(GetBlobs)_ | ClientServer | refs → `ssz_gossip` / `incoming_rpc`; GetBlobs inline | +| `engine_resps` | `EngineResp` | ClientServer | BeaconState, Storage _(GetBlobs)_ | ref → `incoming_engine_resp` | +| `engine_health` | `EngineHealthEvent` | ClientServer | _none (currently unconsumed)_ | inline | ## TCaches @@ -98,12 +99,12 @@ Bulk-byte rings that the queue messages reference, so payloads cross tiles witho | TCache | Producer | Consumer(s) | Payload | |--------|----------|-------------|---------| | `incoming_gossip` | Network | Control _(gossip)_ | raw gossipsub protobuf from the wire | -| `ssz_gossip` | Control _(gossip)_ | BeaconState, Storage (live + persist), Engine | decompressed gossip SSZ | +| `ssz_gossip` | Control _(gossip)_ | BeaconState, Storage (live + persist), ClientServer | 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, Storage (live + persist), Engine, Control (column republish) | RPC response bodies (BeaconBlock / DataColumnSidecar) | +| `incoming_rpc` | Network | BeaconState, Storage (live + persist), ClientServer, 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, Storage (GetBlobs) | EL responses (payloads, blobs, bodies) | +| `incoming_engine_resp` | ClientServer | BeaconState, Storage (GetBlobs) | EL responses (payloads, blobs, bodies) | --- From bd42255cd476b2ac3db93a9be0b2341be3b1f3ce Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 17 Aug 2026 14:50:15 +0100 Subject: [PATCH 08/33] Harden API transport: zero-alloc hot-path test, conn cap, lazy buffers Closes out the consolidation plan (C6). The newPayload transcode's zero-allocation invariant finally gets a failing-capable test: a dedicated integration binary installs a counting global allocator, warms every buffer (scratch, connection write buffer, JWT second-cache, pending map) through real UDS round trips against the fake EL, then asserts the next send performs exactly zero heap allocations -- with the JWT cache's wall-clock second handled by retry rather than a weakened assertion. Server hardening: beacon_api_max_connections (default 64) accepts-and- drops beyond the cap (leaving the backlog unaccepted would go silent under edge-triggered registration until the next SYN). ServerConnection's 16 MiB eagerly-boxed read buffer becomes a 4 KiB lazily-doubling Vec with the same hard cap and byte-identical rejection, and read_space now compacts the partial tail to the buffer front -- previously a long-lived pipelined keep-alive connection crept its offsets toward the cap and would spuriously reject small requests (the new creep test feeds 2x the cap in small requests and fails against the old code, which also could not construct on a default test-thread stack). GET /eth/v1/events is pinned as 404: v1 defers SSE, all surveyed validator clients poll (.local/beacon-api-vc-surface.md). Real-socket smoke coverage audited across {server,client} x {TCP,UDS}: all four combinations already exercised; none added. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 1 + crates/beacon_api/examples/srv.rs | 2 +- crates/beacon_api/src/routes.rs | 7 + crates/beacon_api/src/server.rs | 107 +++++++++++++++ crates/bin/src/main.rs | 1 + crates/client_server/tests/tile.rs | 1 + crates/config/src/lib.rs | 21 +++ crates/engine_api/Cargo.toml | 1 + crates/engine_api/src/lib.rs | 2 + crates/engine_api/tests/newpayload_alloc.rs | 113 ++++++++++++++++ crates/httpcore/src/server.rs | 139 ++++++++++++++++++-- 11 files changed, 383 insertions(+), 12 deletions(-) create mode 100644 crates/engine_api/tests/newpayload_alloc.rs diff --git a/Cargo.lock b/Cargo.lock index 58b6806f..2f2f6074 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4678,6 +4678,7 @@ dependencies = [ "sha2", "silver_common", "silver_config", + "silver_engine_api", "silver_httpcore", "simd-json", "tempfile", diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index 87316c0b..e83466be 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -12,7 +12,7 @@ fn main() { // Never-published reader: state endpoints answer 503, as pre-bootstrap. let state = BeaconStateOwner::empty_test(0).reader(); - let mut api = BeaconApi::new(&bind, &keypair, local_enr, &Identify::default(), state); + let mut api = BeaconApi::new(&bind, 64, &keypair, local_enr, &Identify::default(), state); println!("serving on {:?}", api.local_addr()); loop { api.pump(); diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index d8da5ccf..66aad9ee 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -142,6 +142,13 @@ mod tests { 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"); + } + fn genesis_root(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { let Some(root) = ctx.read_state_or_503(resp, |view| view.imm.genesis_validators_root) else { diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index 82049a6e..b8fab32b 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -25,6 +25,7 @@ pub struct BeaconApi { poll: Poll, events: Events, listener: Listener, + max_connections: usize, current_token: Token, connections: HashMap, router: Router, @@ -34,6 +35,7 @@ pub struct BeaconApi { impl BeaconApi { pub fn new( bind: &Bind, + max_connections: usize, keypair: &Keypair, local_enr: Enr, identify: &Identify, @@ -48,6 +50,7 @@ impl BeaconApi { poll, events: Events::with_capacity(1024), listener, + max_connections, current_token: Token(LISTENER.0 + 1), connections: HashMap::new(), router: Router::new(ROUTES), @@ -76,6 +79,16 @@ impl BeaconApi { }; 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 = next(&mut self.current_token); self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); self.connections @@ -183,6 +196,14 @@ fn interrupted(err: &io::Error) -> bool { #[cfg(test)] mod tests { + use std::{ + net::{SocketAddr, TcpStream}, + thread::JoinHandle, + time::Instant, + }; + + use silver_beacon_state_data::BeaconStateOwner; + use super::*; #[test] @@ -193,4 +214,90 @@ mod tests { assert_ne!(cur, LISTENER, "next token must not alias LISTENER after wrap"); assert_eq!(cur.0, LISTENER.0 + 1); } + + fn pump_until(api: &mut BeaconApi, client: JoinHandle, msg: &str) -> T { + let deadline = Instant::now() + Duration::from_secs(10); + while !client.is_finished() { + assert!(Instant::now() < deadline, "timeout: {msg}"); + api.pump(); + std::thread::sleep(Duration::from_millis(1)); + } + client.join().unwrap() + } + + fn connect(addr: SocketAddr) -> TcpStream { + let stream = TcpStream::connect(addr).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + stream + } + + #[test] + fn connection_cap_drops_excess_then_recovers() { + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + let mut api = BeaconApi::new( + &Bind::parse("127.0.0.1:0"), + 1, + &keypair, + local_enr, + &Identify::default(), + BeaconStateOwner::empty_test(0).reader(), + ); + let Bind::Tcp(addr) = api.local_addr() else { panic!("expected tcp bind") }; + + let held_open = pump_until( + &mut api, + 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 = pump_until( + &mut api, + 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); + let deadline = Instant::now() + Duration::from_secs(10); + while !api.connections.is_empty() { + assert!(Instant::now() < deadline, "timeout: closed connection reaped"); + api.pump(); + std::thread::sleep(Duration::from_millis(1)); + } + + let response = pump_until( + &mut api, + 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")); + } } diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 00fd7290..bfa5479b 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -234,6 +234,7 @@ fn main() -> Result<(), Box> { ); let beacon_api = BeaconApi::new( &Bind::parse(config.beacon_api_bind()), + config.beacon_api_max_connections(), &keypair, local_enr, &identify, diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index 0e9110d4..7f07bc33 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -31,6 +31,7 @@ fn beacon(bind: &Bind) -> BeaconApi { let local_enr = Enr::empty(keypair.secret_key()).unwrap(); BeaconApi::new( bind, + 64, &keypair, local_enr, &Identify::default(), diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 734b4209..55674e07 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -129,6 +129,8 @@ pub struct Config { /// TCP `addr:port` or a unix socket path. #[serde(default = "default_beacon_api_bind")] beacon_api_bind: String, + #[serde(default = "default_usize::<64>")] + beacon_api_max_connections: usize, #[serde(default)] disable_weak_subjectivity_check: bool, } @@ -164,6 +166,7 @@ impl Config { data_storage_dir: default_data_dir(), engine_config: Default::default(), beacon_api_bind: default_beacon_api_bind(), + beacon_api_max_connections: 64, disable_weak_subjectivity_check: false, } } @@ -221,6 +224,11 @@ impl Config { self } + pub fn with_beacon_api_max_connections(mut self, max: usize) -> Self { + self.beacon_api_max_connections = max; + self + } + pub fn keypair(&self) -> Result { Keypair::from_secret(&self.secret_key) } @@ -353,6 +361,10 @@ impl Config { &self.beacon_api_bind } + pub fn beacon_api_max_connections(&self) -> usize { + self.beacon_api_max_connections + } + pub fn disable_weak_subjectivity_check(&self) -> bool { self.disable_weak_subjectivity_check } @@ -384,6 +396,7 @@ mod tests { 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); } #[test] @@ -394,6 +407,14 @@ mod tests { 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 builders_set_external_ip_and_genesis() { let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0) diff --git a/crates/engine_api/Cargo.toml b/crates/engine_api/Cargo.toml index 2ec66152..b0f521f8 100644 --- a/crates/engine_api/Cargo.toml +++ b/crates/engine_api/Cargo.toml @@ -28,6 +28,7 @@ test-el = ["dep:httparse"] [dev-dependencies] httparse.workspace = true +silver_engine_api = { workspace = true, features = ["test-el"] } tempfile = "3" tracing-subscriber.workspace = true diff --git a/crates/engine_api/src/lib.rs b/crates/engine_api/src/lib.rs index fb9ea03f..c72f0708 100644 --- a/crates/engine_api/src/lib.rs +++ b/crates/engine_api/src/lib.rs @@ -11,5 +11,7 @@ mod types; pub use api::EngineApi; pub use client::EngineClient; +#[cfg(feature = "test-el")] +pub use client::{ReqKind, poll, send_new_payload}; pub use error::EngineError; pub use jwt::JwtSecret; diff --git a/crates/engine_api/tests/newpayload_alloc.rs b/crates/engine_api/tests/newpayload_alloc.rs new file mode 100644 index 00000000..61e696a2 --- /dev/null +++ b/crates/engine_api/tests/newpayload_alloc.rs @@ -0,0 +1,113 @@ +//! 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, poll, send_new_payload, + test_el::{FakeEl, write_jwt}, +}; + +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(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; + } + poll(client, |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 client = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 4); + + send_new_payload(&mut client, SIGNED_BLOCK_SSZ, [0u8; 32]).unwrap(); + complete_round_trip(&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 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 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/src/server.rs b/crates/httpcore/src/server.rs index 4371a46c..540b0a8c 100644 --- a/crates/httpcore/src/server.rs +++ b/crates/httpcore/src/server.rs @@ -3,6 +3,7 @@ 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> { @@ -59,7 +60,7 @@ pub enum AfterResponse { } pub struct ServerConnection { - read_buf: Box<[u8; READ_BUF_MAX]>, + read_buf: Vec, read_pos: usize, read_end: usize, write_buf: Vec, @@ -70,7 +71,7 @@ pub struct ServerConnection { impl ServerConnection { pub fn new() -> Self { Self { - read_buf: Box::new([0u8; READ_BUF_MAX]), + read_buf: vec![0u8; READ_BUF_INIT], read_pos: 0, read_end: 0, write_buf: Vec::with_capacity(WRITE_BUF_INIT), @@ -80,14 +81,25 @@ impl ServerConnection { } 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 <= READ_BUF_MAX); + debug_assert!(self.read_end + n <= self.read_buf.len()); self.read_end += n; } @@ -178,6 +190,32 @@ mod tests { 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()); @@ -367,17 +405,96 @@ mod tests { #[test] fn read_space_exhausted_rejects_request_too_large() { let mut conn = ServerConnection::new(); - let space = conn.read_space().unwrap(); - let header = b"POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: 33554432\r\n\r\n"; - space[..header.len()].copy_from_slice(header); - let n = space.len(); - conn.commit_read(n); + feed( + &mut conn, + b"POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: 33554432\r\n\r\n", + ); - assert!( - !conn.dispatch(&|_, _: &mut Vec| panic!("incomplete request must not dispatch")) + 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_just_over_cap_rejects_with_identical_error() { + let mut conn = ServerConnection::new(); + let header = format!( + "POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: {READ_BUF_MAX}\r\n\r\n" ); - let err = conn.read_space().unwrap_err(); + feed(&mut conn, header.as_bytes()); + + 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 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..]); + } } From cc94b7f6847e9dd930d2ab2973c06efcbf1b6acb Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 10:51:31 +0100 Subject: [PATCH 09/33] Add inbound and outbound API deadlines (CL-114, CL-115) Outbound (CL-114): EngineConfig::request_timeout_secs, default 12. Each pooled connection records its request's enqueue time; the poll sweep fails any request older than the deadline through the existing error path, freeing the connection and un-gating spine intake. Age is anchored at enqueue, so a request stuck behind a blackholed connect expires on the same clock. The default clears every per-method floor in the engine-api spec (1s getPayload-class, 8s newPayload/fcu, 10s getPayloadBodies) -- those floors are minimum waits before aborting, and this deadline is a wedge-breaker, not a latency target. Inbound (CL-115): Config::beacon_api_idle_timeout_secs, default 75 -- a keep-alive window spanning several 12s slots. Connections stamp activity on accept and on every read or written byte; a coarse sweep (at most once per second) reaps connections idle past the deadline, treating malformed, partial, and silent input uniformly: a stalled receiver is idle, a trickling-but-progressing peer is not. Reaped connections free their beacon_api_max_connections slot, closing the cap-exhaustion scenario. Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/examples/srv.rs | 10 +- crates/beacon_api/src/server.rs | 255 +++++++++++++++++--- crates/bin/src/main.rs | 1 + crates/client_server/tests/tile.rs | 1 + crates/config/src/engine_config.rs | 11 + crates/config/src/lib.rs | 28 ++- crates/engine_api/src/api.rs | 1 + crates/engine_api/src/client.rs | 27 ++- crates/engine_api/src/pool.rs | 154 +++++++++++- crates/engine_api/tests/newpayload_alloc.rs | 3 +- 10 files changed, 448 insertions(+), 43 deletions(-) diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index e83466be..0660738c 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -12,7 +12,15 @@ fn main() { // Never-published reader: state endpoints answer 503, as pre-bootstrap. let state = BeaconStateOwner::empty_test(0).reader(); - let mut api = BeaconApi::new(&bind, 64, &keypair, local_enr, &Identify::default(), state); + let mut api = BeaconApi::new( + &bind, + 64, + Duration::from_secs(75), + &keypair, + local_enr, + &Identify::default(), + state, + ); println!("serving on {:?}", api.local_addr()); loop { api.pump(); diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index b8fab32b..eeac943d 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, io::{self, Read, Write}, - time::Duration, + time::{Duration, Instant}, }; use mio::{Events, Interest, Poll, Token}; @@ -16,9 +16,35 @@ use crate::{ const LISTENER: Token = Token(0); +const MAX_SWEEP_INTERVAL: Duration = Duration::from_secs(1); + struct Connection { stream: Stream, http: ServerConnection, + last_activity: Instant, +} + +/// 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 { @@ -26,6 +52,7 @@ pub struct BeaconApi { events: Events, listener: Listener, max_connections: usize, + idle: IdleSweep, current_token: Token, connections: HashMap, router: Router, @@ -36,6 +63,7 @@ impl BeaconApi { pub fn new( bind: &Bind, max_connections: usize, + idle_timeout: Duration, keypair: &Keypair, local_enr: Enr, identify: &Identify, @@ -51,6 +79,7 @@ impl BeaconApi { events: Events::with_capacity(1024), listener, max_connections, + idle: IdleSweep::new(idle_timeout), current_token: Token(LISTENER.0 + 1), connections: HashMap::new(), router: Router::new(ROUTES), @@ -64,6 +93,7 @@ impl BeaconApi { pub fn pump(&mut self) -> bool { self.poll.poll(&mut self.events, Some(Duration::ZERO)).unwrap(); + let now = Instant::now(); let mut did_work = false; for event in &self.events { @@ -91,13 +121,16 @@ impl BeaconApi { } let token = next(&mut self.current_token); self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); - self.connections - .insert(token, Connection { stream, http: ServerConnection::new() }); + self.connections.insert(token, Connection { + stream, + http: ServerConnection::new(), + last_activity: now, + }); }, token => { if let Some(conn) = self.connections.get_mut(&token) { did_work = true; - match handle_event(self.poll.registry(), conn, event, &|req, out| { + match handle_event(self.poll.registry(), conn, event, now, &|req, out| { self.router.dispatch(req, &self.ctx, out) }) { Ok(true) => { @@ -116,14 +149,34 @@ impl BeaconApi { } } + if self.idle.due(now) { + did_work |= self.close_idle(now); + } + did_work } + + fn close_idle(&mut self, now: Instant) -> bool { + let Self { connections, poll, idle, .. } = self; + let before = connections.len(); + connections.retain(|_, conn| { + let idle_for = now.duration_since(conn.last_activity); + if idle_for <= idle.timeout { + return true; + } + tracing::warn!("beacon api connection idle for {idle_for:?}, closing"); + let _ = poll.registry().deregister(&mut conn.stream); + false + }); + connections.len() != before + } } fn handle_event, &mut Vec)>( registry: &mio::Registry, conn: &mut Connection, event: &mio::event::Event, + now: Instant, request_handler: &F, ) -> io::Result { if event.is_readable() { @@ -131,7 +184,10 @@ fn handle_event, &mut Vec)>( let space = conn.http.read_space()?; match conn.stream.read(space) { Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), - Ok(n) => conn.http.commit_read(n), + Ok(n) => { + conn.last_activity = now; + conn.http.commit_read(n); + } Err(e) if would_block(&e) => break, Err(e) if interrupted(&e) => continue, Err(e) => return Err(e), @@ -152,6 +208,7 @@ fn handle_event, &mut Vec)>( return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")) } Ok(n) => { + conn.last_activity = now; conn.http.commit_write(n); if conn.http.pending_write().is_empty() { break; @@ -215,13 +272,39 @@ mod tests { assert_eq!(cur.0, LISTENER.0 + 1); } - fn pump_until(api: &mut BeaconApi, client: JoinHandle, msg: &str) -> T { + /// Longer than any test's 10 s spin deadline: the idle sweep never reaps. + const LONG_TIMEOUT: Duration = Duration::from_secs(60); + + fn api_with(max_connections: usize, idle_timeout: Duration) -> BeaconApi { + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); + let local_enr = Enr::empty(keypair.secret_key()).unwrap(); + BeaconApi::new( + &Bind::parse("127.0.0.1:0"), + max_connections, + idle_timeout, + &keypair, + local_enr, + &Identify::default(), + BeaconStateOwner::empty_test(0).reader(), + ) + } + + fn tcp_addr(api: &BeaconApi) -> SocketAddr { + let Bind::Tcp(addr) = api.local_addr() else { panic!("expected tcp bind") }; + addr + } + + fn pump_until(api: &mut BeaconApi, msg: &str, mut done: impl FnMut(&BeaconApi) -> bool) { let deadline = Instant::now() + Duration::from_secs(10); - while !client.is_finished() { + while !done(api) { assert!(Instant::now() < deadline, "timeout: {msg}"); api.pump(); std::thread::sleep(Duration::from_millis(1)); } + } + + fn serve(api: &mut BeaconApi, client: JoinHandle, msg: &str) -> T { + pump_until(api, msg, |_| client.is_finished()); client.join().unwrap() } @@ -231,21 +314,24 @@ mod tests { stream } + fn read_to_eof(mut stream: TcpStream) -> 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}"), + } + } + } + #[test] fn connection_cap_drops_excess_then_recovers() { - let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); - let local_enr = Enr::empty(keypair.secret_key()).unwrap(); - let mut api = BeaconApi::new( - &Bind::parse("127.0.0.1:0"), - 1, - &keypair, - local_enr, - &Identify::default(), - BeaconStateOwner::empty_test(0).reader(), - ); - let Bind::Tcp(addr) = api.local_addr() else { panic!("expected tcp bind") }; + let mut api = api_with(1, LONG_TIMEOUT); + let addr = tcp_addr(&api); - let held_open = pump_until( + let held_open = serve( &mut api, std::thread::spawn(move || { let mut stream = connect(addr); @@ -263,7 +349,7 @@ mod tests { "first client served", ); - let denied = pump_until( + let denied = serve( &mut api, std::thread::spawn(move || { let mut stream = connect(addr); @@ -279,14 +365,9 @@ mod tests { ); drop(held_open); - let deadline = Instant::now() + Duration::from_secs(10); - while !api.connections.is_empty() { - assert!(Instant::now() < deadline, "timeout: closed connection reaped"); - api.pump(); - std::thread::sleep(Duration::from_millis(1)); - } + pump_until(&mut api, "closed connection reaped", |api| api.connections.is_empty()); - let response = pump_until( + let response = serve( &mut api, std::thread::spawn(move || { let mut stream = connect(addr); @@ -300,4 +381,124 @@ mod tests { ); assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); } + + /// CL-115: a request that never completes holds its slot forever. Partial + /// and malformed input are treated alike — neither dispatches, so both are + /// reaped by the same idle deadline. + #[test] + fn partial_request_is_reaped_after_the_idle_deadline() { + let mut api = api_with(64, Duration::from_millis(200)); + let addr = tcp_addr(&api); + + let received = serve( + &mut api, + 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!(api.connections.is_empty(), "reaped connection must leave the map"); + } + + #[test] + fn idle_keep_alive_connection_is_reaped_after_the_idle_deadline() { + let idle_timeout = Duration::from_millis(200); + let mut api = api_with(64, idle_timeout); + let addr = tcp_addr(&api); + + let (received, alive_for) = serve( + &mut api, + 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!(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 api = api_with(64, idle_timeout); + let addr = tcp_addr(&api); + + // 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 api, + 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!(api.connections.len(), 1, "an active connection must survive the sweep"); + } + + /// The CL-115 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 api = api_with(1, Duration::from_millis(800)); + let addr = tcp_addr(&api); + + 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 api, "hung client holds the only slot", |api| api.connections.len() == 1); + + let denied = serve( + &mut api, + 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 api, hung, "hung client reaped").is_empty()); + + let response = serve( + &mut api, + 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/bin/src/main.rs b/crates/bin/src/main.rs index bfa5479b..db0338ce 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -235,6 +235,7 @@ fn main() -> Result<(), Box> { let beacon_api = BeaconApi::new( &Bind::parse(config.beacon_api_bind()), config.beacon_api_max_connections(), + config.beacon_api_idle_timeout(), &keypair, local_enr, &identify, diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index 7f07bc33..efaca5bf 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -32,6 +32,7 @@ fn beacon(bind: &Bind) -> BeaconApi { BeaconApi::new( bind, 64, + Duration::from_secs(75), &keypair, local_enr, &Identify::default(), diff --git a/crates/config/src/engine_config.rs b/crates/config/src/engine_config.rs index f48d9405..f22680f4 100644 --- a/crates/config/src/engine_config.rs +++ b/crates/config/src/engine_config.rs @@ -8,6 +8,13 @@ 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, @@ -17,6 +24,9 @@ pub struct EngineConfig { 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. @@ -31,6 +41,7 @@ impl Default for EngineConfig { 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 55674e07..700c28a6 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, +}; use chain_config::ChainConfig; pub use discovery_config::DiscoveryConfig; @@ -131,6 +134,10 @@ pub struct Config { beacon_api_bind: String, #[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, } @@ -167,6 +174,7 @@ impl Config { 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, } } @@ -229,6 +237,11 @@ impl Config { 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) } @@ -365,6 +378,10 @@ impl Config { 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 } @@ -397,6 +414,7 @@ mod tests { 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)); } #[test] @@ -415,6 +433,14 @@ mod tests { 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] fn builders_set_external_ip_and_genesis() { let cfg = Config::new([1u8; 32], [0u8; 4], [0u8; 4], 0) diff --git a/crates/engine_api/src/api.rs b/crates/engine_api/src/api.rs index 42bed05f..3c485e1c 100644 --- a/crates/engine_api/src/api.rs +++ b/crates/engine_api/src/api.rs @@ -47,6 +47,7 @@ impl EngineApi { &config.execution_endpoint, &config.jwt_secret, config.max_connections, + Duration::from_secs(config.request_timeout_secs), )) }; Self { diff --git a/crates/engine_api/src/client.rs b/crates/engine_api/src/client.rs index 48b6ae34..a155f7d7 100644 --- a/crates/engine_api/src/client.rs +++ b/crates/engine_api/src/client.rs @@ -55,18 +55,33 @@ pub struct EngineClient { } impl EngineClient { - pub fn new(endpoint: &str, jwt: &str, max_connections: usize) -> Self { - Self::with_endpoint(parse_endpoint(endpoint), jwt, max_connections) + pub fn new( + endpoint: &str, + jwt: &str, + max_connections: usize, + request_timeout: Duration, + ) -> Self { + Self::with_endpoint(parse_endpoint(endpoint), jwt, max_connections, request_timeout) } - pub fn new_uds(path: impl Into, jwt: &str, max_connections: usize) -> Self { - Self::with_endpoint(Endpoint::Uds(path.into()), jwt, max_connections) + pub fn new_uds( + path: impl Into, + jwt: &str, + max_connections: usize, + request_timeout: Duration, + ) -> Self { + Self::with_endpoint(Endpoint::Uds(path.into()), jwt, max_connections, request_timeout) } - fn with_endpoint(endpoint: Endpoint, jwt: &str, max_connections: usize) -> Self { + fn with_endpoint( + 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 { - pool: HttpPool::new(endpoint, jwt, max_connections), + pool: HttpPool::new(endpoint, jwt, max_connections, request_timeout), poll: Poll::new().expect("mio Poll::new failed"), events: Events::with_capacity(EVENTS_CAPACITY), id: 1, diff --git a/crates/engine_api/src/pool.rs b/crates/engine_api/src/pool.rs index 2d06ba03..9691d3f7 100644 --- a/crates/engine_api/src/pool.rs +++ b/crates/engine_api/src/pool.rs @@ -2,6 +2,7 @@ use std::{ io::{self, Read, Write}, net::{SocketAddr, ToSocketAddrs}, path::PathBuf, + time::{Duration, Instant}, }; use mio::{Events, Interest, Poll, Token}; @@ -54,6 +55,7 @@ struct PooledConnection { machine: ClientConnection, in_flight: Option, pending_id: Option, + request_started: Option, } impl PooledConnection { @@ -69,6 +71,7 @@ impl PooledConnection { machine: ClientConnection::with_capacity(READ_BUF_CAPACITY, WRITE_BUF_CAPACITY), in_flight: None, pending_id: None, + request_started: None, } } @@ -76,11 +79,18 @@ impl PooledConnection { 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], poll: &mut Poll) { 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(poll), @@ -213,11 +223,12 @@ impl PooledConnection { where F: FnMut(u64, Result<&mut [u8], EngineError>), { - let Self { conn, machine, in_flight, .. } = self; + 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)); } } @@ -243,6 +254,7 @@ impl PooledConnection { 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 { @@ -277,12 +289,18 @@ pub(crate) struct HttpPool { endpoint: Endpoint, jwt: JwtSecret, max_connections: usize, + request_timeout: Duration, } impl HttpPool { - pub(crate) fn new(endpoint: Endpoint, jwt: JwtSecret, max_connections: usize) -> Self { + pub(crate) fn new( + endpoint: Endpoint, + jwt: JwtSecret, + max_connections: usize, + request_timeout: Duration, + ) -> Self { let connections = vec![PooledConnection::new(endpoint.clone(), jwt.clone(), Token(0))]; - Self { connections, endpoint, jwt, max_connections } + Self { connections, endpoint, jwt, max_connections, request_timeout } } /// `enqueue` never refuses work; every caller gates on this before @@ -312,12 +330,15 @@ impl HttpPool { 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(poll, on_complete, "connect failed to start"); + } else if conn.expired(now, self.request_timeout) { + conn.fail(poll, on_complete, "request timed out"); } conn.handle_events(events, poll, on_complete); } @@ -326,10 +347,11 @@ impl HttpPool { #[cfg(test)] mod tests { - use std::time::{Duration, Instant}; + use std::os::unix::net::UnixListener; use tempfile::TempDir; + use super::*; use crate::{ EngineClient, client::{ReqKind, poll, send_fcu}, @@ -337,6 +359,9 @@ mod tests { types::ForkchoiceState, }; + /// Longer than any test's 10 s spin deadline: the sweep never fires. + const LONG_TIMEOUT: Duration = Duration::from_secs(60); + fn fcu_state(byte: u8) -> ForkchoiceState { ForkchoiceState { head_block_hash: [byte; 32], @@ -360,7 +385,8 @@ mod tests { let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32); + let mut client = + EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32, LONG_TIMEOUT); let block_root = [7u8; 32]; send_fcu(&mut client, block_root, fcu_state(1), None); @@ -400,7 +426,8 @@ mod tests { // max_connections = 1: after the failure, has_capacity() can only be // true again if the zombie connection was actually freed. - let mut client = EngineClient::new_uds(&missing_socket, jwt_path.to_str().unwrap(), 1); + let mut client = + EngineClient::new_uds(&missing_socket, jwt_path.to_str().unwrap(), 1, LONG_TIMEOUT); let block_root = [3u8; 32]; send_fcu(&mut client, block_root, fcu_state(3), None); assert!(!client.has_capacity(), "request occupies the only connection"); @@ -426,7 +453,8 @@ mod tests { let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32); + let mut client = + EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32, LONG_TIMEOUT); let block_root = [9u8; 32]; send_fcu(&mut client, block_root, fcu_state(2), None); @@ -448,4 +476,116 @@ mod tests { assert_eq!(failure.unwrap(), block_root); } + + /// CL-114: an EL that accepts a request and never answers used to wedge the + /// connection — and with `max_connections` reached, the gated spine intake + /// behind it — for the lifetime of the process. + #[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 = EngineClient::new_uds( + &socket, + jwt_path.to_str().unwrap(), + 1, + Duration::from_millis(200), + ); + send_fcu(&mut client, [1u8; 32], fcu_state(1), None); + + let mut timed_out: Option<[u8; 32]> = None; + spin_until("unanswered request times out", || { + poll(&mut client, |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.has_capacity(), "timed-out connection must be reusable"); + + send_fcu(&mut client, [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", || { + poll(&mut client, |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 = + EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 1, Duration::from_secs(2)); + send_fcu(&mut client, [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", || { + poll(&mut client, |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 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, 1, Duration::from_millis(100)); + let mut poll = Poll::new().unwrap(); + let events = Events::with_capacity(1); + + pool.enqueue(7, b"{}", &mut poll); + 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.poll_events(&events, &mut poll, &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_api/tests/newpayload_alloc.rs b/crates/engine_api/tests/newpayload_alloc.rs index 61e696a2..2bf8dbdd 100644 --- a/crates/engine_api/tests/newpayload_alloc.rs +++ b/crates/engine_api/tests/newpayload_alloc.rs @@ -82,7 +82,8 @@ fn warm_new_payload_send_allocates_nothing() { let jwt_path = write_jwt(dir.path()); let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 4); + let mut client = + EngineClient::new_uds(&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 client, &mut el, 0); From e68926de51fcb1db18aa27d6f7e383c6f8678e43 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 11:13:38 +0100 Subject: [PATCH 10/33] Support multiple simultaneous beacon-api binds beacon_api_bind becomes a list: a TOML array in the config file (default ["0.0.0.0:5051"], single-bind behavior unchanged), comma-delimited values on --beacon-api-bind (a comma cannot appear in a socket address and is pathological in a socket path). BeaconApi holds one listener per bind -- TCP and unix sockets side by side, multiple interfaces, several UDS paths with distinct permissions. Listeners occupy the reserved token range 0..n; connection tokens allocate above it and wrap back to it. The connection cap and idle sweep count connections across all listeners. Bind::parse now rejects a string that contains ':' but is not a valid socket address instead of silently treating it as a unix path: hostnames are not resolved, and with several binds a typo'd address would otherwise bind a stray socket file and half-serve rather than fail loudly at startup. An empty bind list panics at construction: a node with no API surface is the same class of misconfiguration as an unbindable address. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 1 + crates/beacon_api/Cargo.toml | 3 + crates/beacon_api/examples/srv.rs | 7 +- crates/beacon_api/src/server.rs | 236 +++++++++++++++++++++++++---- crates/bin/src/main.rs | 36 ++++- crates/client_server/tests/tile.rs | 8 +- crates/config/src/lib.rs | 39 +++-- crates/httpcore/src/stream.rs | 24 ++- 8 files changed, 301 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f2f6074..17c068ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4456,6 +4456,7 @@ dependencies = [ "silver_beacon_state_data", "silver_common", "silver_httpcore", + "tempfile", "tracing", ] diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index e62d76bf..f2452619 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -15,5 +15,8 @@ serde.workspace = true tracing.workspace = true serde_json = "1.0.149" +[dev-dependencies] +tempfile = "3" + [lints] workspace = true diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index 0660738c..a2885c81 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -6,14 +6,15 @@ use silver_common::{Enr, Identify, Keypair}; use silver_httpcore::Bind; fn main() { - let bind = Bind::parse(&std::env::args().nth(1).unwrap_or_else(|| "0.0.0.0:5051".into())); + 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 api = BeaconApi::new( - &bind, + &binds, 64, Duration::from_secs(75), &keypair, @@ -21,7 +22,7 @@ fn main() { &Identify::default(), state, ); - println!("serving on {:?}", api.local_addr()); + println!("serving on {:?}", api.local_addrs()); loop { api.pump(); std::thread::sleep(Duration::from_millis(1)); diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index eeac943d..eb07d8d1 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -14,8 +14,6 @@ use crate::{ routes::{ApiCtx, ROUTES}, }; -const LISTENER: Token = Token(0); - const MAX_SWEEP_INTERVAL: Duration = Duration::from_secs(1); struct Connection { @@ -50,7 +48,7 @@ impl IdleSweep { pub struct BeaconApi { poll: Poll, events: Events, - listener: Listener, + listeners: Vec, max_connections: usize, idle: IdleSweep, current_token: Token, @@ -61,7 +59,7 @@ pub struct BeaconApi { impl BeaconApi { pub fn new( - bind: &Bind, + binds: &[Bind], max_connections: usize, idle_timeout: Duration, keypair: &Keypair, @@ -69,26 +67,34 @@ impl BeaconApi { identify: &Identify, state: BeaconStateReader, ) -> Self { + assert!(!binds.is_empty(), "beacon api needs at least one bind"); let poll = Poll::new().unwrap(); - let mut listener = - Listener::bind(bind).unwrap_or_else(|e| panic!("beacon api bind {bind:?}: {e}")); - poll.registry().register(&mut listener, LISTENER, Interest::READABLE).unwrap(); + let listeners = binds + .iter() + .enumerate() + .map(|(index, bind)| { + let mut listener = Listener::bind(bind) + .unwrap_or_else(|e| panic!("beacon api bind {bind:?}: {e}")); + poll.registry().register(&mut listener, Token(index), Interest::READABLE).unwrap(); + listener + }) + .collect::>(); Self { poll, events: Events::with_capacity(1024), - listener, max_connections, idle: IdleSweep::new(idle_timeout), - current_token: Token(LISTENER.0 + 1), + current_token: Token(listeners.len()), + listeners, connections: HashMap::new(), router: Router::new(ROUTES), ctx: ApiCtx::new(keypair, &local_enr, identify, state), } } - pub fn local_addr(&self) -> Bind { - self.listener.local_addr() + pub fn local_addrs(&self) -> Vec { + self.listeners.iter().map(Listener::local_addr).collect() } pub fn pump(&mut self) -> bool { @@ -97,9 +103,9 @@ impl BeaconApi { let mut did_work = false; for event in &self.events { - match event.token() { - LISTENER => loop { - let mut stream = match self.listener.accept() { + match self.listeners.get(event.token().0) { + Some(listener) => loop { + let mut stream = match listener.accept() { Ok(stream) => stream, Err(e) if would_block(&e) => break, Err(e) => { @@ -119,7 +125,7 @@ impl BeaconApi { ); continue; } - let token = next(&mut self.current_token); + let token = next(&mut self.current_token, self.listeners.len()); self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); self.connections.insert(token, Connection { stream, @@ -127,7 +133,8 @@ impl BeaconApi { last_activity: now, }); }, - token => { + None => { + let token = event.token(); if let Some(conn) = self.connections.get_mut(&token) { did_work = true; match handle_event(self.poll.registry(), conn, event, now, &|req, out| { @@ -235,11 +242,12 @@ fn handle_event, &mut Vec)>( Ok(false) } -fn next(current: &mut Token) -> Token { +/// Connection tokens sit above the listener range `0..reserved`, which the +/// wrap must skip to avoid aliasing an accept socket. +fn next(current: &mut Token, reserved: usize) -> Token { let tok = Token(current.0); let n = current.0.wrapping_add(1); - // Skip Token(0) == LISTENER on wrap to avoid aliasing the accept socket. - current.0 = if n == LISTENER.0 { LISTENER.0 + 1 } else { n }; + current.0 = if n < reserved { reserved } else { n }; tok } @@ -255,6 +263,8 @@ fn interrupted(err: &io::Error) -> bool { mod tests { use std::{ net::{SocketAddr, TcpStream}, + os::unix::net::UnixStream, + path::Path, thread::JoinHandle, time::Instant, }; @@ -264,22 +274,27 @@ mod tests { use super::*; #[test] - fn token_wrap_skips_listener() { + fn token_wrap_skips_the_listener_range() { + let reserved = 3; + let mut cur = Token(usize::MAX); - let assigned = next(&mut cur); - assert_ne!(assigned, LISTENER, "returned token must not alias LISTENER"); - assert_ne!(cur, LISTENER, "next token must not alias LISTENER after wrap"); - assert_eq!(cur.0, LISTENER.0 + 1); + let assigned = next(&mut cur, reserved); + assert!(assigned.0 >= reserved, "returned token must not alias a listener"); + assert_eq!(cur, Token(reserved), "the wrap must land above the listener range"); + + let mut cur = Token(reserved); + assert_eq!(next(&mut cur, reserved), Token(reserved)); + assert_eq!(cur, Token(reserved + 1)); } /// Longer than any test's 10 s spin deadline: the idle sweep never reaps. const LONG_TIMEOUT: Duration = Duration::from_secs(60); - fn api_with(max_connections: usize, idle_timeout: Duration) -> BeaconApi { + fn api_bound_to(binds: &[Bind], max_connections: usize, idle_timeout: Duration) -> BeaconApi { let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); BeaconApi::new( - &Bind::parse("127.0.0.1:0"), + binds, max_connections, idle_timeout, &keypair, @@ -289,9 +304,22 @@ mod tests { ) } + fn api_with(max_connections: usize, idle_timeout: Duration) -> BeaconApi { + api_bound_to(&[Bind::parse("127.0.0.1:0")], max_connections, idle_timeout) + } + + fn tcp_addrs(api: &BeaconApi) -> Vec { + api.local_addrs() + .into_iter() + .map(|bind| { + let Bind::Tcp(addr) = bind else { panic!("expected tcp bind") }; + addr + }) + .collect() + } + fn tcp_addr(api: &BeaconApi) -> SocketAddr { - let Bind::Tcp(addr) = api.local_addr() else { panic!("expected tcp bind") }; - addr + tcp_addrs(api)[0] } fn pump_until(api: &mut BeaconApi, msg: &str, mut done: impl FnMut(&BeaconApi) -> bool) { @@ -308,12 +336,45 @@ mod tests { client.join().unwrap() } + fn serve_both( + api: &mut BeaconApi, + first: JoinHandle, + second: JoinHandle, + msg: &str, + ) -> (T, T) { + pump_until(api, msg, |_| first.is_finished() && second.is_finished()); + (first.join().unwrap(), second.join().unwrap()) + } + 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: TcpStream) -> Vec { let mut received = Vec::new(); let mut chunk = [0u8; 1024]; @@ -326,6 +387,125 @@ mod tests { } } + #[test] + #[should_panic(expected = "at least one bind")] + fn an_empty_bind_list_is_rejected() { + api_bound_to(&[], 64, LONG_TIMEOUT); + } + + #[test] + fn every_tcp_listener_serves_the_api() { + let mut api = api_bound_to( + &[Bind::parse("127.0.0.1:0"), Bind::parse("127.0.0.1:0")], + 64, + LONG_TIMEOUT, + ); + + let addrs = tcp_addrs(&api); + 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 api, + 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 api = api_bound_to( + &[Bind::parse("127.0.0.1:0"), Bind::Unix(socket.clone())], + 64, + LONG_TIMEOUT, + ); + + let addrs = 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 api, + 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 api = api_bound_to( + &[Bind::parse("127.0.0.1:0"), Bind::parse("127.0.0.1:0")], + 1, + LONG_TIMEOUT, + ); + let addrs = tcp_addrs(&api); + let (held, other) = (addrs[0], addrs[1]); + + let held_open = serve( + &mut api, + 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!(api.connections.len(), 1); + assert!( + api.connections.keys().all(|token| token.0 >= 2), + "connection tokens must clear the listener range: {:?}", + api.connections.keys().collect::>() + ); + + let denied = serve( + &mut api, + 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 api, "closed connection reaped", |api| api.connections.is_empty()); + + let response = serve( + &mut api, + 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 api = api_with(1, LONG_TIMEOUT); diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index db0338ce..bd39ddd0 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -232,8 +232,10 @@ fn main() -> Result<(), Box> { !config.disable_weak_subjectivity_check(), state, ); + let beacon_api_binds = + config.beacon_api_bind().iter().map(String::as_str).map(Bind::parse).collect::>(); let beacon_api = BeaconApi::new( - &Bind::parse(config.beacon_api_bind()), + &beacon_api_binds, config.beacon_api_max_connections(), config.beacon_api_idle_timeout(), &keypair, @@ -331,10 +333,10 @@ fn load_config() -> Result { if args.iter().any(|a| a == "--unsafe-no-el") { config = config.with_unsafe_no_el(true); } - if let Some(bind) = + 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(bind.clone()); + config = config.with_beacon_api_bind(comma_separated(binds)); } tracing::info!("loaded config: {config:#?}"); @@ -342,6 +344,13 @@ fn load_config() -> Result { 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 { @@ -376,3 +385,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/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index efaca5bf..ac2cf5bc 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -30,7 +30,7 @@ fn beacon(bind: &Bind) -> BeaconApi { let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); BeaconApi::new( - bind, + std::slice::from_ref(bind), 64, Duration::from_secs(75), &keypair, @@ -96,7 +96,7 @@ fn serves_identity_over_tcp() { }; let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); - let Bind::Tcp(addr) = tile.beacon.local_addr() else { panic!("expected tcp bind") }; + 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 = std::thread::spawn(move || { @@ -125,7 +125,7 @@ fn serves_identity_over_uds() { }; let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); - assert_eq!(tile.beacon.local_addr(), Bind::Unix(socket.clone())); + assert_eq!(tile.beacon.local_addrs(), [Bind::Unix(socket.clone())]); let client = std::thread::spawn(move || { let stream = UnixStream::connect(&socket).unwrap(); @@ -190,7 +190,7 @@ fn serves_beacon_api_while_engine_call_in_flight() { // 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_addr() else { panic!("expected tcp bind") }; + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; let client = std::thread::spawn(move || { let stream = TcpStream::connect(addr).unwrap(); stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 700c28a6..1f7a4ac1 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -36,8 +36,8 @@ const fn default_u64() -> u64 { V } -fn default_beacon_api_bind() -> String { - "0.0.0.0:5051".into() +fn default_beacon_api_bind() -> Vec { + vec!["0.0.0.0:5051".into()] } fn default_data_dir() -> String { @@ -129,9 +129,10 @@ pub struct Config { data_storage_dir: String, #[serde(default)] engine_config: EngineConfig, - /// TCP `addr:port` or a unix socket path. + /// 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: String, + 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 @@ -227,8 +228,8 @@ impl Config { self } - pub fn with_beacon_api_bind(mut self, bind: String) -> Self { - self.beacon_api_bind = bind; + pub fn with_beacon_api_bind(mut self, binds: Vec) -> Self { + self.beacon_api_bind = binds; self } @@ -370,7 +371,7 @@ impl Config { self.engine_config.clone() } - pub fn beacon_api_bind(&self) -> &str { + pub fn beacon_api_bind(&self) -> &[String] { &self.beacon_api_bind } @@ -412,17 +413,33 @@ 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_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)); } + #[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("/run/beacon.sock".into()); - assert_eq!(cfg.beacon_api_bind(), "/run/beacon.sock"); + 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] diff --git a/crates/httpcore/src/stream.rs b/crates/httpcore/src/stream.rs index e63eda53..a87a843c 100644 --- a/crates/httpcore/src/stream.rs +++ b/crates/httpcore/src/stream.rs @@ -20,7 +20,14 @@ impl Bind { pub fn parse(text: &str) -> Self { match text.parse() { Ok(addr) => Self::Tcp(addr), - Err(_) => Self::Unix(PathBuf::from(text)), + 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)) + } } } } @@ -205,9 +212,18 @@ mod tests { 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())); - // Hostnames don't parse as SocketAddr (no resolution here), so they - // fall through to a path. - assert_eq!(Bind::parse("localhost:5051"), Bind::Unix("localhost:5051".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] From 828ebdb2ef2499cec7a0bbb3db718b34b28803cf Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 11:22:05 +0100 Subject: [PATCH 11/33] Refresh ADRs 0002 and 0004 for team decisions of 2026-08-18 ADR-0002 records the QUIC/HTTP-3 rejection: QUIC mandates TLS 1.3 (RFC 9001), which the ADR already declares a non-goal, and no validator client speaks HTTP/3 -- noted so the alternative is not re-litigated. ADR-0004's SSE paragraph reflected a deferral the team has since reversed: /eth/v1/events will be served, in-process, as the single sanctioned exception to the materialized-response model, fed from a spine events queue. The 404 shipped today is interim behavior; implementation follows the initial endpoint surface, and the SSE design round will amend the ADR with the concrete mechanism. Both ADRs are still status: proposed, so they are amended in place rather than superseded. Assisted-by: Claude:claude-fable-5 --- docs/adr/0002-hand-rolled-http.md | 5 ++++- docs/adr/0004-sync-materialized-api.md | 25 ++++++++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/adr/0002-hand-rolled-http.md b/docs/adr/0002-hand-rolled-http.md index a3593756..10a9a49e 100644 --- a/docs/adr/0002-hand-rolled-http.md +++ b/docs/adr/0002-hand-rolled-http.md @@ -15,7 +15,10 @@ existed twice (engine `http.rs` and the beacon_api prototype, plus a dead 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. Auth is protocol-layer, +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/0004-sync-materialized-api.md b/docs/adr/0004-sync-materialized-api.md index 63e1f0c1..d8be9652 100644 --- a/docs/adr/0004-sync-materialized-api.md +++ b/docs/adr/0004-sync-materialized-api.md @@ -11,13 +11,20 @@ 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 the whole surface v1 targets: verified against the -beacon-APIs spec and five validator clients (see +This holds for every request/response endpoint in the targeted surface: +verified against the beacon-APIs spec and five validator clients (see `.local/beacon-api-vc-surface.md`, untracked), nothing a validator client -requires streams or long-polls except the optional `/eth/v1/events` SSE -stream, which every surveyed client can replace with polling. v1 answers it -with a clean 404 and tolerates client reconnect retries. If subscriptions -are ever wanted, they may be served out-of-process (e.g. a circular-buffer -export read by a separate serving process) rather than by adding streaming -here. Endpoints whose response cannot be materialized in a bounded buffer -are out of scope by construction; revisit this ADR before accepting one. +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. From bbfee39445a6e82ccdb94cd00612768dd150a65b Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 13:38:22 +0100 Subject: [PATCH 12/33] httpcore: negotiation headers, query decoding, 400 on provable garbage First M2 infrastructure commit (I1). ParsedRequest exposes the three request headers content negotiation needs -- Accept, Content-Type, Eth-Consensus-Version -- as borrowed fields (no general header map). A Query iterator percent-decodes key/value pairs, zero-alloc when no escape is present; '+' stays literal (RFC 3986, not form encoding), and malformed escapes pass through rather than panic. The parse path now distinguishes knowledge from ambiguity (CL-115's framing): definitively malformed input -- httparse errors including more than 64 headers, an unparseable or overflowing Content-Length -- gets an immediate 400-and-close instead of silently stalling until the idle sweep, while genuinely partial input still waits for more bytes. Verified against httparse 1.10.1 at every truncation offset that a request within limits can never be misclassified mid-stream. Side effect: an HTTP/2 preface now draws a 400 instead of a silent stall. Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/src/router.rs | 15 +- crates/beacon_api/src/routes.rs | 3 + crates/beacon_api/src/server.rs | 8 +- crates/engine_api/src/pool.rs | 3 - crates/httpcore/src/lib.rs | 2 + crates/httpcore/src/query.rs | 141 +++++++++++++++++ crates/httpcore/src/server.rs | 273 +++++++++++++++++++++++++++----- 7 files changed, 399 insertions(+), 46 deletions(-) create mode 100644 crates/httpcore/src/query.rs diff --git a/crates/beacon_api/src/router.rs b/crates/beacon_api/src/router.rs index f0d258df..fc25b584 100644 --- a/crates/beacon_api/src/router.rs +++ b/crates/beacon_api/src/router.rs @@ -165,7 +165,17 @@ mod tests { use crate::routes::preboot_ctx; fn request<'a>(method: &'a str, path: &'a str) -> ParsedRequest<'a> { - ParsedRequest { method, path, query: "", body: b"", version: 1, keep_alive: true } + 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 { @@ -252,6 +262,9 @@ mod tests { path: "/submit", query: "k=v", body: b"payload", + accept: None, + content_type: None, + eth_consensus_version: None, version: 1, keep_alive: true, }; diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index 66aad9ee..758ee28a 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -87,6 +87,9 @@ mod tests { path, query: "", body: b"", + accept: None, + content_type: None, + eth_consensus_version: None, version: 1, keep_alive: true, }; diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index eb07d8d1..443b216d 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -562,9 +562,9 @@ mod tests { assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); } - /// CL-115: a request that never completes holds its slot forever. Partial - /// and malformed input are treated alike — neither dispatches, so both are - /// reaped by the same idle deadline. + /// 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 api = api_with(64, Duration::from_millis(200)); @@ -636,7 +636,7 @@ mod tests { assert_eq!(api.connections.len(), 1, "an active connection must survive the sweep"); } - /// The CL-115 exhaustion scenario end to end: a hung client owns the only + /// 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() { diff --git a/crates/engine_api/src/pool.rs b/crates/engine_api/src/pool.rs index 9691d3f7..9c03a400 100644 --- a/crates/engine_api/src/pool.rs +++ b/crates/engine_api/src/pool.rs @@ -477,9 +477,6 @@ mod tests { assert_eq!(failure.unwrap(), block_root); } - /// CL-114: an EL that accepts a request and never answers used to wedge the - /// connection — and with `max_connections` reached, the gated spine intake - /// behind it — for the lifetime of the process. #[test] fn unanswered_request_times_out_and_frees_connection() { let dir = TempDir::new().unwrap(); diff --git a/crates/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs index 7efa53d4..54a14417 100644 --- a/crates/httpcore/src/lib.rs +++ b/crates/httpcore/src/lib.rs @@ -1,7 +1,9 @@ mod client; +mod query; mod server; mod stream; pub use client::{ClientConnection, frame_request}; +pub use query::Query; pub use server::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; pub use stream::{Bind, Listener, Stream}; 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/server.rs b/crates/httpcore/src/server.rs index 540b0a8c..9177493c 100644 --- a/crates/httpcore/src/server.rs +++ b/crates/httpcore/src/server.rs @@ -11,46 +11,84 @@ pub struct ParsedRequest<'a> { 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` means +/// the bytes can never become a request, so no amount of waiting helps. +enum ParseOutcome<'a> { + Complete { consumed: usize, request: ParsedRequest<'a> }, + Incomplete, + Malformed, +} + impl<'a> ParsedRequest<'a> { - fn parse(buf: &'a [u8]) -> Option<(usize, Self)> { + 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, - _ => return None, + 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 method = req.method?; - let raw_path = req.path?; let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); - let version = req.version?; 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 content_length: usize = - match headers.iter().find(|h| h.name.eq_ignore_ascii_case("content-length")) { - None => 0, - Some(h) => std::str::from_utf8(h.value).ok().and_then(|v| v.trim().parse().ok())?, - }; - let total = headers_end + content_length; + + 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; + }; if buf.len() < total { - return None; + 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, + }, } - Some((total, Self { - method, - path, - query, - body: &buf[headers_end..total], - 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 { @@ -104,11 +142,20 @@ impl ServerConnection { } pub fn dispatch, &mut Vec)>(&mut self, handler: &F) -> bool { - let Some((consumed, req)) = - ParsedRequest::parse(&self.read_buf[self.read_pos..self.read_end]) - else { - return false; - }; + 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, + // Framing is lost, so there is nothing left to resynchronise + // on: answer, drop the whole buffer and let the caller close. + ParseOutcome::Malformed => { + self.keep_alive = false; + frame_response(&mut self.write_buf, "400 Bad Request", None, b""); + self.read_pos = 0; + self.read_end = 0; + return true; + } + }; if req.version != 1 { tracing::warn!("rejecting HTTP/1.0 request"); self.keep_alive = false; @@ -184,6 +231,16 @@ mod tests { 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); @@ -226,10 +283,31 @@ mod tests { 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"), + } + } + + fn reject_and_close(request: &[u8]) { + let mut conn = ServerConnection::new(); + feed(&mut conn, request); + + assert!(conn.dispatch(&|_, _: &mut Vec| { + panic!("malformed request must not reach the handler") + })); + assert_eq!(conn.pending_write(), b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); + + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Close); + } + #[test] fn parse_http11_defaults_keep_alive() { let req = get_req("/eth/v1/node/identity", "HTTP/1.1"); - let (_, r) = ParsedRequest::parse(&req).unwrap(); + let (_, r) = parsed(&req); assert_eq!(r.path, "/eth/v1/node/identity"); assert!(r.keep_alive); } @@ -237,7 +315,7 @@ mod tests { #[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) = ParsedRequest::parse(req).unwrap(); + let (_, r) = parsed(req); assert_eq!(r.path, "/metrics"); assert!(!r.keep_alive); } @@ -245,19 +323,20 @@ mod tests { #[test] fn parse_http10_defaults_close() { let req = get_req("/", "HTTP/1.0"); - let (_, r) = ParsedRequest::parse(&req).unwrap(); + let (_, r) = parsed(&req); assert!(!r.keep_alive); } #[test] - fn parse_partial_returns_none() { - assert!(ParsedRequest::parse(b"GET /eth/v1/node/identity HTTP/1.1\r\n").is_none()); + 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) = ParsedRequest::parse(&req).unwrap(); + let (_, r) = parsed(&req); assert_eq!(r.path, "/eth/v1/beacon/states/head/validators"); assert_eq!(r.query, "status=active"); } @@ -270,10 +349,9 @@ mod tests { body.len() ); let mut buf = req.into_bytes(); - // incomplete — body not yet arrived - assert!(ParsedRequest::parse(&buf).is_none()); + assert!(matches!(ParsedRequest::parse(&buf), ParseOutcome::Incomplete), "body not arrived"); buf.extend_from_slice(body); - let (consumed, r) = ParsedRequest::parse(&buf).unwrap(); + let (consumed, r) = parsed(&buf); assert_eq!(r.method, "POST"); assert_eq!(r.body, body.as_ref()); assert_eq!(consumed, buf.len()); @@ -285,17 +363,136 @@ mod tests { 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) = ParsedRequest::parse(&buf).unwrap(); + let (consumed, r) = parsed(&buf); assert_eq!(r.path, "/metrics"); assert_eq!(consumed, req1.len()); - let (_, r2) = ParsedRequest::parse(&buf[consumed..]).unwrap(); + let (_, r2) = parsed(&buf[consumed..]); assert_eq!(r2.path, "/eth/v1/node/identity"); } #[test] - fn parse_invalid_content_length_returns_none() { + 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!(ParsedRequest::parse(req).is_none()); + 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_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_closes() { + reject_and_close(b"NOT A VALID REQUEST\r\n\r\n"); + } + + #[test] + fn dispatch_more_headers_than_fit_writes_400_then_closes() { + reject_and_close(&overlong_header_req()); + } + + #[test] + fn dispatch_invalid_content_length_writes_400_then_closes() { + reject_and_close(b"POST /foo HTTP/1.1\r\nHost: x\r\nContent-Length: abc\r\n\r\n"); + } + + #[test] + fn dispatch_content_length_beyond_usize_writes_400_then_closes() { + reject_and_close( + b"POST /foo HTTP/1.1\r\nHost: x\r\nContent-Length: 99999999999999999999\r\n\r\n", + ); + } + + #[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\nContent-Length: 0\r\n\r\n"); + + drain(&mut conn); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Close); } #[test] From 89d6bcd2c47f4fa6cee7bc8abca95aea479c738a Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 14:03:10 +0100 Subject: [PATCH 13/33] Widen Response: any status, extra headers, indexed errors M2 infrastructure (I2), pure mechanics. Response::send frames any status via a code -> status-line map (zero-alloc for mapped codes; unmapped codes frame a bare numeric status line, legal per RFC 9112 s4.1 where the reason phrase is optional, with a warn preserving the diagnostic the old unreachable! carried). frame_response_with_headers emits extra response headers in caller order; frame_response delegates to it. Response::indexed_error writes the beacon-api IndexedErrorMessage shape for per-item publish failures; an empty failures array is emitted as-is (required but no minItems in the spec schema). All existing response bytes are unchanged, pinned by the pre-existing byte-exact tests. Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/src/response.rs | 156 +++++++++++++++++++++++++++--- crates/httpcore/src/lib.rs | 4 +- crates/httpcore/src/server.rs | 70 ++++++++++++-- 3 files changed, 209 insertions(+), 21 deletions(-) diff --git a/crates/beacon_api/src/response.rs b/crates/beacon_api/src/response.rs index 732aa23c..1ba70055 100644 --- a/crates/beacon_api/src/response.rs +++ b/crates/beacon_api/src/response.rs @@ -1,40 +1,111 @@ -use silver_httpcore::frame_response; +use std::{borrow::Cow, fmt::Write}; + +use silver_httpcore::frame_response_with_headers; + +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]) { - frame_response(self.out, "200 OK", Some("application/json"), body); + self.send(200, Some(JSON_CONTENT_TYPE), &[], body); } pub(crate) fn empty(&mut self, content_type: &str) { - frame_response(self.out, "200 OK", Some(content_type), b""); + self.send(200, Some(content_type), &[], b""); + } + + pub(crate) fn send( + &mut self, + code: u16, + content_type: Option<&str>, + headers: &[(&str, &str)], + body: &[u8], + ) { + let status = match status_line(code) { + Some(status) => Cow::Borrowed(status), + None => { + tracing::warn!("no reason phrase for status {code}"); + Cow::Owned(format!("{code} ")) + } + }; + 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!(!message.contains(['"', '\\']), "message goes into JSON unescaped"); - let status = match code { - 400 => "400 Bad Request", - 405 => "405 Method Not Allowed", - 503 => "503 Service Unavailable", - _ => unreachable!("unmapped error code {code}"), - }; + debug_assert!(json_safe(message), "message goes into JSON unescaped"); let body = format!("{{\"code\":{code},\"message\":\"{message}\"}}"); - frame_response(self.out, status, Some("application/json"), body.as_bytes()); + 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", + 400 => "400 Bad Request", + 404 => "404 Not Found", + 405 => "405 Method Not Allowed", + 406 => "406 Not Acceptable", + 415 => "415 Unsupported Media Type", + 500 => "500 Internal Server Error", + 501 => "501 Not Implemented", + 503 => "503 Service Unavailable", + _ => return None, + }) +} + +fn json_safe(text: &str) -> bool { + !text.contains(['"', '\\']) } #[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(); @@ -62,4 +133,67 @@ mod tests { 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"); + } + + #[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/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs index 54a14417..1365b477 100644 --- a/crates/httpcore/src/lib.rs +++ b/crates/httpcore/src/lib.rs @@ -5,5 +5,7 @@ mod stream; pub use client::{ClientConnection, frame_request}; pub use query::Query; -pub use server::{AfterResponse, ParsedRequest, ServerConnection, frame_response}; +pub use server::{ + AfterResponse, ParsedRequest, ServerConnection, frame_response, frame_response_with_headers, +}; pub use stream::{Bind, Listener, Stream}; diff --git a/crates/httpcore/src/server.rs b/crates/httpcore/src/server.rs index 9177493c..11abb56b 100644 --- a/crates/httpcore/src/server.rs +++ b/crates/httpcore/src/server.rs @@ -209,15 +209,26 @@ impl Default for ServerConnection { } pub fn frame_response(out: &mut Vec, status: &str, content_type: Option<&str>, body: &[u8]) { - match content_type { - Some(ct) => write!( - out, - "HTTP/1.1 {status}\r\nContent-Type: {ct}\r\nContent-Length: {}\r\n\r\n", - body.len() - ), - None => write!(out, "HTTP/1.1 {status}\r\nContent-Length: {}\r\n\r\n", body.len()), - } - .unwrap(); + 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); } @@ -512,6 +523,47 @@ mod tests { ); } + #[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(); From 29ee6f2a1ccbf23f12d23a5dfe61d91610199f6c Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 15:55:17 +0100 Subject: [PATCH 14/33] Spec-flavour JSON writers for beacon-api responses M2 infrastructure (I3). New json.rs: writer primitives following the beacon-api conventions -- every integer a quoted decimal string, byte arrays lowercase 0x-hex, RFC 8259-complete string escaping -- plus the ten container writers Phase A consumes (genesis, fork, checkpoint, block header signed and bare, validator and its response entry, proposer/sync duties, liveness), each golden-tested against shapes verified in the beacon-APIs spec and cross-checked with Lighthouse's conformance-tested types. Writers append into a caller-borrowed buffer so handlers can render into reused scratch. Comma placement is stateless, derived from the preceding byte, so writers compose without threading state. This lands the encoder decision from the M2 plan in code shape: hand-written writers over SSZ views are the default (the SSZ-backed containers have no structs to derive Serialize on); serde_json stays reserved for startup-precomputed bodies as identity.rs already does, and beacon_api's off-workspace serde_json pin is normalized to the workspace entry (lockfile unchanged, identity golden bytes untouched). Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/Cargo.toml | 2 +- crates/beacon_api/src/json.rs | 600 ++++++++++++++++++++++++++++++ crates/beacon_api/src/lib.rs | 1 + crates/beacon_api/src/response.rs | 6 +- 4 files changed, 604 insertions(+), 5 deletions(-) create mode 100644 crates/beacon_api/src/json.rs diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index f2452619..f4464793 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -12,8 +12,8 @@ silver_beacon_state_data.workspace = true silver_common.workspace = true silver_httpcore.workspace = true serde.workspace = true +serde_json.workspace = true tracing.workspace = true -serde_json = "1.0.149" [dev-dependencies] tempfile = "3" diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs new file mode 100644 index 00000000..ab27ee65 --- /dev/null +++ b/crates/beacon_api/src/json.rs @@ -0,0 +1,600 @@ +//! 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`). +// Each writer lands ahead of the endpoint commit that calls it. +#![allow(dead_code)] + +use silver_beacon_state_data::{ + BLSPubkey, BLSSignature, BeaconBlockHeader, Checkpoint, Fork, Immutable, ValidatorsView, +}; + +const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; + +/// Appends JSON to a caller-owned buffer, so a handler can render into a +/// reused response scratch rather than a fresh allocation per request. +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','); + } + } +} + +/// Containers, in the field order the beacon-API schemas declare. +impl Json<'_> { + pub(crate) fn genesis(&mut self, imm: &Immutable) { + self.begin_object(); + self.key("genesis_time"); + self.quoted_u64(imm.genesis_time); + self.key("genesis_validators_root"); + self.hex(&imm.genesis_validators_root); + self.key("genesis_fork_version"); + self.hex(&imm.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 block_header(&mut self, header: &BeaconBlockHeader) { + self.begin_object(); + self.key("slot"); + self.quoted_u64(header.slot); + self.key("proposer_index"); + self.quoted_u64(header.proposer_index); + self.key("parent_root"); + self.hex(&header.parent_root); + self.key("state_root"); + self.hex(&header.state_root); + self.key("body_root"); + self.hex(&header.body_root); + self.end_object(); + } + + pub(crate) fn signed_block_header( + &mut self, + header: &BeaconBlockHeader, + signature: &BLSSignature, + ) { + self.begin_object(); + self.key("message"); + self.block_header(header); + self.key("signature"); + self.hex(signature); + self.end_object(); + } + + pub(crate) fn validator(&mut self, validators: &ValidatorsView<'_>, index: usize) { + self.begin_object(); + self.key("pubkey"); + self.hex(validators.pubkey(index)); + self.key("withdrawal_credentials"); + self.hex(&validators.credentials(index).0); + self.key("effective_balance"); + self.quoted_u64(validators.effective_balance(index)); + self.key("slashed"); + self.bool(validators.is_slashed(index)); + self.key("activation_eligibility_epoch"); + self.quoted_u64(validators.activation_eligibility_epoch(index)); + self.key("activation_epoch"); + self.quoted_u64(validators.activation_epoch(index)); + self.key("exit_epoch"); + self.quoted_u64(validators.exit_epoch(index)); + self.key("withdrawable_epoch"); + self.quoted_u64(validators.withdrawable_epoch(index)); + self.end_object(); + } + + pub(crate) fn validator_entry( + &mut self, + validators: &ValidatorsView<'_>, + index: usize, + balance: u64, + status: &str, + ) { + self.begin_object(); + self.key("index"); + self.quoted_u64(index as u64); + self.key("balance"); + self.quoted_u64(balance); + self.key("status"); + self.string(status); + self.key("validator"); + self.validator(validators, index); + self.end_object(); + } + + pub(crate) fn proposer_duty(&mut self, pubkey: &BLSPubkey, validator_index: u64, slot: u64) { + self.begin_object(); + self.key("pubkey"); + self.hex(pubkey); + self.key("validator_index"); + self.quoted_u64(validator_index); + self.key("slot"); + self.quoted_u64(slot); + self.end_object(); + } + + pub(crate) fn sync_duty( + &mut self, + pubkey: &BLSPubkey, + validator_index: u64, + committee_indices: &[u64], + ) { + self.begin_object(); + self.key("pubkey"); + self.hex(pubkey); + self.key("validator_index"); + self.quoted_u64(validator_index); + self.key("validator_sync_committee_indices"); + self.begin_array(); + for &position in committee_indices { + self.quoted_u64(position); + } + self.end_array(); + self.end_object(); + } + + pub(crate) fn liveness(&mut self, index: u64, is_live: bool) { + self.begin_object(); + self.key("index"); + self.quoted_u64(index); + self.key("is_live"); + self.bool(is_live); + self.end_object(); + } +} + +/// Whether `text` survives being spliced into JSON without escaping — the +/// guard for compile-time field names and messages, not for user input +/// ([`Json::string`] escapes). +pub(crate) fn json_safe(text: &str) -> bool { + !text.contains(['"', '\\']) +} + +#[cfg(test)] +mod tests { + use silver_beacon_state_data::{ + BeaconState, BeaconStateOwner, EpochStateFinalized, FAR_FUTURE_EPOCH, StateId, ValSeed, + Withdrawals, + }; + + 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 mut imm = Immutable::default(); + imm.genesis_time = 1_606_824_023; + imm.genesis_validators_root = [0x4b; 32]; + imm.genesis_fork_version = [0x00, 0x00, 0x00, 0x01]; + assert_body( + |j| j.genesis(&imm), + "{\"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\"}", + ); + } + + /// The three-checkpoint body of `getStateFinalityCheckpoints` — the one + /// place a container writer is called more than once per body. + #[test] + fn finality_checkpoints_body_reuses_the_checkpoint_writer() { + let previous = Checkpoint { epoch: 12_344, root: [0x01; 32] }; + let current = Checkpoint { epoch: 12_345, root: [0x02; 32] }; + let finalized = Checkpoint { epoch: 12_343, root: [0x03; 32] }; + let body = write(|j| { + j.begin_object(); + j.key("previous_justified"); + j.checkpoint(&previous); + j.key("current_justified"); + j.checkpoint(¤t); + j.key("finalized"); + j.checkpoint(&finalized); + j.end_object() + }); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed["previous_justified"]["epoch"], "12344"); + assert_eq!(parsed["current_justified"]["epoch"], "12345"); + assert_eq!(parsed["finalized"]["epoch"], "12343"); + assert!(body.starts_with("{\"previous_justified\":{\"epoch\":\"12344\",")); + } + + /// Field names/order: SSZ `BeaconBlockHeader` / `SignedBeaconBlockHeader`, + /// as used by `apis/beacon/blocks/header.yaml`. + #[test] + fn signed_block_header_golden() { + let header = BeaconBlockHeader { + slot: 7_654_321, + proposer_index: 4_242, + parent_root: [0x11; 32], + state_root: [0x22; 32], + body_root: [0x33; 32], + }; + assert_body( + |j| j.signed_block_header(&header, &[0x44; 96]), + "{\"message\":{\"slot\":\"7654321\",\"proposer_index\":\"4242\",\ + \"parent_root\":\"0x1111111111111111111111111111111111111111111111111111111111111111\",\ + \"state_root\":\"0x2222222222222222222222222222222222222222222222222222222222222222\",\ + \"body_root\":\"0x3333333333333333333333333333333333333333333333333333333333333333\"},\ + \"signature\":\"0x444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444\"}", + ); + } + + /// One validator with every field distinct, so a golden catches a + /// swapped pair as well as a renamed key. + fn state_with_one_validator() -> (BeaconStateOwner, StateId) { + let mut pubkey = [0u8; 48]; + pubkey[0] = 0x93; + pubkey[47] = 0x07; + let seeds = [ValSeed { + pubkey, + withdrawal_credentials: Withdrawals::eth1(&[0xab; 20]), + effective_balance: 32_000_000_000, + balance: 32_500_000_000, + activation_epoch: 10, + exit_epoch: FAR_FUTURE_EPOCH, + }]; + let mut owner = + BeaconStateOwner::new(BeaconState::for_test(EpochStateFinalized::default(), &seeds, 0)); + let anchor = owner.roll_fresh(); + let (mut writer, _, _) = owner.apply_block_view(anchor); + writer.validators.set_slashed(0, true); + writer.validators.set_activation_eligibility_epoch(0, 9); + writer.validators.set_withdrawable_epoch(0, 8_192); + let head = writer.commit(None, None); + (owner, head) + } + + /// Field names/order: SSZ `Validator` container, as inlined by + /// `apis/beacon/states/validators.yaml`. + #[test] + fn validator_golden() { + let (owner, head) = state_with_one_validator(); + let view = owner.read_view(head); + assert_body( + |j| j.validator(&view.validators, 0), + "{\"pubkey\":\"0x930000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007\",\ + \"withdrawal_credentials\":\"0x010000000000000000000000abababababababababababababababababababab\",\ + \"effective_balance\":\"32000000000\",\"slashed\":true,\ + \"activation_eligibility_epoch\":\"9\",\"activation_epoch\":\"10\",\ + \"exit_epoch\":\"18446744073709551615\",\"withdrawable_epoch\":\"8192\"}", + ); + } + + /// Field names/order: `ValidatorResponse` of + /// `apis/beacon/states/validators.yaml`. + #[test] + fn validator_entry_golden() { + let (owner, head) = state_with_one_validator(); + let view = owner.read_view(head); + let body = + write(|j| j.validator_entry(&view.validators, 0, 32_500_000_000, "active_slashed")); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed["index"], "0"); + assert_eq!(parsed["balance"], "32500000000"); + assert_eq!(parsed["status"], "active_slashed"); + assert_eq!(parsed["validator"]["effective_balance"], "32000000000"); + assert!(body.starts_with( + "{\"index\":\"0\",\"balance\":\"32500000000\",\"status\":\"active_slashed\",\"validator\":{" + )); + } + + /// Field names/order: `ProposerDuty` of + /// `apis/validator/duties/proposer.yaml`. + #[test] + fn proposer_duty_golden() { + let mut pubkey = [0u8; 48]; + pubkey[0] = 0xb0; + assert_body( + |j| j.proposer_duty(&pubkey, 17, 4_096), + "{\"pubkey\":\"0xb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\ + \"validator_index\":\"17\",\"slot\":\"4096\"}", + ); + } + + /// Field names/order: `SyncCommitteeDuty` of + /// `apis/validator/duties/sync.yaml` — the committee positions are a + /// list of quoted integers. + #[test] + fn sync_duty_golden() { + let mut pubkey = [0u8; 48]; + pubkey[0] = 0xb0; + assert_body( + |j| j.sync_duty(&pubkey, 17, &[3, 511]), + "{\"pubkey\":\"0xb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\ + \"validator_index\":\"17\",\"validator_sync_committee_indices\":[\"3\",\"511\"]}", + ); + } + + #[test] + fn sync_duty_with_no_committee_positions_keeps_an_empty_array() { + let pubkey = [0u8; 48]; + let body = write(|j| j.sync_duty(&pubkey, 17, &[])); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed["validator_sync_committee_indices"].as_array().unwrap().len(), 0); + } + + /// Field names: `apis/validator/liveness.yaml`. + #[test] + fn liveness_golden() { + assert_body(|j| j.liveness(17, false), "{\"index\":\"17\",\"is_live\":false}"); + assert_body(|j| j.liveness(0, true), "{\"index\":\"0\",\"is_live\":true}"); + } + + #[test] + fn json_safe_rejects_what_would_break_an_unescaped_splice() { + assert!(json_safe("active_ongoing")); + assert!(!json_safe("say \"hi\"")); + assert!(!json_safe("back\\slash")); + } +} diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index 56e01769..d58a0d66 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -1,4 +1,5 @@ mod identity; +mod json; mod response; mod router; mod routes; diff --git a/crates/beacon_api/src/response.rs b/crates/beacon_api/src/response.rs index 1ba70055..ed4c4899 100644 --- a/crates/beacon_api/src/response.rs +++ b/crates/beacon_api/src/response.rs @@ -2,6 +2,8 @@ use std::{borrow::Cow, fmt::Write}; use silver_httpcore::frame_response_with_headers; +use crate::json::json_safe; + const JSON_CONTENT_TYPE: &str = "application/json"; pub(crate) struct Response<'a> { @@ -92,10 +94,6 @@ fn status_line(code: u16) -> Option<&'static str> { }) } -fn json_safe(text: &str) -> bool { - !text.contains(['"', '\\']) -} - #[cfg(test)] mod tests { use super::*; From 89850ce8adaa07763872a08d19d031a7a327d41e Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 16:34:59 +0100 Subject: [PATCH 15/33] Thread NodeStatus and spec config into the beacon API Last M2 infrastructure commit (I4), and beacon_api's first spine-fed data. NodeStatus (head/wall slot, syncing flag, EL status) lives in ApiCtx as a single copy the owning tile refreshes in place each loop: ClientServerTile drains SyncUpdate and BeaconStateEvent unconditionally -- the flux broadcast cursor snaps on first consume, so a gated consume would silently miss early messages -- and copies the sibling engine client's sync status after its spin. consume_last was rejected deliberately: beacon_events is one multiplexed enum queue, so the newest message is usually a PersistBlock and taking only it would drop the Status behind it; the drain-and-match idiom Control and Columns already use keeps the last Status specifically. SpecConfig grows the full fork schedule (Altair through Gloas) and deposit-contract fields, serde defaults verified against both the consensus-specs mainnet config and Lighthouse's built-ins; the four hand-rolled fork-version defaults collapse into one const-generic that reads like the YAML. ForkName (closed enum, ADR-0003's principle) maps epoch/slot to the wire spelling for Eth-Consensus-Version and version fields. head_slot is Option-shaped: zero before the first Status is indistinguishable from genesis, and node/health's 503 needs the difference. No-EL mode now records the Synced status it advertises so NodeStatus agrees with what peers are told. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 1 + crates/beacon_api/examples/srv.rs | 5 +- crates/beacon_api/src/lib.rs | 2 + crates/beacon_api/src/node_status.rs | 29 +++ crates/beacon_api/src/routes.rs | 41 +++- crates/beacon_api/src/server.rs | 14 +- crates/beacon_state/data/src/lib.rs | 2 +- crates/bin/src/main.rs | 1 + crates/client_server/src/lib.rs | 26 ++- crates/client_server/tests/tile.rs | 122 +++++++++- crates/common/src/spine/messages.rs | 3 +- crates/config/chain_spec/Cargo.toml | 3 + crates/config/chain_spec/src/lib.rs | 320 +++++++++++++++++++++++---- crates/engine_api/src/api.rs | 7 + 14 files changed, 510 insertions(+), 66 deletions(-) create mode 100644 crates/beacon_api/src/node_status.rs diff --git a/Cargo.lock b/Cargo.lock index 17c068ef..445d2cf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4509,6 +4509,7 @@ version = "0.0.1" dependencies = [ "hex", "serde", + "toml", ] [[package]] diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index a2885c81..43a19a75 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -1,7 +1,7 @@ -use std::time::Duration; +use std::{sync::Arc, time::Duration}; use silver_beacon_api::BeaconApi; -use silver_beacon_state_data::BeaconStateOwner; +use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; use silver_common::{Enr, Identify, Keypair}; use silver_httpcore::Bind; @@ -20,6 +20,7 @@ fn main() { &keypair, local_enr, &Identify::default(), + Arc::new(SpecConfig::mainnet()), state, ); println!("serving on {:?}", api.local_addrs()); diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index d58a0d66..325fc4f3 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -1,8 +1,10 @@ mod identity; mod json; +mod node_status; mod response; mod router; mod routes; mod server; +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..d41d1e2b --- /dev/null +++ b/crates/beacon_api/src/node_status.rs @@ -0,0 +1,29 @@ +use silver_common::ELSyncStatus; + +/// 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 per-slot + /// status, i.e. while the node has nothing to report a head against. + pub slots: Option, + pub syncing: bool, + pub el: ELSyncStatus, +} + +/// Announced once per slot, not once per block, so `head_slot` trails the +/// imported head by up to a slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SlotStatus { + pub head_slot: u64, + pub wall_slot: u64, +} + +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/routes.rs b/crates/beacon_api/src/routes.rs index 758ee28a..b10bf2c3 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -1,9 +1,12 @@ +use std::sync::Arc; + #[cfg(test)] use silver_beacon_state_data::BeaconStateOwner; -use silver_beacon_state_data::{BeaconStateReader, StateReadView}; +use silver_beacon_state_data::{BeaconStateReader, SpecConfig, StateReadView}; use silver_common::{Enr, Identify, Keypair}; use crate::{ + NodeStatus, identity::build_identity_json, response::Response, router::{Handler, Method, Request}, @@ -17,6 +20,12 @@ pub(crate) const ROUTES: &[(Method, &str, Handler)] = pub(crate) struct ApiCtx { pub(crate) identity_json: Vec, pub(crate) state: BeaconStateReader, + // The config and node-status endpoints land after this; until then only + // the owning tile writes `node_status`. + #[allow(dead_code)] + pub(crate) spec: Arc, + #[allow(dead_code)] + pub(crate) node_status: NodeStatus, } impl ApiCtx { @@ -24,9 +33,15 @@ impl ApiCtx { keypair: &Keypair, local_enr: &Enr, identify: &Identify, + spec: Arc, state: BeaconStateReader, ) -> Self { - Self { identity_json: build_identity_json(keypair, local_enr, identify), state } + Self { + identity_json: build_identity_json(keypair, local_enr, identify), + state, + spec, + node_status: NodeStatus::default(), + } } #[allow(dead_code)] @@ -55,7 +70,17 @@ fn metrics(_req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { /// bootstrap. #[cfg(test)] pub(crate) fn preboot_ctx() -> ApiCtx { - ApiCtx { identity_json: Vec::new(), state: BeaconStateOwner::empty_test(0).reader() } + test_ctx(Vec::new(), BeaconStateOwner::empty_test(0).reader()) +} + +#[cfg(test)] +fn test_ctx(identity_json: Vec, state: BeaconStateReader) -> ApiCtx { + ApiCtx { + identity_json, + state, + spec: Arc::new(SpecConfig::mainnet()), + node_status: NodeStatus::default(), + } } #[cfg(test)] @@ -77,7 +102,13 @@ mod tests { 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)); - ApiCtx::new(&kp, &enr, &identify, BeaconStateOwner::empty_test(0).reader()) + ApiCtx::new( + &kp, + &enr, + &identify, + Arc::new(SpecConfig::mainnet()), + BeaconStateOwner::empty_test(0).reader(), + ) } fn get(router: &Router, ctx: &ApiCtx, path: &str) -> Vec { @@ -173,7 +204,7 @@ mod tests { let mut owner = BeaconStateOwner::new(BeaconState::empty_test(0)); let anchor = owner.roll_fresh(); owner.publish_state_id(anchor); - let ctx = ApiCtx { identity_json: Vec::new(), state: owner.reader() }; + let ctx = test_ctx(Vec::new(), owner.reader()); let router = Router::new(&[(Method::Get, "/test/genesis_root", genesis_root)]); let resp = get(&router, &ctx, "/test/genesis_root"); diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index 443b216d..c0a7e7c8 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -1,15 +1,17 @@ use std::{ collections::HashMap, io::{self, Read, Write}, + sync::Arc, time::{Duration, Instant}, }; use mio::{Events, Interest, Poll, Token}; -use silver_beacon_state_data::BeaconStateReader; +use silver_beacon_state_data::{BeaconStateReader, SpecConfig}; use silver_common::{Enr, Identify, Keypair}; use silver_httpcore::{AfterResponse, Bind, Listener, ParsedRequest, ServerConnection, Stream}; use crate::{ + NodeStatus, router::Router, routes::{ApiCtx, ROUTES}, }; @@ -58,6 +60,7 @@ pub struct BeaconApi { } impl BeaconApi { + #[allow(clippy::too_many_arguments)] pub fn new( binds: &[Bind], max_connections: usize, @@ -65,6 +68,7 @@ impl BeaconApi { keypair: &Keypair, local_enr: Enr, identify: &Identify, + spec: Arc, state: BeaconStateReader, ) -> Self { assert!(!binds.is_empty(), "beacon api needs at least one bind"); @@ -89,7 +93,7 @@ impl BeaconApi { listeners, connections: HashMap::new(), router: Router::new(ROUTES), - ctx: ApiCtx::new(keypair, &local_enr, identify, state), + ctx: ApiCtx::new(keypair, &local_enr, identify, spec, state), } } @@ -97,6 +101,11 @@ impl BeaconApi { 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) -> bool { self.poll.poll(&mut self.events, Some(Duration::ZERO)).unwrap(); let now = Instant::now(); @@ -300,6 +309,7 @@ mod tests { &keypair, local_enr, &Identify::default(), + Arc::new(SpecConfig::mainnet()), BeaconStateOwner::empty_test(0).reader(), ) } diff --git a/crates/beacon_state/data/src/lib.rs b/crates/beacon_state/data/src/lib.rs index 5e57384b..cedaafaf 100644 --- a/crates/beacon_state/data/src/lib.rs +++ b/crates/beacon_state/data/src/lib.rs @@ -25,7 +25,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/bin/src/main.rs b/crates/bin/src/main.rs index bd39ddd0..191ed019 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -241,6 +241,7 @@ fn main() -> Result<(), Box> { &keypair, local_enr, &identify, + spec.clone(), beacon_state_tile.reader(), ); diff --git a/crates/client_server/src/lib.rs b/crates/client_server/src/lib.rs index c07638e9..9ff06462 100644 --- a/crates/client_server/src/lib.rs +++ b/crates/client_server/src/lib.rs @@ -1,6 +1,6 @@ use flux::{spine::SpineAdapter, tile::Tile}; -use silver_beacon_api::BeaconApi; -use silver_common::SilverSpine; +use silver_beacon_api::{BeaconApi, SlotStatus}; +use silver_common::{BeaconStateEvent, SilverSpine, SyncUpdate}; use silver_engine_api::EngineApi; pub struct ClientServerTile { @@ -12,8 +12,30 @@ impl Tile for ClientServerTile { fn loop_body(&mut self, adapter: &mut SpineAdapter) { self.engine.intake(adapter); self.engine.spin(adapter); + self.refresh_node_status(adapter); if self.beacon.pump() { adapter.mark_work(); } } } + +impl ClientServerTile { + /// Unconditional 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. + fn refresh_node_status(&mut self, adapter: &mut SpineAdapter) { + let status = self.beacon.node_status_mut(); + + adapter.consume(|event: BeaconStateEvent, _| { + if let BeaconStateEvent::Status { latest_block_slot, wall_slot, .. } = event { + status.slots = Some(SlotStatus { head_slot: latest_block_slot, wall_slot }); + } + }); + adapter.consume(|update: SyncUpdate, _| { + status.syncing = !matches!(update, SyncUpdate::Following); + }); + + status.el = self.engine.sync_status(); + } +} diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index ac2cf5bc..e235fcce 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -2,16 +2,17 @@ use std::{ io::{Read, Write}, net::TcpStream, os::unix::net::UnixStream, + sync::Arc, time::{Duration, Instant}, }; use flux::{spine::SpineAdapter, tile::Tile}; -use silver_beacon_api::BeaconApi; -use silver_beacon_state_data::BeaconStateOwner; +use silver_beacon_api::{BeaconApi, SlotStatus}; +use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; use silver_client_server::ClientServerTile; use silver_common::{ - EngineFcuReq, EngineReq, EngineResp, Enr, Identify, Keypair, SilverSpine, TCache, - TCacheProducer, + BeaconStateEvent, ELSyncStatus, EngineFcuReq, EngineReq, EngineResp, Enr, Identify, Keypair, + SilverSpine, SyncUpdate, TCache, TCacheProducer, ssz_view::STATUS_V2_SIZE, }; use silver_config::EngineConfig; use silver_engine_api::{ @@ -36,6 +37,7 @@ fn beacon(bind: &Bind) -> BeaconApi { &keypair, local_enr, &Identify::default(), + Arc::new(SpecConfig::mainnet()), BeaconStateOwner::empty_test(0).reader(), ) } @@ -86,6 +88,15 @@ fn head_block_hash_json(byte: u8) -> String { format!("\"headBlockHash\":\"0x{}\"", hex::encode([byte; 32])) } +fn status_event(head_slot: u64, wall_slot: u64) -> BeaconStateEvent { + BeaconStateEvent::Status { + ssz: [0u8; STATUS_V2_SIZE], + latest_block_slot: head_slot, + wall_slot, + enr_fork_id: [0u8; 16], + } +} + #[test] fn serves_identity_over_tcp() { let base = TempDir::new().unwrap(); @@ -299,3 +310,106 @@ fn pool_cap_gates_spine_intake() { }); assert_eq!(completed, vec![[12u8; 32]], "out-of-order completion correlated"); } + +/// 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 = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(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)); + 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)); + 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 })); + assert_eq!(status.slots.unwrap().sync_distance(), 2); + assert!(status.syncing); + + inj.produce(SyncUpdate::Following); + tile.loop_body(&mut adapter); + assert!(!tile.beacon.node_status_mut().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 = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(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 ClientServerTile, 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)); + 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 })); + assert!(!status.syncing); + assert_eq!(status.el, ELSyncStatus::Synced); +} diff --git a/crates/common/src/spine/messages.rs b/crates/common/src/spine/messages.rs index 0cca37d3..02c64861 100644 --- a/crates/common/src/spine/messages.rs +++ b/crates/common/src/spine/messages.rs @@ -952,9 +952,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/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 d8f9ef24..0e27adac 100644 --- a/crates/config/chain_spec/src/lib.rs +++ b/crates/config/chain_spec/src/lib.rs @@ -4,6 +4,18 @@ 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,93 @@ 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 { + /// 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 { /// 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], + /// 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, /// 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,15 +122,19 @@ 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, + /// 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, @@ -122,24 +197,13 @@ 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] -} - -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 +213,27 @@ 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. +/// 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"))) } } @@ -179,6 +249,31 @@ 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) + } + /// Whether `epoch` is at or past the Gloas activation. #[inline] pub fn is_gloas_at(&self, epoch: u64) -> bool { @@ -223,8 +318,6 @@ impl SpecConfig { /// 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 @@ -232,17 +325,29 @@ impl SpecConfig { pub fn hoodi() -> Self { Self { // 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, + genesis_fork_version: default_fork_version::<0x10000910>(), + altair_fork_version: default_fork_version::<0x20000910>(), + altair_fork_epoch: 0, + bellatrix_fork_version: default_fork_version::<0x30000910>(), + bellatrix_fork_epoch: 0, + 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(), // 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![], - electra_fork_epoch: 2048, max_blobs_per_block_electra: 9, + deposit_chain_id: 560048, + deposit_network_id: 560048, + deposit_contract_address: default_deposit_contract_address(), // Identical to mainnet preset / config below this line. seconds_per_slot: 12, shard_committee_period: 256, @@ -266,14 +371,26 @@ 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(), + genesis_fork_version: default_fork_version::<0x00000000>(), + altair_fork_version: default_fork_version::<0x01000000>(), + altair_fork_epoch: 74240, + bellatrix_fork_version: default_fork_version::<0x02000000>(), + bellatrix_fork_epoch: 144896, + 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, + deposit_chain_id: 1, + deposit_network_id: 1, + deposit_contract_address: default_deposit_contract_address(), seconds_per_slot: 12, shard_committee_period: 256, min_validator_withdrawability_delay: 256, @@ -300,3 +417,108 @@ impl Default for SpecConfig { Self::mainnet() } } + +#[cfg(test)] +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); + } + + #[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"); + } + + #[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"] + ); + } +} diff --git a/crates/engine_api/src/api.rs b/crates/engine_api/src/api.rs index 3c485e1c..8da1caf3 100644 --- a/crates/engine_api/src/api.rs +++ b/crates/engine_api/src/api.rs @@ -64,6 +64,12 @@ impl EngineApi { } } + /// 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(); @@ -73,6 +79,7 @@ impl EngineApi { // 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; From 24b5755b5fd0e3237f0c0ed100460f2641e5c00e Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 18 Aug 2026 18:16:16 +0100 Subject: [PATCH 16/33] Doc: ClientServer consumes beacon_events and sync_target for node status The spine-flow doc predated the NodeStatus wiring and still claimed the beacon_api server side has no spine edges. Add the two consumer edges (diagram, tile list, queue table) that refresh_node_status introduced. Assisted-by: Claude:claude-fable-5 --- docs/spine-message-flow.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/spine-message-flow.md b/docs/spine-message-flow.md index 1deeeee2..ea1a937c 100644 --- a/docs/spine-message-flow.md +++ b/docs/spine-message-flow.md @@ -9,8 +9,8 @@ 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), **ClientServer** (hosting the `engine_api` client and the `beacon_api` server; the -server talks HTTP only, so it has no spine edges of its own), **DataColumns** (column -validation, DA tracking, EL blob fetch — split out of Storage). +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 @@ -46,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 @@ -54,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 @@ -94,9 +96,9 @@ rest. | `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, ClientServer | 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, ClientServer | inline | | `replay_blocks` | `ReplayBlock` | Storage | BeaconState | ref → `replay_blocks` tcache | | `syncing_strategy` | `SyncingStrategy` | Control | Storage, DataColumns | inline | | `engine_reqs` | `EngineReq` | BeaconState, DataColumns _(GetBlobs)_ | ClientServer | refs → `ssz_gossip` / `incoming_rpc`; GetBlobs inline | From da355849864dc2a09913a41bec416b3b41a6a59e Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 19 Aug 2026 15:58:31 +0100 Subject: [PATCH 17/33] Serve the beacon-API bootstrap statics: node/version+health, config/* Five endpoints on the existing route table: /eth/v1/node/version, /eth/v1/node/health, /eth/v1/config/{spec,fork_schedule,deposit_contract}. All bodies except health are rendered once at boot into StaticBodies; health is status-only (200 ready / 206 syncing, overridable via the spec's syncing_status query parameter / 503 before the first slot status) and reads NodeStatus per dispatch. SpecConfig grows the full per-fork version/epoch table (fork_version_at now walks the real cascade instead of gloas-else-fulu; the EF harnesses pin Fulu at genesis, matching the fixtures' own fork fields as decoded from their SSZ) and 16 config fields previously hardcoded off-config: the genesis trio, the merge trio (TERMINAL_TOTAL_DIFFICULTY as u128, quoted in TOML since the mainnet value exceeds i64), the eth1 pair, the five blob-sidecar counts, and CONFIG_NAME - each with mainnet-true serde defaults and hoodi() overrides transcribed from the upstream hoodi config, including the real two-entry BPO blob schedule (which also fixes hoodi fork digests, previously computed off an empty schedule). config/spec serves every key of consensus-specs v1.6.0 configs/mainnet.yaml plus the mainnet preset, verified key-by-key on both the mainnet and hoodi bodies (zero mismatches, zero absent keys). Explicit limitations of this surface: - Mainnet spec preset only (hoodi uses the mainnet preset and is fully supported); the spec's "minimal" testing preset is unsupported. - EIP{7441,7805,7928}_FORK_VERSION/_EPOCH, the inclusion-list keys and PROPOSER_REORG_CUTOFF_BPS are served at their v1.6.0 values although silver implements none of those features; the stubs are kept out of ForkName so they can never reach fork_schedule or a signing-domain derivation. - ATTESTATION_DUE_BPS and its GLOAS variant are test-pinned to SlotTicker's own 1/3 and 1/4 slot divisors, so the served deadlines cannot drift from the ones the node acts on. - MIN_PER_EPOCH_CHURN_LIMIT (4), MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT (8) and MAX_BLOBS_PER_BLOCK (6) are frozen pre-Electra historical values; their live _ELECTRA successors are served from config. - SLOT_DURATION_MS is derived as seconds_per_slot * 1000 from the deprecated SECONDS_PER_SLOT scalar, not modelled; the derivation must flip if a network ever needs sub-second slots. - fork_schedule omits unscheduled forks, matching Lighthouse and Teku. The slashing/inactivity keys whose scalars the state transition actually runs on (MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA, INACTIVITY_PENALTY_QUOTIENT_BELLATRIX, PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX) are served from the live SpecConfig fields, not preset literals - a TOML override flows through to the API, test-pinned. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 1 + crates/beacon_api/Cargo.toml | 1 + crates/beacon_api/examples/srv.rs | 4 +- crates/beacon_api/src/config.rs | 715 ++++++++++++++++++++ crates/beacon_api/src/json.rs | 30 +- crates/beacon_api/src/lib.rs | 2 + crates/beacon_api/src/node_status.rs | 25 + crates/beacon_api/src/response.rs | 65 +- crates/beacon_api/src/router.rs | 4 +- crates/beacon_api/src/routes.rs | 234 +++++-- crates/beacon_api/src/server.rs | 5 +- crates/beacon_api/src/statics.rs | 49 ++ crates/beacon_state/tile/tests/common.rs | 14 +- crates/beacon_state/tile/tests/ef_common.rs | 5 +- crates/bin/src/main.rs | 2 +- crates/client_server/tests/tile.rs | 3 +- crates/config/chain_spec/src/lib.rs | 290 +++++++- 17 files changed, 1355 insertions(+), 94 deletions(-) create mode 100644 crates/beacon_api/src/config.rs create mode 100644 crates/beacon_api/src/statics.rs diff --git a/Cargo.lock b/Cargo.lock index 14dea2f3..29bd8d95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4457,6 +4457,7 @@ dependencies = [ "silver_common", "silver_httpcore", "tempfile", + "toml", "tracing", ] diff --git a/crates/beacon_api/Cargo.toml b/crates/beacon_api/Cargo.toml index f4464793..a26a32c7 100644 --- a/crates/beacon_api/Cargo.toml +++ b/crates/beacon_api/Cargo.toml @@ -17,6 +17,7 @@ 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 index 43a19a75..c46c2449 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::time::Duration; use silver_beacon_api::BeaconApi; use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; @@ -20,7 +20,7 @@ fn main() { &keypair, local_enr, &Identify::default(), - Arc::new(SpecConfig::mainnet()), + &SpecConfig::mainnet(), state, ); println!("serving on {:?}", api.local_addrs()); diff --git a/crates/beacon_api/src/config.rs b/crates/beacon_api/src/config.rs new file mode 100644 index 00000000..06e6716e --- /dev/null +++ b/crates/beacon_api/src/config.rs @@ -0,0 +1,715 @@ +//! 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, 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", 256), + ("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.config_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"); + } + + #[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/json.rs b/crates/beacon_api/src/json.rs index ab27ee65..60aa3022 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -2,8 +2,6 @@ //! 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`). -// Each writer lands ahead of the endpoint commit that calls it. -#![allow(dead_code)] use silver_beacon_state_data::{ BLSPubkey, BLSSignature, BeaconBlockHeader, Checkpoint, Fork, Immutable, ValidatorsView, @@ -119,17 +117,6 @@ impl<'a> Json<'a> { /// Containers, in the field order the beacon-API schemas declare. impl Json<'_> { - pub(crate) fn genesis(&mut self, imm: &Immutable) { - self.begin_object(); - self.key("genesis_time"); - self.quoted_u64(imm.genesis_time); - self.key("genesis_validators_root"); - self.hex(&imm.genesis_validators_root); - self.key("genesis_fork_version"); - self.hex(&imm.genesis_fork_version); - self.end_object(); - } - pub(crate) fn fork(&mut self, fork: &Fork) { self.begin_object(); self.key("previous_version"); @@ -140,6 +127,23 @@ impl Json<'_> { self.quoted_u64(fork.epoch); self.end_object(); } +} + +/// The containers no endpoint calls yet, in the same schema field order. Each +/// lands ahead of the endpoint commit that calls it; the allow stops here so +/// dead-code checking stays real for the writers already wired up. +#[allow(dead_code)] +impl Json<'_> { + pub(crate) fn genesis(&mut self, imm: &Immutable) { + self.begin_object(); + self.key("genesis_time"); + self.quoted_u64(imm.genesis_time); + self.key("genesis_validators_root"); + self.hex(&imm.genesis_validators_root); + self.key("genesis_fork_version"); + self.hex(&imm.genesis_fork_version); + self.end_object(); + } pub(crate) fn checkpoint(&mut self, checkpoint: &Checkpoint) { self.begin_object(); diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index 325fc4f3..4c806d27 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -1,3 +1,4 @@ +mod config; mod identity; mod json; mod node_status; @@ -5,6 +6,7 @@ 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 index d41d1e2b..1ca2df2d 100644 --- a/crates/beacon_api/src/node_status.rs +++ b/crates/beacon_api/src/node_status.rs @@ -12,6 +12,31 @@ pub struct NodeStatus { 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 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.syncing || self.el != ELSyncStatus::Synced { + Health::Syncing + } else { + Health::Ready + } + } +} + /// Announced once per slot, not once per block, so `head_slot` trails the /// imported head by up to a slot. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/beacon_api/src/response.rs b/crates/beacon_api/src/response.rs index ed4c4899..a26225fc 100644 --- a/crates/beacon_api/src/response.rs +++ b/crates/beacon_api/src/response.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, fmt::Write}; +use std::{fmt::Write, str}; use silver_httpcore::frame_response_with_headers; @@ -37,14 +37,37 @@ impl<'a> Response<'a> { headers: &[(&str, &str)], body: &[u8], ) { - let status = match status_line(code) { - Some(status) => Cow::Borrowed(status), - None => { - tracing::warn!("no reason phrase for status {code}"); - Cow::Owned(format!("{code} ")) - } - }; - frame_response_with_headers(self.out, &status, content_type, headers, body); + 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":"..."}`. @@ -82,6 +105,7 @@ 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", @@ -167,6 +191,29 @@ mod tests { 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 { diff --git a/crates/beacon_api/src/router.rs b/crates/beacon_api/src/router.rs index fc25b584..84cd4670 100644 --- a/crates/beacon_api/src/router.rs +++ b/crates/beacon_api/src/router.rs @@ -22,8 +22,8 @@ impl Method { pub(crate) type Handler = fn(&Request<'_>, &ApiCtx, &mut Response<'_>); -// Fields become live with the first parameterised endpoints; until then only -// tests read them. +// Everything but `query` becomes live with the first parameterised and first +// POST endpoints; until then only tests read those fields. #[allow(dead_code)] pub(crate) struct Request<'a> { pub(crate) method: Method, diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index b10bf2c3..b7126b04 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -1,30 +1,38 @@ -use std::sync::Arc; +#[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, - identity::build_identity_json, + node_status::Health, response::Response, router::{Handler, Method, Request}, + statics::StaticBodies, }; const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; -pub(crate) const ROUTES: &[(Method, &str, Handler)] = - &[(Method::Get, "/eth/v1/node/identity", identity), (Method::Get, "/metrics", metrics)]; +/// 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/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/version", version), + (Method::Get, "/metrics", metrics), +]; pub(crate) struct ApiCtx { - pub(crate) identity_json: Vec, + pub(crate) statics: StaticBodies, pub(crate) state: BeaconStateReader, - // The config and node-status endpoints land after this; until then only - // the owning tile writes `node_status`. - #[allow(dead_code)] - pub(crate) spec: Arc, - #[allow(dead_code)] pub(crate) node_status: NodeStatus, } @@ -33,17 +41,17 @@ impl ApiCtx { keypair: &Keypair, local_enr: &Enr, identify: &Identify, - spec: Arc, + spec: &SpecConfig, state: BeaconStateReader, ) -> Self { Self { - identity_json: build_identity_json(keypair, local_enr, identify), + statics: StaticBodies::new(keypair, local_enr, identify, spec), state, - spec, node_status: NodeStatus::default(), } } + // Live with the first endpoint that reads the published state. #[allow(dead_code)] pub(crate) fn read_state_or_503( &self, @@ -59,7 +67,50 @@ impl ApiCtx { } fn identity(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { - resp.json(&ctx.identity_json); + 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<'_>) { @@ -70,53 +121,41 @@ fn metrics(_req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { /// bootstrap. #[cfg(test)] pub(crate) fn preboot_ctx() -> ApiCtx { - test_ctx(Vec::new(), BeaconStateOwner::empty_test(0).reader()) + test_ctx(&SpecConfig::mainnet(), BeaconStateOwner::empty_test(0).reader()) } #[cfg(test)] -fn test_ctx(identity_json: Vec, state: BeaconStateReader) -> ApiCtx { - ApiCtx { - identity_json, - state, - spec: Arc::new(SpecConfig::mainnet()), - node_status: NodeStatus::default(), - } +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 std::net::{IpAddr, Ipv4Addr, SocketAddr}; - use silver_beacon_state_data::BeaconState; + use silver_common::{AGENT_VERSION, ELSyncStatus}; use silver_httpcore::ParsedRequest; use super::*; - use crate::router::Router; + 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 fixture_ctx() -> ApiCtx { - 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)); - ApiCtx::new( - &kp, - &enr, - &identify, - Arc::new(SpecConfig::mainnet()), - BeaconStateOwner::empty_test(0).reader(), - ) + fn get(router: &Router, ctx: &ApiCtx, path: &str) -> Vec { + query_get(router, ctx, path, "") } - fn get(router: &Router, ctx: &ApiCtx, path: &str) -> Vec { + fn query_get(router: &Router, ctx: &ApiCtx, path: &str, query: &str) -> Vec { let mut out = Vec::new(); let req = ParsedRequest { method: "GET", path, - query: "", + query, body: b"", accept: None, content_type: None, @@ -136,14 +175,14 @@ mod tests { #[test] fn identity_wire_bytes_match_pre_table_implementation() { let router = Router::new(ROUTES); - let resp = get(&router, &fixture_ctx(), "/eth/v1/node/identity"); + 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, &fixture_ctx(), "/eth/v1/node/identity"); + 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] @@ -159,6 +198,117 @@ mod tests { 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 }), + syncing: false, + el: ELSyncStatus::Synced, + } + } + + 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}" + ); + } + } + #[test] fn metrics_response_valid_prometheus_format() { let router = Router::new(ROUTES); @@ -204,7 +354,7 @@ mod tests { let mut owner = BeaconStateOwner::new(BeaconState::empty_test(0)); let anchor = owner.roll_fresh(); owner.publish_state_id(anchor); - let ctx = test_ctx(Vec::new(), owner.reader()); + let ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); let router = Router::new(&[(Method::Get, "/test/genesis_root", genesis_root)]); let resp = get(&router, &ctx, "/test/genesis_root"); diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index c0a7e7c8..a78de343 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -1,7 +1,6 @@ use std::{ collections::HashMap, io::{self, Read, Write}, - sync::Arc, time::{Duration, Instant}, }; @@ -68,7 +67,7 @@ impl BeaconApi { keypair: &Keypair, local_enr: Enr, identify: &Identify, - spec: Arc, + spec: &SpecConfig, state: BeaconStateReader, ) -> Self { assert!(!binds.is_empty(), "beacon api needs at least one bind"); @@ -309,7 +308,7 @@ mod tests { &keypair, local_enr, &Identify::default(), - Arc::new(SpecConfig::mainnet()), + &SpecConfig::mainnet(), BeaconStateOwner::empty_test(0).reader(), ) } 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/tile/tests/common.rs b/crates/beacon_state/tile/tests/common.rs index 8efb8c52..60d8e324 100644 --- a/crates/beacon_state/tile/tests/common.rs +++ b/crates/beacon_state/tile/tests/common.rs @@ -136,14 +136,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, @@ -334,7 +342,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"); // Hold a fresh fork's writers directly (`roll_fresh` anchors each at diff --git a/crates/beacon_state/tile/tests/ef_common.rs b/crates/beacon_state/tile/tests/ef_common.rs index 6e6849fd..bcff6c04 100644 --- a/crates/beacon_state/tile/tests/ef_common.rs +++ b/crates/beacon_state/tile/tests/ef_common.rs @@ -438,7 +438,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/bin/src/main.rs b/crates/bin/src/main.rs index 1646f717..3d993b64 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -242,7 +242,7 @@ fn main() -> Result<(), Box> { &keypair, local_enr, &identify, - spec.clone(), + &spec, beacon_state_tile.reader(), ); diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index e235fcce..9cca99db 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -2,7 +2,6 @@ use std::{ io::{Read, Write}, net::TcpStream, os::unix::net::UnixStream, - sync::Arc, time::{Duration, Instant}, }; @@ -37,7 +36,7 @@ fn beacon(bind: &Bind) -> BeaconApi { &keypair, local_enr, &Identify::default(), - Arc::new(SpecConfig::mainnet()), + &SpecConfig::mainnet(), BeaconStateOwner::empty_test(0).reader(), ) } diff --git a/crates/config/chain_spec/src/lib.rs b/crates/config/chain_spec/src/lib.rs index 0e27adac..7c782ee4 100644 --- a/crates/config/chain_spec/src/lib.rs +++ b/crates/config/chain_spec/src/lib.rs @@ -44,6 +44,19 @@ pub enum ForkName { } 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. @@ -70,11 +83,25 @@ impl ForkName { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub struct SpecConfig { + /// The network's own name for itself. Silver derives nothing from it; it + /// is carried because a validator client builds its runtime config from + /// the spec this node serves and labels its logs with this string. + #[serde(default = "default_config_name")] + pub config_name: String, /// 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_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 @@ -87,6 +114,16 @@ pub struct SpecConfig { 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_fork_version::<0x03000000>", with = "hex_0x")] @@ -126,6 +163,20 @@ pub struct SpecConfig { /// 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. @@ -138,6 +189,13 @@ pub struct SpecConfig { /// 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, @@ -197,6 +255,15 @@ pub struct SpecConfig { pub ejection_balance: u64, } +fn default_config_name() -> String { + "mainnet".to_owned() +} + +/// Mainnet's merge threshold, crossed 2022-09-15. +const fn default_terminal_total_difficulty() -> u128 { + 58_750_000_000_000_000_000_000 +} + /// Mainnet deposit contract, live since 2020-11-04. Hoodi reuses the very /// same address. fn default_deposit_contract_address() -> [u8; 20] { @@ -237,6 +304,31 @@ mod hex_0x { } } +/// 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()), + } + } +} + impl SpecConfig { /// Active blob params when no `blob_schedule` entry covers `epoch`. /// Built from the Electra activation epoch + Electra-era blob count @@ -274,6 +366,33 @@ impl SpecConfig { 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 { @@ -298,7 +417,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 @@ -312,24 +431,29 @@ impl SpecConfig { } } - /// 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: - /// - `*_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. + /// 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: "hoodi".to_owned(), // Hoodi fork-version pattern is `0xN0000910`. 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>(), @@ -340,16 +464,23 @@ impl SpecConfig { fulu_fork_epoch: 50688, gloas_fork_version: default_fork_version::<0x80000910>(), gloas_fork_epoch: unscheduled(), - // 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![], + 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, + 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(), - // Identical to mainnet preset / config below this line. 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, @@ -371,11 +502,18 @@ impl SpecConfig { pub fn mainnet() -> Self { Self { + config_name: default_config_name(), 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>(), @@ -388,10 +526,17 @@ impl SpecConfig { 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, @@ -441,6 +586,41 @@ mod tests { 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.config_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.config_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] @@ -494,6 +674,84 @@ mod tests { 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() }; From c2207e263d85e098f7d9eada8cf02a3188635a26 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 19 Aug 2026 17:21:35 +0100 Subject: [PATCH 18/33] Serve beacon/genesis and per-state fork + finality checkpoints Three endpoints: /eth/v1/beacon/genesis (bare data wrapper) and /eth/v1/beacon/states/{state_id}/{fork,finality_checkpoints} (the execution_optimistic/finalized envelope, shared with later state endpoints via Json::state_envelope). First live reads through BeaconStateReader: the closure lifts Copy scalars out of one seqlock snapshot - the envelope flags and the data can never be torn against each other - and JSON rendering happens outside the read. state_id resolution: silver keeps exactly one published state, so only `head` serves. `justified`, `finalized`, `genesis`, slots and roots are recognized ids answered 404 "state not found" - serving head data under them would be a silent substitution (the finalized state's own checkpoints provably differ from head's). Anything that names no state id at all is 400 "invalid state_id" per fork.yaml, decided before the state is read. Pre-bootstrap all three answer their spec-declared 404s (genesis with "Chain genesis info is not yet known"). execution_optimistic is a node-level approximation: true unless the node is synced AND the EL reports itself synced. Silver's head is optimistic by construction - newPayload is fire-and-forget, imported nodes are born ExecutionStatus::Optimistic, head viability filters only Invalid, and disk replay never notifies the EL - while the per-head verdict stays inside the fork-choice tile. The approximation under-reports: a head whose payloads the EL never verified reads non-optimistic while eth_syncing stays healthy, as does the replayed branch after a restart until a VALID verdict lifts its ancestors. The finalized flag is true only for the genesis state: the STF keeps finalized_checkpoint.epoch at least one epoch behind the state's own, so genesis is the only state that is its own finalized history. Bodies verified spec-exact against beacon-APIs v4.0.0 (field spelling, required-field order, quoted integers, an inner checkpoint named "finalized" coexisting with the envelope flag - pinned byte-exact). Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/src/json.rs | 161 +++++++++---- crates/beacon_api/src/node_status.rs | 12 + crates/beacon_api/src/response.rs | 10 +- crates/beacon_api/src/router.rs | 6 +- crates/beacon_api/src/routes.rs | 348 +++++++++++++++++++++++++-- 5 files changed, 463 insertions(+), 74 deletions(-) diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index 60aa3022..24c7428d 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -4,13 +4,14 @@ //! `serde_json` is reserved for bodies built once at startup (`identity.rs`). use silver_beacon_state_data::{ - BLSPubkey, BLSSignature, BeaconBlockHeader, Checkpoint, Fork, Immutable, ValidatorsView, + B256, BLSPubkey, BLSSignature, BeaconBlockHeader, Checkpoint, Fork, ValidatorsView, Version, }; const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; -/// Appends JSON to a caller-owned buffer, so a handler can render into a -/// reused response scratch rather than a fresh allocation per request. +/// 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, @@ -115,33 +116,62 @@ impl<'a> Json<'a> { } } +/// 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, +} + +/// What a state read reports about the snapshot it came from; both flags are +/// required beside `data` by every `states/{state_id}` schema. +#[derive(Clone, Copy)] +pub(crate) struct StateFlags { + pub(crate) execution_optimistic: bool, + pub(crate) finalized: bool, +} + /// Containers, in the field order the beacon-API schemas declare. impl Json<'_> { - pub(crate) fn fork(&mut self, fork: &Fork) { + pub(crate) fn state_envelope(&mut self, flags: StateFlags, data: impl FnOnce(&mut Self)) { 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.key("execution_optimistic"); + self.bool(flags.execution_optimistic); + self.key("finalized"); + self.bool(flags.finalized); + self.key("data"); + data(self); self.end_object(); } -} -/// The containers no endpoint calls yet, in the same schema field order. Each -/// lands ahead of the endpoint commit that calls it; the allow stops here so -/// dead-code checking stays real for the writers already wired up. -#[allow(dead_code)] -impl Json<'_> { - pub(crate) fn genesis(&mut self, imm: &Immutable) { + pub(crate) fn genesis(&mut self, genesis: &GenesisData) { self.begin_object(); self.key("genesis_time"); - self.quoted_u64(imm.genesis_time); + self.quoted_u64(genesis.genesis_time); self.key("genesis_validators_root"); - self.hex(&imm.genesis_validators_root); + self.hex(&genesis.genesis_validators_root); self.key("genesis_fork_version"); - self.hex(&imm.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(); } @@ -154,6 +184,23 @@ impl Json<'_> { 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(); + } +} + +/// The containers no endpoint calls yet, in the same schema field order. Each +/// lands ahead of the endpoint commit that calls it; the allow stops here so +/// dead-code checking stays real for the writers already wired up. +#[allow(dead_code)] +impl Json<'_> { pub(crate) fn block_header(&mut self, header: &BeaconBlockHeader) { self.begin_object(); self.key("slot"); @@ -412,12 +459,13 @@ mod tests { /// Field names/order: `GenesisData`, `apis/beacon/genesis.yaml`. #[test] fn genesis_golden() { - let mut imm = Immutable::default(); - imm.genesis_time = 1_606_824_023; - imm.genesis_validators_root = [0x4b; 32]; - imm.genesis_fork_version = [0x00, 0x00, 0x00, 0x01]; + 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(&imm), + |j| j.genesis(&genesis), "{\"genesis_time\":\"1606824023\",\"genesis_validators_root\":\"0x4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b\",\"genesis_fork_version\":\"0x00000001\"}", ); } @@ -449,28 +497,51 @@ mod tests { ); } - /// The three-checkpoint body of `getStateFinalityCheckpoints` — the one - /// place a container writer is called more than once per body. + 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 state_envelope_golden() { + let flags = StateFlags { execution_optimistic: false, finalized: true }; + assert_body( + |j| j.state_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 finality_checkpoints_body_reuses_the_checkpoint_writer() { - let previous = Checkpoint { epoch: 12_344, root: [0x01; 32] }; - let current = Checkpoint { epoch: 12_345, root: [0x02; 32] }; - let finalized = Checkpoint { epoch: 12_343, root: [0x03; 32] }; - let body = write(|j| { - j.begin_object(); - j.key("previous_justified"); - j.checkpoint(&previous); - j.key("current_justified"); - j.checkpoint(¤t); - j.key("finalized"); - j.checkpoint(&finalized); - j.end_object() - }); + fn envelope_flags_and_data_of_the_same_name_both_survive() { + let flags = StateFlags { execution_optimistic: true, finalized: false }; + let body = write(|j| j.state_envelope(flags, |j| j.finality_checkpoints(&checkpoints()))); let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(parsed["previous_justified"]["epoch"], "12344"); - assert_eq!(parsed["current_justified"]["epoch"], "12345"); - assert_eq!(parsed["finalized"]["epoch"], "12343"); - assert!(body.starts_with("{\"previous_justified\":{\"epoch\":\"12344\",")); + assert_eq!(parsed["execution_optimistic"], true); + assert_eq!(parsed["finalized"], false); + assert_eq!(parsed["data"]["finalized"]["epoch"], "12343"); } /// Field names/order: SSZ `BeaconBlockHeader` / `SignedBeaconBlockHeader`, diff --git a/crates/beacon_api/src/node_status.rs b/crates/beacon_api/src/node_status.rs index 1ca2df2d..ee639908 100644 --- a/crates/beacon_api/src/node_status.rs +++ b/crates/beacon_api/src/node_status.rs @@ -22,6 +22,18 @@ pub(crate) enum Health { } impl NodeStatus { + /// A node-wide stand-in for the spec's per-head bit: a node that is behind, + /// or whose EL does not report itself synced, may serve wrong data. It + /// under-reports — a head whose payload the EL never verified reads + /// non-optimistic while `eth_syncing` stays healthy through failing + /// `newPayload` calls, as does the branch replayed from disk after a + /// restart until a `VALID` verdict lifts its ancestors. The per-head + /// truth is the head's `ExecutionStatus`, which + /// `BeaconStateEvent::Status` does not carry. + pub(crate) fn execution_optimistic(&self) -> bool { + self.health() != Health::Ready + } + /// 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 diff --git a/crates/beacon_api/src/response.rs b/crates/beacon_api/src/response.rs index a26225fc..d5597479 100644 --- a/crates/beacon_api/src/response.rs +++ b/crates/beacon_api/src/response.rs @@ -2,7 +2,7 @@ use std::{fmt::Write, str}; use silver_httpcore::frame_response_with_headers; -use crate::json::json_safe; +use crate::json::{Json, json_safe}; const JSON_CONTENT_TYPE: &str = "application/json"; @@ -26,6 +26,14 @@ impl<'a> Response<'a> { 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""); } diff --git a/crates/beacon_api/src/router.rs b/crates/beacon_api/src/router.rs index 84cd4670..47536f0c 100644 --- a/crates/beacon_api/src/router.rs +++ b/crates/beacon_api/src/router.rs @@ -22,8 +22,9 @@ impl Method { pub(crate) type Handler = fn(&Request<'_>, &ApiCtx, &mut Response<'_>); -// Everything but `query` becomes live with the first parameterised and first -// POST endpoints; until then only tests read those fields. +// `body` becomes live with the first POST endpoint, `method` and `path` with a +// handler that answers on more than the route it was dispatched by; until then +// only tests read those three. #[allow(dead_code)] pub(crate) struct Request<'a> { pub(crate) method: Method, @@ -39,7 +40,6 @@ pub(crate) struct Params<'a> { } impl<'a> Params<'a> { - #[allow(dead_code)] pub(crate) fn get(&self, name: &str) -> Option<&'a str> { self.entries[..self.len].iter().find(|(n, _)| *n == name).map(|&(_, value)| value) } diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index b7126b04..e0a1f56a 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -9,6 +9,7 @@ use silver_httpcore::Query; use crate::{ NodeStatus, + json::{FinalityCheckpoints, GenesisData, Json, StateFlags}, node_status::Health, response::Response, router::{Handler, Method, Request}, @@ -21,6 +22,13 @@ const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; const DEFAULT_SYNCING_STATUS: u16 = 206; pub(crate) const ROUTES: &[(Method, &str, Handler)] = &[ + (Method::Get, "/eth/v1/beacon/genesis", genesis), + ( + 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/config/deposit_contract", deposit_contract), (Method::Get, "/eth/v1/config/fork_schedule", fork_schedule), (Method::Get, "/eth/v1/config/spec", spec), @@ -51,19 +59,120 @@ impl ApiCtx { } } - // Live with the first endpoint that reads the published state. - #[allow(dead_code)] - pub(crate) fn read_state_or_503( + /// The published state, or a 404 carrying `not_found` while the node has + /// published none — the schemas of these endpoints declare no 503. + pub(crate) fn read_state_or_404( &self, resp: &mut Response<'_>, + not_found: &str, read: impl Fn(StateReadView<'_>) -> R, ) -> Option { let result = self.state.read(&read); if result.is_none() { - resp.error(503, "beacon node not initialized"); + resp.error(404, not_found); } result } + + /// Answers a `{state_id}` read in the envelope its schema requires. `read` + /// runs under the seqlock and is re-run on retry, so it lifts scalars out + /// and `render` writes them once, afterwards. + pub(crate) fn state_response( + &self, + req: &Request<'_>, + resp: &mut Response<'_>, + read: impl Fn(StateReadView<'_>) -> R, + render: impl FnOnce(&mut Json<'_>, &R), + ) { + 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; + } + + let execution_optimistic = self.node_status.execution_optimistic(); + let read = |view: StateReadView<'_>| StateRead { + flags: StateFlags { + 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), + }; + let Some(state) = self.read_state_or_404(resp, "state not found", read) else { + return; + }; + + resp.json_body(|json| json.state_envelope(state.flags, |json| render(json, &state.data))); + } +} + +/// One state read: the flags describe the snapshot `data` came from. +struct StateRead { + flags: StateFlags, + 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") || + is_slot(state_id) || + state_id + .strip_prefix("0x") + .is_some_and(|root| root.len() == 64 && root.bytes().all(|b| b.is_ascii_hexdigit())) +} + +/// `u64::from_str` alone also accepts a leading `+`, which the schemas call an +/// invalid `state_id` rather than a slot. +fn is_slot(state_id: &str) -> bool { + state_id.bytes().all(|byte| byte.is_ascii_digit()) && state_id.parse::().is_ok() +} + +fn genesis(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(genesis) = + ctx.read_state_or_404(resp, "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.begin_object(); + json.key("data"); + json.genesis(&genesis); + json.end_object(); + }); +} + +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<'_>) { @@ -135,7 +244,10 @@ fn test_ctx(spec: &SpecConfig, state: BeaconStateReader) -> ApiCtx { #[cfg(test)] mod tests { - use silver_beacon_state_data::BeaconState; + use silver_beacon_state_data::{ + BeaconState, Checkpoint, EPOCHS_PER_HISTORICAL_VECTOR, EPOCHS_PER_SLASHINGS_VECTOR, + EpochState, EpochStateFinalized, Fork, SLOTS_PER_EPOCH, + }; use silver_common::{AGENT_VERSION, ELSyncStatus}; use silver_httpcore::ParsedRequest; @@ -333,32 +445,218 @@ mod tests { assert_eq!(resp, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); } - fn genesis_root(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { - let Some(root) = ctx.read_state_or_503(resp, |view| view.imm.genesis_validators_root) - else { - return; - }; - resp.json(hex::encode(root).as_bytes()); + /// 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 base = EpochStateFinalized::from_parts( + epoch, + vec![[0u8; 32]; EPOCHS_PER_HISTORICAL_VECTOR].into_boxed_slice(), + vec![0; EPOCHS_PER_SLASHINGS_VECTOR].into_boxed_slice(), + ); + let mut state = BeaconState::for_test(base, &[], 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 state_route_503_before_bootstrap() { - let router = Router::new(&[(Method::Get, "/test/genesis_root", genesis_root)]); - let resp = get(&router, &preboot_ctx(), "/test/genesis_root"); - assert!(resp.starts_with(b"HTTP/1.1 503 Service Unavailable\r\n")); - assert_eq!(body(&resp), br#"{"code":503,"message":"beacon node not initialized"}"#); + 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_route_reads_published_state() { - let mut owner = BeaconStateOwner::new(BeaconState::empty_test(0)); - let anchor = owner.roll_fresh(); - owner.publish_state_id(anchor); - let ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); + 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); + } + } - let router = Router::new(&[(Method::Get, "/test/genesis_root", genesis_root)]); - let resp = get(&router, &ctx, "/test/genesis_root"); - assert!(resp.starts_with(b"HTTP/1.1 200 OK\r\n")); - assert_eq!(body(&resp), hex::encode([0u8; 32]).as_bytes()); + /// `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}"); + } + } + + /// Silver reports no per-head execution status, so a node behind on either + /// layer serves its head as optimistic. + #[test] + fn execution_optimistic_while_either_layer_is_unsynced() { + let mut ctx = published_ctx(epoch_state(), HEAD_SLOT); + for status in [ + NodeStatus { el: ELSyncStatus::Unknown, ..ready() }, + NodeStatus { el: ELSyncStatus::Syncing, ..ready() }, + NodeStatus { el: ELSyncStatus::Offline, ..ready() }, + NodeStatus { syncing: true, ..ready() }, + ] { + ctx.node_status = status; + for path in state_paths("head") { + assert!( + state_body(&ctx, &path).starts_with("{\"execution_optimistic\":true,"), + "{status:?} {path}" + ); + } + } + + ctx.node_status = ready(); + for path in state_paths("head") { + assert!(state_body(&ctx, &path).starts_with("{\"execution_optimistic\":false,")); + } + } + + /// `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,"))); + } } } From c4c6517d87ad0214d1dbc1c3dcd0cafb780228e3 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 19 Aug 2026 18:04:57 +0100 Subject: [PATCH 19/33] Publish the head's execution status on BeaconStateEvent::Status Status gains head_optimistic: whether the head block's payload has an EL VALID verdict, read from the head's fork-choice node at the event's single construction site. The bit and the payload's head_root are computed against the same fork-choice state in one resolution - Status fires on every accepted gossip item, not just slot starts, so the resolution count matters and the event now costs one find_head, not two. The ClientServer tile stores the bit in SlotStatus, and the beacon API's execution_optimistic is now the head's own verdict: a replayed branch after restart reads optimistic until a VALID verdict lifts its ancestors, an unknown head (no status consumed yet) reads optimistic, and a VALID landing between Status events clears at the next one. A checkpoint-sync anchor is born Valid, so a freshly synced node is non-optimistic at its anchor - the trusted-anchor premise. An Invalid head (reachable through the justified fallback, or a Gloas EMPTY resolution) reads optimistic: the envelope has no invalid vocabulary and unverified is the safe direction. Other beacon_events consumers destructure with `..` and are unaffected; surfer does not read this queue. The SlotStatus docs' "once per slot" cadence claim was false against the produce sites and is corrected. just perf-local: fixture match at slot 14817824, apply_block 31.97 ms avg over 128 blocks. Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/src/node_status.rs | 22 +++++------ crates/beacon_api/src/routes.rs | 36 ++++++++++-------- crates/beacon_state/tile/src/tile.rs | 16 +++++--- crates/beacon_state/tile/src/tile/tests.rs | 44 +++++++++++++++++++++- crates/client_server/src/lib.rs | 8 +++- crates/client_server/tests/tile.rs | 28 ++++++++++---- crates/common/src/spine/messages.rs | 1 + 7 files changed, 112 insertions(+), 43 deletions(-) diff --git a/crates/beacon_api/src/node_status.rs b/crates/beacon_api/src/node_status.rs index ee639908..68be4feb 100644 --- a/crates/beacon_api/src/node_status.rs +++ b/crates/beacon_api/src/node_status.rs @@ -5,8 +5,8 @@ use silver_common::ELSyncStatus; /// 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 per-slot - /// status, i.e. while the node has nothing to report a head against. + /// `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, @@ -22,16 +22,10 @@ pub(crate) enum Health { } impl NodeStatus { - /// A node-wide stand-in for the spec's per-head bit: a node that is behind, - /// or whose EL does not report itself synced, may serve wrong data. It - /// under-reports — a head whose payload the EL never verified reads - /// non-optimistic while `eth_syncing` stays healthy through failing - /// `newPayload` calls, as does the branch replayed from disk after a - /// restart until a `VALID` verdict lifts its ancestors. The per-head - /// truth is the head's `ExecutionStatus`, which - /// `BeaconStateEvent::Status` does not carry. + /// 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.health() != Health::Ready + self.slots.is_none_or(|slots| slots.head_optimistic) } /// The spec puts an optimistic or offline execution layer on the same @@ -49,12 +43,14 @@ impl NodeStatus { } } -/// Announced once per slot, not once per block, so `head_slot` trails the -/// imported head by up to a slot. +/// `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 { diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index e0a1f56a..e70fb51e 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -345,7 +345,7 @@ mod tests { fn ready() -> NodeStatus { NodeStatus { - slots: Some(SlotStatus { head_slot: 100, wall_slot: 100 }), + slots: Some(SlotStatus { head_slot: 100, wall_slot: 100, head_optimistic: false }), syncing: false, el: ELSyncStatus::Synced, } @@ -615,30 +615,36 @@ mod tests { } } - /// Silver reports no per-head execution status, so a node behind on either - /// layer serves its head as optimistic. + 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_while_either_layer_is_unsynced() { + fn execution_optimistic_is_the_head_s_own_status() { let mut ctx = published_ctx(epoch_state(), HEAD_SLOT); - for status in [ - NodeStatus { el: ELSyncStatus::Unknown, ..ready() }, - NodeStatus { el: ELSyncStatus::Syncing, ..ready() }, - NodeStatus { el: ELSyncStatus::Offline, ..ready() }, - NodeStatus { syncing: true, ..ready() }, + 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("{\"execution_optimistic\":true,"), + state_body(&ctx, &path) + .starts_with(&format!("{{\"execution_optimistic\":{want},")), "{status:?} {path}" ); } } - - ctx.node_status = ready(); - for path in state_paths("head") { - assert!(state_body(&ctx, &path).starts_with("{\"execution_optimistic\":false,")); - } } /// `finalized` describes the state served, and genesis is the only state diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 997f116c..9727a688 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, orphan_pool::PendingBlock, @@ -414,11 +414,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) @@ -457,10 +456,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 32a13148..db4dfd81 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -10,7 +10,7 @@ use silver_common::{ GossipTopic, MessageId, P2pStreamId, StreamProtocol, TCache, TCacheProducer, TProducer, ssz_view::{ AttestationView, PROPOSER_SLASHING_SIZE, SIGNED_AGG_PROOF_MIN, SIGNED_BLS_CHANGE_SIZE, - SIGNED_VOLUNTARY_EXIT_SIZE, SignedAggregateAndProofView, SingleAttestationView, + SIGNED_VOLUNTARY_EXIT_SIZE, SignedAggregateAndProofView, SingleAttestationView, StatusView, }, }; @@ -306,6 +306,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/client_server/src/lib.rs b/crates/client_server/src/lib.rs index 9ff06462..3f8d6951 100644 --- a/crates/client_server/src/lib.rs +++ b/crates/client_server/src/lib.rs @@ -28,8 +28,12 @@ impl ClientServerTile { let status = self.beacon.node_status_mut(); adapter.consume(|event: BeaconStateEvent, _| { - if let BeaconStateEvent::Status { latest_block_slot, wall_slot, .. } = event { - status.slots = Some(SlotStatus { head_slot: latest_block_slot, wall_slot }); + 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, _| { diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index 9cca99db..2ff957f2 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -87,9 +87,10 @@ fn head_block_hash_json(byte: u8) -> String { format!("\"headBlockHash\":\"0x{}\"", hex::encode([byte; 32])) } -fn status_event(head_slot: u64, wall_slot: u64) -> BeaconStateEvent { +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], @@ -325,25 +326,35 @@ fn node_status_tracks_the_spine_once_the_cursor_snaps() { let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); - inj.produce(status_event(1, 1)); + 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)); + 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 })); + 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); - assert!(!tile.beacon.node_status_mut().syncing, "reaching the target clears the syncing flag"); + 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 @@ -400,7 +411,7 @@ fn node_status_updates_while_the_engine_pool_is_at_cap() { crank(&mut tile, &mut el, "pool saturated with unanswered FCUs"); } - inj.produce(status_event(7, 9)); + 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"); @@ -408,7 +419,10 @@ fn node_status_updates_while_the_engine_pool_is_at_cap() { } let status = *tile.beacon.node_status_mut(); - assert_eq!(status.slots, Some(SlotStatus { head_slot: 7, wall_slot: 9 })); + assert_eq!( + status.slots, + Some(SlotStatus { head_slot: 7, wall_slot: 9, head_optimistic: false }) + ); assert!(!status.syncing); assert_eq!(status.el, ELSyncStatus::Synced); } diff --git a/crates/common/src/spine/messages.rs b/crates/common/src/spine/messages.rs index 92063f91..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 { From bb57694aef5cc6d1646704d2154c9e1fad6aaf4d Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 20 Aug 2026 11:45:56 +0100 Subject: [PATCH 20/33] Serve node/syncing and node/peer_count is_syncing is distance-based, not a mirror of the spine's SyncUpdate. The control tile publishes that message only when its sync target changes, and an idle engine reports Following, so a node that has yet to find a peer to sync from never sends one and NodeStatus::syncing stays at its default false however far behind the node falls. Reporting that verbatim tells Teku, Nimbus, Prysm and Vouch the node is synced. The head lag past which the node's own sync engine stops calling itself caught up decides instead, and node/health shares the predicate so the two endpoints cannot disagree. Before the first head, sync_distance is u64::MAX rather than zero: zero is the value go-eth2-client and Nimbus read as synced when head_slot is also zero, which is exactly the state of a node with nothing to serve. Peer counts reach the API through NodeStatus rather than a handler reading the counters directly, so a beacon_api test sets them like any other field instead of racing on the process-global gauge file. Limitations this serves honestly and does not fix: - peer_count.disconnected and .disconnecting are always "0". Silver keeps no count of a peer outside connected and dialing. - .connecting counts outbound dials in flight; an inbound connection mid-handshake is tracked nowhere, so the bucket undercounts. - Peer counts are as fresh as the peer manager's 700ms tick. - head_slot comes from BeaconStateEvent::Status, so it can be a slot stale. - el_offline is true during the startup window before the first eth_syncing completes, and permanently false under --unsafe-no-el, which reports the EL synced outright. - is_syncing does not distinguish a node syncing from one whose head fell behind while the sync engine still believes it is following. Assisted-by: Claude:claude-opus-5 --- Cargo.lock | 1 + crates/beacon_api/src/json.rs | 51 +++++++++ crates/beacon_api/src/lib.rs | 2 +- crates/beacon_api/src/node_status.rs | 55 +++++++++- crates/beacon_api/src/routes.rs | 154 +++++++++++++++++++++++++-- crates/client_server/Cargo.toml | 1 + crates/client_server/src/lib.rs | 15 ++- crates/client_server/tests/tile.rs | 33 +++++- crates/peer/src/lib.rs | 2 + crates/peer/src/manager.rs | 19 ++++ 10 files changed, 318 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a2f3a34c..147a5ca4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4526,6 +4526,7 @@ dependencies = [ "silver_config", "silver_engine_api", "silver_httpcore", + "silver_peer", "tempfile", ] diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index 24c7428d..c20a61b5 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -132,6 +132,21 @@ pub(crate) struct FinalityCheckpoints { pub(crate) finalized: Checkpoint, } +/// The five flags and slots `getSyncingStatus` answers with +/// (`apis/node/syncing.yaml`). +pub(crate) struct SyncingData { + pub(crate) head_slot: u64, + pub(crate) sync_distance: u64, + pub(crate) is_syncing: bool, + pub(crate) is_optimistic: bool, + pub(crate) el_offline: bool, +} + +pub(crate) struct PeerCountData { + pub(crate) connected: u64, + pub(crate) connecting: u64, +} + /// What a state read reports about the snapshot it came from; both flags are /// required beside `data` by every `states/{state_id}` schema. #[derive(Clone, Copy)] @@ -142,6 +157,13 @@ pub(crate) struct StateFlags { /// 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 state_envelope(&mut self, flags: StateFlags, data: impl FnOnce(&mut Self)) { self.begin_object(); self.key("execution_optimistic"); @@ -184,6 +206,35 @@ impl Json<'_> { self.end_object(); } + pub(crate) fn syncing(&mut self, syncing: &SyncingData) { + self.begin_object(); + self.key("head_slot"); + self.quoted_u64(syncing.head_slot); + self.key("sync_distance"); + self.quoted_u64(syncing.sync_distance); + self.key("is_syncing"); + self.bool(syncing.is_syncing); + self.key("is_optimistic"); + self.bool(syncing.is_optimistic); + self.key("el_offline"); + self.bool(syncing.el_offline); + self.end_object(); + } + + /// All four buckets are required. + pub(crate) fn peer_count(&mut self, peers: &PeerCountData) { + self.begin_object(); + self.key("disconnected"); + self.quoted_u64(0); + self.key("connecting"); + self.quoted_u64(peers.connecting); + self.key("connected"); + self.quoted_u64(peers.connected); + self.key("disconnecting"); + self.quoted_u64(0); + self.end_object(); + } + pub(crate) fn finality_checkpoints(&mut self, checkpoints: &FinalityCheckpoints) { self.begin_object(); self.key("previous_justified"); diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index 4c806d27..724dc70c 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -8,5 +8,5 @@ mod routes; mod server; mod statics; -pub use node_status::{NodeStatus, SlotStatus}; +pub use node_status::{NodeStatus, PeerCounts, SlotStatus}; pub use server::BeaconApi; diff --git a/crates/beacon_api/src/node_status.rs b/crates/beacon_api/src/node_status.rs index 68be4feb..657d301b 100644 --- a/crates/beacon_api/src/node_status.rs +++ b/crates/beacon_api/src/node_status.rs @@ -1,5 +1,11 @@ use silver_common::ELSyncStatus; +use crate::json::{PeerCountData, SyncingData}; + +/// `SyncingConfig::head_lag_threshold_slots`'s default: the lag past which +/// the node's own sync engine stops treating itself as at the head. +const SYNC_TOLERANCE_SLOTS: u64 = 8; + /// The node's own condition, as against the chain state a /// `BeaconStateReader` serves. Assembled and refreshed by its single /// writer; handlers read one consistent snapshot per dispatch. @@ -10,6 +16,7 @@ pub struct NodeStatus { pub slots: Option, pub syncing: bool, pub el: ELSyncStatus, + pub peers: PeerCounts, } /// What `getHealth` answers with: 200, the syncing code (206 unless the @@ -35,12 +42,58 @@ impl NodeStatus { pub(crate) fn health(&self) -> Health { if self.slots.is_none() { Health::Uninitialized - } else if self.syncing || self.el != ELSyncStatus::Synced { + } 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(), + } + } + + pub(crate) fn peer_count_data(&self) -> PeerCountData { + PeerCountData { connected: self.peers.connected, connecting: self.peers.connecting } + } + + /// `syncing` alone would answer for the head this node is chasing, not the + /// one the chain is at: the control tile publishes a `SyncUpdate` only when + /// its target changes, so a node that has yet to find a peer to sync from + /// stays `false` however far behind it falls. + fn is_syncing(&self) -> bool { + self.syncing || self.sync_distance() > SYNC_TOLERANCE_SLOTS + } + + /// `u64::MAX` before the first head: no distance the schema can carry is + /// truthful there, and the zero it would otherwise report is the one value + /// every validator client reads as synced. + fn sync_distance(&self) -> u64 { + self.slots.map_or(u64::MAX, |slots| slots.sync_distance()) + } + + /// True while nothing has come back from the EL: an `Unknown` EL has + /// answered no healthcheck, which is no better evidence that it can be + /// reached than a failed one. A *syncing* EL answered, so it is reachable; + /// what it cannot yet do is reported by `is_optimistic`. + fn el_offline(&self) -> bool { + matches!(self.el, ELSyncStatus::Unknown | ELSyncStatus::Offline) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PeerCounts { + pub connected: u64, + pub connecting: u64, } /// `head_slot` is the highest imported block's slot, so a `sync_distance` of diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index c66db8c5..ac5419f4 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -34,6 +34,8 @@ pub(crate) const ROUTES: &[(Method, &str, Handler)] = &[ (Method::Get, "/eth/v1/config/spec", spec), (Method::Get, "/eth/v1/node/health", health), (Method::Get, "/eth/v1/node/identity", identity), + (Method::Get, "/eth/v1/node/peer_count", peer_count), + (Method::Get, "/eth/v1/node/syncing", syncing), (Method::Get, "/eth/v1/node/version", version), (Method::Get, "/metrics", metrics), ]; @@ -147,12 +149,19 @@ fn genesis(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { else { return; }; - resp.json_body(|json| { - json.begin_object(); - json.key("data"); - json.genesis(&genesis); - json.end_object(); - }); + resp.json_body(|json| json.data_envelope(|json| json.genesis(&genesis))); +} + +/// This reads no beacon state, and its schema declares no code but 200, so a +/// node before bootstrap answers out of the status it has. +fn syncing(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let syncing = ctx.node_status.syncing_data(); + resp.json_body(|json| json.data_envelope(|json| json.syncing(&syncing))); +} + +fn peer_count(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let peers = ctx.node_status.peer_count_data(); + resp.json_body(|json| json.data_envelope(|json| json.peer_count(&peers))); } fn state_fork(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { @@ -252,7 +261,7 @@ mod tests { use silver_httpcore::ParsedRequest; use super::*; - use crate::{SlotStatus, router::Router}; + use crate::{PeerCounts, SlotStatus, router::Router}; /// Wire bytes the pre-table implementation produced for these exact /// inputs (captured before the table dispatch landed). @@ -348,6 +357,14 @@ mod tests { slots: Some(SlotStatus { head_slot: 100, wall_slot: 100, head_optimistic: false }), syncing: false, el: ELSyncStatus::Synced, + peers: PeerCounts::default(), + } + } + + fn head_at(head_slot: u64, wall_slot: u64) -> NodeStatus { + NodeStatus { + slots: Some(SlotStatus { head_slot, wall_slot, head_optimistic: false }), + ..ready() } } @@ -421,6 +438,129 @@ mod tests { } } + /// Both node-status endpoints answer from `NodeStatus` alone, so a + /// never-published reader is the whole context they need. + fn status_body(status: NodeStatus, path: &str) -> String { + let mut ctx = preboot_ctx(); + ctx.node_status = status; + let resp = get(&Router::new(ROUTES), &ctx, path); + assert!( + resp.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{path}: {}", + String::from_utf8_lossy(&resp) + ); + String::from_utf8(body(&resp).to_vec()).unwrap() + } + + fn syncing_data(status: NodeStatus) -> serde_json::Value { + let body = status_body(status, "/eth/v1/node/syncing"); + let mut parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + parsed["data"].take() + } + + /// Body shape: `apis/node/syncing.yaml` — five required fields, slots + /// quoted and flags bare. + #[test] + fn syncing_body_reports_the_head_and_both_layers() { + assert_eq!( + status_body(ready(), "/eth/v1/node/syncing"), + "{\"data\":{\"head_slot\":\"100\",\"sync_distance\":\"0\",\"is_syncing\":false,\ + \"is_optimistic\":false,\"el_offline\":false}}" + ); + } + + /// `syncing.yaml` declares no 503, and a node with nothing published has + /// an answer: no head, the farthest distance the schema can carry, and + /// every flag in the not-usable direction. + #[test] + fn syncing_answers_before_bootstrap_as_a_node_with_no_head() { + assert_eq!( + status_body(NodeStatus::default(), "/eth/v1/node/syncing"), + "{\"data\":{\"head_slot\":\"0\",\"sync_distance\":\"18446744073709551615\",\ + \"is_syncing\":true,\"is_optimistic\":true,\"el_offline\":true}}" + ); + } + + /// The sync flag answers for the head this node is *chasing*: the control + /// tile publishes a `SyncUpdate` only when its target changes, so a node + /// that has found no peer to sync from carries `syncing: false` however + /// far behind the chain it falls. + #[test] + fn is_syncing_is_true_past_the_head_tolerance_whatever_the_sync_flag() { + assert_eq!(syncing_data(NodeStatus { syncing: true, ..ready() })["is_syncing"], true); + let within = syncing_data(head_at(992, 1_000)); + assert_eq!(within["is_syncing"], false, "within the head tolerance"); + assert_eq!(syncing_data(head_at(991, 1_000))["is_syncing"], true, "past the tolerance"); + assert_eq!( + syncing_data(head_at(10, 1_000_000))["is_syncing"], + true, + "a node that never found a peer to sync from is still syncing" + ); + } + + /// One node, one answer: a validator client gating on `/node/health` and + /// reading the head from `/node/syncing` must not see the two disagree. + #[test] + fn health_reports_syncing_wherever_the_syncing_endpoint_does() { + let far_behind = head_at(10, 1_000_000); + assert_eq!(syncing_data(far_behind)["is_syncing"], true); + assert!(health_response(far_behind, "").starts_with(b"HTTP/1.1 206 Partial Content\r\n")); + } + + /// The same flag the state envelopes carry: the head's own execution + /// status, not a constant and not a reading of the node's sync state. + #[test] + fn syncing_is_optimistic_is_the_head_s_own_status() { + assert_eq!(syncing_data(with_head_optimistic(true))["is_optimistic"], true); + assert_eq!(syncing_data(with_head_optimistic(false))["is_optimistic"], false); + let syncing_node = NodeStatus { syncing: true, ..with_head_optimistic(false) }; + assert_eq!(syncing_data(syncing_node)["is_optimistic"], false); + let offline_el = NodeStatus { el: ELSyncStatus::Offline, ..with_head_optimistic(false) }; + assert_eq!(syncing_data(offline_el)["is_optimistic"], false); + } + + /// An EL that answered a healthcheck is reachable whatever it answered; + /// one that has never answered is no more reachable than a failed one. + #[test] + fn el_offline_is_true_only_while_the_el_has_answered_nothing() { + for (el, offline) in [ + (ELSyncStatus::Unknown, true), + (ELSyncStatus::Offline, true), + (ELSyncStatus::Syncing, false), + (ELSyncStatus::Synced, false), + ] { + let data = syncing_data(NodeStatus { el, ..ready() }); + assert_eq!(data["el_offline"], offline, "{el:?}"); + assert_eq!(data["is_syncing"], false, "{el:?}: the EL is not the node's own sync"); + } + } + + #[test] + fn sync_distance_is_the_wall_clock_gap_and_never_underflows() { + assert_eq!(syncing_data(head_at(90, 100))["sync_distance"], "10"); + assert_eq!(syncing_data(head_at(100, 100))["sync_distance"], "0"); + let head_ahead = syncing_data(head_at(101, 100)); + assert_eq!(head_ahead["sync_distance"], "0", "head ahead of the wall slot"); + assert_eq!(syncing_data(head_at(0, u64::MAX))["sync_distance"], "18446744073709551615"); + } + + /// Body shape: `apis/node/peer_count.yaml` — all four buckets required, + /// so the two silver does not track are zero rather than absent. + #[test] + fn peer_count_body_carries_all_four_buckets() { + let peers = PeerCounts { connected: 56, connecting: 34 }; + assert_eq!( + status_body(NodeStatus { peers, ..ready() }, "/eth/v1/node/peer_count"), + "{\"data\":{\"disconnected\":\"0\",\"connecting\":\"34\",\"connected\":\"56\",\ + \"disconnecting\":\"0\"}}" + ); + assert_eq!( + status_body(NodeStatus::default(), "/eth/v1/node/peer_count"), + "{\"data\":{\"disconnected\":\"0\",\"connecting\":\"0\",\"connected\":\"0\",\ + \"disconnecting\":\"0\"}}" + ); + } + #[test] fn metrics_response_valid_prometheus_format() { let router = Router::new(ROUTES); diff --git a/crates/client_server/Cargo.toml b/crates/client_server/Cargo.toml index 29660382..ffce3bf6 100644 --- a/crates/client_server/Cargo.toml +++ b/crates/client_server/Cargo.toml @@ -10,6 +10,7 @@ flux.workspace = true silver_beacon_api.workspace = true silver_common.workspace = true silver_engine_api.workspace = true +silver_peer.workspace = true [dev-dependencies] hex.workspace = true diff --git a/crates/client_server/src/lib.rs b/crates/client_server/src/lib.rs index 3f8d6951..f52ec5c3 100644 --- a/crates/client_server/src/lib.rs +++ b/crates/client_server/src/lib.rs @@ -1,7 +1,8 @@ use flux::{spine::SpineAdapter, tile::Tile}; -use silver_beacon_api::{BeaconApi, SlotStatus}; +use silver_beacon_api::{BeaconApi, PeerCounts, SlotStatus}; use silver_common::{BeaconStateEvent, SilverSpine, SyncUpdate}; use silver_engine_api::EngineApi; +use silver_peer::PeerCounters; pub struct ClientServerTile { pub beacon: BeaconApi, @@ -20,13 +21,13 @@ impl Tile for ClientServerTile { } impl ClientServerTile { - /// Unconditional 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. 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, .. @@ -41,5 +42,9 @@ impl ClientServerTile { }); status.el = self.engine.sync_status(); + status.peers = PeerCounts { + connected: PeerCounters::PeersConnected.get(), + connecting: PeerCounters::PeersConnecting.get(), + }; } } diff --git a/crates/client_server/tests/tile.rs b/crates/client_server/tests/tile.rs index 2ff957f2..8e2804c7 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/client_server/tests/tile.rs @@ -6,7 +6,7 @@ use std::{ }; use flux::{spine::SpineAdapter, tile::Tile}; -use silver_beacon_api::{BeaconApi, SlotStatus}; +use silver_beacon_api::{BeaconApi, PeerCounts, SlotStatus}; use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; use silver_client_server::ClientServerTile; use silver_common::{ @@ -19,6 +19,7 @@ use silver_engine_api::{ test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}, }; use silver_httpcore::Bind; +use silver_peer::PeerCounters; use tempfile::TempDir; struct Injector; @@ -27,6 +28,14 @@ impl Tile for Injector { } fn beacon(bind: &Bind) -> BeaconApi { + // Every `loop_body` below samples the peer gauges; left at the default + // base that is the counter file a node running on this machine serves. + PeerCounters::init_with_base( + std::env::temp_dir().join(format!("silver_client_server_test_{}", std::process::id())), + "silver", + ) + .unwrap(); + let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); BeaconApi::new( @@ -426,3 +435,25 @@ fn node_status_updates_while_the_engine_pool_is_at_cap() { assert!(!status.syncing); assert_eq!(status.el, ELSyncStatus::Synced); } + +/// Peer counts reach the api through shared memory, not the spine: the peer +/// manager sets its gauges on its own tick, in another tile. +#[test] +fn node_status_tracks_the_peer_gauges() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = ClientServerTile { + beacon: beacon(&Bind::parse("127.0.0.1:0")), + engine: engine(no_el(), ["cs_peers_gossip", "cs_peers_rpc", "cs_peers_resp"]), + }; + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + + PeerCounters::PeersConnected.set(56); + PeerCounters::PeersConnecting.set(3); + tile.loop_body(&mut adapter); + assert_eq!(tile.beacon.node_status_mut().peers, PeerCounts { connected: 56, connecting: 3 }); + + PeerCounters::PeersConnected.set(55); + tile.loop_body(&mut adapter); + assert_eq!(tile.beacon.node_status_mut().peers.connected, 55, "refreshed every iteration"); +} diff --git a/crates/peer/src/lib.rs b/crates/peer/src/lib.rs index 035c4886..ae95f52b 100644 --- a/crates/peer/src/lib.rs +++ b/crates/peer/src/lib.rs @@ -26,5 +26,7 @@ silver_common::declare_counters! { GossipInvalidFrame, GossipInvalidControl, GossipInvalidMsg, + // Live in-flight dial count (set each tick). + PeersConnecting, } } diff --git a/crates/peer/src/manager.rs b/crates/peer/src/manager.rs index ffefdf31..31e4478c 100644 --- a/crates/peer/src/manager.rs +++ b/crates/peer/src/manager.rs @@ -258,6 +258,11 @@ impl PeerManager { custody_columns: u128, awaiting_local_replay: bool, ) -> Self { + // The counters file is mapped, never truncated: until the first tick + // these gauges still read the last run's peer counts. + crate::PeerCounters::PeersConnected.set(0); + crate::PeerCounters::PeersConnecting.set(0); + let now = Instant::now(); let mesh = our_topics.iter().map(|t| (*t, Vec::with_capacity(params.d_high as usize))).collect(); @@ -818,6 +823,7 @@ impl PeerManager { }); crate::PeerCounters::PeersConnected.set(self.peers.len() as u64); + crate::PeerCounters::PeersConnecting.set(self.dialing.len() as u64); } // ── Lifecycle ─────────────────────────────────────────────────────── @@ -2402,6 +2408,15 @@ mod tests { params: ScoreParams, awaiting_replay: bool, ) -> (PeerManager, Captured) { + // `PeerManager::new` and `tick` write the peer gauges; left at the + // default base that is the counter file a node running on this + // machine is serving from. + crate::PeerCounters::init_with_base( + std::env::temp_dir().join(format!("silver_peer_test_{}", std::process::id())), + "silver", + ) + .unwrap(); + ( PeerManager::new( peer_id(99), @@ -3682,9 +3697,13 @@ mod tests { mgr.redial_known_peers(t_drop, &mut |c| cap.0.push(c)); assert_eq!(dials(&cap), 0, "in-flight dial must not repeat"); + mgr.tick(t_drop, &mut |c| cap.0.push(c)); + assert_eq!(crate::PeerCounters::PeersConnecting.get(), 1, "the dial is in flight"); + // Dial times out via the stale-dial sweep -> 1h backoff. let after_sweep = t_drop + Duration::from_secs(16); mgr.tick(after_sweep, &mut |c| cap.0.push(c)); + assert_eq!(crate::PeerCounters::PeersConnecting.get(), 0, "the sweep dropped the dial"); cap.0.clear(); mgr.redial_known_peers(after_sweep, &mut |c| cap.0.push(c)); assert_eq!(dials(&cap), 0, "failed dial must back off"); From 0c5652c9853c5c59eee090ea7e751bc741773fdb Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 20 Aug 2026 13:41:48 +0100 Subject: [PATCH 21/33] Serve the validator registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET and POST states/{state_id}/validators and states/{state_id}/validators/ {validator_id}. The filters are a submitted-id cap plus a status bitmask: the spec puts maxItems 64 on the query's id array, which GET answers with 414, while POST bounds its own list and answers 400 — the two codes each verb actually declares. status is a u16 mask over the nine lifecycle states, so a repeated value costs nothing per validator and uniqueItems holds by construction rather than by validation. An empty filter returns the whole registry, as the schema requires. Sizing that answer is what the endpoint cannot do: refusing it would have been a compatibility wall, since Teku, Prysm and Nimbus post their whole key set in one request and go-eth2-client deactivates a node that answers 5xx. Serving it instead means the response is bounded only by the registry, and the connection write buffer now releases its capacity after each response so a single large answer does not pin memory for the connection's life. The sweep reads the validator count once per step rather than once per request: it spans the delta's appended vec, which the state reader does not list among the reads that are safe to take optimistically, and a ring roll does not bump the version a torn read would retry on. Measured at a 2.1M-validator registry: a status filter matching nothing sweeps in 9.8ms (~5ns/validator), and an unfiltered query takes ~930ms for a 992MiB body, of which rendering is 58%. Limitations this serves honestly and does not fix: - An unfiltered or status=active query occupies the tile for ~0.9s, during which the engine_api client does not run, and it is repeatable on an unauthenticated default bind. The drain interleaves with engine work but the render does not. - One in-flight response holds ~1GiB, and a client that stops reading holds it for the drain; the connection cap bounds this at 64 such requests. - head only. justified, finalized, genesis, slot and root are 404 until state-id resolution exists. - A POST body over the transport's 16MiB limit drops the connection with no HTTP response, so the pubkey spelling binds before the id cap does. - serde_json materializes the submitted id list before the cap applies, so an oversized body costs its parse before the 400. - POST does not check Content-Type; 415 belongs with content negotiation. Assisted-by: Claude:claude-opus-5 --- crates/beacon_api/src/json.rs | 151 ++--- crates/beacon_api/src/lib.rs | 1 + crates/beacon_api/src/response.rs | 1 + crates/beacon_api/src/router.rs | 5 +- crates/beacon_api/src/routes.rs | 45 +- crates/beacon_api/src/validators/entry.rs | 96 +++ crates/beacon_api/src/validators/filter.rs | 355 +++++++++++ crates/beacon_api/src/validators/mod.rs | 656 +++++++++++++++++++++ crates/beacon_api/src/validators/status.rs | 327 ++++++++++ crates/httpcore/src/server.rs | 22 + 10 files changed, 1574 insertions(+), 85 deletions(-) create mode 100644 crates/beacon_api/src/validators/entry.rs create mode 100644 crates/beacon_api/src/validators/filter.rs create mode 100644 crates/beacon_api/src/validators/mod.rs create mode 100644 crates/beacon_api/src/validators/status.rs diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index c20a61b5..d8918416 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -4,9 +4,11 @@ //! `serde_json` is reserved for bodies built once at startup (`identity.rs`). use silver_beacon_state_data::{ - B256, BLSPubkey, BLSSignature, BeaconBlockHeader, Checkpoint, Fork, ValidatorsView, Version, + B256, BLSPubkey, BLSSignature, BeaconBlockHeader, Checkpoint, Fork, Version, }; +use crate::validators::entry::{Validator, ValidatorEntry}; + const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; /// Appends JSON to a buffer the caller owns — fresh or reused is the caller's @@ -245,6 +247,49 @@ impl Json<'_> { self.checkpoint(&checkpoints.finalized); self.end_object(); } + + pub(crate) fn validator(&mut self, validator: &Validator) { + self.begin_object(); + self.key("pubkey"); + self.hex(&validator.pubkey); + self.key("withdrawal_credentials"); + self.hex(&validator.withdrawal_credentials.0); + self.key("effective_balance"); + self.quoted_u64(validator.effective_balance); + let lifecycle = &validator.lifecycle; + self.key("slashed"); + self.bool(lifecycle.slashed); + self.key("activation_eligibility_epoch"); + self.quoted_u64(lifecycle.activation_eligibility_epoch); + self.key("activation_epoch"); + self.quoted_u64(lifecycle.activation_epoch); + self.key("exit_epoch"); + self.quoted_u64(lifecycle.exit_epoch); + self.key("withdrawable_epoch"); + self.quoted_u64(lifecycle.withdrawable_epoch); + self.end_object(); + } + + pub(crate) fn validator_entry(&mut self, entry: &ValidatorEntry) { + self.begin_object(); + self.key("index"); + self.quoted_u64(entry.index); + self.key("balance"); + self.quoted_u64(entry.balance); + self.key("status"); + self.string(entry.status.name()); + self.key("validator"); + self.validator(&entry.validator); + self.end_object(); + } + + pub(crate) fn validators(&mut self, entries: &[ValidatorEntry]) { + self.begin_array(); + for entry in entries { + self.validator_entry(entry); + } + self.end_array(); + } } /// The containers no endpoint calls yet, in the same schema field order. Each @@ -280,46 +325,6 @@ impl Json<'_> { self.end_object(); } - pub(crate) fn validator(&mut self, validators: &ValidatorsView<'_>, index: usize) { - self.begin_object(); - self.key("pubkey"); - self.hex(validators.pubkey(index)); - self.key("withdrawal_credentials"); - self.hex(&validators.credentials(index).0); - self.key("effective_balance"); - self.quoted_u64(validators.effective_balance(index)); - self.key("slashed"); - self.bool(validators.is_slashed(index)); - self.key("activation_eligibility_epoch"); - self.quoted_u64(validators.activation_eligibility_epoch(index)); - self.key("activation_epoch"); - self.quoted_u64(validators.activation_epoch(index)); - self.key("exit_epoch"); - self.quoted_u64(validators.exit_epoch(index)); - self.key("withdrawable_epoch"); - self.quoted_u64(validators.withdrawable_epoch(index)); - self.end_object(); - } - - pub(crate) fn validator_entry( - &mut self, - validators: &ValidatorsView<'_>, - index: usize, - balance: u64, - status: &str, - ) { - self.begin_object(); - self.key("index"); - self.quoted_u64(index as u64); - self.key("balance"); - self.quoted_u64(balance); - self.key("status"); - self.string(status); - self.key("validator"); - self.validator(validators, index); - self.end_object(); - } - pub(crate) fn proposer_duty(&mut self, pubkey: &BLSPubkey, validator_index: u64, slot: u64) { self.begin_object(); self.key("pubkey"); @@ -370,12 +375,10 @@ pub(crate) fn json_safe(text: &str) -> bool { #[cfg(test)] mod tests { - use silver_beacon_state_data::{ - BeaconState, BeaconStateOwner, EpochStateFinalized, FAR_FUTURE_EPOCH, StateId, ValSeed, - Withdrawals, - }; + use silver_beacon_state_data::{FAR_FUTURE_EPOCH, Withdrawals}; use super::*; + use crate::validators::status::{Lifecycle, Status}; fn write(render: impl FnOnce(&mut Json<'_>)) -> String { let mut out = Vec::new(); @@ -618,37 +621,35 @@ mod tests { /// One validator with every field distinct, so a golden catches a /// swapped pair as well as a renamed key. - fn state_with_one_validator() -> (BeaconStateOwner, StateId) { + fn one_validator() -> ValidatorEntry { let mut pubkey = [0u8; 48]; pubkey[0] = 0x93; pubkey[47] = 0x07; - let seeds = [ValSeed { - pubkey, - withdrawal_credentials: Withdrawals::eth1(&[0xab; 20]), - effective_balance: 32_000_000_000, + ValidatorEntry { + index: 0, balance: 32_500_000_000, - activation_epoch: 10, - exit_epoch: FAR_FUTURE_EPOCH, - }]; - let mut owner = - BeaconStateOwner::new(BeaconState::for_test(EpochStateFinalized::default(), &seeds, 0)); - let anchor = owner.roll_fresh(); - let (mut writer, _, _) = owner.apply_block_view(anchor); - writer.validators.set_slashed(0, true); - writer.validators.set_activation_eligibility_epoch(0, 9); - writer.validators.set_withdrawable_epoch(0, 8_192); - let head = writer.commit(None, None); - (owner, head) + status: Status::ActiveSlashed, + validator: Validator { + pubkey, + withdrawal_credentials: Withdrawals::eth1(&[0xab; 20]), + effective_balance: 32_000_000_000, + lifecycle: Lifecycle { + slashed: true, + activation_eligibility_epoch: 9, + activation_epoch: 10, + exit_epoch: FAR_FUTURE_EPOCH, + withdrawable_epoch: 8_192, + }, + }, + } } /// Field names/order: SSZ `Validator` container, as inlined by /// `apis/beacon/states/validators.yaml`. #[test] fn validator_golden() { - let (owner, head) = state_with_one_validator(); - let view = owner.read_view(head); assert_body( - |j| j.validator(&view.validators, 0), + |j| j.validator(&one_validator().validator), "{\"pubkey\":\"0x930000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007\",\ \"withdrawal_credentials\":\"0x010000000000000000000000abababababababababababababababababababab\",\ \"effective_balance\":\"32000000000\",\"slashed\":true,\ @@ -661,10 +662,7 @@ mod tests { /// `apis/beacon/states/validators.yaml`. #[test] fn validator_entry_golden() { - let (owner, head) = state_with_one_validator(); - let view = owner.read_view(head); - let body = - write(|j| j.validator_entry(&view.validators, 0, 32_500_000_000, "active_slashed")); + let body = write(|j| j.validator_entry(&one_validator())); let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); assert_eq!(parsed["index"], "0"); assert_eq!(parsed["balance"], "32500000000"); @@ -675,6 +673,21 @@ mod tests { )); } + /// The `data` array of `GetStateValidatorsResponse`: siblings separated, + /// and an empty result set still an array. + #[test] + fn validators_array_golden() { + assert_body(|j| j.validators(&[]), "[]"); + let entry = one_validator(); + let second = ValidatorEntry { index: 1, ..entry.clone() }; + let body = write(|j| j.validators(&[entry, second])); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + let entries = parsed.as_array().expect("an array"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["index"], "0"); + assert_eq!(entries[1]["index"], "1"); + } + /// Field names/order: `ProposerDuty` of /// `apis/validator/duties/proposer.yaml`. #[test] diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index 724dc70c..669d62c0 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -7,6 +7,7 @@ mod router; mod routes; mod server; mod statics; +mod validators; pub use node_status::{NodeStatus, PeerCounts, SlotStatus}; pub use server::BeaconApi; diff --git a/crates/beacon_api/src/response.rs b/crates/beacon_api/src/response.rs index d5597479..7ea54f7f 100644 --- a/crates/beacon_api/src/response.rs +++ b/crates/beacon_api/src/response.rs @@ -118,6 +118,7 @@ fn status_line(code: u16) -> Option<&'static str> { 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", diff --git a/crates/beacon_api/src/router.rs b/crates/beacon_api/src/router.rs index 47536f0c..9637512b 100644 --- a/crates/beacon_api/src/router.rs +++ b/crates/beacon_api/src/router.rs @@ -22,9 +22,8 @@ impl Method { pub(crate) type Handler = fn(&Request<'_>, &ApiCtx, &mut Response<'_>); -// `body` becomes live with the first POST endpoint, `method` and `path` with a -// handler that answers on more than the route it was dispatched by; until then -// only tests read those three. +// `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, diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index ac5419f4..1fd3164b 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -14,6 +14,7 @@ use crate::{ response::Response, router::{Handler, Method, Request}, statics::StaticBodies, + validators::{get_state_validator, get_state_validators, post_state_validators}, }; const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; @@ -29,6 +30,13 @@ pub(crate) const ROUTES: &[(Method, &str, Handler)] = &[ state_finality_checkpoints, ), (Method::Get, "/eth/v1/beacon/states/{state_id}/fork", state_fork), + (Method::Get, "/eth/v1/beacon/states/{state_id}/validators", get_state_validators), + (Method::Post, "/eth/v1/beacon/states/{state_id}/validators", post_state_validators), + ( + Method::Get, + "/eth/v1/beacon/states/{state_id}/validators/{validator_id}", + get_state_validator, + ), (Method::Get, "/eth/v1/config/deposit_contract", deposit_contract), (Method::Get, "/eth/v1/config/fork_schedule", fork_schedule), (Method::Get, "/eth/v1/config/spec", spec), @@ -76,16 +84,16 @@ impl ApiCtx { result } - /// Answers a `{state_id}` read in the envelope its schema requires. `read` - /// runs under the seqlock and is re-run on retry, so it lifts scalars out - /// and `render` writes them once, afterwards. - pub(crate) fn state_response( + /// 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, - render: impl FnOnce(&mut Json<'_>, &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) { @@ -93,7 +101,7 @@ impl ApiCtx { } else { resp.error(400, "invalid state_id"); } - return; + return None; } let execution_optimistic = self.node_status.execution_optimistic(); @@ -107,18 +115,29 @@ impl ApiCtx { }, data: read(view), }; - let Some(state) = self.read_state_or_404(resp, "state not found", read) else { + self.read_state_or_404(resp, "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.state_envelope(state.flags, |json| render(json, &state.data))); } } /// One state read: the flags describe the snapshot `data` came from. -struct StateRead { - flags: StateFlags, - data: R, +pub(crate) struct StateRead { + pub(crate) flags: StateFlags, + pub(crate) data: R, } /// Whether `state_id` is one of the forms the schemas define — the `head`, @@ -243,7 +262,7 @@ pub(crate) fn preboot_ctx() -> ApiCtx { } #[cfg(test)] -fn test_ctx(spec: &SpecConfig, state: BeaconStateReader) -> ApiCtx { +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(); diff --git a/crates/beacon_api/src/validators/entry.rs b/crates/beacon_api/src/validators/entry.rs new file mode 100644 index 00000000..290a48ab --- /dev/null +++ b/crates/beacon_api/src/validators/entry.rs @@ -0,0 +1,96 @@ +use silver_beacon_state_data::{BLSPubkey, Epoch, StateReadView, ValidatorsView, Withdrawals}; + +use crate::validators::{ + filter::Filter, + status::{Lifecycle, Status}, +}; + +/// SSZ `Validator`, copied out of the columnar registry — the read closure +/// owns everything the body needs so rendering happens outside the seqlock. +#[derive(Clone, PartialEq, Eq, Debug)] +pub(crate) struct Validator { + pub(crate) pubkey: BLSPubkey, + pub(crate) withdrawal_credentials: Withdrawals, + pub(crate) effective_balance: u64, + pub(crate) lifecycle: Lifecycle, +} + +/// `ValidatorResponse` of `types/api.yaml`. +#[derive(Clone, PartialEq, Eq, Debug)] +pub(crate) struct ValidatorEntry { + pub(crate) index: u64, + pub(crate) balance: u64, + pub(crate) status: Status, + pub(crate) validator: Validator, +} + +impl Validator { + fn read(validators: &ValidatorsView<'_>, index: usize, lifecycle: Lifecycle) -> Self { + Self { + pubkey: *validators.pubkey(index), + withdrawal_credentials: *validators.credentials(index), + effective_balance: validators.effective_balance(index), + lifecycle, + } + } +} + +impl ValidatorEntry { + pub(crate) fn read(view: &StateReadView<'_>, index: u32, epoch: Epoch) -> Self { + Self::accepted(view, index, epoch, &Filter::default()) + .expect("a filter naming no status accepts every status") + } + + /// The entry at `index` unless `filter` rejects its status. The pubkey and + /// credentials copies the body needs are paid only for a validator the + /// answer carries; deriving the status costs the five lifecycle columns. + fn accepted( + view: &StateReadView<'_>, + index: u32, + epoch: Epoch, + filter: &Filter, + ) -> Option { + let ix = index as usize; + let balance = view.balances.get(ix); + let lifecycle = Lifecycle::read(&view.validators, ix); + let status = Status::of(&lifecycle, balance, epoch); + filter.accepts(status).then(|| Self { + index: index as u64, + balance, + status, + validator: Validator::read(&view.validators, ix, lifecycle), + }) + } + + /// Every validator `filter` selects, in registry order. Runs under the + /// seqlock and is re-run whole on a finalize retry, so it must stay a pure + /// function of `view`: it owns its result and touches nothing else. + /// + /// `count()` spans the fork delta's `appended` vec, which + /// [`silver_beacon_state_data::BeaconStateReader::read`] does not list + /// among the reads that are safe to make optimistically. Re-reading it per + /// step stops a concurrent shrink from indexing past the registry, which + /// latching one bound up front would not. + pub(crate) fn matching(view: &StateReadView<'_>, filter: &Filter) -> Vec { + let epoch = view.slot.current_epoch(); + let in_registry = |index: &u32| (*index as usize) < view.validators.count(); + match filter.resolve_ids(&view.validators) { + Some(indices) => indices + .into_iter() + .filter(in_registry) + .filter_map(|index| Self::accepted(view, index, epoch, filter)) + .collect(), + None => { + let mut entries = Vec::new(); + let mut index = 0; + while in_registry(&index) { + if let Some(entry) = Self::accepted(view, index, epoch, filter) { + entries.push(entry); + } + index += 1; + } + entries + } + } + } +} diff --git a/crates/beacon_api/src/validators/filter.rs b/crates/beacon_api/src/validators/filter.rs new file mode 100644 index 00000000..e3b3a425 --- /dev/null +++ b/crates/beacon_api/src/validators/filter.rs @@ -0,0 +1,355 @@ +use serde::Deserialize; +use silver_beacon_state_data::{BLSPubkey, ValidatorsView}; +use silver_httpcore::Query; + +use crate::{ + response::Response, + validators::status::{Status, StatusMask}, +}; + +/// `maxItems` on the GET `id` array (`apis/beacon/states/validators.yaml`). +const MAX_QUERY_IDS: usize = 64; + +/// The POST variant carries lists a query string cannot and declares no +/// `maxItems`, but an unbounded one turns a 16 MiB body into millions of ids +/// that [`Filter::resolve_ids`] then sorts inside the seqlock read. A quarter +/// of a million keys is an order of magnitude past the largest single +/// validator client in production, against a mainnet registry of ~2M. +const MAX_BODY_IDS: usize = 256 * 1024; + +pub(crate) enum ValidatorId { + Index(u64), + Pubkey(BLSPubkey), +} + +impl ValidatorId { + /// `None` for a value that names no validator at all, which the schemas + /// answer 400 — distinct from a well-formed id no validator carries. + pub(crate) fn parse(text: &str) -> Option { + let Some(hex) = text.strip_prefix("0x") else { + // `u64::from_str` alone also accepts a leading `+`. + return text + .bytes() + .all(|byte| byte.is_ascii_digit()) + .then(|| text.parse().ok()) + .flatten() + .map(Self::Index); + }; + let mut pubkey = [0u8; 48]; + hex::decode_to_slice(hex, &mut pubkey).ok()?; + Some(Self::Pubkey(pubkey)) + } + + pub(crate) fn resolve(&self, validators: &ValidatorsView<'_>) -> Option { + match self { + Self::Index(index) => { + u32::try_from(*index).ok().filter(|&i| (i as usize) < validators.count()) + } + Self::Pubkey(pubkey) => validators.find_by_pubkey(pubkey), + } + } +} + +/// The `id`/`status` pair both list endpoints filter on. Naming no id and no +/// status is the spec's "no filtering on that attribute", which selects the +/// whole registry. +#[derive(Default)] +pub(crate) struct Filter { + ids: Vec, + statuses: StatusMask, +} + +impl Filter { + /// `id` and `status` repeat, and each occurrence may itself be a + /// comma-separated list — no id or status value can contain a comma, so + /// accepting both spellings can never split a legal value. + pub(crate) fn from_query(query: &str) -> Result { + let mut filter = Self::default(); + for (name, value) in Query::new(query) { + match name.as_ref() { + "id" => { + for item in value.split(',') { + if filter.ids.len() == MAX_QUERY_IDS { + return Err(FilterError::TooManyQueryIds); + } + filter.ids.push(ValidatorId::parse(item).ok_or(FilterError::InvalidId)?); + } + } + "status" => { + for item in value.split(',') { + let mask = StatusMask::parse(item).ok_or(FilterError::InvalidStatus)?; + filter.statuses.insert(mask); + } + } + _ => {} + } + } + Ok(filter) + } + + pub(crate) fn from_body(body: &[u8]) -> Result { + let parsed: RequestBody = + serde_json::from_slice(body).map_err(|_| FilterError::InvalidBody)?; + let submitted_ids = parsed.ids.unwrap_or_default(); + if submitted_ids.len() > MAX_BODY_IDS { + return Err(FilterError::TooManyBodyIds); + } + + let mut filter = Self::default(); + for id in submitted_ids { + filter.ids.push(ValidatorId::parse(&id).ok_or(FilterError::InvalidId)?); + } + for status in parsed.statuses.unwrap_or_default() { + let mask = StatusMask::parse(&status).ok_or(FilterError::InvalidStatus)?; + filter.statuses.insert(mask); + } + Ok(filter) + } + + /// The indices this filter's ids name, deduplicated and in registry order; + /// `None` when it names none, which selects the whole registry. Ids that + /// resolve to no validator are dropped — the schemas return no information + /// for them rather than an error. + pub(crate) fn resolve_ids(&self, validators: &ValidatorsView<'_>) -> Option> { + if self.ids.is_empty() { + return None; + } + let mut indices: Vec<_> = self.ids.iter().filter_map(|id| id.resolve(validators)).collect(); + indices.sort_unstable(); + indices.dedup(); + Some(indices) + } + + pub(crate) fn accepts(&self, status: Status) -> bool { + self.statuses.accepts(status) + } +} + +/// A request naming a filter this endpoint cannot answer from. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum FilterError { + InvalidId, + InvalidStatus, + TooManyQueryIds, + TooManyBodyIds, + InvalidBody, +} + +impl FilterError { + pub(crate) fn respond(self, resp: &mut Response<'_>) { + match self { + Self::InvalidId => resp.error(400, "invalid validator id"), + Self::InvalidStatus => resp.error(400, "invalid validator status"), + Self::TooManyQueryIds => resp.error(414, "too many validator ids in request"), + Self::TooManyBodyIds => resp.error(400, "too many validator ids in request body"), + Self::InvalidBody => resp.error(400, "invalid request body"), + } + } +} + +/// `postStateValidators`' request body; either list may be absent or `null`. +#[derive(Deserialize)] +struct RequestBody { + ids: Option>, + statuses: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(query: &str) -> Vec { + Filter::from_query(query) + .unwrap() + .ids + .iter() + .map(|id| match id { + ValidatorId::Index(index) => index.to_string(), + ValidatorId::Pubkey(pubkey) => format!("0x{}", hex::encode(pubkey)), + }) + .collect() + } + + fn pubkey_text(byte: u8) -> String { + format!("0x{}", hex::encode([byte; 48])) + } + + #[test] + fn ids_repeat_or_come_comma_separated_or_both() { + assert_eq!(ids("id=1&id=2"), ["1", "2"]); + assert_eq!(ids("id=1,2,3"), ["1", "2", "3"]); + assert_eq!(ids("id=1,2&id=3"), ["1", "2", "3"]); + assert_eq!(ids(""), Vec::::new()); + assert_eq!(ids("status=active"), Vec::::new()); + } + + #[test] + fn a_pubkey_id_keeps_its_48_bytes_whatever_case_it_arrives_in() { + let key = pubkey_text(0xab); + assert_eq!(ids(&format!("id={key}")), [key.clone()]); + assert_eq!(ids(&format!("id={}", key.to_uppercase().replace("0X", "0x"))), [key]); + } + + #[test] + fn a_query_value_reaches_the_filter_percent_decoded() { + assert_eq!(ids("id=%31%32"), ["12"]); + } + + #[test] + fn statuses_repeat_or_come_comma_separated() { + let filter = Filter::from_query("status=active_ongoing,exited&status=pending").unwrap(); + assert!(filter.accepts(Status::ActiveOngoing)); + assert!(filter.accepts(Status::ExitedSlashed)); + assert!(filter.accepts(Status::PendingQueued)); + assert!(!filter.accepts(Status::WithdrawalDone)); + } + + #[test] + fn no_status_filter_accepts_every_status() { + let filter = Filter::from_query("id=1").unwrap(); + assert!(filter.accepts(Status::ActiveOngoing)); + assert!(filter.accepts(Status::WithdrawalDone)); + } + + /// The `status` array carries no `maxItems`, so what bounds the sweep is + /// that the filter holds a set: a value repeated 100k times leaves the + /// same mask as naming it once, and the same O(1) test per validator. + #[test] + fn a_status_repeated_to_the_size_of_a_request_collapses_to_one_mask() { + let once = Filter::from_query("status=active").unwrap(); + let repeated = "active,".repeat(100_000); + let flooded = Filter::from_query(&format!("status={}", repeated.trim_end_matches(','))) + .expect("every item is a legal status"); + assert_eq!(flooded.statuses, once.statuses); + assert!(flooded.ids.is_empty()); + } + + /// A value that names no validator is malformed input, not an unknown + /// validator: the empty item a stray comma or a bare `id=` produces + /// included, since answering it as "no filter" would serve the whole + /// registry to a client that asked for nothing. + #[test] + fn an_id_that_names_no_validator_at_all_is_rejected() { + for query in [ + "id=", + "id=1,,2", + "id=-1", + "id=+1", + "id=1.5", + "id=abc", + "id=0x", + "id=0xzz", + &format!("id=0x{}", "ab".repeat(47)), + &format!("id=0x{}", "ab".repeat(49)), + "id=18446744073709551616", + ] { + assert_eq!(Filter::from_query(query).err(), Some(FilterError::InvalidId), "{query}"); + } + } + + #[test] + fn a_status_that_is_not_in_the_schema_is_rejected() { + for query in ["status=", "status=active,bogus", "status=ACTIVE"] { + assert_eq!( + Filter::from_query(query).err(), + Some(FilterError::InvalidStatus), + "{query}" + ); + } + } + + /// `maxItems: 64` on the GET `id` array; the 65th is over the cap however + /// the client spells the list. + #[test] + fn the_65th_query_id_is_a_414() { + let sixty_four = (0..64).map(|i| i.to_string()).collect::>().join(","); + assert_eq!(Filter::from_query(&format!("id={sixty_four}")).unwrap().ids.len(), 64); + assert_eq!( + Filter::from_query(&format!("id={sixty_four}&id=64")).err(), + Some(FilterError::TooManyQueryIds) + ); + assert_eq!( + Filter::from_query(&format!("id={sixty_four},64")).err(), + Some(FilterError::TooManyQueryIds) + ); + } + + /// The POST body exists to carry lists longer than the GET query allows, + /// so the 64-id cap is not applied to it — but its own bound is. + #[test] + fn the_post_body_takes_far_more_ids_than_a_query_may_and_still_has_a_bound() { + let list = |count: usize| { + let many: Vec = (0..count).map(|i| i.to_string()).collect(); + serde_json::json!({ "ids": many }).to_string() + }; + assert_eq!(Filter::from_body(list(1_000).as_bytes()).unwrap().ids.len(), 1_000); + assert_eq!( + Filter::from_body(list(MAX_BODY_IDS).as_bytes()).unwrap().ids.len(), + MAX_BODY_IDS + ); + assert_eq!( + Filter::from_body(list(MAX_BODY_IDS + 1).as_bytes()).err(), + Some(FilterError::TooManyBodyIds) + ); + } + + #[test] + fn an_absent_null_or_empty_body_list_filters_on_nothing() { + for body in [ + "{}", + r#"{"ids":null,"statuses":null}"#, + r#"{"ids":[],"statuses":[]}"#, + r#"{"ids":[],"unknown_field":3}"#, + ] { + let filter = Filter::from_body(body.as_bytes()).expect(body); + assert!(filter.ids.is_empty(), "{body}"); + assert!(filter.accepts(Status::WithdrawalDone), "{body}"); + } + } + + #[test] + fn a_body_that_is_not_the_schema_s_object_is_rejected() { + for body in ["", "not json", "[]", r#"{"ids":"1"}"#, r#"{"ids":[1]}"#] { + assert_eq!(Filter::from_body(body.as_bytes()).err(), Some(FilterError::InvalidBody)); + } + } + + #[test] + fn body_ids_and_statuses_are_validated_the_same_way_a_query_s_are() { + assert_eq!( + Filter::from_body(br#"{"ids":["1","banana"]}"#).err(), + Some(FilterError::InvalidId) + ); + assert_eq!( + Filter::from_body(br#"{"statuses":["nope"]}"#).err(), + Some(FilterError::InvalidStatus) + ); + } + + /// 414 is declared on the GET alone, so the bound the body carries has to + /// answer with a code the POST declares. + #[test] + fn each_refusal_answers_with_a_code_the_verb_that_raises_it_declares() { + let status_line = |error: FilterError| { + let mut out = Vec::new(); + error.respond(&mut Response::new(&mut out)); + String::from_utf8(out).unwrap().lines().next().unwrap().to_owned() + }; + assert_eq!(status_line(FilterError::TooManyQueryIds), "HTTP/1.1 414 URI Too Long"); + for error in [ + FilterError::TooManyBodyIds, + FilterError::InvalidBody, + FilterError::InvalidId, + FilterError::InvalidStatus, + ] { + assert_eq!(status_line(error), "HTTP/1.1 400 Bad Request", "{error:?}"); + } + } + + /// Commas separate values in a query string only; a body carries a real + /// array, so a comma inside one of its strings is part of that string. + #[test] + fn a_body_id_is_not_split_on_commas() { + assert_eq!(Filter::from_body(br#"{"ids":["1,2"]}"#).err(), Some(FilterError::InvalidId)); + } +} diff --git a/crates/beacon_api/src/validators/mod.rs b/crates/beacon_api/src/validators/mod.rs new file mode 100644 index 00000000..2aa58611 --- /dev/null +++ b/crates/beacon_api/src/validators/mod.rs @@ -0,0 +1,656 @@ +pub(crate) mod entry; +mod filter; +pub(crate) mod status; + +use silver_beacon_state_data::StateReadView; + +use crate::{ + response::Response, + router::Request, + routes::ApiCtx, + validators::{ + entry::ValidatorEntry, + filter::{Filter, ValidatorId}, + }, +}; + +pub(crate) fn get_state_validators(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + match Filter::from_query(req.query) { + Ok(filter) => respond_with_matches(req, ctx, resp, &filter), + Err(error) => error.respond(resp), + } +} + +/// Same answer as the GET, from a body that carries lists too long for a +/// query string. +pub(crate) fn post_state_validators(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + match Filter::from_body(req.body) { + Ok(filter) => respond_with_matches(req, ctx, resp, &filter), + Err(error) => error.respond(resp), + } +} + +pub(crate) fn get_state_validator(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let named = req.params.get("validator_id").expect("{validator_id} in the route pattern"); + let Some(validator_id) = ValidatorId::parse(named) else { + resp.error(400, "invalid validator_id"); + return; + }; + let read = |view: StateReadView<'_>| { + let index = validator_id.resolve(&view.validators)?; + Some(ValidatorEntry::read(&view, index, view.slot.current_epoch())) + }; + let Some(state) = ctx.state_read(req, resp, read) else { + return; + }; + match &state.data { + Some(entry) => resp + .json_body(|json| json.state_envelope(state.flags, |json| json.validator_entry(entry))), + None => resp.error(404, "validator not found"), + } +} + +fn respond_with_matches(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>, filter: &Filter) { + let Some(state) = ctx.state_read(req, resp, |view| ValidatorEntry::matching(&view, filter)) + else { + return; + }; + resp.json_body(|json| json.state_envelope(state.flags, |json| json.validators(&state.data))); +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use silver_beacon_state_data::{ + BeaconState, BeaconStateOwner, Epoch, EpochStateFinalized, FAR_FUTURE_EPOCH, + SLOTS_PER_EPOCH, SpecConfig, StateId, ValSeed, Withdrawals, + }; + use silver_common::ELSyncStatus; + use silver_httpcore::ParsedRequest; + + use super::*; + use crate::{ + NodeStatus, PeerCounts, SlotStatus, + json::Json, + router::Router, + routes::{ROUTES, preboot_ctx, test_ctx}, + }; + + const HEAD_EPOCH: Epoch = 100; + const HEAD_SLOT: u64 = HEAD_EPOCH * SLOTS_PER_EPOCH; + const VALIDATORS_PATH: &str = "/eth/v1/beacon/states/head/validators"; + + /// One validator per status at [`HEAD_EPOCH`], in status order, so an + /// index in [`one_per_status`] doubles as the status it must carry. + const STATUSES: [&str; 9] = [ + "pending_initialized", + "pending_queued", + "active_ongoing", + "active_exiting", + "active_slashed", + "exited_unslashed", + "exited_slashed", + "withdrawal_possible", + "withdrawal_done", + ]; + + /// The three fields `ValSeed` does not carry, applied through a writer. + struct Extra { + slashed: bool, + activation_eligibility_epoch: Epoch, + withdrawable_epoch: Epoch, + } + + fn pubkey_of(index: usize) -> [u8; 48] { + [0xa0 + index as u8; 48] + } + + fn pubkey_text(index: usize) -> String { + format!("0x{}", hex::encode(pubkey_of(index))) + } + + fn credentials_of(index: usize) -> Withdrawals { + Withdrawals::eth1(&[0xc0 + index as u8; 20]) + } + + /// Nine validators, one per status at [`HEAD_EPOCH`], with every field + /// distinct enough that a golden catches a swapped one. + fn one_per_status() -> (Vec, Vec) { + let far = FAR_FUTURE_EPOCH; + // effective balance activation exit slashed elig withdrawable + let rows: [(u64, u64, Epoch, Epoch, bool, Epoch, Epoch); 9] = [ + (1_000_000_000, 1_000_000_000, far, far, false, far, far), + (32_000_000_000, 32_000_000_000, 105, far, false, 98, far), + (32_000_000_000, 32_100_000_000, 10, far, false, 8, far), + (32_000_000_000, 32_200_000_000, 10, 110, false, 8, 366), + (32_000_000_000, 16_300_000_000, 10, 110, true, 8, 8_300), + (32_000_000_000, 32_400_000_000, 10, 90, false, 8, 101), + (16_000_000_000, 16_500_000_000, 10, 90, true, 8, 8_290), + (32_000_000_000, 32_600_000_000, 10, 90, false, 8, 95), + (0, 0, 10, 90, false, 8, 95), + ]; + rows.iter() + .enumerate() + .map(|(index, &(effective, balance, activation, exit, slashed, elig, withdrawable))| { + ( + ValSeed { + pubkey: pubkey_of(index), + withdrawal_credentials: credentials_of(index), + effective_balance: effective, + balance, + activation_epoch: activation, + exit_epoch: exit, + }, + Extra { + slashed, + activation_eligibility_epoch: elig, + withdrawable_epoch: withdrawable, + }, + ) + }) + .unzip() + } + + fn published_state( + seeds: &[ValSeed], + extras: &[Extra], + slot: u64, + ) -> (BeaconStateOwner, StateId) { + let mut owner = BeaconStateOwner::new(BeaconState::for_test( + EpochStateFinalized::default(), + seeds, + slot, + )); + let anchor = owner.roll_fresh(); + let (mut writer, _, _) = owner.apply_block_view(anchor); + for (index, extra) in extras.iter().enumerate() { + let index = index as u32; + writer.validators.set_slashed(index, extra.slashed); + writer + .validators + .set_activation_eligibility_epoch(index, extra.activation_eligibility_epoch); + writer.validators.set_withdrawable_epoch(index, extra.withdrawable_epoch); + } + let head = writer.commit(None, None); + owner.publish_state_id(head); + (owner, head) + } + + fn published_ctx(seeds: &[ValSeed], extras: &[Extra], slot: u64) -> ApiCtx { + let (owner, _) = published_state(seeds, extras, slot); + let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); + ctx.node_status = NodeStatus { + slots: Some(SlotStatus { head_slot: slot, wall_slot: slot, head_optimistic: false }), + syncing: false, + el: ELSyncStatus::Synced, + peers: PeerCounts::default(), + }; + ctx + } + + fn registry_ctx() -> ApiCtx { + let (seeds, extras) = one_per_status(); + published_ctx(&seeds, &extras, HEAD_SLOT) + } + + /// More validators than the widest query string could name, all but one + /// of them active_ongoing. + const BULK_REGISTRY: usize = 20_000; + + fn bulk_registry_ctx() -> ApiCtx { + let seeds: Vec<_> = (0..BULK_REGISTRY) + .map(|index| { + let mut pubkey = [0u8; 48]; + pubkey[..8].copy_from_slice(&(index as u64).to_le_bytes()); + ValSeed { + pubkey, + withdrawal_credentials: Withdrawals::ZERO, + effective_balance: 32_000_000_000, + balance: 32_000_000_000, + activation_epoch: if index == 0 { FAR_FUTURE_EPOCH } else { 10 }, + exit_epoch: FAR_FUTURE_EPOCH, + } + }) + .collect(); + // `ValSeed`'s registry defaults are already this test's `Extra`s: + // unslashed, with no eligibility and no withdrawable epoch. + published_ctx(&seeds, &[], HEAD_SLOT) + } + + fn request<'a>( + method: &'a str, + path: &'a str, + query: &'a str, + body: &'a [u8], + ) -> ParsedRequest<'a> { + ParsedRequest { + method, + path, + query, + body, + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + } + } + + fn dispatch(ctx: &ApiCtx, req: &ParsedRequest<'_>) -> Vec { + let mut out = Vec::new(); + Router::new(ROUTES).dispatch(req, ctx, &mut out); + out + } + + fn get(ctx: &ApiCtx, path: &str, query: &str) -> Vec { + dispatch(ctx, &request("GET", path, query, b"")) + } + + fn post(ctx: &ApiCtx, path: &str, body: &str) -> Vec { + dispatch(ctx, &request("POST", path, "", body.as_bytes())) + } + + fn body(response: &[u8]) -> &[u8] { + let text = std::str::from_utf8(response).unwrap(); + &response[text.find("\r\n\r\n").unwrap() + 4..] + } + + fn ok_body(response: &[u8]) -> String { + assert!( + response.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{}", + String::from_utf8_lossy(response) + ); + String::from_utf8(body(response).to_vec()).unwrap() + } + + /// The `index`/`status` pairs a list body carries, in the order it lists + /// them. + fn listed(response: &[u8]) -> Vec<(u64, String)> { + let parsed: serde_json::Value = serde_json::from_str(&ok_body(response)).unwrap(); + parsed["data"] + .as_array() + .expect("data is an array") + .iter() + .map(|entry| { + ( + entry["index"].as_str().unwrap().parse().unwrap(), + entry["status"].as_str().unwrap().to_owned(), + ) + }) + .collect() + } + + fn indices(response: &[u8]) -> Vec { + listed(response).into_iter().map(|(index, _)| index).collect() + } + + fn assert_error(response: &[u8], status: &str, code: u16, message: &str) { + assert!( + response.starts_with(format!("HTTP/1.1 {status}\r\n").as_bytes()), + "{}", + String::from_utf8_lossy(response) + ); + assert_eq!( + std::str::from_utf8(body(response)).unwrap(), + format!("{{\"code\":{code},\"message\":\"{message}\"}}") + ); + } + + /// Body shape: `GetStateValidatorsResponse` of + /// `apis/beacon/states/validators.yaml` — both envelope flags, then a + /// `data` array of `ValidatorResponse` with every integer a quoted decimal + /// string and every byte string lowercase `0x`-hex. + #[test] + fn validators_body_is_the_envelope_around_a_validator_response_array() { + let expected = format!( + "{{\"execution_optimistic\":false,\"finalized\":false,\"data\":[\ + {{\"index\":\"0\",\"balance\":\"1000000000\",\"status\":\"pending_initialized\",\ + \"validator\":{{\"pubkey\":\"0x{a0}\",\"withdrawal_credentials\":\"0x{c0}\",\ + \"effective_balance\":\"1000000000\",\"slashed\":false,\ + \"activation_eligibility_epoch\":\"18446744073709551615\",\ + \"activation_epoch\":\"18446744073709551615\",\ + \"exit_epoch\":\"18446744073709551615\",\ + \"withdrawable_epoch\":\"18446744073709551615\"}}}},\ + {{\"index\":\"4\",\"balance\":\"16300000000\",\"status\":\"active_slashed\",\ + \"validator\":{{\"pubkey\":\"0x{a4}\",\"withdrawal_credentials\":\"0x{c4}\",\ + \"effective_balance\":\"32000000000\",\"slashed\":true,\ + \"activation_eligibility_epoch\":\"8\",\"activation_epoch\":\"10\",\ + \"exit_epoch\":\"110\",\"withdrawable_epoch\":\"8300\"}}}}]}}", + a0 = hex::encode(pubkey_of(0)), + c0 = hex::encode(credentials_of(0).0), + a4 = hex::encode(pubkey_of(4)), + c4 = hex::encode(credentials_of(4).0), + ); + assert_eq!(ok_body(&get(®istry_ctx(), VALIDATORS_PATH, "id=0,4")), expected); + serde_json::from_str::(&expected).expect("valid JSON"); + } + + #[test] + fn an_empty_result_set_keeps_the_envelope_and_an_empty_array() { + assert_eq!( + ok_body(&get(®istry_ctx(), VALIDATORS_PATH, "id=999")), + "{\"execution_optimistic\":false,\"finalized\":false,\"data\":[]}" + ); + } + + /// Every status the schema names, against a registry holding one of each. + #[test] + fn each_status_is_derived_from_the_validator_s_epochs_at_the_state_s_epoch() { + let expected: Vec<_> = + STATUSES.iter().enumerate().map(|(i, s)| (i as u64, s.to_string())).collect(); + assert_eq!(listed(&get(®istry_ctx(), VALIDATORS_PATH, "")), expected); + } + + /// The boundaries the derivation keys on are the epoch of the state being + /// read: the same registry one epoch on moves validators across them. + #[test] + fn a_later_state_epoch_moves_validators_across_the_status_boundaries() { + let (seeds, extras) = one_per_status(); + let next = published_ctx(&seeds, &extras, (HEAD_EPOCH + 1) * SLOTS_PER_EPOCH); + // Index 5 becomes withdrawable at epoch 101 and still holds a balance; + // index 3's exit at epoch 110 has not arrived yet. + assert_eq!(listed(&get(&next, VALIDATORS_PATH, "id=5"))[0].1, "withdrawal_possible"); + assert_eq!(listed(&get(&next, VALIDATORS_PATH, "id=3"))[0].1, "active_exiting"); + } + + #[test] + fn ids_select_by_index_by_pubkey_and_by_both_at_once() { + let ctx = registry_ctx(); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "id=3")), [3]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, &format!("id={}", pubkey_text(6)))), [6]); + assert_eq!( + indices(&get(&ctx, VALIDATORS_PATH, &format!("id=3&id={}&id=1", pubkey_text(6)))), + [1, 3, 6] + ); + } + + /// `uniqueItems` in the schema: one validator named twice, by either + /// spelling, is still one entry. + #[test] + fn a_validator_named_twice_is_listed_once() { + let ctx = registry_ctx(); + let query = format!("id=2,2&id={}", pubkey_text(2)); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, &query)), [2]); + } + + /// "If an index or public key does not match any known validator, no + /// information will be returned but this will not cause an error." + #[test] + fn an_id_matching_no_validator_is_left_out_rather_than_erroring() { + let ctx = registry_ctx(); + let unknown_key = format!("0x{}", hex::encode([0xff; 48])); + assert!(indices(&get(&ctx, VALIDATORS_PATH, "id=9")).is_empty()); + assert!(indices(&get(&ctx, VALIDATORS_PATH, &format!("id={unknown_key}"))).is_empty()); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, &format!("id=2&id=9&id={unknown_key}"))), [ + 2 + ]); + } + + #[test] + fn statuses_select_by_exact_name_and_by_stage() { + let ctx = registry_ctx(); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=active_slashed")), [4]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=pending")), [0, 1]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=active")), [2, 3, 4]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=exited")), [5, 6]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=withdrawal")), [7, 8]); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "status=pending,withdrawal")), [0, 1, 7, 8]); + } + + #[test] + fn the_two_filters_narrow_each_other() { + let ctx = registry_ctx(); + assert_eq!(indices(&get(&ctx, VALIDATORS_PATH, "id=2,3,7&status=active")), [2, 3]); + assert!(indices(&get(&ctx, VALIDATORS_PATH, "id=2&status=exited")).is_empty()); + } + + /// The POST body exists only to carry longer lists, so the same filter + /// must produce the same bytes down either verb. + #[test] + fn post_and_get_answer_the_same_filter_with_the_same_bytes() { + let ctx = registry_ctx(); + for (query, json_body) in [ + ("", "{}"), + ("id=2,3", r#"{"ids":["2","3"]}"#), + ("status=active", r#"{"statuses":["active"]}"#), + ("id=2,3,7&status=active", r#"{"ids":["2","3","7"],"statuses":["active"]}"#), + ] { + assert_eq!( + ok_body(&get(&ctx, VALIDATORS_PATH, query)), + ok_body(&post(&ctx, VALIDATORS_PATH, json_body)), + "{query}" + ); + } + } + + #[test] + fn a_pubkey_id_reaches_the_registry_index_through_the_post_body_too() { + let ctx = registry_ctx(); + let json_body = format!(r#"{{"ids":["{}"]}}"#, pubkey_text(6)); + assert_eq!(indices(&post(&ctx, VALIDATORS_PATH, &json_body)), [6]); + } + + #[test] + fn a_query_naming_no_validator_or_status_at_all_is_a_400() { + let ctx = registry_ctx(); + assert_error( + &get(&ctx, VALIDATORS_PATH, "id=banana"), + "400 Bad Request", + 400, + "invalid validator id", + ); + assert_error( + &get(&ctx, VALIDATORS_PATH, "status=banana"), + "400 Bad Request", + 400, + "invalid validator status", + ); + } + + #[test] + fn more_query_ids_than_the_schema_allows_is_a_414() { + let ids = (0..65).map(|i| i.to_string()).collect::>().join(","); + assert_error( + &get(®istry_ctx(), VALIDATORS_PATH, &format!("id={ids}")), + "414 URI Too Long", + 414, + "too many validator ids in request", + ); + } + + #[test] + fn a_post_body_that_is_not_the_schema_s_object_is_a_400() { + let ctx = registry_ctx(); + for body in ["not json", "", "[]"] { + assert_error( + &post(&ctx, VALIDATORS_PATH, body), + "400 Bad Request", + 400, + "invalid request body", + ); + } + } + + /// Body shape: `GetStateValidatorResponse` of + /// `apis/beacon/states/validator.yaml` — one `ValidatorResponse`, not an + /// array holding one. + #[test] + fn the_single_validator_body_is_the_envelope_around_one_entry() { + let ctx = registry_ctx(); + let by_index = ok_body(&get(&ctx, "/eth/v1/beacon/states/head/validators/2", "")); + let path = format!("/eth/v1/beacon/states/head/validators/{}", pubkey_text(2)); + assert_eq!(by_index, ok_body(&get(&ctx, &path, ""))); + assert!( + by_index.starts_with( + "{\"execution_optimistic\":false,\"finalized\":false,\"data\":{\"index\":\"2\",\ + \"balance\":\"32100000000\",\"status\":\"active_ongoing\",\"validator\":{" + ), + "{by_index}" + ); + let parsed: serde_json::Value = serde_json::from_str(&by_index).unwrap(); + assert!(parsed["data"].is_object(), "one entry, not an array"); + } + + /// The list endpoint leaves out an id it cannot resolve; this one has + /// nothing left to return, so its schema answers 404. + #[test] + fn a_single_validator_id_matching_no_validator_is_a_404() { + let ctx = registry_ctx(); + for id in ["9", &format!("0x{}", hex::encode([0xff; 48]))] { + assert_error( + &get(&ctx, &format!("/eth/v1/beacon/states/head/validators/{id}"), ""), + "404 Not Found", + 404, + "validator not found", + ); + } + } + + /// A segment naming no validator at all is malformed whatever state it was + /// asked against, so it is answered ahead of the state_id — including + /// ahead of the 404 a state silver does not keep would get. + #[test] + fn a_validator_id_naming_no_validator_at_all_is_a_400() { + let ctx = registry_ctx(); + for id in ["banana", "-1", "+1", "0x", "0xzz", "18446744073709551616", ""] { + for state_id in ["head", "finalized", "banana"] { + assert_error( + &get(&ctx, &format!("/eth/v1/beacon/states/{state_id}/validators/{id}"), ""), + "400 Bad Request", + 400, + "invalid validator_id", + ); + } + } + } + + fn all_three_routes(state_id: &str) -> [(String, &'static str); 3] { + [ + (format!("/eth/v1/beacon/states/{state_id}/validators"), "GET"), + (format!("/eth/v1/beacon/states/{state_id}/validators"), "POST"), + (format!("/eth/v1/beacon/states/{state_id}/validators/0"), "GET"), + ] + } + + fn answer(ctx: &ApiCtx, path: &str, method: &str) -> Vec { + match method { + "POST" => post(ctx, path, "{}"), + _ => get(ctx, path, ""), + } + } + + /// Silver publishes one state, the head. Every other identifier the + /// schemas define names a state it does not keep; anything else names no + /// state at all. All three routes answer by the same table. + #[test] + fn the_state_id_table_is_the_one_every_state_read_answers_by() { + let ctx = registry_ctx(); + for (path, method) in all_three_routes("head") { + assert!(answer(&ctx, &path, method).starts_with(b"HTTP/1.1 200 OK\r\n"), "{path}"); + } + let root = format!("0x{}", "ab".repeat(32)); + for state_id in ["genesis", "justified", "finalized", "0", "3200", &root] { + for (path, method) in all_three_routes(state_id) { + assert_error(&answer(&ctx, &path, method), "404 Not Found", 404, "state not found"); + } + } + for state_id in ["current", "banana", "-1", "+5", "0x", "1.5", "HEAD"] { + for (path, method) in all_three_routes(state_id) { + assert_error( + &answer(&ctx, &path, method), + "400 Bad Request", + 400, + "invalid state_id", + ); + } + } + } + + /// The table carries both verbs on the list route and only GET on the + /// single-validator one, so a POST there is a 405 rather than a 404. + #[test] + fn the_route_table_takes_the_two_verbs_the_schemas_declare() { + let ctx = registry_ctx(); + assert!( + post(&ctx, "/eth/v1/beacon/states/head/validators/0", "{}") + .starts_with(b"HTTP/1.1 405 Method Not Allowed\r\n") + ); + assert!( + dispatch(&ctx, &request("PUT", VALIDATORS_PATH, "", b"")) + .starts_with(b"HTTP/1.1 405 Method Not Allowed\r\n") + ); + } + + #[test] + fn every_validator_route_is_404_before_the_first_state_is_published() { + let ctx = preboot_ctx(); + for (path, method) in all_three_routes("head") { + assert_error(&answer(&ctx, &path, method), "404 Not Found", 404, "state not found"); + } + } + + /// [`BeaconStateReader::read`] re-runs its closure whole when a finalize + /// lands mid-read, so the sweep must be a pure function of the view: two + /// runs against one view produce the same entries, and rendering them + /// twice produces two identical bodies rather than one doubled body. + #[test] + fn re_running_the_sweep_against_one_view_repeats_it_rather_than_extending_it() { + let (seeds, extras) = one_per_status(); + let (owner, head) = published_state(&seeds, &extras, HEAD_SLOT); + let view = owner.read_view(head); + let filter = Filter::from_query("status=active").unwrap(); + + let first = ValidatorEntry::matching(&view, &filter); + let second = ValidatorEntry::matching(&view, &filter); + assert_eq!(first, second); + assert_eq!(first.len(), 3); + + let render = |entries: &[ValidatorEntry]| { + let mut out = Vec::new(); + Json::new(&mut out).validators(entries); + out + }; + assert_eq!(render(&first), render(&second)); + assert_eq!(render(&second).len(), render(&first).len()); + } + + /// "If the supplied list is empty (i.e. the value is `[]`) or the property + /// is omitted then all validators will be returned" — whatever the + /// registry holds, down either verb, and with the status filter alone + /// answerable the same way. + #[test] + fn an_empty_filter_returns_the_whole_registry_however_large_it_is() { + let ctx = bulk_registry_ctx(); + let unfiltered = get(&ctx, VALIDATORS_PATH, ""); + assert_eq!(listed(&unfiltered).len(), BULK_REGISTRY); + assert_eq!(post(&ctx, VALIDATORS_PATH, "{}"), unfiltered); + assert_eq!(post(&ctx, VALIDATORS_PATH, r#"{"ids":[]}"#), unfiltered); + assert_eq!(post(&ctx, VALIDATORS_PATH, r#"{"ids":null,"statuses":null}"#), unfiltered); + + let active = get(&ctx, VALIDATORS_PATH, "status=active"); + assert_eq!(listed(&active).len(), BULK_REGISTRY - 1, "index 0 is still pending"); + assert_eq!(post(&ctx, VALIDATORS_PATH, r#"{"statuses":["active"]}"#), active); + } + + /// The schemas put no `maxItems` on `status`, and a request may repeat one + /// value as many times as it has bytes for. A value no validator carries + /// is the worst case — nothing short-circuits — so this is the sweep the + /// registry-times-list cost would show up in: it must stay one sweep. + #[test] + fn a_status_repeated_across_a_whole_request_still_costs_one_sweep() { + let ctx = bulk_registry_ctx(); + let flood = vec!["status=exited_slashed"; 100_000].join("&"); + + let started = Instant::now(); + let flooded = get(&ctx, VALIDATORS_PATH, &flood); + let elapsed = started.elapsed(); + + assert_eq!(flooded, get(&ctx, VALIDATORS_PATH, "status=exited_slashed")); + assert!(listed(&flooded).is_empty(), "no validator here is exited_slashed"); + // One sweep of this registry is well under a millisecond; the + // registry-times-list product would be 2e9 status comparisons. + assert!(elapsed < Duration::from_secs(5), "{elapsed:?} for one sweep"); + } +} diff --git a/crates/beacon_api/src/validators/status.rs b/crates/beacon_api/src/validators/status.rs new file mode 100644 index 00000000..250b22c5 --- /dev/null +++ b/crates/beacon_api/src/validators/status.rs @@ -0,0 +1,327 @@ +use silver_beacon_state_data::{Epoch, FAR_FUTURE_EPOCH, ValidatorsView}; + +/// `ValidatorStatus` of `types/api.yaml`, derived from the validator's epochs +/// and balance against the epoch of the state being read. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Status { + PendingInitialized, + PendingQueued, + ActiveOngoing, + ActiveExiting, + ActiveSlashed, + ExitedUnslashed, + ExitedSlashed, + WithdrawalPossible, + WithdrawalDone, +} + +/// The `Validator` columns a status is derived from, apart from the balance — +/// all a sweep needs before it knows whether the filter keeps this validator. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) struct Lifecycle { + pub(crate) slashed: bool, + pub(crate) activation_eligibility_epoch: Epoch, + pub(crate) activation_epoch: Epoch, + pub(crate) exit_epoch: Epoch, + pub(crate) withdrawable_epoch: Epoch, +} + +impl Lifecycle { + pub(crate) fn read(validators: &ValidatorsView<'_>, index: usize) -> Self { + Self { + slashed: validators.is_slashed(index), + activation_eligibility_epoch: validators.activation_eligibility_epoch(index), + activation_epoch: validators.activation_epoch(index), + exit_epoch: validators.exit_epoch(index), + withdrawable_epoch: validators.withdrawable_epoch(index), + } + } +} + +impl Status { + pub(crate) fn of(lifecycle: &Lifecycle, balance: u64, epoch: Epoch) -> Self { + if epoch < lifecycle.activation_epoch { + if lifecycle.activation_eligibility_epoch == FAR_FUTURE_EPOCH { + Self::PendingInitialized + } else { + Self::PendingQueued + } + } else if epoch < lifecycle.exit_epoch { + if lifecycle.exit_epoch == FAR_FUTURE_EPOCH { + Self::ActiveOngoing + } else if lifecycle.slashed { + Self::ActiveSlashed + } else { + Self::ActiveExiting + } + } else if epoch < lifecycle.withdrawable_epoch { + if lifecycle.slashed { Self::ExitedSlashed } else { Self::ExitedUnslashed } + } else if balance == 0 { + Self::WithdrawalDone + } else { + Self::WithdrawalPossible + } + } + + pub(crate) fn name(self) -> &'static str { + match self { + Self::PendingInitialized => "pending_initialized", + Self::PendingQueued => "pending_queued", + Self::ActiveOngoing => "active_ongoing", + Self::ActiveExiting => "active_exiting", + Self::ActiveSlashed => "active_slashed", + Self::ExitedUnslashed => "exited_unslashed", + Self::ExitedSlashed => "exited_slashed", + Self::WithdrawalPossible => "withdrawal_possible", + Self::WithdrawalDone => "withdrawal_done", + } + } + + fn parse(name: &str) -> Option { + Some(match name { + "pending_initialized" => Self::PendingInitialized, + "pending_queued" => Self::PendingQueued, + "active_ongoing" => Self::ActiveOngoing, + "active_exiting" => Self::ActiveExiting, + "active_slashed" => Self::ActiveSlashed, + "exited_unslashed" => Self::ExitedUnslashed, + "exited_slashed" => Self::ExitedSlashed, + "withdrawal_possible" => Self::WithdrawalPossible, + "withdrawal_done" => Self::WithdrawalDone, + _ => return None, + }) + } + + fn bit(self) -> u16 { + 1 << self as u16 + } +} + +/// The statuses a `status` filter selects, one bit per [`Status`]. A set is +/// what the schema's `uniqueItems` asks for, and it keeps the per-validator +/// test a single mask test however long the submitted list was. +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] +pub(crate) struct StatusMask(u16); + +impl StatusMask { + /// The nine exact status names, and the four coarse ones each covering the + /// statuses of one stage of a validator's life. + pub(crate) fn parse(name: &str) -> Option { + let bits = match name { + "pending" => Status::PendingInitialized.bit() | Status::PendingQueued.bit(), + "active" => { + Status::ActiveOngoing.bit() | + Status::ActiveExiting.bit() | + Status::ActiveSlashed.bit() + } + "exited" => Status::ExitedUnslashed.bit() | Status::ExitedSlashed.bit(), + "withdrawal" => Status::WithdrawalPossible.bit() | Status::WithdrawalDone.bit(), + _ => Status::parse(name)?.bit(), + }; + Some(Self(bits)) + } + + pub(crate) fn insert(&mut self, other: Self) { + self.0 |= other.0; + } + + /// The empty mask is the spec's "no filtering on that attribute". + pub(crate) fn accepts(self, status: Status) -> bool { + self.0 == 0 || self.0 & status.bit() != 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL: [Status; 9] = [ + Status::PendingInitialized, + Status::PendingQueued, + Status::ActiveOngoing, + Status::ActiveExiting, + Status::ActiveSlashed, + Status::ExitedUnslashed, + Status::ExitedSlashed, + Status::WithdrawalPossible, + Status::WithdrawalDone, + ]; + + const STAGES: [&str; 4] = ["pending", "active", "exited", "withdrawal"]; + + /// Every activated validator with no exit filed, the overwhelming + /// majority of a live registry. + fn active_ongoing() -> Lifecycle { + Lifecycle { + slashed: false, + activation_eligibility_epoch: 5, + activation_epoch: 10, + exit_epoch: FAR_FUTURE_EPOCH, + withdrawable_epoch: FAR_FUTURE_EPOCH, + } + } + + fn status_at(lifecycle: &Lifecycle, epoch: Epoch) -> Status { + Status::of(lifecycle, 32_000_000_000, epoch) + } + + /// The activation epoch is the first epoch of membership, not the last + /// epoch of the queue. + #[test] + fn pending_becomes_active_at_the_activation_epoch() { + let lifecycle = active_ongoing(); + assert_eq!(status_at(&lifecycle, 9), Status::PendingQueued); + assert_eq!(status_at(&lifecycle, 10), Status::ActiveOngoing); + assert_eq!(status_at(&lifecycle, 11), Status::ActiveOngoing); + } + + /// A deposit that has not been through an eligibility sweep yet carries no + /// eligibility epoch, which is what separates the two pending statuses. + #[test] + fn pending_is_initialized_until_an_eligibility_epoch_is_set() { + let initialized = Lifecycle { + activation_eligibility_epoch: FAR_FUTURE_EPOCH, + activation_epoch: FAR_FUTURE_EPOCH, + ..active_ongoing() + }; + assert_eq!(status_at(&initialized, 0), Status::PendingInitialized); + assert_eq!(status_at(&initialized, 1_000_000), Status::PendingInitialized); + + let queued = Lifecycle { activation_eligibility_epoch: 3, ..initialized }; + assert_eq!(status_at(&queued, 0), Status::PendingQueued); + } + + /// A filed exit and a slashing both schedule an exit; the validator stays + /// active — and the two apart — until that epoch arrives. + #[test] + fn a_scheduled_exit_splits_the_active_statuses_until_the_exit_epoch() { + let exiting = Lifecycle { exit_epoch: 20, withdrawable_epoch: 30, ..active_ongoing() }; + assert_eq!(status_at(&exiting, 19), Status::ActiveExiting); + assert_eq!(status_at(&exiting, 20), Status::ExitedUnslashed); + + let slashed = Lifecycle { slashed: true, ..exiting }; + assert_eq!(status_at(&slashed, 19), Status::ActiveSlashed); + assert_eq!(status_at(&slashed, 20), Status::ExitedSlashed); + } + + #[test] + fn exited_becomes_withdrawable_at_the_withdrawable_epoch() { + let exited = Lifecycle { exit_epoch: 20, withdrawable_epoch: 30, ..active_ongoing() }; + assert_eq!(status_at(&exited, 29), Status::ExitedUnslashed); + assert_eq!(status_at(&exited, 30), Status::WithdrawalPossible); + + let slashed = Lifecycle { slashed: true, ..exited }; + assert_eq!(status_at(&slashed, 29), Status::ExitedSlashed); + assert_eq!(status_at(&slashed, 30), Status::WithdrawalPossible); + } + + /// The one status the epochs alone cannot tell: past the withdrawable + /// epoch it is the balance that says whether the funds have moved. + #[test] + fn a_withdrawn_balance_is_the_only_difference_between_the_two_withdrawal_statuses() { + let withdrawable = Lifecycle { exit_epoch: 20, withdrawable_epoch: 30, ..active_ongoing() }; + assert_eq!(Status::of(&withdrawable, 1, 30), Status::WithdrawalPossible); + assert_eq!(Status::of(&withdrawable, 0, 30), Status::WithdrawalDone); + assert_eq!(Status::of(&withdrawable, 0, 29), Status::ExitedUnslashed); + } + + /// A validator slashed while active is scheduled to exit, so a slashed + /// flag never reaches the active_ongoing branch in practice — but the + /// derivation must not depend on that, since `exit_epoch` is what the + /// spec keys on. + #[test] + fn a_slashed_flag_alone_does_not_end_an_active_validator() { + let slashed = Lifecycle { slashed: true, ..active_ongoing() }; + assert_eq!(status_at(&slashed, 1_000_000), Status::ActiveOngoing); + } + + #[test] + fn every_status_name_parses_back_to_its_own_status() { + for status in ALL { + assert_eq!(Status::parse(status.name()), Some(status), "{}", status.name()); + } + assert_eq!(Status::parse("pending"), None, "a stage name is not an exact status"); + assert_eq!(Status::parse("Active_Ongoing"), None, "status names are case-sensitive"); + assert_eq!(Status::parse(""), None); + } + + /// Each of the four coarse names covers its own statuses and no others, + /// and every status falls under exactly one. + #[test] + fn a_stage_filter_accepts_exactly_the_statuses_of_its_stage() { + for stage in STAGES { + let mask = StatusMask::parse(stage).expect(stage); + let accepted: Vec<_> = + ALL.into_iter().filter(|&s| mask.accepts(s)).map(Status::name).collect(); + assert!( + accepted.iter().all(|name| name.starts_with(stage)), + "{stage} accepted {accepted:?}" + ); + assert!(!accepted.is_empty(), "{stage} accepted nothing"); + } + + for status in ALL { + let stages = STAGES + .into_iter() + .filter(|stage| StatusMask::parse(stage).unwrap().accepts(status)) + .count(); + assert_eq!(stages, 1, "{} falls under {stages} stages", status.name()); + } + } + + #[test] + fn an_exact_filter_accepts_only_its_own_status() { + for status in ALL { + let mask = StatusMask::parse(status.name()).expect(status.name()); + for other in ALL { + assert_eq!(mask.accepts(other), other == status, "{}", other.name()); + } + } + } + + #[test] + fn a_name_that_is_neither_a_status_nor_a_stage_parses_to_nothing() { + for name in ["", "withdrawn", "active_", "pending_initialised", "exit", "ACTIVE"] { + assert_eq!(StatusMask::parse(name), None, "{name}"); + } + } + + /// One bit per status, so the nine exact names partition the mask and a + /// stage is exactly the union of the statuses it covers. + #[test] + fn a_mask_of_every_exact_name_is_the_union_of_every_stage() { + let union = |names: &[&str]| { + let mut mask = StatusMask::default(); + for name in names { + mask.insert(StatusMask::parse(name).expect(name)); + } + mask + }; + let exact: Vec<_> = ALL.iter().map(|s| s.name()).collect(); + assert_eq!(union(&exact), union(&STAGES)); + assert_eq!(union(&exact).0.count_ones(), ALL.len() as u32); + } + + /// `uniqueItems` on the schema's `status`/`statuses` arrays: inserting a + /// value again leaves the mask, and so the sweep's cost, unchanged. + #[test] + fn inserting_a_status_twice_leaves_the_same_mask() { + let active = StatusMask::parse("active").unwrap(); + let mut repeated = active; + for _ in 0..1_000 { + repeated.insert(active); + } + assert_eq!(repeated, active); + } + + /// An empty mask filters on nothing; a mask that names something filters + /// on exactly that. + #[test] + fn an_empty_mask_accepts_every_status() { + let empty = StatusMask::default(); + for status in ALL { + assert!(empty.accepts(status), "{}", status.name()); + } + assert!(!StatusMask::parse("pending").unwrap().accepts(Status::ActiveOngoing)); + } +} diff --git a/crates/httpcore/src/server.rs b/crates/httpcore/src/server.rs index 11abb56b..d1426dba 100644 --- a/crates/httpcore/src/server.rs +++ b/crates/httpcore/src/server.rs @@ -190,6 +190,10 @@ impl ServerConnection { return AfterResponse::Close; } 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 @@ -723,6 +727,24 @@ mod tests { } } + #[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(); From 6ec5f7a58810e51721352049406f72a25e61191a Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 20 Aug 2026 13:42:04 +0100 Subject: [PATCH 22/33] Amend ADR-0004 for the validator registry's cost The bounded-buffer and non-blocking claims read as universal, and the validator registry endpoint is a standing counter-example to both. Assisted-by: Claude:claude-opus-5 --- docs/adr/0004-sync-materialized-api.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/adr/0004-sync-materialized-api.md b/docs/adr/0004-sync-materialized-api.md index d8be9652..0e267f9b 100644 --- a/docs/adr/0004-sync-materialized-api.md +++ b/docs/adr/0004-sync-materialized-api.md @@ -28,3 +28,16 @@ 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. From cb01bc00dae2383cf260fbf393391bf36e17f58c Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 20 Aug 2026 16:01:38 +0100 Subject: [PATCH 23/33] Publish the head block's root beside the state it produced A beacon-API request that names the head needs the root of the block the published state was applied from, and until now nothing published it: the state's own ring records a block's root only at the next slot, so between a block arriving and that slot the head block is anonymous. Stage the root as the block is applied and write it into the control word alongside the state id, so a reader takes both from one seqlock read and cannot pair a root with a state applied from a different block. The ring and finality predicates a caller needs to interpret those roots move down here too, where the wrap arithmetic lives and a real base ring can test it: block_root_proposed_at distinguishes a block's own slot from an empty one repeating its predecessor, and finalizes_slot answers whether a slot is covered by the finalized checkpoint. Limitations: - The published root is the last applied block's, not fork choice's head. They diverge when fork choice prefers a branch the last applied block is not on, and fork choice re-heads on attestations without publishing, so nothing published tracks its head between blocks. - Staging is not enforced against publishing: an owner that never stages publishes a zero root, as the storage, e2e and beacon-API fixtures do. - block_root_proposed_at, finalizes_slot and finalized_block_root have no consumer at this commit. Assisted-by: Claude:claude-opus-5 --- crates/beacon_state/data/src/epoch/delta.rs | 17 +++- crates/beacon_state/data/src/epoch/tests.rs | 30 ++++++- .../beacon_state/data/src/slot_state/delta.rs | 24 ++++++ .../beacon_state/data/src/slot_state/tests.rs | 77 ++++++++++++++++++ crates/beacon_state/data/src/view.rs | 49 +++++++++--- crates/beacon_state/tile/src/tile.rs | 1 + crates/beacon_state/tile/src/tile/block.rs | 17 ++-- crates/beacon_state/tile/src/tile/tests.rs | 79 ++++++++++++++++++- 8 files changed, 272 insertions(+), 22 deletions(-) diff --git a/crates/beacon_state/data/src/epoch/delta.rs b/crates/beacon_state/data/src/epoch/delta.rs index 916d184c..ef12069a 100644 --- a/crates/beacon_state/data/src/epoch/delta.rs +++ b/crates/beacon_state/data/src/epoch/delta.rs @@ -3,7 +3,7 @@ use crate::{ gloas::{PTC_WINDOW_LEN, PtcCommittee, zeroed_ptc_window}, reanchor::drain_promoted_prefix, ring::{Reset, Slot as RingSlot}, - types::{B256, Epoch, EpochState, Fork, SLOTS_PER_EPOCH, Version}, + types::{B256, Epoch, EpochState, Fork, SLOTS_PER_EPOCH, Slot, Version}, }; #[derive(Clone)] @@ -73,6 +73,21 @@ impl<'a> EpochView<'a> { self.delta.map_or(&self.base.state, |d| &d.state) } + /// The block the finalized checkpoint names. Zero is the placeholder a + /// chain carries until its first finalization, and names none. + pub fn finalized_block_root(&self) -> Option { + let root = self.state().finalized_checkpoint.root; + (root != [0u8; 32]).then_some(root) + } + + /// Whether finalization covers a block of this chain at `slot`: the + /// finalized checkpoint names the newest block at or before its epoch's + /// first slot, so every block at or below that slot is that one or an + /// ancestor. + pub fn finalizes_slot(&self, slot: Slot) -> bool { + slot <= self.state().finalized_checkpoint.epoch.saturating_mul(SLOTS_PER_EPOCH) + } + #[inline] pub fn ptc_window(&self) -> &'a [PtcCommittee; PTC_WINDOW_LEN] { self.delta.map_or(&self.base.ptc_window, |d| &d.ptc_window) diff --git a/crates/beacon_state/data/src/epoch/tests.rs b/crates/beacon_state/data/src/epoch/tests.rs index 7849e5f4..5f163d8b 100644 --- a/crates/beacon_state/data/src/epoch/tests.rs +++ b/crates/beacon_state/data/src/epoch/tests.rs @@ -1,5 +1,8 @@ use super::{EpochGroup, EpochStateFinalized, delta::EpochStateDelta}; -use crate::{gloas::zeroed_ptc_window, types::SLOTS_PER_EPOCH}; +use crate::{ + gloas::zeroed_ptc_window, + types::{Checkpoint, SLOTS_PER_EPOCH}, +}; const FIN_SLOT: u64 = 100; const FIN_EPOCH: u64 = FIN_SLOT / SLOTS_PER_EPOCH; @@ -152,3 +155,28 @@ fn ptc_window_carried_by_roll_from() { let child = g.roll_from(parent).commit(); assert_eq!(g.view(child).ptc_window()[0][0], 5); } + +/// Finalization covers every slot up to the checkpoint epoch's first: the +/// checkpoint names the newest block at or before it, so a block anywhere +/// below is that one or an ancestor. The epoch is state-derived, so its +/// boundary is computed without overflowing. +#[test] +fn finalization_covers_the_slots_up_to_the_checkpoint_epoch_s_first() { + const BOUNDARY: u64 = FIN_EPOCH * SLOTS_PER_EPOCH; + + let mut base = EpochStateFinalized::default(); + base.state.finalized_checkpoint = Checkpoint { epoch: FIN_EPOCH, root: [0x77; 32] }; + let g = EpochGroup::new(base); + let view = g.finalized_view(); + + assert!(view.finalizes_slot(0)); + assert!(view.finalizes_slot(BOUNDARY)); + assert!(!view.finalizes_slot(BOUNDARY + 1)); + assert_eq!(view.finalized_block_root(), Some([0x77; 32])); + + let mut unfinalized = EpochStateFinalized::default(); + unfinalized.state.finalized_checkpoint.epoch = u64::MAX; + let g = EpochGroup::new(unfinalized); + assert!(g.finalized_view().finalizes_slot(u64::MAX), "no epoch may overflow the boundary"); + assert_eq!(g.finalized_view().finalized_block_root(), None, "the pre-finalization zero"); +} diff --git a/crates/beacon_state/data/src/slot_state/delta.rs b/crates/beacon_state/data/src/slot_state/delta.rs index ed13b459..070c6cfd 100644 --- a/crates/beacon_state/data/src/slot_state/delta.rs +++ b/crates/beacon_state/data/src/slot_state/delta.rs @@ -126,6 +126,30 @@ impl<'a> SlotStateView<'a> { ) } + /// The root of the block proposed at `slot`, when `block_roots` proves one + /// was: `process_slot` repeats the previous entry through a slot that + /// carried no block, so an entry differing from its predecessor is the mark + /// of a block of the slot's own. + pub fn block_root_proposed_at(&self, slot: Slot) -> Option { + if !self.records_slot(slot) { + return None; + } + let root = self.block_root_at_slot(slot); + let Some(previous) = slot.checked_sub(1) else { + return Some(root); + }; + (self.records_slot(previous) && self.block_root_at_slot(previous) != root).then_some(root) + } + + /// Whether `block_roots` holds the entry for `slot`: the ring covers the + /// `SLOTS_PER_HISTORICAL_ROOT` slots below the state's own, and the entry + /// for the state's own slot is written by the `process_slot` that leaves + /// it. + fn records_slot(&self, slot: Slot) -> bool { + let state_slot = self.state().slot; + slot < state_slot && state_slot <= slot + SLOTS_PER_HISTORICAL_ROOT as u64 + } + fn root_at_slot(fin_slot: Slot, delta_roots: &[B256], fin_roots: &[B256], slot: Slot) -> B256 { if slot >= fin_slot { let i = (slot - fin_slot) as usize; diff --git a/crates/beacon_state/data/src/slot_state/tests.rs b/crates/beacon_state/data/src/slot_state/tests.rs index 9cd58aee..1b84b860 100644 --- a/crates/beacon_state/data/src/slot_state/tests.rs +++ b/crates/beacon_state/data/src/slot_state/tests.rs @@ -54,3 +54,80 @@ fn finalize_advances_base_slot_and_writes_roots() { let roots = base.finalized_block_roots(); assert_eq!(roots[FIN_SLOT as usize % roots.len()], [0x55; 32]); } + +/// A chain older than `SLOTS_PER_HISTORICAL_ROOT`, so `slot % cap` is the +/// wrapped index rather than the slot, and the base ring is what answers for +/// everything below the fork's own delta. +mod proposed_at { + use super::*; + use crate::types::{B256, SLOTS_PER_HISTORICAL_ROOT}; + + const WRAPPED_FIN_SLOT: u64 = SLOTS_PER_HISTORICAL_ROOT as u64 + 500; + const STATE_SLOT: u64 = WRAPPED_FIN_SLOT + 2; + + fn root_of(slot: u64) -> B256 { + let mut root = [0xA0; 32]; + root[24..].copy_from_slice(&slot.to_be_bytes()); + root + } + + /// Base ring holding a distinct root for every slot below + /// `WRAPPED_FIN_SLOT`, bar `repeated_at`, which carries its predecessor's + /// the way `process_slot` does through a slot that had no block. + fn wrapped_group(repeated_at: Option) -> SlotStateGroup { + let mut base = SlotStateFinalized::default(); + base.slot.slot = WRAPPED_FIN_SLOT; + let cap = base.block_roots.len() as u64; + for slot in WRAPPED_FIN_SLOT - cap..WRAPPED_FIN_SLOT { + let named = if repeated_at == Some(slot) { slot - 1 } else { slot }; + base.block_roots[slot as usize % cap as usize] = root_of(named); + } + SlotStateGroup::new(base) + } + + /// An entry differing from its predecessor is a block of the slot's own; + /// a repeated one is a slot that carried none. Both sides of the base / + /// delta join answer, and the base side reads at the wrapped index. + #[test] + fn a_repeated_entry_is_an_empty_slot_and_a_new_one_a_block() { + let empty = WRAPPED_FIN_SLOT - 1; + let mut g = wrapped_group(Some(empty)); + let mut wv = g.roll_fresh(); + wv.push_block_root(root_of(WRAPPED_FIN_SLOT)); + wv.push_block_root(root_of(WRAPPED_FIN_SLOT)); + wv.state_mut().slot = STATE_SLOT; + let view = wv.reader(); + + let cap = view.finalized_block_roots().len(); + assert!(WRAPPED_FIN_SLOT as usize > cap, "the fixture must wrap for the wrap to be tested"); + assert_eq!( + view.block_root_at_slot(empty), + root_of(empty - 1), + "the base ring answers at the wrapped index", + ); + + assert_eq!(view.block_root_proposed_at(empty - 1), Some(root_of(empty - 1))); + assert_eq!(view.block_root_proposed_at(empty), None); + assert_eq!(view.block_root_proposed_at(WRAPPED_FIN_SLOT), Some(root_of(WRAPPED_FIN_SLOT))); + assert_eq!(view.block_root_proposed_at(WRAPPED_FIN_SLOT + 1), None); + } + + /// The ring covers the `SLOTS_PER_HISTORICAL_ROOT` slots below the state's + /// own, and naming a block needs its predecessor's entry too — so the + /// floor itself cannot be named however distinct its entry is, and the + /// state's own slot has no entry until the `process_slot` that leaves it. + #[test] + fn the_ring_bounds_which_slots_can_be_named() { + let mut g = wrapped_group(None); + let mut wv = g.roll_fresh(); + wv.state_mut().slot = STATE_SLOT; + let view = wv.reader(); + + let floor = STATE_SLOT - view.finalized_block_roots().len() as u64; + assert_eq!(view.block_root_proposed_at(floor + 1), Some(root_of(floor + 1))); + assert_eq!(view.block_root_proposed_at(floor), None, "the floor has no predecessor"); + assert_eq!(view.block_root_proposed_at(floor - 1), None, "below the floor"); + assert_eq!(view.block_root_proposed_at(STATE_SLOT), None, "the state's own slot"); + assert_eq!(view.block_root_proposed_at(STATE_SLOT + 1), None, "past it"); + } +} diff --git a/crates/beacon_state/data/src/view.rs b/crates/beacon_state/data/src/view.rs index cb0962c7..a8c08343 100644 --- a/crates/beacon_state/data/src/view.rs +++ b/crates/beacon_state/data/src/view.rs @@ -9,7 +9,7 @@ use flux::communication::Seqlock; use flux_profiler::timed; use crate::{ - BeaconState, EpochGroup, LongtailGroup, StateId, StateReadView, StateWriterView, + B256, BeaconState, EpochGroup, LongtailGroup, StateId, StateReadView, StateWriterView, encode::GLOAS_VAR_LEN_SECTIONS, }; @@ -42,6 +42,7 @@ impl StateCell { pub struct BeaconStateOwner { state: Arc, inner: Arc>, + head_block_root: B256, } impl BeaconStateOwner { @@ -52,6 +53,7 @@ impl BeaconStateOwner { Self { state: Arc::new(StateCell(UnsafeCell::new(state))), inner: Arc::new(Seqlock::default()), + head_block_root: [0u8; 32], } } @@ -136,6 +138,14 @@ impl BeaconStateOwner { self.inner.read_copy().map_or_else(|_| ControlInner::default(), |(value, _)| value) } + /// Name the block whose post-state the publishes from here on carry. Set + /// per applied block rather than per publish: the empty-slot advances and + /// the finalize window between two blocks publish states of that same + /// block, and each carries the root forward untouched. + pub fn set_head_block_root(&mut self, root: B256) { + self.head_block_root = root; + } + /// Publish the head's index bundle for cross-thread readers — call only /// once the per-tier slots it names will no longer be mutated. The first /// publish is what makes the state observable at all. Carries the @@ -145,6 +155,7 @@ impl BeaconStateOwner { let mut value = self.current_control(); debug_assert!(value.finalize_version & 1 == 0, "publish inside a write window"); value.state_id = Some(state_id); + value.head_block_root = self.head_block_root; // Single producer; `write` also handles the never-written 0→2 case. self.inner.write(&value); } @@ -203,10 +214,24 @@ impl BeaconStateReader { /// inactivity boxes) are safe to read optimistically. The pending / /// longtail bases are realloc-prone `Vec`s — reading their CONTENT here /// can race a finalize realloc; those reads need the lock-guarded path. - #[timed] pub fn read(&self, reader: &F) -> Option where F: Fn(StateReadView<'_>) -> R, + { + self.read_head(&|view, _| reader(view)) + } + + /// [`Self::read`], also naming the block whose post-state the snapshot is. + /// Both come off one control word, so no request can pair a root with a + /// state that was applied from another block. The state cannot supply the + /// root itself: between a block's arrival and the next slot its + /// `latest_block_header` still carries the zero `state_root` the STF left, + /// and the `block_roots` entry naming the block is written by the + /// `process_slot` that fills it. + #[timed] + pub fn read_head(&self, reader: &F) -> Option + where + F: Fn(StateReadView<'_>, B256) -> R, { loop { // `Err(Empty)` = never written; `state_id: None` = a pre-publish @@ -219,7 +244,7 @@ impl BeaconStateReader { } let state_id = control.state_id?; sync::atomic::fence(Ordering::Acquire); - let result = reader(self.state.get().read_view(state_id)); + let result = reader(self.state.get().read_view(state_id), control.head_block_root); // Validate: no finalize ran while we were reading the state. sync::atomic::fence(Ordering::Acquire); @@ -419,16 +444,18 @@ impl<'a> Drop for WriteGuard<'a> { } } -/// The control word: the published head's per-tier index bundle plus the -/// finalize counter (odd = finalize window open). Publishes rewrite -/// `state_id` but keep the counter — tiers are append-only between -/// finalizations, so a publish never invalidates an in-flight read; only the -/// finalize window (which rebases and frees tier slots) does. `Default` -/// exists only because `Seqlock::default()` requires it; readers treat -/// `state_id: None` (only reachable when a finalize window closes before the -/// first publish) as "no state yet" — every publish writes `Some`. +/// The control word: the published head's per-tier index bundle, the root of +/// the block it was applied from, plus the finalize counter (odd = finalize +/// window open). Publishes rewrite `state_id` but keep the counter — tiers are +/// append-only between finalizations, so a publish never invalidates an +/// in-flight read; only the finalize window (which rebases and frees tier +/// slots) does. `Default` exists only because `Seqlock::default()` requires +/// it; readers treat `state_id: None` (only reachable when a finalize window +/// closes before the first publish) as "no state yet" — every publish writes +/// `Some`. #[derive(Clone, Copy, Default)] struct ControlInner { state_id: Option, + head_block_root: B256, finalize_version: u64, } diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 232859f2..17383dc6 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -346,6 +346,7 @@ impl BeaconStateTile { let trusted = Checkpoint { epoch: slot.div_ceil(SLOTS_PER_EPOCH), root: block_root }; self.last_applied_block_root = block_root; + self.state.set_head_block_root(block_root); let anchor_is_gloas = self.state.read_view(anchor).is_gloas(); self.fork_choice = ForkChoice::init( diff --git a/crates/beacon_state/tile/src/tile/block.rs b/crates/beacon_state/tile/src/tile/block.rs index 525a8566..6789a4df 100644 --- a/crates/beacon_state/tile/src/tile/block.rs +++ b/crates/beacon_state/tile/src/tile/block.rs @@ -14,13 +14,13 @@ use crate::{ ssz_hash, stf, }; -struct AppliedBlock { - id: StateId, - justified: Checkpoint, - finalized: Checkpoint, - unrealized: (Checkpoint, Checkpoint), - execution_block_hash: B256, - bid_block_hash: B256, +pub(super) struct AppliedBlock { + pub(super) id: StateId, + pub(super) justified: Checkpoint, + pub(super) finalized: Checkpoint, + pub(super) unrealized: (Checkpoint, Checkpoint), + pub(super) execution_block_hash: B256, + pub(super) bid_block_hash: B256, } impl BeaconStateTile { @@ -265,7 +265,7 @@ impl BeaconStateTile { } #[timed] - fn publish_applied_block( + pub(super) fn publish_applied_block( &mut self, parsed: &ParsedBlock, block_data: &[u8], @@ -340,6 +340,7 @@ impl BeaconStateTile { // advance lands this import, not one recompute later. self.last_applied = new_id; self.last_applied_block_root = parsed.block_root; + self.state.set_head_block_root(parsed.block_root); if is_gloas { self.notify_ptc_from_block(block_data); diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index 9d2991db..4994b4cc 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -15,7 +15,7 @@ use silver_common::{ }, }; -use super::*; +use super::{block::AppliedBlock, *}; use crate::{ fork_choice::{BlockImport, PayloadStatus}, stf::AttestationVote, @@ -194,6 +194,7 @@ fn arm_tile_state( // epoch/longtail stay lazy. Rolled before the owner wraps the state. let anchor = bs.roll_fresh(); let mut owner = BeaconStateOwner::new(bs); + owner.set_head_block_root(ANCHOR_ROOT); owner.publish_state_id(anchor); tile.state = owner; @@ -359,6 +360,81 @@ fn status_event_carries_the_head_s_execution_status() { assert!(!head_optimistic(&mut tile)); } +fn published_head_block_root(tile: &BeaconStateTile) -> B256 { + tile.reader().read_head(&|_, root| root).expect("a state is published") +} + +/// The published state names the block it was applied from, from the publish +/// that first makes that state visible — which is the only way a reader can +/// name it mid-slot: the header `process_block_header` left still carries the +/// zero `state_root`, and the `block_roots` entry naming the block is written +/// by the `process_slot` that fills it, a slot later. The empty slots between +/// two blocks republish the state, and keep naming the same block. +#[test] +fn the_published_state_names_the_block_it_was_applied_from() { + const CHILD_SLOT: Slot = 11; + const CHILD_ROOT: B256 = [0x0C; 32]; + + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 10); + assert_eq!(published_head_block_root(&tile), ANCHOR_ROOT); + + let header = BeaconBlockHeader { + slot: CHILD_SLOT, + proposer_index: 0, + parent_root: ANCHOR_ROOT, + state_root: [0u8; 32], + body_root: [0u8; 32], + }; + // The slot tier as the STF leaves it: the `process_slot` out of slot 10 + // recorded the anchor block's root, then `process_block_header` installed + // the child's header with the zero `state_root` the block arrived with. + let child_slot_idx = { + let mut g = tile.state.write(); + let mut sw = g.slot_states.roll_from(tile.last_applied.slot_idx); + sw.push_block_root(ANCHOR_ROOT); + sw.advance_slot(); + sw.state_mut().latest_block_header = header; + sw.commit() + }; + let anchor_cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; + tile.publish_applied_block( + &ParsedBlock { + header, + block_root: CHILD_ROOT, + has_data_columns: false, + parent_state_id: tile.last_applied, + is_gloas: false, + parent_payload_status: PayloadStatus::Full, + }, + &[], + AppliedBlock { + id: StateId { slot_idx: child_slot_idx, ..tile.last_applied }, + justified: anchor_cp, + finalized: anchor_cp, + unrealized: (anchor_cp, anchor_cp), + execution_block_hash: [0u8; 32], + bid_block_hash: [0u8; 32], + }, + ); + + assert_eq!(published_head_block_root(&tile), CHILD_ROOT); + let (state_root, recorded) = tile + .reader() + .read(&|v| { + ( + v.slot.state().latest_block_header.state_root, + v.slot.block_root_proposed_at(CHILD_SLOT), + ) + }) + .unwrap(); + assert_eq!(state_root, [0u8; 32], "the published state does not name its own block"); + assert_eq!(recorded, None, "nor does the ring, until the next slot"); + + tile.on_slot_start(CHILD_SLOT + 1); + assert_eq!(published_head_block_root(&tile), CHILD_ROOT, "an empty slot changes no head"); +} + #[test] fn block_unknown_parent_rejected() { let mut tile = make_tile(); @@ -1712,6 +1788,7 @@ fn multi_fork_finalize_promotes_and_rebases() { tile.last_applied_block_root = D_ROOT; tile.fork_choice.finalized_checkpoint = f_cp; // Republish so the seqlock control matches the new head. + tile.state.set_head_block_root(D_ROOT); tile.state.publish_state_id(d_id); // Sanity: pre-finalize state. From 0d7f348e4a0b853b3b76f50c8d078fb63c5ae2f6 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 20 Aug 2026 16:03:42 +0100 Subject: [PATCH 24/33] Serve block roots and the head's header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blocks/{block_id}/root and headers/{block_id}. The head answers all slot long, from the root the state now publishes: naming it from the state's own ring would answer with the parent's root between a block arriving and the next slot, which is when validator clients ask — the sync-committee message path names the head a third of the way into every slot. A header is a narrower thing than a root. The state keeps one header, the head's, and zeroes its state_root until the next slot backfills it, so a header is served only for the head block and only once that field is filled. Every other identifier resolves to a root or to 404: the ring proves whether a slot carried a block of its own or repeated its predecessor through an empty one, and the finalized checkpoint names its own root. Limitations: - headers/head is 404 between a block arriving and the next slot: the header's state_root is the post-state root, which this crate cannot compute. Roots are unaffected. - No block but the head has a reachable header, and no root but the head's resolves. This crate has no silver_storage dependency and the API tile has no channel to the store, so the block index there cannot be consulted. - The header's signature is 96 zero bytes; the state does not keep it. The root is the hash tree root of the message, which is what the schema asks for. - canonical is always true. It is asserted, not proved: the published state is the last applied block's, which fork choice may not have selected. - Slot lookups reach only what the ring holds, so genesis stops answering once the chain is 8192 slots old, and finalized is 404 until the chain first finalizes. - The headers list endpoint and GET /eth/v2/beacon/blocks/{block_id} are not served. Assisted-by: Claude:claude-opus-5 --- crates/beacon_api/src/blocks.rs | 571 ++++++++++++++++++++++++ crates/beacon_api/src/ids.rs | 17 + crates/beacon_api/src/json.rs | 99 ++-- crates/beacon_api/src/lib.rs | 2 + crates/beacon_api/src/routes.rs | 39 +- crates/beacon_api/src/validators/mod.rs | 7 +- 6 files changed, 674 insertions(+), 61 deletions(-) create mode 100644 crates/beacon_api/src/blocks.rs create mode 100644 crates/beacon_api/src/ids.rs diff --git a/crates/beacon_api/src/blocks.rs b/crates/beacon_api/src/blocks.rs new file mode 100644 index 00000000..0743d3fa --- /dev/null +++ b/crates/beacon_api/src/blocks.rs @@ -0,0 +1,571 @@ +use silver_beacon_state_data::{B256, BLSSignature, BeaconBlockHeader, Slot, StateReadView}; + +use crate::{ + ids::{parse_root, parse_slot}, + json::ReadFlags, + response::Response, + router::Request, + routes::ApiCtx, +}; + +/// `Block not found` in `apis/beacon/blocks/root.yaml` — the only answer +/// either schema offers for a block silver cannot name, whether the chain has +/// none there or this crate has no read path to it. +const NOT_FOUND: &str = "block not found"; + +const ZERO_ROOT: B256 = [0u8; 32]; + +/// The state carries `latest_block_header`, which the spec strips of the +/// signature the block arrived with, and the beacon-API tile has no channel to +/// the block store to read the original from. `SignedBeaconBlockHeader` +/// requires the field. +const UNAVAILABLE_SIGNATURE: BLSSignature = [0u8; 96]; + +/// Required beside `root` by the header schema. Asserted, not proved: these +/// endpoints read the last state applied, and fork choice — which re-heads on +/// attestations, between publishes — may have passed that branch over. +const CANONICAL: bool = true; + +pub(crate) fn get_block_root(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(block) = read_block(req, ctx, resp) else { + return; + }; + let flags = block.flags(ctx.node_status.execution_optimistic()); + resp.json_body(|json| json.flagged_envelope(flags, |json| json.block_root(&block.root))); +} + +pub(crate) fn get_block_header(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(block) = read_block(req, ctx, resp) else { + return; + }; + let Some(header) = block.header else { + resp.error(404, NOT_FOUND); + return; + }; + let flags = block.flags(ctx.node_status.execution_optimistic()); + resp.json_body(|json| { + json.flagged_envelope(flags, |json| { + json.block_header_data(&block.root, CANONICAL, &header, &UNAVAILABLE_SIGNATURE) + }) + }); +} + +fn read_block(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) -> Option { + let named = req.params.get("block_id").expect("{block_id} in the route pattern"); + let Some(block_id) = BlockId::parse(named) else { + resp.error(400, "invalid block_id"); + return None; + }; + let block = ctx + .read_state_or_404(resp, NOT_FOUND, |view, head_root| block_id.resolve(&view, head_root))?; + if block.is_none() { + resp.error(404, NOT_FOUND); + } + block +} + +struct Block { + root: B256, + /// The head block's header, and only while the state carries it whole — + /// every other block this crate can name, it can name only by root. + header: Option, + finalized: bool, +} + +impl Block { + fn flags(&self, execution_optimistic: bool) -> ReadFlags { + ReadFlags { execution_optimistic, finalized: self.finalized } + } +} + +/// `block_id` (`params/index.yaml#/BlockId`): a keyword, a slot, or a block +/// root. Not `state_id`'s vocabulary — `justified` names a state, never a +/// block. `genesis` is the first slot's block by another name. +#[derive(Clone, Copy)] +enum BlockId { + Head, + Finalized, + Slot(Slot), + Root(B256), +} + +impl BlockId { + fn parse(text: &str) -> Option { + Some(match text { + "head" => Self::Head, + "genesis" => Self::Slot(0), + "finalized" => Self::Finalized, + _ => match parse_slot(text) { + Some(slot) => Self::Slot(slot), + None => Self::Root(parse_root(text)?), + }, + }) + } + + /// `head_root` names the block the published state was applied from, which + /// the state itself cannot: between that block's arrival and the next slot + /// its header is the state's own with `state_root` still zero, and the + /// `block_roots` entry naming it is written by the `process_slot` that + /// fills it. + fn resolve(self, view: &StateReadView<'_>, head_root: B256) -> Option { + let head = view.slot.state().latest_block_header; + let (root, finalized) = match self { + Self::Head => (head_root, view.epoch.finalizes_slot(head.slot)), + Self::Slot(slot) if slot == head.slot => (head_root, view.epoch.finalizes_slot(slot)), + Self::Slot(slot) => { + (view.slot.block_root_proposed_at(slot)?, view.epoch.finalizes_slot(slot)) + } + Self::Finalized => (view.epoch.finalized_block_root()?, true), + Self::Root(root) if root == head_root => (root, view.epoch.finalizes_slot(head.slot)), + // Every other root is a block this crate has no read path to. + Self::Root(_) => return None, + }; + let header = (root == head_root && head.state_root != ZERO_ROOT).then_some(head); + Some(Block { root, header, finalized }) + } +} + +#[cfg(test)] +mod tests { + use silver_beacon_state_data::{ + BeaconState, BeaconStateOwner, Checkpoint, EPOCHS_PER_HISTORICAL_VECTOR, EpochState, + EpochStateFinalized, SLOTS_PER_EPOCH, SpecConfig, ValSeed, + }; + use silver_common::ELSyncStatus; + use silver_httpcore::ParsedRequest; + + use super::*; + use crate::{ + NodeStatus, PeerCounts, SlotStatus, + router::Router, + routes::{ROUTES, preboot_ctx, test_ctx}, + }; + + const FINALIZED_EPOCH: u64 = 280; + const FINALIZED_SLOT: Slot = FINALIZED_EPOCH * SLOTS_PER_EPOCH; + + const HEAD_BLOCK_SLOT: Slot = 9_000; + const STATE_SLOT: Slot = HEAD_BLOCK_SLOT + 1; + const EMPTY_SLOT: Slot = HEAD_BLOCK_SLOT - 1; + + /// How far back the fixture chains are recorded: the slot tier's base sits + /// there and the head fork's delta holds an entry per slot from it up. A + /// real base ring also carries the `SLOTS_PER_HISTORICAL_ROOT` slots below + /// it, whose wrap arithmetic belongs to — and is tested with — + /// `SlotStateView::block_root_proposed_at`. + const RECORDED_SLOTS: Slot = 64; + + /// A chain young enough that its first slot is still recorded, and too + /// young to have finalized anything. + const YOUNG_STATE_SLOT: Slot = 11; + + /// Roots keyed by the slot they belong to and by what they are, so a body + /// that mixes two of them up says which. + fn block_root_of(slot: Slot) -> B256 { + tagged(0xb0, slot) + } + + fn state_root_of(slot: Slot) -> B256 { + tagged(0x57, slot) + } + + fn body_root_of(slot: Slot) -> B256 { + tagged(0xd0, slot) + } + + fn tagged(kind: u8, slot: Slot) -> B256 { + let mut root = [kind; 32]; + root[24..].copy_from_slice(&slot.to_be_bytes()); + root + } + + /// Distinct from the slot it proposed at, so a body that reports one for + /// the other says which. + fn proposer_of(slot: Slot) -> u64 { + slot + 11 + } + + fn root_text(root: B256) -> String { + format!("0x{}", hex::encode(root)) + } + + fn epoch_base(finalized: Checkpoint) -> EpochStateFinalized { + EpochStateFinalized::from_parts( + EpochState { finalized_checkpoint: finalized, ..Default::default() }, + vec![[0u8; 32]; EPOCHS_PER_HISTORICAL_VECTOR].into_boxed_slice(), + ) + } + + /// A published head state grown a slot at a time the way the tile grows + /// one: every slot carries a block bar `empty_slots`, and the + /// `process_slot` that leaves a slot fills the previous header's + /// `state_root` and records the latest block's root. The walk stops with + /// the state at `state_slot`, so a block there is one no slot has followed + /// — and the newest block's root is published beside the state, as the + /// tile publishes it. + fn published_chain(finalized: Checkpoint, empty_slots: &[Slot], state_slot: Slot) -> ApiCtx { + let base_slot = state_slot.saturating_sub(RECORDED_SLOTS); + let mut owner = BeaconStateOwner::new(BeaconState::for_test( + epoch_base(finalized), + &[ValSeed::default()], + base_slot, + )); + let anchor = owner.roll_fresh(); + let (mut writer, _, _) = owner.apply_block_view(anchor); + let mut head_root = ZERO_ROOT; + for slot in base_slot..=state_slot { + if !empty_slots.contains(&slot) { + writer.slot.state_mut().latest_block_header = BeaconBlockHeader { + slot, + proposer_index: proposer_of(slot), + parent_root: head_root, + state_root: ZERO_ROOT, + body_root: body_root_of(slot), + }; + head_root = block_root_of(slot); + } + if slot == state_slot { + break; + } + writer.slot.fill_latest_block_header_state_root(state_root_of(slot)); + let latest_block = writer.slot.state().latest_block_header.slot; + writer.slot.push_block_root(block_root_of(latest_block)); + writer.slot.advance_slot(); + } + let head = writer.commit(None, None); + owner.set_head_block_root(head_root); + owner.publish_state_id(head); + + let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); + ctx.node_status = NodeStatus { + slots: Some(SlotStatus { + head_slot: state_slot, + wall_slot: state_slot, + head_optimistic: false, + }), + syncing: false, + el: ELSyncStatus::Synced, + peers: PeerCounts::default(), + }; + ctx + } + + fn finalized_checkpoint() -> Checkpoint { + Checkpoint { epoch: FINALIZED_EPOCH, root: block_root_of(FINALIZED_SLOT) } + } + + /// The ordinary state: the last block is a slot behind, so its own + /// `state_root` is in and the state carries its whole header. + fn head_ctx() -> ApiCtx { + published_chain(finalized_checkpoint(), &[EMPTY_SLOT, STATE_SLOT], STATE_SLOT) + } + + /// The state between a block's arrival and the next slot: the head block + /// is the state's own, at [`STATE_SLOT`], with `state_root` still zero. + fn just_applied_ctx() -> ApiCtx { + published_chain(finalized_checkpoint(), &[EMPTY_SLOT], STATE_SLOT) + } + + fn young_ctx() -> ApiCtx { + published_chain(Checkpoint::default(), &[YOUNG_STATE_SLOT], YOUNG_STATE_SLOT) + } + + fn root_path(block_id: &str) -> String { + format!("/eth/v1/beacon/blocks/{block_id}/root") + } + + fn header_path(block_id: &str) -> String { + format!("/eth/v1/beacon/headers/{block_id}") + } + + fn paths(block_id: &str) -> [String; 2] { + [root_path(block_id), header_path(block_id)] + } + + fn get(ctx: &ApiCtx, path: &str) -> Vec { + let req = ParsedRequest { + method: "GET", + path, + query: "", + body: b"", + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + }; + let mut out = Vec::new(); + Router::new(ROUTES).dispatch(&req, ctx, &mut out); + out + } + + fn body(response: &[u8]) -> &[u8] { + let text = std::str::from_utf8(response).unwrap(); + &response[text.find("\r\n\r\n").unwrap() + 4..] + } + + fn ok_body(response: &[u8]) -> String { + assert!( + response.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{}", + String::from_utf8_lossy(response) + ); + String::from_utf8(body(response).to_vec()).unwrap() + } + + fn parsed(ctx: &ApiCtx, path: &str) -> serde_json::Value { + serde_json::from_str(&ok_body(&get(ctx, path))).expect("valid JSON") + } + + fn served_root(ctx: &ApiCtx, block_id: &str) -> String { + parsed(ctx, &root_path(block_id))["data"]["root"].as_str().unwrap().to_owned() + } + + fn assert_error(response: &[u8], status: &str, code: u16, message: &str) { + assert!( + response.starts_with(format!("HTTP/1.1 {status}\r\n").as_bytes()), + "{}", + String::from_utf8_lossy(response) + ); + assert_eq!( + std::str::from_utf8(body(response)).unwrap(), + format!("{{\"code\":{code},\"message\":\"{message}\"}}") + ); + } + + fn assert_not_found(ctx: &ApiCtx, block_id: &str) { + for path in paths(block_id) { + assert_error(&get(ctx, &path), "404 Not Found", 404, NOT_FOUND); + } + } + + fn assert_no_header(ctx: &ApiCtx, block_id: &str) { + assert_error(&get(ctx, &header_path(block_id)), "404 Not Found", 404, NOT_FOUND); + } + + /// Body shape: `GetBlockRootResponse` of `apis/beacon/blocks/root.yaml` — + /// both envelope flags, then a `data` object of the one root. + #[test] + fn block_root_body_is_the_envelope_around_one_root() { + assert_eq!( + ok_body(&get(&head_ctx(), &root_path("head"))), + format!( + "{{\"execution_optimistic\":false,\"finalized\":false,\ + \"data\":{{\"root\":\"{root}\"}}}}", + root = root_text(block_root_of(HEAD_BLOCK_SLOT)), + ) + ); + } + + /// Body shape: `GetBlockHeaderResponse` of `apis/beacon/blocks/header.yaml` + /// — root, canonical and a `Phase0.SignedBeaconBlockHeader`, whose five + /// message fields are quoted decimals and lowercase `0x`-hex. + #[test] + fn block_header_body_is_the_envelope_around_the_signed_header() { + let expected = format!( + "{{\"execution_optimistic\":false,\"finalized\":false,\"data\":{{\ + \"root\":\"{root}\",\"canonical\":true,\"header\":{{\"message\":{{\ + \"slot\":\"{HEAD_BLOCK_SLOT}\",\"proposer_index\":\"{proposer}\",\ + \"parent_root\":\"{parent}\",\"state_root\":\"{state}\",\"body_root\":\"{body}\"}},\ + \"signature\":\"{signature}\"}}}}}}", + root = root_text(block_root_of(HEAD_BLOCK_SLOT)), + proposer = proposer_of(HEAD_BLOCK_SLOT), + parent = root_text(block_root_of(EMPTY_SLOT - 1)), + state = root_text(state_root_of(HEAD_BLOCK_SLOT)), + body = root_text(body_root_of(HEAD_BLOCK_SLOT)), + signature = format!("0x{}", hex::encode(UNAVAILABLE_SIGNATURE)), + ); + assert_eq!(ok_body(&get(&head_ctx(), &header_path("head"))), expected); + serde_json::from_str::(&expected).expect("valid JSON"); + } + + /// The head block answers to all three of its names, and to the same root + /// under each — the header endpoint serves whichever of them resolves to + /// the block whose header the state carries. + #[test] + fn the_head_block_answers_to_head_to_its_slot_and_to_its_root() { + let ctx = head_ctx(); + let root = root_text(block_root_of(HEAD_BLOCK_SLOT)); + for block_id in ["head", &HEAD_BLOCK_SLOT.to_string(), &root] { + assert_eq!(served_root(&ctx, block_id), root, "{block_id}"); + assert_eq!(parsed(&ctx, &header_path(block_id))["data"]["root"], root, "{block_id}"); + } + } + + /// A slot older than the head names its own block — the ring holds the + /// roots, and only the roots: the header endpoint has no header for it. + #[test] + fn an_older_slot_names_its_block_by_root_alone() { + let ctx = head_ctx(); + let older = EMPTY_SLOT - 1; + assert_eq!(served_root(&ctx, &older.to_string()), root_text(block_root_of(older))); + assert_no_header(&ctx, &older.to_string()); + } + + /// `process_slot` repeats the last block's root through a slot that + /// carried none, so the ring holding a root for a slot is not the same as + /// a block being there. + #[test] + fn a_slot_that_carried_no_block_is_not_found() { + assert_not_found(&head_ctx(), &EMPTY_SLOT.to_string()); + } + + /// Past the head block there is nothing to name: the state's own slot is + /// where the next block will go, and the ring stops below it. + #[test] + fn a_slot_past_the_head_block_is_not_found() { + let ctx = head_ctx(); + for slot in [STATE_SLOT, STATE_SLOT + 1, u64::MAX] { + assert_not_found(&ctx, &slot.to_string()); + } + } + + /// `genesis` is the first slot's block under another name, so the two + /// forms cannot drift: both answer while the chain's first slot is still + /// recorded, and neither does once it has fallen out. + #[test] + fn genesis_answers_exactly_as_the_first_slot_does() { + let young = young_ctx(); + assert_eq!(served_root(&young, "genesis"), root_text(block_root_of(0))); + assert_eq!(served_root(&young, "0"), served_root(&young, "genesis")); + assert_eq!(parsed(&young, &root_path("genesis"))["finalized"], true); + assert_not_found(&head_ctx(), "genesis"); + assert_not_found(&head_ctx(), "0"); + } + + /// `finalized` is the checkpoint's own root, and the block it names + /// answers to its slot as well. + #[test] + fn finalized_names_the_block_the_checkpoint_points_at() { + let ctx = head_ctx(); + let root = root_text(block_root_of(FINALIZED_SLOT)); + assert_eq!(served_root(&ctx, "finalized"), root); + assert_eq!(served_root(&ctx, &FINALIZED_SLOT.to_string()), root); + assert_eq!(parsed(&ctx, &root_path("finalized"))["finalized"], true); + assert_no_header(&ctx, "finalized"); + } + + /// The zero root a chain carries until its first finalization names no + /// block, and answering with it would name the wrong one. + #[test] + fn finalized_before_the_first_finalization_is_not_found() { + assert_not_found(&young_ctx(), "finalized"); + } + + /// The published head block root is the one root this crate can confirm; + /// every other is a block it has no read path to. + #[test] + fn a_root_is_answered_only_for_the_head_block() { + let ctx = head_ctx(); + assert_eq!( + served_root(&ctx, &root_text(block_root_of(HEAD_BLOCK_SLOT))), + root_text(block_root_of(HEAD_BLOCK_SLOT)) + ); + for block_id in [ + root_text(block_root_of(EMPTY_SLOT - 1)), + root_text(block_root_of(FINALIZED_SLOT)), + root_text([0xab; 32]), + ] { + assert_not_found(&ctx, &block_id); + } + } + + /// The window this pair of endpoints exists for: between a block's arrival + /// and the next slot the state cannot name it — `latest_block_header` + /// still carries the zero `state_root`, and no ring entry names the block + /// — but the root published beside the state can, under all three of its + /// names. The header is what waits: it is not the block's until the next + /// `process_slot` fills it. + #[test] + fn the_head_is_named_before_its_own_state_can_name_it() { + let ctx = just_applied_ctx(); + let root = root_text(block_root_of(STATE_SLOT)); + for block_id in ["head", &STATE_SLOT.to_string(), &root] { + assert_eq!(served_root(&ctx, block_id), root, "{block_id}"); + assert_no_header(&ctx, block_id); + } + assert_eq!( + served_root(&ctx, &HEAD_BLOCK_SLOT.to_string()), + root_text(block_root_of(HEAD_BLOCK_SLOT)), + "the block before it is still recorded" + ); + } + + /// A served header is served with the root the state was published with, + /// not with one derived from the header itself — the two agree only + /// because the header is complete. + #[test] + fn a_served_header_carries_the_state_root_of_its_own_post_state() { + let header = &parsed(&head_ctx(), &header_path("head"))["data"]["header"]["message"]; + assert_eq!(header["state_root"], root_text(state_root_of(HEAD_BLOCK_SLOT))); + assert_ne!(header["state_root"], root_text(ZERO_ROOT)); + } + + /// `finalized` describes the block served, not the state read: the same + /// state answers both ways either side of the finalized checkpoint's slot. + #[test] + fn the_finalized_flag_follows_the_block_not_the_state() { + let ctx = head_ctx(); + for (slot, want) in + [(FINALIZED_SLOT - 1, true), (FINALIZED_SLOT, true), (FINALIZED_SLOT + 1, false)] + { + assert_eq!(parsed(&ctx, &root_path(&slot.to_string()))["finalized"], want, "{slot}"); + } + assert_eq!(parsed(&ctx, &root_path("head"))["finalized"], false); + assert_eq!(parsed(&ctx, &header_path("head"))["finalized"], false); + } + + /// The same flag the state reads carry: the head's own execution status. + #[test] + fn execution_optimistic_is_the_head_s_own_status() { + let mut ctx = head_ctx(); + for optimistic in [true, false] { + ctx.node_status.slots = + Some(SlotStatus { head_optimistic: optimistic, ..ctx.node_status.slots.unwrap() }); + for path in paths("head") { + assert_eq!(parsed(&ctx, &path)["execution_optimistic"], optimistic, "{path}"); + } + } + } + + /// `Invalid block ID` in both schemas: a value that identifies no block at + /// all is a 400, where a block silver cannot name is a 404. `justified` is + /// a `state_id` and no part of this vocabulary. + #[test] + fn a_block_id_naming_no_block_at_all_is_400() { + let ctx = head_ctx(); + let short_root = format!("0x{}", "ab".repeat(31)); + let unhex_root = format!("0x{}", "zz".repeat(32)); + for block_id in [ + "justified", + "current", + "banana", + "", + "-1", + "+5", + "0x", + "1.5", + &short_root, + &unhex_root, + "HEAD", + ] { + for path in paths(block_id) { + assert_error(&get(&ctx, &path), "400 Bad Request", 400, "invalid block_id"); + } + } + } + + /// Neither schema declares a 503, so a node with no state published + /// answers the only way it can — and the `block_id` verdict does not wait + /// on a state to read. + #[test] + fn reads_are_404_before_bootstrap_and_400_stays_400() { + let ctx = preboot_ctx(); + for block_id in ["head", "genesis", "finalized", "9000", &root_text([0xab; 32])] { + assert_not_found(&ctx, block_id); + } + for path in paths("banana") { + assert_error(&get(&ctx, &path), "400 Bad Request", 400, "invalid block_id"); + } + } +} diff --git a/crates/beacon_api/src/ids.rs b/crates/beacon_api/src/ids.rs new file mode 100644 index 00000000..4fbd5faf --- /dev/null +++ b/crates/beacon_api/src/ids.rs @@ -0,0 +1,17 @@ +//! The two identifier forms `state_id` and `block_id` share +//! (`params/index.yaml`): a slot, or a `0x`-prefixed 32-byte root. Each +//! endpoint's keywords are its own. + +use silver_beacon_state_data::{B256, Slot}; + +/// `u64::from_str` alone also accepts a leading `+`, which the schemas call an +/// invalid identifier rather than a slot. +pub(crate) fn parse_slot(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) +} diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index d8918416..f10fe1eb 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -149,10 +149,11 @@ pub(crate) struct PeerCountData { pub(crate) connecting: u64, } -/// What a state read reports about the snapshot it came from; both flags are -/// required beside `data` by every `states/{state_id}` schema. +/// 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 StateFlags { +pub(crate) struct ReadFlags { pub(crate) execution_optimistic: bool, pub(crate) finalized: bool, } @@ -166,7 +167,7 @@ impl Json<'_> { self.end_object(); } - pub(crate) fn state_envelope(&mut self, flags: StateFlags, data: impl FnOnce(&mut Self)) { + 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); @@ -248,6 +249,58 @@ impl Json<'_> { self.end_object(); } + pub(crate) fn block_root(&mut self, root: &B256) { + self.begin_object(); + self.key("root"); + self.hex(root); + self.end_object(); + } + + pub(crate) fn block_header(&mut self, header: &BeaconBlockHeader) { + self.begin_object(); + self.key("slot"); + self.quoted_u64(header.slot); + self.key("proposer_index"); + self.quoted_u64(header.proposer_index); + self.key("parent_root"); + self.hex(&header.parent_root); + self.key("state_root"); + self.hex(&header.state_root); + self.key("body_root"); + self.hex(&header.body_root); + self.end_object(); + } + + pub(crate) fn signed_block_header( + &mut self, + header: &BeaconBlockHeader, + signature: &BLSSignature, + ) { + self.begin_object(); + self.key("message"); + self.block_header(header); + self.key("signature"); + self.hex(signature); + self.end_object(); + } + + pub(crate) fn block_header_data( + &mut self, + root: &B256, + canonical: bool, + header: &BeaconBlockHeader, + signature: &BLSSignature, + ) { + self.begin_object(); + self.key("root"); + self.hex(root); + self.key("canonical"); + self.bool(canonical); + self.key("header"); + self.signed_block_header(header, signature); + self.end_object(); + } + pub(crate) fn validator(&mut self, validator: &Validator) { self.begin_object(); self.key("pubkey"); @@ -297,34 +350,6 @@ impl Json<'_> { /// dead-code checking stays real for the writers already wired up. #[allow(dead_code)] impl Json<'_> { - pub(crate) fn block_header(&mut self, header: &BeaconBlockHeader) { - self.begin_object(); - self.key("slot"); - self.quoted_u64(header.slot); - self.key("proposer_index"); - self.quoted_u64(header.proposer_index); - self.key("parent_root"); - self.hex(&header.parent_root); - self.key("state_root"); - self.hex(&header.state_root); - self.key("body_root"); - self.hex(&header.body_root); - self.end_object(); - } - - pub(crate) fn signed_block_header( - &mut self, - header: &BeaconBlockHeader, - signature: &BLSSignature, - ) { - self.begin_object(); - self.key("message"); - self.block_header(header); - self.key("signature"); - self.hex(signature); - self.end_object(); - } - pub(crate) fn proposer_duty(&mut self, pubkey: &BLSPubkey, validator_index: u64, slot: u64) { self.begin_object(); self.key("pubkey"); @@ -577,10 +602,10 @@ mod tests { /// Field names/order: `GetStateForkResponse` and its siblings, which /// require both flags beside `data`. #[test] - fn state_envelope_golden() { - let flags = StateFlags { execution_optimistic: false, finalized: true }; + fn flagged_envelope_golden() { + let flags = ReadFlags { execution_optimistic: false, finalized: true }; assert_body( - |j| j.state_envelope(flags, |j| j.checkpoint(&Checkpoint::default())), + |j| j.flagged_envelope(flags, |j| j.checkpoint(&Checkpoint::default())), "{\"execution_optimistic\":false,\"finalized\":true,\"data\":{\"epoch\":\"0\",\ \"root\":\"0x0000000000000000000000000000000000000000000000000000000000000000\"}}", ); @@ -590,8 +615,8 @@ mod tests { /// name must not overwrite or be overwritten by it. #[test] fn envelope_flags_and_data_of_the_same_name_both_survive() { - let flags = StateFlags { execution_optimistic: true, finalized: false }; - let body = write(|j| j.state_envelope(flags, |j| j.finality_checkpoints(&checkpoints()))); + 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); diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index 669d62c0..54ed544e 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -1,5 +1,7 @@ +mod blocks; mod config; mod identity; +mod ids; mod json; mod node_status; mod response; diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index 1fd3164b..2cd7a1f0 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -3,13 +3,15 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; #[cfg(test)] use silver_beacon_state_data::BeaconStateOwner; -use silver_beacon_state_data::{BeaconStateReader, SpecConfig, StateReadView}; +use silver_beacon_state_data::{B256, BeaconStateReader, SpecConfig, StateReadView}; use silver_common::{Enr, Identify, Keypair}; use silver_httpcore::Query; use crate::{ NodeStatus, - json::{FinalityCheckpoints, GenesisData, Json, StateFlags}, + blocks::{get_block_header, get_block_root}, + ids::{parse_root, parse_slot}, + json::{FinalityCheckpoints, GenesisData, Json, ReadFlags}, node_status::Health, response::Response, router::{Handler, Method, Request}, @@ -23,7 +25,9 @@ const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; const DEFAULT_SYNCING_STATUS: u16 = 206; pub(crate) const ROUTES: &[(Method, &str, Handler)] = &[ + (Method::Get, "/eth/v1/beacon/blocks/{block_id}/root", get_block_root), (Method::Get, "/eth/v1/beacon/genesis", genesis), + (Method::Get, "/eth/v1/beacon/headers/{block_id}", get_block_header), ( Method::Get, "/eth/v1/beacon/states/{state_id}/finality_checkpoints", @@ -69,15 +73,16 @@ impl ApiCtx { } } - /// The published state, or a 404 carrying `not_found` while the node has - /// published none — the schemas of these endpoints declare no 503. + /// The published state and the root of the block it was applied from, or a + /// 404 carrying `not_found` while the node has published none — the schemas + /// of these endpoints declare no 503. pub(crate) fn read_state_or_404( &self, resp: &mut Response<'_>, not_found: &str, - read: impl Fn(StateReadView<'_>) -> R, + read: impl Fn(StateReadView<'_>, B256) -> R, ) -> Option { - let result = self.state.read(&read); + let result = self.state.read_head(&read); if result.is_none() { resp.error(404, not_found); } @@ -105,8 +110,8 @@ impl ApiCtx { } let execution_optimistic = self.node_status.execution_optimistic(); - let read = |view: StateReadView<'_>| StateRead { - flags: StateFlags { + 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 @@ -130,13 +135,13 @@ impl ApiCtx { let Some(state) = self.state_read(req, resp, read) else { return; }; - resp.json_body(|json| json.state_envelope(state.flags, |json| render(json, &state.data))); + 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: StateFlags, + pub(crate) flags: ReadFlags, pub(crate) data: R, } @@ -146,21 +151,13 @@ pub(crate) struct StateRead { /// 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") || - is_slot(state_id) || - state_id - .strip_prefix("0x") - .is_some_and(|root| root.len() == 64 && root.bytes().all(|b| b.is_ascii_hexdigit())) -} - -/// `u64::from_str` alone also accepts a leading `+`, which the schemas call an -/// invalid `state_id` rather than a slot. -fn is_slot(state_id: &str) -> bool { - state_id.bytes().all(|byte| byte.is_ascii_digit()) && state_id.parse::().is_ok() + parse_slot(state_id).is_some() || + parse_root(state_id).is_some() } fn genesis(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { let Some(genesis) = - ctx.read_state_or_404(resp, "Chain genesis info is not yet known", |view| GenesisData { + ctx.read_state_or_404(resp, "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, diff --git a/crates/beacon_api/src/validators/mod.rs b/crates/beacon_api/src/validators/mod.rs index 2aa58611..50166725 100644 --- a/crates/beacon_api/src/validators/mod.rs +++ b/crates/beacon_api/src/validators/mod.rs @@ -44,8 +44,9 @@ pub(crate) fn get_state_validator(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Re return; }; match &state.data { - Some(entry) => resp - .json_body(|json| json.state_envelope(state.flags, |json| json.validator_entry(entry))), + Some(entry) => resp.json_body(|json| { + json.flagged_envelope(state.flags, |json| json.validator_entry(entry)) + }), None => resp.error(404, "validator not found"), } } @@ -55,7 +56,7 @@ fn respond_with_matches(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_> else { return; }; - resp.json_body(|json| json.state_envelope(state.flags, |json| json.validators(&state.data))); + resp.json_body(|json| json.flagged_envelope(state.flags, |json| json.validators(&state.data))); } #[cfg(test)] From 3c06453cb02630ca38c8b4700a67663f0e3f34c0 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 20 Aug 2026 18:30:11 +0100 Subject: [PATCH 25/33] Serve proposer and sync-committee duties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit duties/proposer/{epoch} under both v1 and v2, and duties/sync/{epoch}. Lighthouse's validator client asks for v2 alone and upstream has marked v1 deprecated, while three of the other four clients know only v1, so both are served. The two differ only in which epoch the dependent root is taken against, and v2's choice turns on the Fulu activation: silver holds no pre-Fulu state, so that branch is reachable only for a checkpoint landing inside the activation epoch itself. Which epochs answer is decided by the head state's lookahead, but a request the head cannot answer is classified against the wall clock. An epoch the chain has reached and this node has not is a 503 the caller should retry, where blaming the request would have a validator client drop the node; an epoch the chain has not scheduled is a 400 however far behind the node is. Sync duties read the committee the state holds and resolve pubkeys per requested index rather than resolving all five hundred and twelve seats through the registry. That takes no lock inside the state read, costs the same for the seated and the following period, and pairs each index with the pubkey the registry holds for it rather than trusting a table built from the finalized base alone — which would have denied a duty to a member the base does not yet carry. A validator in no seat is omitted from the response rather than carried with an empty position list, as the schema's minimum of one requires. Limitations: - Duties come from the head state, so past epochs and past sync-committee periods are 400: no state they could be computed from is kept. - Every sync-duties request rebuilds the seat table; nothing is cached per period. - The dependent root for the epoch after the head's is the last applied block's root, not fork choice's head. - A caller cannot confirm a dependent root against an event stream, since head events are not served. - No SSZ or Eth-Consensus-Version negotiation on these routes. Assisted-by: Claude:claude-opus-5 --- crates/beacon_api/src/blocks.rs | 9 +- crates/beacon_api/src/config.rs | 11 +- crates/beacon_api/src/duties/mod.rs | 127 +++ crates/beacon_api/src/duties/proposer.rs | 164 ++++ crates/beacon_api/src/duties/sync.rs | 129 +++ crates/beacon_api/src/duties/tests.rs | 811 ++++++++++++++++++ crates/beacon_api/src/ids.rs | 20 +- crates/beacon_api/src/json.rs | 129 ++- crates/beacon_api/src/lib.rs | 1 + crates/beacon_api/src/node_status.rs | 9 + crates/beacon_api/src/routes.rs | 43 +- crates/beacon_api/src/validators/filter.rs | 8 +- .../beacon_state/data/src/slot_state/delta.rs | 14 +- .../beacon_state/data/src/slot_state/tests.rs | 23 +- crates/beacon_state/data/src/types.rs | 1 + crates/beacon_state/tile/src/stf/epoch.rs | 12 +- crates/beacon_state/tile/src/stf/mod.rs | 7 +- .../tile/tests/ef_epoch_processing.rs | 3 +- 18 files changed, 1433 insertions(+), 88 deletions(-) create mode 100644 crates/beacon_api/src/duties/mod.rs create mode 100644 crates/beacon_api/src/duties/proposer.rs create mode 100644 crates/beacon_api/src/duties/sync.rs create mode 100644 crates/beacon_api/src/duties/tests.rs diff --git a/crates/beacon_api/src/blocks.rs b/crates/beacon_api/src/blocks.rs index 0743d3fa..8d9662c4 100644 --- a/crates/beacon_api/src/blocks.rs +++ b/crates/beacon_api/src/blocks.rs @@ -1,7 +1,7 @@ use silver_beacon_state_data::{B256, BLSSignature, BeaconBlockHeader, Slot, StateReadView}; use crate::{ - ids::{parse_root, parse_slot}, + ids::{parse_root, parse_uint64}, json::ReadFlags, response::Response, router::Request, @@ -56,8 +56,9 @@ fn read_block(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) -> Optio resp.error(400, "invalid block_id"); return None; }; - let block = ctx - .read_state_or_404(resp, NOT_FOUND, |view, head_root| block_id.resolve(&view, head_root))?; + let block = ctx.read_state_or(resp, 404, NOT_FOUND, |view, head_root| { + block_id.resolve(&view, head_root) + })?; if block.is_none() { resp.error(404, NOT_FOUND); } @@ -95,7 +96,7 @@ impl BlockId { "head" => Self::Head, "genesis" => Self::Slot(0), "finalized" => Self::Finalized, - _ => match parse_slot(text) { + _ => match parse_uint64(text) { Some(slot) => Self::Slot(slot), None => Self::Root(parse_root(text)?), }, diff --git a/crates/beacon_api/src/config.rs b/crates/beacon_api/src/config.rs index 06e6716e..c831c978 100644 --- a/crates/beacon_api/src/config.rs +++ b/crates/beacon_api/src/config.rs @@ -6,10 +6,11 @@ use silver_beacon_state_data::{ BYTES_PER_LOGS_BLOOM, EFFECTIVE_BALANCE_INCREMENT, EPOCHS_PER_HISTORICAL_VECTOR, - EPOCHS_PER_SLASHINGS_VECTOR, 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, + 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, @@ -62,7 +63,7 @@ const PRESET: &[(&str, u64)] = &[ ("MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR", 64), ("PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR", 2), ("SYNC_COMMITTEE_SIZE", SYNC_COMMITTEE_SIZE as u64), - ("EPOCHS_PER_SYNC_COMMITTEE_PERIOD", 256), + ("EPOCHS_PER_SYNC_COMMITTEE_PERIOD", EPOCHS_PER_SYNC_COMMITTEE_PERIOD), ("MIN_SYNC_COMMITTEE_PARTICIPANTS", 1), ("UPDATE_TIMEOUT", 8192), // bellatrix.yaml diff --git a/crates/beacon_api/src/duties/mod.rs b/crates/beacon_api/src/duties/mod.rs new file mode 100644 index 00000000..11d4527e --- /dev/null +++ b/crates/beacon_api/src/duties/mod.rs @@ -0,0 +1,127 @@ +mod proposer; +mod sync; +#[cfg(test)] +mod tests; + +use silver_beacon_state_data::{ + EPOCHS_PER_SYNC_COMMITTEE_PERIOD, Epoch, PROPOSER_LOOKAHEAD_SIZE, SLOTS_PER_EPOCH, +}; + +pub(crate) use self::{ + proposer::{ProposerDuty, get_proposer_duties, get_proposer_duties_v2}, + sync::{SyncDuty, post_sync_duties}, +}; +use crate::{ids::parse_uint64, response::Response, router::Request, routes::ApiCtx}; + +/// The phrase `CurrentlySyncing` carries in `types/http.yaml`; every duties +/// schema declares that response and none declares a 404. +const CURRENTLY_SYNCING: &str = + "Beacon node is currently syncing and not serving request on that endpoint"; + +/// `proposer_lookahead` is anchored to the state's own epoch and holds this +/// many epochs of slots from that epoch's first. +const LOOKAHEAD_EPOCHS: u64 = PROPOSER_LOOKAHEAD_SIZE as u64 / SLOTS_PER_EPOCH; + +/// `LongtailState` seats the committee serving the head's own period and the +/// one after it. +const SEATED_PERIODS: u64 = 2; + +/// The epoch a request named, beside the epoch the wall clock is in. Duties +/// are read from the head state's window, and an epoch outside it is either +/// one this node will answer once its head arrives or one no node would +/// answer at all — which the wall clock alone separates. +#[derive(Clone, Copy)] +struct RequestedEpoch { + epoch: Epoch, + /// `None` until the first status: nothing to judge an early request by. + wall_epoch: Option, +} + +impl RequestedEpoch { + /// Every duties schema answers 400 "Invalid epoch" for a path parameter + /// that names no epoch at all. + fn parse(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) -> Option { + let named = req.params.get("epoch").expect("{epoch} in the route pattern"); + let Some(epoch) = parse_uint64(named) else { + resp.error(400, "invalid epoch"); + return None; + }; + Some(Self { epoch, wall_epoch: ctx.node_status.wall_epoch() }) + } +} + +/// The unit a duties endpoint schedules by, and the span of them one state +/// answers for. +#[derive(Clone, Copy)] +enum DutyWindow { + ProposerLookahead, + SeatedSyncCommittees, +} + +/// Why the epoch is outside the window the head state answers for. +enum OutOfWindow { + /// The wall clock has not reached it either, so this node answers it once + /// its head does — which is what `CurrentlySyncing` says, where a 400 + /// would blame a request the schemas call valid. + NotYet, + /// No node at the head would answer it: below the window silver keeps no + /// state the duties could come from, and above the wall clock's own + /// window no chain has scheduled them. + Never, +} + +impl DutyWindow { + /// How many units past the head's own the request falls, while the head + /// state answers for it. + fn units_ahead(self, requested: RequestedEpoch, head_epoch: Epoch) -> Result { + let Some(ahead) = self.unit_of(requested.epoch).checked_sub(self.unit_of(head_epoch)) + else { + return Err(OutOfWindow::Never); + }; + if ahead < self.width() { + return Ok(ahead); + } + if requested.wall_epoch.is_none_or(|wall| self.within_reach(requested.epoch, wall)) { + Err(OutOfWindow::NotYet) + } else { + Err(OutOfWindow::Never) + } + } + + fn respond(self, out: OutOfWindow, resp: &mut Response<'_>) { + match out { + OutOfWindow::NotYet => resp.error(503, CURRENTLY_SYNCING), + OutOfWindow::Never => resp.error(400, self.out_of_range()), + } + } + + fn unit_of(self, epoch: Epoch) -> u64 { + match self { + Self::ProposerLookahead => epoch, + Self::SeatedSyncCommittees => epoch / EPOCHS_PER_SYNC_COMMITTEE_PERIOD, + } + } + + fn width(self) -> u64 { + match self { + Self::ProposerLookahead => LOOKAHEAD_EPOCHS, + Self::SeatedSyncCommittees => SEATED_PERIODS, + } + } + + /// Whether the wall clock leaves the epoch within reach: it names a unit + /// this node's head has still to pass, or the one after it — the window a + /// head caught up to the wall clock would answer for. + fn within_reach(self, epoch: Epoch, wall_epoch: Epoch) -> bool { + self.unit_of(epoch) < self.unit_of(wall_epoch).saturating_add(self.width()) + } + + const fn out_of_range(self) -> &'static str { + match self { + Self::ProposerLookahead => "epoch is outside the proposer lookahead", + Self::SeatedSyncCommittees => { + "epoch is outside the current and next sync committee periods" + } + } + } +} diff --git a/crates/beacon_api/src/duties/proposer.rs b/crates/beacon_api/src/duties/proposer.rs new file mode 100644 index 00000000..7e91f45a --- /dev/null +++ b/crates/beacon_api/src/duties/proposer.rs @@ -0,0 +1,164 @@ +use silver_beacon_state_data::{B256, BLSPubkey, Epoch, SLOTS_PER_EPOCH, Slot, StateReadView}; + +use crate::{ + duties::{CURRENTLY_SYNCING, DutyWindow, OutOfWindow, RequestedEpoch}, + response::Response, + router::Request, + routes::ApiCtx, +}; + +/// One entry of `GetProposerDutiesResponse.data` (`ProposerDuty` in +/// `types/duty.yaml`), which v1 and v2 share. +pub(crate) struct ProposerDuty { + pub(crate) pubkey: BLSPubkey, + pub(crate) validator_index: u64, + pub(crate) slot: Slot, +} + +pub(crate) fn get_proposer_duties(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + respond_with_proposers(req, ctx, resp, DependentEpoch::Requested); +} + +pub(crate) fn get_proposer_duties_v2(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let dependent = DependentEpoch::PrecedingSinceFulu { fulu_fork_epoch: ctx.fulu_fork_epoch }; + respond_with_proposers(req, ctx, resp, dependent); +} + +fn respond_with_proposers( + req: &Request<'_>, + ctx: &ApiCtx, + resp: &mut Response<'_>, + dependent: DependentEpoch, +) { + let Some(requested) = RequestedEpoch::parse(req, ctx, resp) else { + return; + }; + let read = |view: StateReadView<'_>, head_root| { + EpochProposers::read(&view, head_root, requested, dependent) + }; + let Some(answer) = ctx.read_state_or(resp, 503, CURRENTLY_SYNCING, read) else { + return; + }; + match answer { + Ok(proposers) => { + let execution_optimistic = ctx.node_status.execution_optimistic(); + resp.json_body(|json| { + json.dependent_envelope(&proposers.dependent_root, execution_optimistic, |json| { + json.proposer_duties(&proposers.duties) + }) + }); + } + Err(error) => error.respond(resp), + } +} + +/// Every slot of one epoch and who proposes it, with the root the answer +/// depends on. +struct EpochProposers { + dependent_root: B256, + duties: Vec, +} + +impl EpochProposers { + fn read( + view: &StateReadView<'_>, + head_root: B256, + requested: RequestedEpoch, + dependent: DependentEpoch, + ) -> Result { + let epochs_ahead = + DutyWindow::ProposerLookahead.units_ahead(requested, view.slot.current_epoch())?; + let dependent_root = dependent + .root(view, head_root, requested.epoch) + .ok_or(ProposerError::NoDependentRoot)?; + + let first_slot = requested.epoch * SLOTS_PER_EPOCH; + let first_entry = epochs_ahead * SLOTS_PER_EPOCH; + let duties = (0..SLOTS_PER_EPOCH) + .map(|offset| { + let validator_index = view + .epoch + .proposer_at((first_entry + offset) as usize) + .expect("the window bounds the lookahead index"); + let ix = usize::try_from(validator_index) + .ok() + .filter(|&ix| ix < view.validators.count()) + .ok_or(ProposerError::UnknownProposer)?; + Ok(ProposerDuty { + pubkey: *view.validators.pubkey(ix), + validator_index, + slot: first_slot + offset, + }) + }) + .collect::>()?; + Ok(Self { dependent_root, duties }) + } +} + +/// Whose first slot the answer's `dependent_root` is the entry before. +#[derive(Clone, Copy)] +enum DependentEpoch { + /// The epoch asked about (`apis/validator/duties/proposer.yaml`). + Requested, + /// The epoch before it (`proposer.v2.yaml`), which is where the lookahead + /// an epoch's proposers come from was seeded — the deterministic lookahead + /// of EIP-7917, and the reason a v2 exists at all. Fulu's own activation + /// epoch is the exception: its lookahead is seeded by the fork transition, + /// at its own boundary, so it and every epoch before it fall back to + /// [`Self::Requested`]. + PrecedingSinceFulu { fulu_fork_epoch: Epoch }, +} + +impl DependentEpoch { + /// `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch) - 1)`, + /// or the genesis block root on underflow — the entry slot zero records. + /// A [`Self::Requested`] epoch above the head state's own names a slot + /// that has not happened, and the endpoint's head-event rule applies + /// instead: a client matches the answer against `event.block` unless the + /// head is in the epoch it asked for, so the answer is the head block's + /// root. + fn root(self, view: &StateReadView<'_>, head_root: B256, epoch: Epoch) -> Option { + let dependent = self.of(epoch); + if dependent > view.slot.current_epoch() { + return Some(head_root); + } + view.slot.recorded_block_root_at((dependent * SLOTS_PER_EPOCH).saturating_sub(1)) + } + + fn of(self, epoch: Epoch) -> Epoch { + match self { + Self::PrecedingSinceFulu { fulu_fork_epoch } if epoch > fulu_fork_epoch => epoch - 1, + Self::Requested | Self::PrecedingSinceFulu { .. } => epoch, + } + } +} + +enum ProposerError { + OutOfWindow(OutOfWindow), + /// `block_roots` holds no entry for the slot the dependent root names, + /// which past genesis it always does — only a state still on slot zero + /// depends on a block root the state itself does not carry. + NoDependentRoot, + /// The lookahead names a validator this state's own registry does not + /// hold. Nothing a request can send causes it, and leaving the slot out of + /// the answer would hide a proposal duty. + UnknownProposer, +} + +impl From for ProposerError { + fn from(out: OutOfWindow) -> Self { + Self::OutOfWindow(out) + } +} + +impl ProposerError { + fn respond(self, resp: &mut Response<'_>) { + match self { + Self::OutOfWindow(out) => DutyWindow::ProposerLookahead.respond(out, resp), + Self::NoDependentRoot => resp.error(503, CURRENTLY_SYNCING), + Self::UnknownProposer => { + resp.error(500, "proposer lookahead names a validator this state does not hold") + } + } + } +} diff --git a/crates/beacon_api/src/duties/sync.rs b/crates/beacon_api/src/duties/sync.rs new file mode 100644 index 00000000..b668ad90 --- /dev/null +++ b/crates/beacon_api/src/duties/sync.rs @@ -0,0 +1,129 @@ +use silver_beacon_state_data::{ + BLSPubkey, SYNC_COMMITTEE_SIZE, StateReadView, SyncCommittee, ValidatorsView, +}; + +use crate::{ + duties::{CURRENTLY_SYNCING, DutyWindow, OutOfWindow, RequestedEpoch}, + ids::{MAX_BODY_IDS, parse_uint64}, + response::Response, + router::Request, + routes::ApiCtx, +}; + +/// One entry of `GetSyncCommitteeDutiesResponse.data` (`Altair.SyncDuty`); +/// `committee_positions` is its `validator_sync_committee_indices`, and is +/// never empty — see [`CommitteeSeats::duty_of`]. +pub(crate) struct SyncDuty { + pub(crate) pubkey: BLSPubkey, + pub(crate) validator_index: u64, + pub(crate) committee_positions: Vec, +} + +pub(crate) fn post_sync_duties(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + let Some(requested) = RequestedEpoch::parse(req, ctx, resp) else { + return; + }; + let indices = match requested_indices(req.body) { + Ok(indices) => indices, + Err(message) => { + resp.error(400, message); + return; + } + }; + let read = |view: StateReadView<'_>, _| { + CommitteeSeats::serving(&view, requested) + .map(|seats| seats.duties(&view.validators, &indices)) + }; + let Some(answer) = ctx.read_state_or(resp, 503, CURRENTLY_SYNCING, read) else { + return; + }; + match answer { + Ok(duties) => { + let execution_optimistic = ctx.node_status.execution_optimistic(); + resp.json_body(|json| { + json.optimistic_envelope(execution_optimistic, |json| json.sync_duties(&duties)) + }); + } + Err(out) => DutyWindow::SeatedSyncCommittees.respond(out, resp), + } +} + +/// A sync committee read the direction a duty request needs it: the state +/// holds a position's pubkey, a request names an index, and the registry +/// answers what pubkey that index carries — so `by_pubkey` orders the +/// positions by the pubkey seated there and one validator's seats are a +/// contiguous run of it. Resolving the request's own index rather than the +/// committee's 512 pubkeys also makes the `(validator_index, pubkey)` pair +/// each duty carries agree by construction. +struct CommitteeSeats<'a> { + committee: &'a SyncCommittee, + by_pubkey: Vec, +} + +impl<'a> CommitteeSeats<'a> { + fn serving(view: &StateReadView<'a>, requested: RequestedEpoch) -> Result { + let periods_ahead = + DutyWindow::SeatedSyncCommittees.units_ahead(requested, view.slot.current_epoch())?; + let longtail = view.longtail.state(); + let committee = if periods_ahead == 0 { + &longtail.current_sync_committee + } else { + &longtail.next_sync_committee + }; + + let pubkeys = &committee.pubkeys; + let mut by_pubkey: Vec = (0..SYNC_COMMITTEE_SIZE as u32).collect(); + by_pubkey.sort_unstable_by_key(|&position| (&pubkeys[position as usize], position)); + Ok(Self { committee, by_pubkey }) + } + + fn duties(&self, validators: &ValidatorsView<'_>, requested: &[u64]) -> Vec { + requested.iter().filter_map(|&index| self.duty_of(validators, index)).collect() + } + + /// `None` for an index the registry does not hold and for a validator this + /// committee does not seat: the schema puts `minItems: 1` on + /// `validator_sync_committee_indices`, so a non-member is left out of the + /// response rather than carried with an empty list. + fn duty_of(&self, validators: &ValidatorsView<'_>, validator_index: u64) -> Option { + let ix = usize::try_from(validator_index).ok().filter(|&ix| ix < validators.count())?; + let pubkey = validators.pubkey(ix); + let committee_positions = self.positions_of(pubkey); + (!committee_positions.is_empty()).then_some(SyncDuty { + pubkey: *pubkey, + validator_index, + committee_positions, + }) + } + + /// Every seat `pubkey` holds, ascending. + fn positions_of(&self, pubkey: &BLSPubkey) -> Vec { + let seated = |&position: &u32| &self.committee.pubkeys[position as usize]; + let from = self.by_pubkey.partition_point(|position| seated(position) < pubkey); + self.by_pubkey[from..] + .iter() + .take_while(|position| seated(position) == pubkey) + .map(|&position| position as u64) + .collect() + } +} + +/// `GetSyncCommitteeDutiesBody`: an array of `Uint64` — quoted decimal +/// strings — with `minItems: 1`. Deduplicated, so a validator named twice is +/// answered once. +fn requested_indices(body: &[u8]) -> Result, &'static str> { + let submitted: Vec<&str> = serde_json::from_slice(body).map_err(|_| "invalid request body")?; + if submitted.is_empty() { + return Err("no validator index in request body"); + } + if submitted.len() > MAX_BODY_IDS { + return Err("too many validator indices in request body"); + } + let mut indices = submitted + .iter() + .map(|text| parse_uint64(text).ok_or("invalid validator index")) + .collect::, _>>()?; + indices.sort_unstable(); + indices.dedup(); + Ok(indices) +} diff --git a/crates/beacon_api/src/duties/tests.rs b/crates/beacon_api/src/duties/tests.rs new file mode 100644 index 00000000..d85291ad --- /dev/null +++ b/crates/beacon_api/src/duties/tests.rs @@ -0,0 +1,811 @@ +use silver_beacon_state_data::{ + B256, BLSPubkey, BeaconBlockHeader, BeaconState, BeaconStateOwner, + EPOCHS_PER_HISTORICAL_VECTOR, EpochState, EpochStateFinalized, LongtailGroup, LongtailState, + SYNC_COMMITTEE_SIZE, Slot, SpecConfig, SyncCommittee, ValSeed, +}; +use silver_common::ELSyncStatus; +use silver_httpcore::ParsedRequest; + +use super::*; +use crate::{ + NodeStatus, PeerCounts, SlotStatus, + ids::MAX_BODY_IDS, + router::Router, + routes::{ROUTES, preboot_ctx, test_ctx}, +}; + +/// Mid-epoch and mid-period: the head sits in epoch 300, which is in sync +/// committee period 1 (epochs 256..=511), so no window edge is the head's +/// own slot or epoch. +const HEAD_EPOCH: Epoch = 300; +const HEAD_SLOT: Slot = HEAD_EPOCH * SLOTS_PER_EPOCH + 5; +const NEXT_EPOCH: Epoch = HEAD_EPOCH + 1; + +/// One validator per lookahead entry, so a proposer's index doubles as +/// the entry that named it: a body built from the wrong half of the +/// window says so. +const VALIDATOR_COUNT: usize = PROPOSER_LOOKAHEAD_SIZE; + +/// How far back the fixture chain records block roots. The base ring +/// below it is zeroed, as `BeaconState::for_test` leaves it. +const RECORDED_SLOTS: Slot = 96; + +/// The last slot of the epoch before the head's, whose entry is what v1 names +/// for the head epoch and v2 for the one after it. +const DEPENDENT_SLOT: Slot = HEAD_EPOCH * SLOTS_PER_EPOCH - 1; + +/// The entry v2 names for the head epoch: one epoch further back than v1's. +const V2_DEPENDENT_SLOT: Slot = (HEAD_EPOCH - 1) * SLOTS_PER_EPOCH - 1; + +/// Who sits where. Validator 7 sits at one position of each committee, +/// validator 11 at two of the current one, and validator 20 only in the +/// next; every other position is filler no test asks about. +const CURRENT_SEATS: &[(usize, u64)] = &[(3, 7), (0, 11), (SYNC_COMMITTEE_SIZE - 1, 11)]; +const NEXT_SEATS: &[(usize, u64)] = &[(1, 7), (5, 20)]; +const CURRENT_FILLER: u64 = 63; +const NEXT_FILLER: u64 = 62; +const SEATED_ONCE: u64 = CURRENT_SEATS[0].1; +const SEATED_TWICE: u64 = CURRENT_SEATS[1].1; +const SEATED_NEXT_PERIOD_ONLY: u64 = NEXT_SEATS[1].1; + +const PROPOSER_OUT_OF_RANGE: &str = DutyWindow::ProposerLookahead.out_of_range(); +const SYNC_OUT_OF_RANGE: &str = DutyWindow::SeatedSyncCommittees.out_of_range(); + +fn period_of(epoch: Epoch) -> u64 { + DutyWindow::SeatedSyncCommittees.unit_of(epoch) +} + +fn pubkey_of(index: usize) -> BLSPubkey { + let mut pubkey = [0xd0u8; 48]; + pubkey[40..].copy_from_slice(&(index as u64).to_be_bytes()); + pubkey +} + +fn pubkey_text(index: usize) -> String { + format!("0x{}", hex::encode(pubkey_of(index))) +} + +fn block_root_of(slot: Slot) -> B256 { + let mut root = [0xb0u8; 32]; + root[24..].copy_from_slice(&slot.to_be_bytes()); + root +} + +fn state_root_of(slot: Slot) -> B256 { + let mut root = [0x57u8; 32]; + root[24..].copy_from_slice(&slot.to_be_bytes()); + root +} + +/// A distinct index per list entry, so a capped request is not +/// deduplicated down to one lookup. +fn unquoted_index(position: usize) -> String { + format!("\"{position}\"") +} + +fn root_text(root: B256) -> String { + format!("0x{}", hex::encode(root)) +} + +/// The lookahead entry for the slot `head_epoch * SLOTS_PER_EPOCH + i` is +/// `i`, so the entries of the head epoch name validators 0..32 and those +/// of the next name 32..64. +fn epoch_base() -> EpochStateFinalized { + EpochStateFinalized::from_parts( + EpochState { proposer_lookahead: std::array::from_fn(|i| i as u64), ..Default::default() }, + vec![[0u8; 32]; EPOCHS_PER_HISTORICAL_VECTOR].into_boxed_slice(), + ) +} + +fn committee(seats: &[(usize, u64)], filler: u64) -> SyncCommittee { + let mut committee = SyncCommittee { + pubkeys: [pubkey_of(filler as usize); SYNC_COMMITTEE_SIZE], + aggregate_pubkey: [0u8; 48], + }; + for &(position, validator) in seats { + committee.pubkeys[position] = pubkey_of(validator as usize); + } + committee +} + +/// Both committees seated, with the current one's pubkeys resolved to +/// indices the way the rotation that installed it leaves them. +fn longtail() -> LongtailState { + let mut sync_committee_indices = [CURRENT_FILLER as u32; SYNC_COMMITTEE_SIZE]; + for &(position, validator) in CURRENT_SEATS { + sync_committee_indices[position] = validator as u32; + } + LongtailState { + current_sync_committee: committee(CURRENT_SEATS, CURRENT_FILLER), + next_sync_committee: committee(NEXT_SEATS, NEXT_FILLER), + sync_committee_indices, + historical_summaries: Vec::new(), + } +} + +struct Fixture { + state_slot: Slot, + empty_slots: Vec, + validator_count: usize, + longtail: LongtailState, + /// Silver holds Fulu states and later ones only — it has no Electra state + /// transition and no upgrade into Fulu — so the default fixture is a chain + /// that has been Fulu throughout. + fulu_fork_epoch: Epoch, +} + +impl Default for Fixture { + fn default() -> Self { + Self { + state_slot: HEAD_SLOT, + empty_slots: Vec::new(), + validator_count: VALIDATOR_COUNT, + longtail: longtail(), + fulu_fork_epoch: 0, + } + } +} + +impl Fixture { + /// A published head state grown a slot at a time the way the tile + /// grows one: every slot carries a block bar `empty_slots`, and the + /// `process_slot` that leaves a slot records the latest block's root + /// — so an empty slot's entry repeats its predecessor's. The newest + /// block's root is published beside the state, as the tile publishes + /// it. + fn published(self) -> ApiCtx { + let seeds: Vec = (0..self.validator_count) + .map(|index| ValSeed { pubkey: pubkey_of(index), ..Default::default() }) + .collect(); + let base_slot = self.state_slot.saturating_sub(RECORDED_SLOTS); + let mut state = BeaconState::for_test(epoch_base(), &seeds, base_slot); + state.longtail = LongtailGroup::new(self.longtail); + + let mut owner = BeaconStateOwner::new(state); + let anchor = owner.roll_fresh(); + let (mut writer, _, _) = owner.apply_block_view(anchor); + let mut head_root = [0u8; 32]; + for slot in base_slot..=self.state_slot { + if !self.empty_slots.contains(&slot) { + writer.slot.state_mut().latest_block_header = + BeaconBlockHeader { slot, parent_root: head_root, ..Default::default() }; + head_root = block_root_of(slot); + } + if slot == self.state_slot { + break; + } + writer.slot.fill_latest_block_header_state_root(state_root_of(slot)); + let latest_block = writer.slot.state().latest_block_header.slot; + writer.slot.push_block_root(block_root_of(latest_block)); + writer.slot.advance_slot(); + } + let head = writer.commit(None, None); + owner.set_head_block_root(head_root); + owner.publish_state_id(head); + + let spec = SpecConfig { fulu_fork_epoch: self.fulu_fork_epoch, ..SpecConfig::mainnet() }; + let mut ctx = test_ctx(&spec, owner.reader()); + ctx.node_status = synced_at(self.state_slot); + ctx + } +} + +fn at_wall_slot(head_slot: Slot, wall_slot: Slot) -> NodeStatus { + NodeStatus { + slots: Some(SlotStatus { head_slot, wall_slot, head_optimistic: false }), + syncing: false, + el: ELSyncStatus::Synced, + peers: PeerCounts::default(), + } +} + +fn synced_at(head_slot: Slot) -> NodeStatus { + at_wall_slot(head_slot, head_slot) +} + +fn head_ctx() -> ApiCtx { + Fixture::default().published() +} + +/// The same head state and chain, with the node reporting a wall clock that +/// has moved past it. +fn head_behind_wall(head_epoch: Epoch, wall_epoch: Epoch) -> ApiCtx { + let state_slot = head_epoch * SLOTS_PER_EPOCH + 1; + let mut ctx = Fixture { state_slot, ..Default::default() }.published(); + ctx.node_status = at_wall_slot(state_slot, wall_epoch * SLOTS_PER_EPOCH); + ctx +} + +fn proposer_path(version: u8, epoch: &str) -> String { + format!("/eth/v{version}/validator/duties/proposer/{epoch}") +} + +fn sync_path(epoch: &str) -> String { + format!("/eth/v1/validator/duties/sync/{epoch}") +} + +fn request<'a>(method: &'a str, path: &'a str, body: &'a [u8]) -> ParsedRequest<'a> { + ParsedRequest { + method, + path, + query: "", + body, + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + } +} + +fn dispatch(ctx: &ApiCtx, req: &ParsedRequest<'_>) -> Vec { + let mut out = Vec::new(); + Router::new(ROUTES).dispatch(req, ctx, &mut out); + out +} + +fn get_proposers_v(ctx: &ApiCtx, version: u8, epoch: &str) -> Vec { + dispatch(ctx, &request("GET", &proposer_path(version, epoch), b"")) +} + +fn get_proposers(ctx: &ApiCtx, epoch: &str) -> Vec { + get_proposers_v(ctx, 1, epoch) +} + +fn post_sync(ctx: &ApiCtx, epoch: &str, body: &str) -> Vec { + dispatch(ctx, &request("POST", &sync_path(epoch), body.as_bytes())) +} + +fn body(response: &[u8]) -> &[u8] { + let text = std::str::from_utf8(response).unwrap(); + &response[text.find("\r\n\r\n").unwrap() + 4..] +} + +fn ok_body(response: &[u8]) -> String { + assert!( + response.starts_with(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{}", + String::from_utf8_lossy(response) + ); + String::from_utf8(body(response).to_vec()).unwrap() +} + +fn assert_error(response: &[u8], status: &str, code: u16, message: &str) { + assert!( + response.starts_with(format!("HTTP/1.1 {status}\r\n").as_bytes()), + "{}", + String::from_utf8_lossy(response) + ); + assert_eq!( + std::str::from_utf8(body(response)).unwrap(), + format!("{{\"code\":{code},\"message\":\"{message}\"}}") + ); +} + +fn assert_syncing(response: &[u8]) { + assert_error(response, "503 Service Unavailable", 503, CURRENTLY_SYNCING); +} + +fn assert_bad_request(response: &[u8], message: &str) { + assert_error(response, "400 Bad Request", 400, message); +} + +/// The body the proposer schema declares for `epoch`, spelled out: the +/// dependent root and the optimistic flag beside one entry per slot, each +/// naming the validator the slot's lookahead entry does. +fn expected_proposers(epoch: Epoch, dependent_root: B256) -> String { + let first_entry = (epoch - HEAD_EPOCH) * SLOTS_PER_EPOCH; + let duties: Vec = (0..SLOTS_PER_EPOCH) + .map(|i| { + format!( + "{{\"pubkey\":\"{}\",\"validator_index\":\"{}\",\"slot\":\"{}\"}}", + pubkey_text((first_entry + i) as usize), + first_entry + i, + epoch * SLOTS_PER_EPOCH + i + ) + }) + .collect(); + format!( + "{{\"dependent_root\":\"{}\",\"execution_optimistic\":false,\"data\":[{}]}}", + root_text(dependent_root), + duties.join(",") + ) +} + +/// Body shape: `apis/validator/duties/proposer.yaml` — every slot of the +/// epoch, and the block root the epoch's duties depend on. +#[test] +fn proposer_duties_body_is_the_epoch_under_its_dependent_root() { + let ctx = head_ctx(); + assert_eq!( + ok_body(&get_proposers(&ctx, &HEAD_EPOCH.to_string())), + expected_proposers(HEAD_EPOCH, block_root_of(DEPENDENT_SLOT)) + ); +} + +/// The lookahead is anchored to the head state's epoch, so the next +/// epoch's proposers are its second half — and the dependent slot has not +/// happened, which the endpoint's head-event rule answers with the head +/// block's root. +#[test] +fn the_next_epoch_reads_the_far_half_of_the_lookahead() { + let ctx = head_ctx(); + assert_eq!( + ok_body(&get_proposers(&ctx, &NEXT_EPOCH.to_string())), + expected_proposers(NEXT_EPOCH, block_root_of(HEAD_SLOT)) + ); +} + +/// `proposer.v2.yaml` names `compute_start_slot_at_epoch(epoch - 1) - 1`, one +/// epoch further back than v1: the boundary the lookahead an epoch's +/// proposers came from was seeded at. Both epochs the window serves therefore +/// depend on a slot `block_roots` already records, so v2 never falls back to +/// the head block's root the way v1 does for the next epoch — and the duties +/// themselves are the same list. +#[test] +fn v2_depends_on_the_epoch_before_the_one_asked_for() { + let ctx = head_ctx(); + for (epoch, dependent_slot) in [(HEAD_EPOCH, V2_DEPENDENT_SLOT), (NEXT_EPOCH, DEPENDENT_SLOT)] { + let answered = ok_body(&get_proposers_v(&ctx, 2, &epoch.to_string())); + assert_eq!(answered, expected_proposers(epoch, block_root_of(dependent_slot))); + assert!(!answered.contains(&root_text(block_root_of(HEAD_SLOT))), "{answered}"); + } +} + +/// v2's rule is the one EIP-7917 introduced, so it holds from Fulu on and no +/// earlier: the activation epoch's own lookahead is seeded by the fork +/// transition at its own boundary, and no epoch before it has a lookahead at +/// all. Both fall back to v1's dependent root — which is what a client +/// calling v2 on a chain that schedules Fulu ahead of it must be answered +/// with. +#[test] +fn v2_falls_back_to_v1s_dependent_root_up_to_the_fulu_activation_epoch() { + let activating = Fixture { fulu_fork_epoch: HEAD_EPOCH, ..Default::default() }.published(); + for epoch in [HEAD_EPOCH, NEXT_EPOCH] { + assert_eq!( + ok_body(&get_proposers_v(&activating, 2, &epoch.to_string())), + expected_proposers(epoch, block_root_of(DEPENDENT_SLOT)), + "epoch {epoch}" + ); + } + + let unreached = Fixture { fulu_fork_epoch: NEXT_EPOCH, ..Default::default() }.published(); + for epoch in [HEAD_EPOCH, NEXT_EPOCH] { + assert_eq!( + ok_body(&get_proposers_v(&unreached, 2, &epoch.to_string())), + ok_body(&get_proposers(&unreached, &epoch.to_string())), + "epoch {epoch}" + ); + } +} + +/// An empty slot records the last block's root, which is what the spec's +/// own accessor answers there — the dependent root of an epoch whose +/// predecessor's last slot carried no block. +#[test] +fn a_dependent_slot_that_carried_no_block_names_the_block_before_it() { + let ctx = + Fixture { empty_slots: vec![DEPENDENT_SLOT, V2_DEPENDENT_SLOT], ..Default::default() } + .published(); + assert_eq!( + ok_body(&get_proposers(&ctx, &HEAD_EPOCH.to_string())), + expected_proposers(HEAD_EPOCH, block_root_of(DEPENDENT_SLOT - 1)) + ); + assert_eq!( + ok_body(&get_proposers_v(&ctx, 2, &HEAD_EPOCH.to_string())), + expected_proposers(HEAD_EPOCH, block_root_of(V2_DEPENDENT_SLOT - 1)) + ); +} + +/// The window is the head state's epoch and the next: an epoch below it is +/// nothing this node keeps, and one the wall clock has not scheduled is +/// nothing any state's lookahead covers. +#[test] +fn the_epochs_outside_the_lookahead_are_400() { + let ctx = head_ctx(); + for epoch in [HEAD_EPOCH - 1, 0, HEAD_EPOCH + 2, u64::MAX] { + for version in [1, 2] { + assert_bad_request( + &get_proposers_v(&ctx, version, &epoch.to_string()), + PROPOSER_OUT_OF_RANGE, + ); + } + } +} + +/// The epoch the wall clock is in, not the head state's, decides whether an +/// epoch above the window is early or wrong: for the slot between the clock +/// entering an epoch and the tile advancing the head into it the head state +/// is a whole epoch behind, and the epoch a validator client asks about +/// every slot is one this node answers as soon as it catches up. +#[test] +fn an_epoch_the_wall_clock_has_scheduled_is_503_while_the_head_is_behind() { + let wall_epoch = HEAD_EPOCH; + let ctx = head_behind_wall(wall_epoch - 1, wall_epoch); + for version in [1, 2] { + assert_syncing(&get_proposers_v(&ctx, version, &(wall_epoch + 1).to_string())); + assert_bad_request( + &get_proposers_v(&ctx, version, &(wall_epoch + 2).to_string()), + PROPOSER_OUT_OF_RANGE, + ); + } + + let last_of_period = EPOCHS_PER_SYNC_COMMITTEE_PERIOD - 1; + let ctx = head_behind_wall(last_of_period, last_of_period + 1); + let reachable = (period_of(last_of_period) + 2) * EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + assert_syncing(&post_sync(&ctx, &reachable.to_string(), "[\"7\"]")); + assert_bad_request( + &post_sync(&ctx, &(reachable + EPOCHS_PER_SYNC_COMMITTEE_PERIOD).to_string(), "[\"7\"]"), + SYNC_OUT_OF_RANGE, + ); +} + +/// The converse: an epoch no chain has scheduled duties for is the request's +/// own error however far behind the node reports itself, and an epoch below +/// the head's window is one no waiting brings back. +#[test] +fn an_unscheduled_epoch_is_400_even_while_syncing() { + let mut ctx = head_ctx(); + ctx.node_status = NodeStatus { syncing: true, ..synced_at(HEAD_SLOT) }; + for version in [1, 2] { + assert_bad_request( + &get_proposers_v(&ctx, version, &(HEAD_EPOCH + 2).to_string()), + PROPOSER_OUT_OF_RANGE, + ); + assert_bad_request( + &get_proposers_v(&ctx, version, &(HEAD_EPOCH - 1).to_string()), + PROPOSER_OUT_OF_RANGE, + ); + } + assert_bad_request( + &post_sync(&ctx, &(HEAD_EPOCH + 512).to_string(), "[\"7\"]"), + SYNC_OUT_OF_RANGE, + ); + + let ctx = head_behind_wall(HEAD_EPOCH, HEAD_EPOCH + 1_000); + for version in [1, 2] { + assert_bad_request( + &get_proposers_v(&ctx, version, &(HEAD_EPOCH - 1).to_string()), + PROPOSER_OUT_OF_RANGE, + ); + } +} + +/// The same window, counted from the head state and from the wall clock: a +/// unit either holds is answered or waited for, and one neither holds is the +/// request's own error. Both endpoints, driven through the units they +/// schedule by. +#[test] +fn the_wall_clock_only_decides_the_epochs_the_head_cannot_answer() { + let period = EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + for (window, unit) in + [(DutyWindow::ProposerLookahead, 1), (DutyWindow::SeatedSyncCommittees, period)] + { + let head = 8 * unit; + let ahead = |epoch, wall| { + window.units_ahead(RequestedEpoch { epoch, wall_epoch: Some(wall) }, head) + }; + assert!(matches!(ahead(head, head), Ok(0))); + assert!(matches!(ahead(head + unit, head), Ok(1))); + assert!(matches!(ahead(head - 1, head + 99 * unit), Err(OutOfWindow::Never))); + assert!(matches!(ahead(head + 2 * unit, head), Err(OutOfWindow::Never))); + assert!(matches!(ahead(head + 2 * unit, head + unit), Err(OutOfWindow::NotYet))); + assert!(matches!(ahead(head + 99 * unit, head + 98 * unit), Err(OutOfWindow::NotYet))); + assert!(matches!(ahead(head + 99 * unit, head + 97 * unit), Err(OutOfWindow::Never))); + assert!(matches!(ahead(u64::MAX, head), Err(OutOfWindow::Never))); + assert!(matches!(ahead(u64::MAX - 1, u64::MAX), Err(OutOfWindow::NotYet))); + assert!(matches!( + window.units_ahead(RequestedEpoch { epoch: head + 9 * unit, wall_epoch: None }, head), + Err(OutOfWindow::NotYet) + )); + } +} + +/// `Invalid epoch` in every duties schema: a path parameter that names no +/// epoch is answered before any state is read. +#[test] +fn an_epoch_that_is_no_number_is_400() { + let ctx = head_ctx(); + for epoch in ["abc", "-1", "+1", "1.5", "", "0x12c", "300 ", "١٢"] { + for version in [1, 2] { + assert_bad_request(&get_proposers_v(&ctx, version, epoch), "invalid epoch"); + assert_bad_request(&get_proposers_v(&preboot_ctx(), version, epoch), "invalid epoch"); + } + assert_bad_request(&post_sync(&ctx, epoch, "[\"7\"]"), "invalid epoch"); + } +} + +/// No duties schema declares a 404, and a node with no state published is +/// the case their 503 describes. +#[test] +fn duties_are_503_before_bootstrap() { + let ctx = preboot_ctx(); + for version in [1, 2] { + assert_syncing(&get_proposers_v(&ctx, version, &HEAD_EPOCH.to_string())); + } + assert_syncing(&post_sync(&ctx, &HEAD_EPOCH.to_string(), "[\"7\"]")); +} + +/// The one state whose own dependent root neither version can answer with: +/// at slot zero the genesis block's root is not in `block_roots` yet, and +/// both schemas require the field. +#[test] +fn an_epoch_whose_dependent_root_is_not_recorded_yet_is_503() { + let ctx = Fixture { state_slot: 0, ..Default::default() }.published(); + for version in [1, 2] { + assert_syncing(&get_proposers_v(&ctx, version, "0")); + } +} + +/// The epochs whose dependent slot underflows; the spec resolves them to the +/// genesis block root, which is the entry slot zero records once the chain +/// has left it. v1 underflows for epoch zero alone, v2 for the first two. +#[test] +fn an_underflowing_dependent_slot_reads_the_root_recorded_at_slot_zero() { + let ctx = Fixture { state_slot: 20, ..Default::default() }.published(); + let genesis_root = format!("{{\"dependent_root\":\"{}\"", root_text(block_root_of(0))); + for (version, epoch) in [(1, "0"), (2, "0"), (2, "1")] { + let body = ok_body(&get_proposers_v(&ctx, version, epoch)); + assert!(body.starts_with(&genesis_root), "v{version} epoch {epoch}: {body}"); + } +} + +/// A lookahead entry no validator answers to is the state contradicting +/// itself; leaving the slot out of the answer would hide a proposal duty. +#[test] +fn a_proposer_the_registry_does_not_hold_is_500() { + let ctx = Fixture { validator_count: 8, ..Default::default() }.published(); + for version in [1, 2] { + assert_error( + &get_proposers_v(&ctx, version, &HEAD_EPOCH.to_string()), + "500 Internal Server Error", + 500, + "proposer lookahead names a validator this state does not hold", + ); + } +} + +/// The flag is the head's own execution status, the same source every +/// state read answers from. +#[test] +fn execution_optimistic_follows_the_head() { + let mut ctx = head_ctx(); + ctx.node_status = NodeStatus { + slots: Some(SlotStatus { head_optimistic: true, ..synced_at(HEAD_SLOT).slots.unwrap() }), + ..synced_at(HEAD_SLOT) + }; + for version in [1, 2] { + assert!( + ok_body(&get_proposers_v(&ctx, version, &HEAD_EPOCH.to_string())) + .contains("\"execution_optimistic\":true,") + ); + } + assert!( + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), "[\"7\"]")) + .starts_with("{\"execution_optimistic\":true,\"data\":[") + ); +} + +/// Each response carries the flags its own schema declares beside `data` +/// and no others: `finalized` belongs to none of them, and `dependent_root` +/// to the proposer responses alone — the sync committee's dependent root is +/// defined against a period, which its schema does not carry. +#[test] +fn each_response_carries_only_the_wrapper_fields_its_schema_declares() { + let ctx = head_ctx(); + for version in [1, 2] { + let proposers: serde_json::Value = serde_json::from_str(&ok_body(&get_proposers_v( + &ctx, + version, + &HEAD_EPOCH.to_string(), + ))) + .unwrap(); + let proposer_fields = proposers.as_object().unwrap(); + assert!(proposer_fields.contains_key("dependent_root")); + assert!(proposer_fields.contains_key("execution_optimistic")); + assert!(proposer_fields.contains_key("data")); + assert_eq!(proposer_fields.len(), 3, "{proposer_fields:?}"); + } + + let duties: serde_json::Value = + serde_json::from_str(&ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), "[\"7\"]"))) + .unwrap(); + let duty_fields = duties.as_object().unwrap(); + assert!(duty_fields.contains_key("execution_optimistic")); + assert!(duty_fields.contains_key("data")); + assert_eq!(duty_fields.len(), 2, "{duty_fields:?}"); +} + +/// Body shape: `apis/validator/duties/sync.yaml` — one entry per +/// validator the committee holds, each carrying every position it sits +/// at, and nothing for one it does not. +#[test] +fn sync_duties_body_lists_a_seat_per_position_and_omits_non_members() { + let ctx = head_ctx(); + let requested = format!("[\"{SEATED_ONCE}\",\"{SEATED_TWICE}\",\"{SEATED_NEXT_PERIOD_ONLY}\"]"); + assert_eq!( + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), &requested)), + format!( + "{{\"execution_optimistic\":false,\"data\":[\ + {{\"pubkey\":\"{}\",\"validator_index\":\"7\",\ + \"validator_sync_committee_indices\":[\"3\"]}},\ + {{\"pubkey\":\"{}\",\"validator_index\":\"11\",\ + \"validator_sync_committee_indices\":[\"0\",\"511\"]}}]}}", + pubkey_text(SEATED_ONCE as usize), + pubkey_text(SEATED_TWICE as usize), + ) + ); +} + +/// `minItems: 1` on `validator_sync_committee_indices`: a validator the +/// committee does not hold is left out of the array, not carried in it +/// with an empty one. An index past the registry names no pubkey to match +/// against and is left out the same way. +#[test] +fn a_validator_in_no_seat_is_omitted_rather_than_carried_empty() { + let ctx = head_ctx(); + for requested in [ + format!("[\"{SEATED_NEXT_PERIOD_ONLY}\"]"), + format!("[\"{}\"]", VALIDATOR_COUNT + 1_000), + format!("[\"{}\"]", u32::MAX), + format!("[\"{}\"]", u64::MAX), + ] { + assert_eq!( + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), &requested)), + "{\"execution_optimistic\":false,\"data\":[]}", + "{requested}" + ); + } +} + +/// A validator seated in the next period's committee and in no current seat +/// has duties there and none now. +#[test] +fn the_next_period_reads_the_committee_seated_after_this_one() { + let ctx = head_ctx(); + let next_period = (period_of(HEAD_EPOCH) + 1) * EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + let requested = format!("[\"{SEATED_ONCE}\",\"{SEATED_TWICE}\",\"{SEATED_NEXT_PERIOD_ONLY}\"]"); + assert_eq!( + ok_body(&post_sync(&ctx, &next_period.to_string(), &requested)), + format!( + "{{\"execution_optimistic\":false,\"data\":[\ + {{\"pubkey\":\"{}\",\"validator_index\":\"7\",\ + \"validator_sync_committee_indices\":[\"1\"]}},\ + {{\"pubkey\":\"{}\",\"validator_index\":\"20\",\ + \"validator_sync_committee_indices\":[\"5\"]}}]}}", + pubkey_text(SEATED_ONCE as usize), + pubkey_text(SEATED_NEXT_PERIOD_ONLY as usize), + ) + ); +} + +/// A committee serves its whole period, so every epoch of the head's own +/// answers from the current committee — including epochs the head has +/// passed and the period's last — and every epoch of the next from the +/// next committee. +#[test] +fn the_period_and_not_the_epoch_picks_the_committee() { + let ctx = head_ctx(); + let period = period_of(HEAD_EPOCH); + let first_of_period = period * EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + let last_of_period = first_of_period + EPOCHS_PER_SYNC_COMMITTEE_PERIOD - 1; + let current_seat = format!( + "{{\"execution_optimistic\":false,\"data\":[{{\"pubkey\":\"{}\",\ + \"validator_index\":\"7\",\"validator_sync_committee_indices\":[\"3\"]}}]}}", + pubkey_text(SEATED_ONCE as usize) + ); + let next_seat = format!( + "{{\"execution_optimistic\":false,\"data\":[{{\"pubkey\":\"{}\",\ + \"validator_index\":\"7\",\"validator_sync_committee_indices\":[\"1\"]}}]}}", + pubkey_text(SEATED_ONCE as usize) + ); + for epoch in [first_of_period, HEAD_EPOCH, last_of_period] { + assert_eq!( + ok_body(&post_sync(&ctx, &epoch.to_string(), "[\"7\"]")), + current_seat, + "epoch {epoch}" + ); + } + for epoch in [last_of_period + 1, last_of_period + EPOCHS_PER_SYNC_COMMITTEE_PERIOD] { + assert_eq!( + ok_body(&post_sync(&ctx, &epoch.to_string(), "[\"7\"]")), + next_seat, + "epoch {epoch}" + ); + } + for epoch in + [first_of_period - 1, 0, last_of_period + EPOCHS_PER_SYNC_COMMITTEE_PERIOD + 1, u64::MAX] + { + assert_bad_request(&post_sync(&ctx, &epoch.to_string(), "[\"7\"]"), SYNC_OUT_OF_RANGE); + } +} + +/// Duties are matched on the pubkey the registry holds for the index asked +/// about, so a seat the rotation could not resolve to a finalized index — it +/// leaves `u32::MAX` behind — is still served to the validator sitting in it. +#[test] +fn a_member_the_finalized_registry_did_not_hold_still_gets_its_duty() { + let mut longtail = longtail(); + longtail.sync_committee_indices[CURRENT_SEATS[0].0] = u32::MAX; + let ctx = Fixture { longtail, ..Default::default() }.published(); + assert_eq!( + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), &format!("[\"{SEATED_ONCE}\"]"))), + format!( + "{{\"execution_optimistic\":false,\"data\":[{{\"pubkey\":\"{}\",\ + \"validator_index\":\"7\",\"validator_sync_committee_indices\":[\"3\"]}}]}}", + pubkey_text(SEATED_ONCE as usize) + ) + ); +} + +/// A validator named twice is one validator, answered once. +#[test] +fn a_repeated_index_is_answered_once() { + let ctx = head_ctx(); + assert_eq!( + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), "[\"7\",\"7\",\"7\"]")), + ok_body(&post_sync(&ctx, &HEAD_EPOCH.to_string(), "[\"7\"]")) + ); +} + +/// The body is an array of quoted integers with `minItems: 1`; anything +/// else is the 400 the schema declares for it, with no state read. +#[test] +fn a_body_that_is_no_index_array_is_400() { + let ctx = head_ctx(); + let epoch = HEAD_EPOCH.to_string(); + for body in ["", "[]", "{}", "[1,2]", "\"7\"", "[\"7\"", "null", "[[\"7\"]]", "[null]"] { + let expected = if body == "[]" { + "no validator index in request body" + } else { + "invalid request body" + }; + assert_bad_request(&post_sync(&ctx, &epoch, body), expected); + } + for body in ["[\"abc\"]", "[\"-1\"]", "[\"+1\"]", "[\"7\",\"1.5\"]", "[\"\"]"] { + assert_bad_request(&post_sync(&ctx, &epoch, body), "invalid validator index"); + } +} + +/// The schema caps the list at nothing, so this API does: a body naming +/// more validators than any client runs is refused rather than resolved +/// inside the seqlock read. A list at the cap is answered in full — the +/// seated validators it names come back whether it names two of them or a +/// quarter of a million. +#[test] +fn a_body_at_the_index_cap_is_answered_and_one_past_it_is_400() { + let ctx = head_ctx(); + let epoch = HEAD_EPOCH.to_string(); + let at_cap = + format!("[{}]", (0..MAX_BODY_IDS).map(unquoted_index).collect::>().join(",")); + let past_cap = + format!("[{}]", (0..=MAX_BODY_IDS).map(unquoted_index).collect::>().join(",")); + + assert_eq!( + ok_body(&post_sync(&ctx, &epoch, &at_cap)), + ok_body(&post_sync( + &ctx, + &epoch, + &format!("[\"{SEATED_ONCE}\",\"{SEATED_TWICE}\",\"{CURRENT_FILLER}\"]"), + )) + ); + assert_bad_request( + &post_sync(&ctx, &epoch, &past_cap), + "too many validator indices in request body", + ); +} + +/// Each route answers its own method and nothing else — the proposer +/// duties are a GET, the sync duties a POST. +#[test] +fn the_wrong_method_on_either_route_is_405() { + let ctx = head_ctx(); + let epoch = HEAD_EPOCH.to_string(); + for req in [ + request("POST", &proposer_path(1, &epoch), b"[\"7\"]"), + request("POST", &proposer_path(2, &epoch), b"[\"7\"]"), + request("GET", &sync_path(&epoch), b""), + ] { + assert_error(&dispatch(&ctx, &req), "405 Method Not Allowed", 405, "method not allowed"); + } +} diff --git a/crates/beacon_api/src/ids.rs b/crates/beacon_api/src/ids.rs index 4fbd5faf..fc8d9028 100644 --- a/crates/beacon_api/src/ids.rs +++ b/crates/beacon_api/src/ids.rs @@ -1,12 +1,22 @@ -//! The two identifier forms `state_id` and `block_id` share -//! (`params/index.yaml`): a slot, or a `0x`-prefixed 32-byte root. Each +//! The forms an identifier arrives in. A bare `Uint64` is a slot for the +//! `state_id`/`block_id` of `params/index.yaml` and an epoch for the duties +//! endpoints, which declare that parameter themselves; the `0x`-prefixed +//! 32-byte root is a form those two parameters alone also take. Each //! endpoint's keywords are its own. -use silver_beacon_state_data::{B256, Slot}; +use silver_beacon_state_data::B256; + +/// How many identifiers one POST body may name. The two schemas that take a +/// body of them (`beacon/states/{state_id}/validators`, +/// `validator/duties/sync/{epoch}`) set no `maxItems`, and an unbounded list +/// turns a 16 MiB body into millions of ids to resolve inside the seqlock +/// read. 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 slot. -pub(crate) fn parse_slot(text: &str) -> Option { +/// 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() } diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index f10fe1eb..cd56bc48 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -3,11 +3,12 @@ //! 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, BLSPubkey, BLSSignature, BeaconBlockHeader, Checkpoint, Fork, Version, -}; +use silver_beacon_state_data::{B256, BLSSignature, BeaconBlockHeader, Checkpoint, Fork, Version}; -use crate::validators::entry::{Validator, ValidatorEntry}; +use crate::{ + duties::{ProposerDuty, SyncDuty}, + validators::entry::{Validator, ValidatorEntry}, +}; const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; @@ -178,6 +179,39 @@ impl Json<'_> { self.end_object(); } + /// `GetProposerDutiesResponse`: the epoch's dependent root beside `data`, + /// and no `finalized` flag. + pub(crate) fn dependent_envelope( + &mut self, + dependent_root: &B256, + execution_optimistic: bool, + data: impl FnOnce(&mut Self), + ) { + self.begin_object(); + self.key("dependent_root"); + self.hex(dependent_root); + self.key("execution_optimistic"); + self.bool(execution_optimistic); + self.key("data"); + data(self); + self.end_object(); + } + + /// `GetSyncCommitteeDutiesResponse`, whose schema requires that one flag + /// beside `data` and neither of the other two. + pub(crate) fn optimistic_envelope( + &mut self, + execution_optimistic: bool, + data: impl FnOnce(&mut Self), + ) { + self.begin_object(); + self.key("execution_optimistic"); + self.bool(execution_optimistic); + self.key("data"); + data(self); + self.end_object(); + } + pub(crate) fn genesis(&mut self, genesis: &GenesisData) { self.begin_object(); self.key("genesis_time"); @@ -343,44 +377,59 @@ impl Json<'_> { } self.end_array(); } -} -/// The containers no endpoint calls yet, in the same schema field order. Each -/// lands ahead of the endpoint commit that calls it; the allow stops here so -/// dead-code checking stays real for the writers already wired up. -#[allow(dead_code)] -impl Json<'_> { - pub(crate) fn proposer_duty(&mut self, pubkey: &BLSPubkey, validator_index: u64, slot: u64) { + pub(crate) fn proposer_duty(&mut self, duty: &ProposerDuty) { self.begin_object(); self.key("pubkey"); - self.hex(pubkey); + self.hex(&duty.pubkey); self.key("validator_index"); - self.quoted_u64(validator_index); + self.quoted_u64(duty.validator_index); self.key("slot"); - self.quoted_u64(slot); + self.quoted_u64(duty.slot); self.end_object(); } - pub(crate) fn sync_duty( - &mut self, - pubkey: &BLSPubkey, - validator_index: u64, - committee_indices: &[u64], - ) { + pub(crate) fn proposer_duties(&mut self, duties: &[ProposerDuty]) { + self.begin_array(); + for duty in duties { + self.proposer_duty(duty); + } + self.end_array(); + } + + pub(crate) fn sync_duty(&mut self, duty: &SyncDuty) { + debug_assert!( + !duty.committee_positions.is_empty(), + "the schema puts minItems: 1 on validator_sync_committee_indices", + ); self.begin_object(); self.key("pubkey"); - self.hex(pubkey); + self.hex(&duty.pubkey); self.key("validator_index"); - self.quoted_u64(validator_index); + self.quoted_u64(duty.validator_index); self.key("validator_sync_committee_indices"); self.begin_array(); - for &position in committee_indices { + for &position in &duty.committee_positions { self.quoted_u64(position); } self.end_array(); self.end_object(); } + pub(crate) fn sync_duties(&mut self, duties: &[SyncDuty]) { + self.begin_array(); + for duty in duties { + self.sync_duty(duty); + } + self.end_array(); + } +} + +/// The one container no endpoint calls yet, in the same schema field order. It +/// landed ahead of the commit that will call it; the allow stops here so +/// dead-code checking stays real for the writers already wired up. +#[allow(dead_code)] +impl Json<'_> { pub(crate) fn liveness(&mut self, index: u64, is_live: bool) { self.begin_object(); self.key("index"); @@ -400,7 +449,7 @@ pub(crate) fn json_safe(text: &str) -> bool { #[cfg(test)] mod tests { - use silver_beacon_state_data::{FAR_FUTURE_EPOCH, Withdrawals}; + use silver_beacon_state_data::{BLSPubkey, FAR_FUTURE_EPOCH, Withdrawals}; use super::*; use crate::validators::status::{Lifecycle, Status}; @@ -713,39 +762,47 @@ mod tests { assert_eq!(entries[1]["index"], "1"); } + fn duty_pubkey() -> BLSPubkey { + let mut pubkey = [0u8; 48]; + pubkey[0] = 0xb0; + pubkey + } + /// Field names/order: `ProposerDuty` of /// `apis/validator/duties/proposer.yaml`. #[test] fn proposer_duty_golden() { - let mut pubkey = [0u8; 48]; - pubkey[0] = 0xb0; + let duty = ProposerDuty { pubkey: duty_pubkey(), validator_index: 17, slot: 4_096 }; assert_body( - |j| j.proposer_duty(&pubkey, 17, 4_096), + |j| j.proposer_duty(&duty), "{\"pubkey\":\"0xb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\ \"validator_index\":\"17\",\"slot\":\"4096\"}", ); } - /// Field names/order: `SyncCommitteeDuty` of + /// Field names/order: `Altair.SyncDuty` of /// `apis/validator/duties/sync.yaml` — the committee positions are a /// list of quoted integers. #[test] fn sync_duty_golden() { - let mut pubkey = [0u8; 48]; - pubkey[0] = 0xb0; + let duty = SyncDuty { + pubkey: duty_pubkey(), + validator_index: 17, + committee_positions: vec![3, 511], + }; assert_body( - |j| j.sync_duty(&pubkey, 17, &[3, 511]), + |j| j.sync_duty(&duty), "{\"pubkey\":\"0xb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\ \"validator_index\":\"17\",\"validator_sync_committee_indices\":[\"3\",\"511\"]}", ); } + /// The `data` arrays of both duties responses: an epoch nobody in the + /// request proposes or sits in is an empty array, not an absent one. #[test] - fn sync_duty_with_no_committee_positions_keeps_an_empty_array() { - let pubkey = [0u8; 48]; - let body = write(|j| j.sync_duty(&pubkey, 17, &[])); - let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(parsed["validator_sync_committee_indices"].as_array().unwrap().len(), 0); + fn duty_arrays_survive_being_empty() { + assert_body(|j| j.proposer_duties(&[]), "[]"); + assert_body(|j| j.sync_duties(&[]), "[]"); } /// Field names: `apis/validator/liveness.yaml`. diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index 54ed544e..1be69fd1 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -1,5 +1,6 @@ mod blocks; mod config; +mod duties; mod identity; mod ids; mod json; diff --git a/crates/beacon_api/src/node_status.rs b/crates/beacon_api/src/node_status.rs index 657d301b..8bdab79d 100644 --- a/crates/beacon_api/src/node_status.rs +++ b/crates/beacon_api/src/node_status.rs @@ -1,3 +1,4 @@ +use silver_beacon_state_data::{Epoch, SLOTS_PER_EPOCH}; use silver_common::ELSyncStatus; use crate::json::{PeerCountData, SyncingData}; @@ -66,6 +67,14 @@ impl NodeStatus { PeerCountData { connected: self.peers.connected, connecting: self.peers.connecting } } + /// The epoch the chain is in, whatever epoch this node's head has reached: + /// what a duties endpoint tells a request that arrives before the head + /// does from one no chain schedules duties for. `None` until the first + /// status, which leaves nothing to judge either by. + pub(crate) fn wall_epoch(&self) -> Option { + self.slots.map(|slots| slots.wall_slot / SLOTS_PER_EPOCH) + } + /// `syncing` alone would answer for the head this node is chasing, not the /// one the chain is at: the control tile publishes a `SyncUpdate` only when /// its target changes, so a node that has yet to find a peer to sync from diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index 2cd7a1f0..df0559fd 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -3,14 +3,17 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; #[cfg(test)] use silver_beacon_state_data::BeaconStateOwner; -use silver_beacon_state_data::{B256, BeaconStateReader, SpecConfig, StateReadView}; +use silver_beacon_state_data::{ + B256, BeaconStateReader, Epoch, ForkName, SpecConfig, StateReadView, +}; use silver_common::{Enr, Identify, Keypair}; use silver_httpcore::Query; use crate::{ NodeStatus, blocks::{get_block_header, get_block_root}, - ids::{parse_root, parse_slot}, + duties::{get_proposer_duties, get_proposer_duties_v2, post_sync_duties}, + ids::{parse_root, parse_uint64}, json::{FinalityCheckpoints, GenesisData, Json, ReadFlags}, node_status::Health, response::Response, @@ -49,6 +52,9 @@ pub(crate) const ROUTES: &[(Method, &str, Handler)] = &[ (Method::Get, "/eth/v1/node/peer_count", peer_count), (Method::Get, "/eth/v1/node/syncing", syncing), (Method::Get, "/eth/v1/node/version", version), + (Method::Get, "/eth/v1/validator/duties/proposer/{epoch}", get_proposer_duties), + (Method::Post, "/eth/v1/validator/duties/sync/{epoch}", post_sync_duties), + (Method::Get, "/eth/v2/validator/duties/proposer/{epoch}", get_proposer_duties_v2), (Method::Get, "/metrics", metrics), ]; @@ -56,6 +62,10 @@ pub(crate) struct ApiCtx { pub(crate) statics: StaticBodies, pub(crate) state: BeaconStateReader, pub(crate) node_status: NodeStatus, + /// Read per request rather than baked into `statics`: the proposer duties' + /// v2 dependent root is defined against the epoch from which EIP-7917's + /// deterministic lookahead schedules an epoch a boundary in advance. + pub(crate) fulu_fork_epoch: Epoch, } impl ApiCtx { @@ -70,21 +80,24 @@ impl ApiCtx { statics: StaticBodies::new(keypair, local_enr, identify, spec), state, node_status: NodeStatus::default(), + fulu_fork_epoch: spec.fork_epoch(ForkName::Fulu), } } - /// The published state and the root of the block it was applied from, or a - /// 404 carrying `not_found` while the node has published none — the schemas - /// of these endpoints declare no 503. - pub(crate) fn read_state_or_404( + /// The published state and the root of the block it was applied from, or + /// `code`/`message` while the node has published none. Which code that is + /// belongs to the endpoint: the state reads' schemas declare no 503, the + /// duties' no 404. + pub(crate) fn read_state_or( &self, resp: &mut Response<'_>, - not_found: &str, + code: u16, + message: &str, read: impl Fn(StateReadView<'_>, B256) -> R, ) -> Option { let result = self.state.read_head(&read); if result.is_none() { - resp.error(404, not_found); + resp.error(code, message); } result } @@ -120,7 +133,7 @@ impl ApiCtx { }, data: read(view), }; - self.read_state_or_404(resp, "state not found", read) + self.read_state_or(resp, 404, "state not found", read) } /// A `{state_id}` read whose body is the envelope around `render`, for the @@ -151,16 +164,18 @@ pub(crate) struct StateRead { /// 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_slot(state_id).is_some() || + parse_uint64(state_id).is_some() || parse_root(state_id).is_some() } fn genesis(_req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { let Some(genesis) = - ctx.read_state_or_404(resp, "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, + 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; diff --git a/crates/beacon_api/src/validators/filter.rs b/crates/beacon_api/src/validators/filter.rs index e3b3a425..95a84572 100644 --- a/crates/beacon_api/src/validators/filter.rs +++ b/crates/beacon_api/src/validators/filter.rs @@ -3,6 +3,7 @@ use silver_beacon_state_data::{BLSPubkey, ValidatorsView}; use silver_httpcore::Query; use crate::{ + ids::MAX_BODY_IDS, response::Response, validators::status::{Status, StatusMask}, }; @@ -10,13 +11,6 @@ use crate::{ /// `maxItems` on the GET `id` array (`apis/beacon/states/validators.yaml`). const MAX_QUERY_IDS: usize = 64; -/// The POST variant carries lists a query string cannot and declares no -/// `maxItems`, but an unbounded one turns a 16 MiB body into millions of ids -/// that [`Filter::resolve_ids`] then sorts inside the seqlock read. A quarter -/// of a million keys is an order of magnitude past the largest single -/// validator client in production, against a mainnet registry of ~2M. -const MAX_BODY_IDS: usize = 256 * 1024; - pub(crate) enum ValidatorId { Index(u64), Pubkey(BLSPubkey), diff --git a/crates/beacon_state/data/src/slot_state/delta.rs b/crates/beacon_state/data/src/slot_state/delta.rs index 070c6cfd..5c4d32c0 100644 --- a/crates/beacon_state/data/src/slot_state/delta.rs +++ b/crates/beacon_state/data/src/slot_state/delta.rs @@ -126,19 +126,23 @@ impl<'a> SlotStateView<'a> { ) } + /// `get_block_root_at_slot(state, slot)` while `block_roots` still records + /// that slot. An empty slot's entry repeats the last block's root, as the + /// spec accessor's own does. + pub fn recorded_block_root_at(&self, slot: Slot) -> Option { + self.records_slot(slot).then(|| self.block_root_at_slot(slot)) + } + /// The root of the block proposed at `slot`, when `block_roots` proves one /// was: `process_slot` repeats the previous entry through a slot that /// carried no block, so an entry differing from its predecessor is the mark /// of a block of the slot's own. pub fn block_root_proposed_at(&self, slot: Slot) -> Option { - if !self.records_slot(slot) { - return None; - } - let root = self.block_root_at_slot(slot); + let root = self.recorded_block_root_at(slot)?; let Some(previous) = slot.checked_sub(1) else { return Some(root); }; - (self.records_slot(previous) && self.block_root_at_slot(previous) != root).then_some(root) + (self.recorded_block_root_at(previous)? != root).then_some(root) } /// Whether `block_roots` holds the entry for `slot`: the ring covers the diff --git a/crates/beacon_state/data/src/slot_state/tests.rs b/crates/beacon_state/data/src/slot_state/tests.rs index 1b84b860..52dc7d26 100644 --- a/crates/beacon_state/data/src/slot_state/tests.rs +++ b/crates/beacon_state/data/src/slot_state/tests.rs @@ -58,7 +58,7 @@ fn finalize_advances_base_slot_and_writes_roots() { /// A chain older than `SLOTS_PER_HISTORICAL_ROOT`, so `slot % cap` is the /// wrapped index rather than the slot, and the base ring is what answers for /// everything below the fork's own delta. -mod proposed_at { +mod ring_reads { use super::*; use crate::types::{B256, SLOTS_PER_HISTORICAL_ROOT}; @@ -130,4 +130,25 @@ mod proposed_at { assert_eq!(view.block_root_proposed_at(STATE_SLOT), None, "the state's own slot"); assert_eq!(view.block_root_proposed_at(STATE_SLOT + 1), None, "past it"); } + + /// Reading the entry itself asks only that the ring still cover the slot, + /// so the floor answers where naming a block there cannot, and an empty + /// slot answers with the root it repeats. + #[test] + fn a_recorded_entry_reads_back_wherever_the_ring_covers_its_slot() { + let empty = WRAPPED_FIN_SLOT - 1; + let mut g = wrapped_group(Some(empty)); + let mut wv = g.roll_fresh(); + wv.push_block_root(root_of(WRAPPED_FIN_SLOT)); + wv.state_mut().slot = WRAPPED_FIN_SLOT + 1; + let view = wv.reader(); + + let state_slot = view.state().slot; + let floor = state_slot - view.finalized_block_roots().len() as u64; + assert_eq!(view.recorded_block_root_at(empty), Some(root_of(empty - 1))); + assert_eq!(view.recorded_block_root_at(WRAPPED_FIN_SLOT), Some(root_of(WRAPPED_FIN_SLOT))); + assert_eq!(view.recorded_block_root_at(floor), Some(root_of(floor))); + assert_eq!(view.recorded_block_root_at(floor - 1), None, "below the floor"); + assert_eq!(view.recorded_block_root_at(state_slot), None, "the state's own slot"); + } } diff --git a/crates/beacon_state/data/src/types.rs b/crates/beacon_state/data/src/types.rs index b006c325..51fd010d 100644 --- a/crates/beacon_state/data/src/types.rs +++ b/crates/beacon_state/data/src/types.rs @@ -53,6 +53,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 9b73bb11..f02eff39 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/tests/ef_epoch_processing.rs b/crates/beacon_state/tile/tests/ef_epoch_processing.rs index daae3aff..b05a7435 100644 --- a/crates/beacon_state/tile/tests/ef_epoch_processing.rs +++ b/crates/beacon_state/tile/tests/ef_epoch_processing.rs @@ -7,8 +7,9 @@ use ef_common::{ }; use silver_beacon_state::{ ssz_hash::StateHashScratch, - stf::{self, EPOCHS_PER_SYNC_COMMITTEE_PERIOD, HISTORICAL_SUMMARY_PERIOD}, + 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 { From 93cf03aa2ff27c2b0bf3b6c8f8029b577ceaed1f Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 21 Aug 2026 11:05:54 +0100 Subject: [PATCH 26/33] Serve the validator client's receipt POSTs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register_validator, prepare_beacon_proposer, liveness/{epoch} and the two subscription endpoints, plus the Content-Type the first of them needs: the router now carries the parsed header through to the handler, where Request::body_is_json reads it. Accept is left out until SSZ has a consumer. Four of the five answer the bodyless 200 their schemas declare, after checking the body is the array of entries the schema spells — quoted decimals where it says Uint64, 0x-hex of the right length where it says Pubkey or ExecutionAddress. Entries borrow out of the request body, and the list is capped at the same quarter of a million validators a POST filter is, so one array cannot turn a 16MiB body into an unbounded parse. register_validator is the only one of the five whose schema declares a 415, and the only one that declares an SSZ request body beside the JSON one. That code is load-bearing: Teku posts the registrations as application/octet- stream first and turns SSZ off for the session only inside its 415 handler, so a 400 or a 500 there drops every registration with no retry. A request naming no media type is read as JSON — the one type worth refusing announces itself. A POST declaring more body than the read buffer holds is answered with a 413 rather than dropped. The declared length is known from the request headers, before a body byte arrives, so the connection frames the status there and closes once it has drained instead of buffering toward the 16MiB cap and giving up with nothing written. go-eth2-client and Prysm post a whole validator set unchunked, and a large operator's register_validator or prepare_beacon_proposer clears the cap on its own, so this is the difference between a status the client can log and a request that hangs until the socket closes. liveness answers not-live for every index and warns on every call. Limitations this serves honestly and does not fix: - register_validator is accept-and-ignore. Nothing in silver reaches a builder network, so the fee recipient and gas limit an operator registers go nowhere. The endpoint says received because the schema's 200 says exactly that, and the debug line names the count discarded. - prepare_beacon_proposer has no consumer: EngineReq::PreparePayload has no producer anywhere in the tree, so the fee recipients persist for no epochs at all rather than the three the schema describes. - liveness is interim. is_live is false for every validator because silver keeps no record of what it has seen a validator do. The schema calls its answers best-effort, which makes this legal, and it is the permissive direction: not-live is what clears doppelganger protection, so a validator client with it enabled will start attesting on this node's word. Every call logs a WARN saying so, until the seen-attesters source is wired. - Both subscription endpoints are stubs. No subnet control channel exists, so nothing searches discv5 for the subnet's peers, announces the topic or aggregates on it; the committee endpoint logs how many of the entries were aggregators. - No epoch bound on liveness. The schema asks for the current and previous epoch and leaves earlier ones optional, drawing no upper bound; a constant answer is no better at one epoch than another, so only a path segment that is not a Uint64 is refused. - No 503 while syncing, though liveness declares one. The answer costs no state read and would not change after the sync, and a 5xx takes the node out of a go-eth2-client rotation and marks it ERRORED in Teku. - The 413 can still be lost to a client mid-stream: closing with unread bytes queued makes the kernel send RST, which can discard a response the peer has not yet read. Deterministic delivery needs a lingering close (drain and discard until the client stops); Go's net/http reads responses concurrently with body writes, so Vouch sees the 413 whenever it wins that race. Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/src/duties/sync.rs | 24 +- crates/beacon_api/src/ids.rs | 40 ++- crates/beacon_api/src/json.rs | 6 - crates/beacon_api/src/lib.rs | 2 + crates/beacon_api/src/liveness.rs | 187 ++++++++++++ crates/beacon_api/src/receipts.rs | 419 +++++++++++++++++++++++++++ crates/beacon_api/src/response.rs | 5 + crates/beacon_api/src/router.rs | 79 +++++ crates/beacon_api/src/routes.rs | 18 ++ crates/beacon_api/src/server.rs | 46 ++- crates/httpcore/src/server.rs | 99 ++++--- 11 files changed, 855 insertions(+), 70 deletions(-) create mode 100644 crates/beacon_api/src/liveness.rs create mode 100644 crates/beacon_api/src/receipts.rs diff --git a/crates/beacon_api/src/duties/sync.rs b/crates/beacon_api/src/duties/sync.rs index b668ad90..57e03d33 100644 --- a/crates/beacon_api/src/duties/sync.rs +++ b/crates/beacon_api/src/duties/sync.rs @@ -4,7 +4,7 @@ use silver_beacon_state_data::{ use crate::{ duties::{CURRENTLY_SYNCING, DutyWindow, OutOfWindow, RequestedEpoch}, - ids::{MAX_BODY_IDS, parse_uint64}, + ids::submitted_indices, response::Response, router::Request, routes::ApiCtx, @@ -23,7 +23,7 @@ pub(crate) fn post_sync_duties(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Respo let Some(requested) = RequestedEpoch::parse(req, ctx, resp) else { return; }; - let indices = match requested_indices(req.body) { + let indices = match submitted_indices(req.body) { Ok(indices) => indices, Err(message) => { resp.error(400, message); @@ -107,23 +107,3 @@ impl<'a> CommitteeSeats<'a> { .collect() } } - -/// `GetSyncCommitteeDutiesBody`: an array of `Uint64` — quoted decimal -/// strings — with `minItems: 1`. Deduplicated, so a validator named twice is -/// answered once. -fn requested_indices(body: &[u8]) -> Result, &'static str> { - let submitted: Vec<&str> = serde_json::from_slice(body).map_err(|_| "invalid request body")?; - if submitted.is_empty() { - return Err("no validator index in request body"); - } - if submitted.len() > MAX_BODY_IDS { - return Err("too many validator indices in request body"); - } - let mut indices = submitted - .iter() - .map(|text| parse_uint64(text).ok_or("invalid validator index")) - .collect::, _>>()?; - indices.sort_unstable(); - indices.dedup(); - Ok(indices) -} diff --git a/crates/beacon_api/src/ids.rs b/crates/beacon_api/src/ids.rs index fc8d9028..a6f9f417 100644 --- a/crates/beacon_api/src/ids.rs +++ b/crates/beacon_api/src/ids.rs @@ -6,12 +6,11 @@ use silver_beacon_state_data::B256; -/// How many identifiers one POST body may name. The two schemas that take a -/// body of them (`beacon/states/{state_id}/validators`, -/// `validator/duties/sync/{epoch}`) set no `maxItems`, and an unbounded list -/// turns a 16 MiB body into millions of ids to resolve inside the seqlock -/// read. A quarter of a million is an order of magnitude past the largest -/// single validator client in production, against a mainnet registry of ~2M. +/// 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 @@ -25,3 +24,32 @@ pub(crate) fn parse_root(text: &str) -> Option { hex::decode_to_slice(text.strip_prefix("0x")?, &mut root).ok()?; Some(root) } + +/// Whether `text` spells exactly `bytes` bytes in the `0x`-prefixed hex of the +/// schemas' `pattern`, either case, for a field a handler checks and discards. +pub(crate) fn is_hex_bytes(text: &str, bytes: usize) -> bool { + text.strip_prefix("0x").is_some_and(|hex| { + hex.len() == 2 * bytes && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) +} + +/// The whole body of `validator/duties/sync/{epoch}` and +/// `validator/liveness/{epoch}`: an array of `Uint64` — quoted decimal +/// strings — with `minItems: 1`. Deduplicated, so a validator named twice is +/// answered once. +pub(crate) fn submitted_indices(body: &[u8]) -> Result, &'static str> { + let submitted: Vec<&str> = serde_json::from_slice(body).map_err(|_| "invalid request body")?; + if submitted.is_empty() { + return Err("no validator index in request body"); + } + if submitted.len() > MAX_BODY_IDS { + return Err("too many validator indices in request body"); + } + let mut indices = submitted + .iter() + .map(|text| parse_uint64(text).ok_or("invalid validator index")) + .collect::, _>>()?; + indices.sort_unstable(); + indices.dedup(); + Ok(indices) +} diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index cd56bc48..af32b314 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -423,13 +423,7 @@ impl Json<'_> { } self.end_array(); } -} -/// The one container no endpoint calls yet, in the same schema field order. It -/// landed ahead of the commit that will call it; the allow stops here so -/// dead-code checking stays real for the writers already wired up. -#[allow(dead_code)] -impl Json<'_> { pub(crate) fn liveness(&mut self, index: u64, is_live: bool) { self.begin_object(); self.key("index"); diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index 1be69fd1..cdbd5b36 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -4,7 +4,9 @@ mod duties; mod identity; mod ids; mod json; +mod liveness; mod node_status; +mod receipts; mod response; mod router; mod routes; diff --git a/crates/beacon_api/src/liveness.rs b/crates/beacon_api/src/liveness.rs new file mode 100644 index 00000000..928119fd --- /dev/null +++ b/crates/beacon_api/src/liveness.rs @@ -0,0 +1,187 @@ +use crate::{ + ids::{parse_uint64, submitted_indices}, + response::Response, + router::Request, + routes::ApiCtx, +}; + +/// A validator is live when the node has observed it, and silver keeps no +/// record of what it has seen a validator do — so every index comes back +/// not-live, which the schema's "best-effort ... may indicate that a validator +/// is not live when it actually is" makes a legal answer. It is also the +/// dangerous direction: not-live is what clears doppelganger protection, so a +/// validator running twice starts attesting twice on this node's word. Hence +/// the warning on every call. +pub(crate) fn post_liveness(req: &Request<'_>, _ctx: &ApiCtx, resp: &mut Response<'_>) { + let named = req.params.get("epoch").expect("{epoch} in the route pattern"); + let Some(epoch) = parse_uint64(named) else { + resp.error(400, "invalid epoch"); + return; + }; + let indices = match submitted_indices(req.body) { + Ok(indices) => indices, + Err(message) => { + resp.error(400, message); + return; + } + }; + + tracing::warn!( + epoch, + validators = indices.len(), + "liveness answered not-live for every validator: silver observes none, so a client \ + relying on this for doppelganger detection is not protected" + ); + resp.json_body(|json| { + json.data_envelope(|json| { + json.begin_array(); + for &index in &indices { + json.liveness(index, false); + } + json.end_array() + }) + }); +} + +#[cfg(test)] +mod tests { + use silver_httpcore::ParsedRequest; + + use crate::{ + ids::MAX_BODY_IDS, + router::Router, + routes::{ROUTES, preboot_ctx}, + }; + + fn dispatch(method: &str, epoch: &str, content_type: Option<&str>, body: &str) -> Vec { + let path = format!("/eth/v1/validator/liveness/{epoch}"); + let mut out = Vec::new(); + let req = ParsedRequest { + method, + path: &path, + query: "", + body: body.as_bytes(), + accept: None, + content_type, + eth_consensus_version: None, + version: 1, + keep_alive: true, + }; + Router::new(ROUTES).dispatch(&req, &preboot_ctx(), &mut out); + out + } + + fn post(epoch: &str, body: &str) -> Vec { + dispatch("POST", epoch, Some("application/json"), body) + } + + fn ok_body(response: &[u8]) -> String { + let text = String::from_utf8_lossy(response); + assert!( + text.starts_with("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"), + "{text}" + ); + text[text.find("\r\n\r\n").unwrap() + 4..].to_owned() + } + + fn assert_bad_request(response: &[u8], message: &str) { + let text = String::from_utf8_lossy(response); + assert!(text.starts_with("HTTP/1.1 400 Bad Request\r\n"), "{text}"); + assert!(text.ends_with(&format!("{{\"code\":400,\"message\":\"{message}\"}}")), "{text}"); + } + + /// Body shape: `PostLivenessResponseBody` of `apis/validator/liveness.yaml` + /// — a bare `data` wrapper around one `{index, is_live}` per validator, + /// the index a quoted decimal string and the flag bare. + #[test] + fn the_body_is_one_not_live_entry_per_validator() { + assert_eq!( + ok_body(&post("300", "[\"7\",\"0\"]")), + "{\"data\":[{\"index\":\"0\",\"is_live\":false},{\"index\":\"7\",\"is_live\":false}]}" + ); + serde_json::from_str::(&ok_body(&post("300", "[\"1\"]"))) + .expect("valid JSON"); + } + + /// One validator named twice is one entry, as it is for the sync duties + /// the same body shape serves. + #[test] + fn a_validator_named_twice_is_answered_once() { + assert_eq!( + ok_body(&post("300", "[\"4\",\"4\",\"4\"]")), + "{\"data\":[{\"index\":\"4\",\"is_live\":false}]}" + ); + } + + /// The answer is the same constant at every epoch, so refusing one would + /// refuse a request this node can answer as well as it answers any other. + /// The spec asks a node to support the current and previous epoch and + /// leaves earlier ones optional; it draws no upper bound at all. + #[test] + fn every_epoch_the_path_can_spell_is_answered() { + for epoch in ["0", "1", "300", "18446744073709551615"] { + assert_eq!( + ok_body(&post(epoch, "[\"1\"]")), + "{\"data\":[{\"index\":\"1\",\"is_live\":false}]}", + "{epoch}" + ); + } + } + + /// "Invalid epoch: -2" in the schema's own 400 example. + #[test] + fn an_epoch_naming_no_epoch_at_all_is_a_400() { + for epoch in ["-2", "+1", "banana", "", "1.5", "0x1", "18446744073709551616"] { + assert_bad_request(&post(epoch, "[\"1\"]"), "invalid epoch"); + } + } + + /// `PostLivenessRequestBody`: an array of `Uint64` with `minItems: 1`, + /// each a quoted decimal string. + #[test] + fn a_body_that_is_not_the_schema_s_index_array_is_a_400() { + for body in ["", "not json", "{}", "[1]", "[\"1\",2]"] { + assert_bad_request(&post("300", body), "invalid request body"); + } + assert_bad_request(&post("300", "[]"), "no validator index in request body"); + assert_bad_request(&post("300", "[\"-1\"]"), "invalid validator index"); + } + + /// The response carries an entry per index, so the cap bounds what one + /// request can make this node write as well as what it makes it read. + #[test] + fn more_indices_than_the_cap_is_a_400() { + let index = |i: usize| format!("\"{i}\""); + let at_cap = format!("[{}]", (0..MAX_BODY_IDS).map(index).collect::>().join(",")); + let past_cap = format!("[{}]", (0..=MAX_BODY_IDS).map(index).collect::>().join(",")); + + assert!(post("300", &at_cap).starts_with(b"HTTP/1.1 200 OK\r\n")); + assert_bad_request(&post("300", &past_cap), "too many validator indices in request body"); + } + + /// The schema declares no 415, so the content type is not this endpoint's + /// to judge: a body it can read is a body it answers. + #[test] + fn a_non_json_content_type_is_not_refused() { + let expected = "{\"data\":[{\"index\":\"1\",\"is_live\":false}]}"; + for content_type in [None, Some("application/octet-stream"), Some("text/plain")] { + let response = dispatch("POST", "300", content_type, "[\"1\"]"); + assert_eq!(ok_body(&response), expected, "{content_type:?}"); + } + } + + /// The schema declares a 503 for a syncing node, but this answer costs no + /// state read and would be the same one after the sync — and a 5xx here + /// takes the node out of a `go-eth2-client` rotation and marks it errored + /// in Teku. + #[test] + fn a_node_with_no_state_published_still_answers_200() { + assert!(post("300", "[\"1\"]").starts_with(b"HTTP/1.1 200 OK\r\n")); + } + + #[test] + fn the_route_takes_post_and_nothing_else() { + let response = dispatch("GET", "300", None, ""); + assert!(response.starts_with(b"HTTP/1.1 405 Method Not Allowed\r\n")); + } +} diff --git a/crates/beacon_api/src/receipts.rs b/crates/beacon_api/src/receipts.rs new file mode 100644 index 00000000..002f7c5d --- /dev/null +++ b/crates/beacon_api/src/receipts.rs @@ -0,0 +1,419 @@ +//! 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"); + } + } + + /// Teku posts `registerValidator` as SSZ first and turns SSZ off for the + /// session on a 415; under any other code the registrations are lost 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 index 7ea54f7f..6d828405 100644 --- a/crates/beacon_api/src/response.rs +++ b/crates/beacon_api/src/response.rs @@ -38,6 +38,11 @@ impl<'a> Response<'a> { 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, diff --git a/crates/beacon_api/src/router.rs b/crates/beacon_api/src/router.rs index 9637512b..1627382c 100644 --- a/crates/beacon_api/src/router.rs +++ b/crates/beacon_api/src/router.rs @@ -4,6 +4,8 @@ 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, @@ -30,9 +32,33 @@ pub(crate) struct Request<'a> { 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: Teku + /// posts `registerValidator` as `application/octet-stream` first and turns + /// SSZ off for the session on the 415, so any other code there loses the + /// registrations 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, @@ -114,6 +140,7 @@ impl Router { path: req.path, params, query: req.query, + content_type: req.content_type, body: req.body, }; (route.handler)(&request, ctx, &mut Response::new(out)); @@ -216,6 +243,58 @@ mod tests { 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(&[ diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index df0559fd..60666805 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -15,7 +15,12 @@ use crate::{ duties::{get_proposer_duties, get_proposer_duties_v2, post_sync_duties}, ids::{parse_root, parse_uint64}, json::{FinalityCheckpoints, GenesisData, Json, ReadFlags}, + liveness::post_liveness, node_status::Health, + receipts::{ + post_beacon_committee_subscriptions, post_prepare_beacon_proposer, post_register_validator, + post_sync_committee_subscriptions, + }, response::Response, router::{Handler, Method, Request}, statics::StaticBodies, @@ -52,8 +57,21 @@ pub(crate) const ROUTES: &[(Method, &str, Handler)] = &[ (Method::Get, "/eth/v1/node/peer_count", peer_count), (Method::Get, "/eth/v1/node/syncing", syncing), (Method::Get, "/eth/v1/node/version", version), + ( + Method::Post, + "/eth/v1/validator/beacon_committee_subscriptions", + post_beacon_committee_subscriptions, + ), (Method::Get, "/eth/v1/validator/duties/proposer/{epoch}", get_proposer_duties), (Method::Post, "/eth/v1/validator/duties/sync/{epoch}", post_sync_duties), + (Method::Post, "/eth/v1/validator/liveness/{epoch}", post_liveness), + (Method::Post, "/eth/v1/validator/prepare_beacon_proposer", post_prepare_beacon_proposer), + (Method::Post, "/eth/v1/validator/register_validator", post_register_validator), + ( + Method::Post, + "/eth/v1/validator/sync_committee_subscriptions", + post_sync_committee_subscriptions, + ), (Method::Get, "/eth/v2/validator/duties/proposer/{epoch}", get_proposer_duties_v2), (Method::Get, "/metrics", metrics), ]; diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index a78de343..7b66141f 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -195,8 +195,18 @@ fn handle_event, &mut Vec)>( request_handler: &F, ) -> io::Result { 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 = conn.http.read_space()?; + let space = match conn.http.read_space() { + Ok(space) => space, + Err(e) => { + exhausted = Some(e); + break; + } + }; match conn.stream.read(space) { Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), Ok(n) => { @@ -211,6 +221,8 @@ fn handle_event, &mut Vec)>( if conn.http.dispatch(request_handler) { registry.reregister(&mut conn.stream, event.token(), Interest::WRITABLE)?; + } else if let Some(e) = exhausted { + return Err(e); } return Ok(false); } @@ -593,6 +605,38 @@ mod tests { assert!(api.connections.is_empty(), "reaped connection must leave the map"); } + /// go-eth2-client and Prysm post a whole validator set unchunked, so 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 api = api_with(64, LONG_TIMEOUT); + let addr = tcp_addr(&api); + + let received = serve( + &mut api, + std::thread::spawn(move || { + let mut stream = connect(addr); + 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(); + read_to_eof(stream) + }), + "oversized declaration answered", + ); + + assert!( + received.starts_with(b"HTTP/1.1 413 Payload Too Large\r\n"), + "unexpected response: {}", + String::from_utf8_lossy(&received) + ); + assert!(api.connections.is_empty(), "the answered connection must close"); + } + #[test] fn idle_keep_alive_connection_is_reaped_after_the_idle_deadline() { let idle_timeout = Duration::from_millis(200); diff --git a/crates/httpcore/src/server.rs b/crates/httpcore/src/server.rs index d1426dba..7098bf58 100644 --- a/crates/httpcore/src/server.rs +++ b/crates/httpcore/src/server.rs @@ -18,12 +18,14 @@ pub struct ParsedRequest<'a> { pub keep_alive: bool, } -/// `Incomplete` means "no verdict yet, feed me more bytes"; `Malformed` means -/// the bytes can never become a request, so no amount of waiting helps. +/// `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> { @@ -64,6 +66,11 @@ impl<'a> ParsedRequest<'a> { 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; } @@ -141,20 +148,25 @@ impl ServerConnection { 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 let + /// the caller close once the answer has drained. + fn reject(&mut self, status: &str) -> bool { + self.keep_alive = false; + frame_response(&mut self.write_buf, status, None, b""); + self.read_pos = 0; + self.read_end = 0; + true + } + 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, - // Framing is lost, so there is nothing left to resynchronise - // on: answer, drop the whole buffer and let the caller close. - ParseOutcome::Malformed => { - self.keep_alive = false; - frame_response(&mut self.write_buf, "400 Bad Request", None, b""); - self.read_pos = 0; - self.read_end = 0; - return true; - } + 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"); @@ -303,17 +315,26 @@ mod tests { 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 reject_and_close(request: &[u8]) { + 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_close(request: &[u8], status: &str) { let mut conn = ServerConnection::new(); feed(&mut conn, request); assert!(conn.dispatch(&|_, _: &mut Vec| { - panic!("malformed request must not reach the handler") + panic!("a rejected request must not reach the handler") })); - assert_eq!(conn.pending_write(), b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); + assert_eq!( + conn.pending_write(), + format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\n\r\n").as_bytes() + ); drain(&mut conn); assert_eq!(conn.after_response(&echo_path), AfterResponse::Close); @@ -437,6 +458,16 @@ mod tests { 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!( @@ -448,26 +479,40 @@ mod tests { #[test] fn dispatch_unparseable_request_line_writes_400_then_closes() { - reject_and_close(b"NOT A VALID REQUEST\r\n\r\n"); + reject_and_close(b"NOT A VALID REQUEST\r\n\r\n", "400 Bad Request"); } #[test] fn dispatch_more_headers_than_fit_writes_400_then_closes() { - reject_and_close(&overlong_header_req()); + reject_and_close(&overlong_header_req(), "400 Bad Request"); } #[test] fn dispatch_invalid_content_length_writes_400_then_closes() { - reject_and_close(b"POST /foo HTTP/1.1\r\nHost: x\r\nContent-Length: abc\r\n\r\n"); + reject_and_close( + 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_closes() { reject_and_close( 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_closes() { + for declared in [READ_BUF_MAX, READ_BUF_MAX + 1, usize::MAX - 1024] { + reject_and_close(&oversized_post(declared), "413 Payload Too Large"); + } + } + #[test] fn dispatch_partial_request_writes_nothing() { let mut conn = ServerConnection::new(); @@ -655,27 +700,11 @@ mod tests { 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(); - feed( - &mut conn, - b"POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: 33554432\r\n\r\n", - ); - - 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_just_over_cap_rejects_with_identical_error() { - let mut conn = ServerConnection::new(); - let header = format!( - "POST /big HTTP/1.1\r\nHost: localhost\r\nContent-Length: {READ_BUF_MAX}\r\n\r\n" - ); - feed(&mut conn, header.as_bytes()); - let err = fill_with_junk_until_reject(&mut conn); assert_eq!(err.kind(), io::ErrorKind::InvalidData); assert_eq!(err.to_string(), "request too large"); From d894b3824161702ed240f8416eb5adef248466aa Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 21 Aug 2026 12:31:56 +0100 Subject: [PATCH 27/33] Rename the client_server tile to application_boundary Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 36 +++++++++---------- Cargo.toml | 4 +-- .../Cargo.toml | 2 +- .../src/lib.rs | 6 ++-- .../tests/tile.rs | 25 ++++++------- crates/bin/Cargo.toml | 2 +- crates/bin/src/main.rs | 7 ++-- docs/adr/0001-single-api-tile.md | 2 +- docs/spine-message-flow.md | 24 ++++++------- 9 files changed, 55 insertions(+), 53 deletions(-) rename crates/{client_server => application_boundary}/Cargo.toml (93%) rename crates/{client_server => application_boundary}/src/lib.rs (93%) rename crates/{client_server => application_boundary}/tests/tile.rs (95%) diff --git a/Cargo.lock b/Cargo.lock index 147a5ca4..e3e6928c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4427,10 +4427,10 @@ dependencies = [ "mimalloc", "quinn-proto", "rand 0.8.6", + "silver_application_boundary", "silver_beacon_api", "silver_beacon_state", "silver_beacon_state_data", - "silver_client_server", "silver_columns", "silver_common", "silver_config", @@ -4445,6 +4445,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "silver_application_boundary" +version = "0.0.1" +dependencies = [ + "flux", + "hex", + "serde_json", + "silver_beacon_api", + "silver_beacon_state_data", + "silver_common", + "silver_config", + "silver_engine_api", + "silver_httpcore", + "silver_peer", + "tempfile", +] + [[package]] name = "silver_beacon_api" version = "0.0.1" @@ -4513,23 +4530,6 @@ dependencies = [ "toml", ] -[[package]] -name = "silver_client_server" -version = "0.0.1" -dependencies = [ - "flux", - "hex", - "serde_json", - "silver_beacon_api", - "silver_beacon_state_data", - "silver_common", - "silver_config", - "silver_engine_api", - "silver_httpcore", - "silver_peer", - "tempfile", -] - [[package]] name = "silver_columns" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index fdfbc83d..55cd7603 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] members = [ + "crates/application_boundary", "crates/beacon_api", "crates/beacon_state/data", "crates/beacon_state/tile", "crates/bin", - "crates/client_server", "crates/common", "crates/config", "crates/config/chain_spec", @@ -69,11 +69,11 @@ 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" } -silver_client_server = { path = "crates/client_server" } silver_columns = { path = "crates/columns" } silver_common = { path = "crates/common" } silver_config = { path = "crates/config" } diff --git a/crates/client_server/Cargo.toml b/crates/application_boundary/Cargo.toml similarity index 93% rename from crates/client_server/Cargo.toml rename to crates/application_boundary/Cargo.toml index ffce3bf6..5459f471 100644 --- a/crates/client_server/Cargo.toml +++ b/crates/application_boundary/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "silver_client_server" +name = "silver_application_boundary" edition.workspace = true repository.workspace = true rust-version.workspace = true diff --git a/crates/client_server/src/lib.rs b/crates/application_boundary/src/lib.rs similarity index 93% rename from crates/client_server/src/lib.rs rename to crates/application_boundary/src/lib.rs index f52ec5c3..df110432 100644 --- a/crates/client_server/src/lib.rs +++ b/crates/application_boundary/src/lib.rs @@ -4,12 +4,12 @@ use silver_common::{BeaconStateEvent, SilverSpine, SyncUpdate}; use silver_engine_api::EngineApi; use silver_peer::PeerCounters; -pub struct ClientServerTile { +pub struct ApplicationBoundaryTile { pub beacon: BeaconApi, pub engine: EngineApi, } -impl Tile for ClientServerTile { +impl Tile for ApplicationBoundaryTile { fn loop_body(&mut self, adapter: &mut SpineAdapter) { self.engine.intake(adapter); self.engine.spin(adapter); @@ -20,7 +20,7 @@ impl Tile for ClientServerTile { } } -impl ClientServerTile { +impl ApplicationBoundaryTile { fn refresh_node_status(&mut self, adapter: &mut SpineAdapter) { let status = self.beacon.node_status_mut(); diff --git a/crates/client_server/tests/tile.rs b/crates/application_boundary/tests/tile.rs similarity index 95% rename from crates/client_server/tests/tile.rs rename to crates/application_boundary/tests/tile.rs index 8e2804c7..9804a0b3 100644 --- a/crates/client_server/tests/tile.rs +++ b/crates/application_boundary/tests/tile.rs @@ -6,9 +6,9 @@ use std::{ }; use flux::{spine::SpineAdapter, tile::Tile}; +use silver_application_boundary::ApplicationBoundaryTile; use silver_beacon_api::{BeaconApi, PeerCounts, SlotStatus}; use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; -use silver_client_server::ClientServerTile; use silver_common::{ BeaconStateEvent, ELSyncStatus, EngineFcuReq, EngineReq, EngineResp, Enr, Identify, Keypair, SilverSpine, SyncUpdate, TCache, TCacheProducer, ssz_view::STATUS_V2_SIZE, @@ -31,7 +31,8 @@ fn beacon(bind: &Bind) -> BeaconApi { // Every `loop_body` below samples the peer gauges; left at the default // base that is the counter file a node running on this machine serves. PeerCounters::init_with_base( - std::env::temp_dir().join(format!("silver_client_server_test_{}", std::process::id())), + std::env::temp_dir() + .join(format!("silver_application_boundary_test_{}", std::process::id())), "silver", ) .unwrap(); @@ -110,7 +111,7 @@ fn status_event(head_slot: u64, wall_slot: u64, head_optimistic: bool) -> Beacon 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 = ClientServerTile { + let mut tile = ApplicationBoundaryTile { beacon: beacon(&Bind::parse("127.0.0.1:0")), engine: engine(no_el(), ["cs_tcp_gossip", "cs_tcp_rpc", "cs_tcp_resp"]), }; @@ -139,7 +140,7 @@ 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 = ClientServerTile { + let mut tile = ApplicationBoundaryTile { beacon: beacon(&Bind::Unix(socket.clone())), engine: engine(no_el(), ["cs_uds_gossip", "cs_uds_rpc", "cs_uds_resp"]), }; @@ -177,7 +178,7 @@ fn serves_beacon_api_while_engine_call_in_flight() { jwt_secret: jwt_path.to_str().unwrap().to_string(), ..EngineConfig::default() }; - let mut tile = ClientServerTile { + let mut tile = ApplicationBoundaryTile { beacon: beacon(&Bind::parse("127.0.0.1:0")), engine: engine(config, ["cs_flight_gossip", "cs_flight_rpc", "cs_flight_resp"]), }; @@ -186,7 +187,7 @@ fn serves_beacon_api_while_engine_call_in_flight() { inj.consume(|_: EngineResp, _| {}); let deadline = Instant::now() + Duration::from_secs(10); - let mut crank = |tile: &mut ClientServerTile, el: &mut FakeEl, msg: &str| { + let mut crank = |tile: &mut ApplicationBoundaryTile, el: &mut FakeEl, msg: &str| { assert!(Instant::now() < deadline, "timeout: {msg}"); tile.loop_body(&mut adapter); el.pump(); @@ -257,7 +258,7 @@ fn pool_cap_gates_spine_intake() { max_connections: 3, ..EngineConfig::default() }; - let mut tile = ClientServerTile { + let mut tile = ApplicationBoundaryTile { beacon: beacon(&Bind::parse("127.0.0.1:0")), engine: engine(config, ["cs_cap_gossip", "cs_cap_rpc", "cs_cap_resp"]), }; @@ -266,7 +267,7 @@ fn pool_cap_gates_spine_intake() { inj.consume(|_: EngineResp, _| {}); let deadline = Instant::now() + Duration::from_secs(10); - let mut crank = |tile: &mut ClientServerTile, el: &mut FakeEl, msg: &str| { + let mut crank = |tile: &mut ApplicationBoundaryTile, el: &mut FakeEl, msg: &str| { assert!(Instant::now() < deadline, "timeout: {msg}"); tile.loop_body(&mut adapter); el.pump(); @@ -328,7 +329,7 @@ fn pool_cap_gates_spine_intake() { 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 = ClientServerTile { + let mut tile = ApplicationBoundaryTile { beacon: beacon(&Bind::parse("127.0.0.1:0")), engine: engine(no_el(), ["cs_status_gossip", "cs_status_rpc", "cs_status_resp"]), }; @@ -382,7 +383,7 @@ fn node_status_updates_while_the_engine_pool_is_at_cap() { max_connections: 3, ..EngineConfig::default() }; - let mut tile = ClientServerTile { + let mut tile = ApplicationBoundaryTile { beacon: beacon(&Bind::parse("127.0.0.1:0")), engine: engine(config, ["cs_sat_gossip", "cs_sat_rpc", "cs_sat_resp"]), }; @@ -391,7 +392,7 @@ fn node_status_updates_while_the_engine_pool_is_at_cap() { inj.consume(|_: EngineResp, _| {}); let deadline = Instant::now() + Duration::from_secs(10); - let mut crank = |tile: &mut ClientServerTile, el: &mut FakeEl, msg: &str| { + let mut crank = |tile: &mut ApplicationBoundaryTile, el: &mut FakeEl, msg: &str| { assert!(Instant::now() < deadline, "timeout: {msg}"); tile.loop_body(&mut adapter); el.pump(); @@ -442,7 +443,7 @@ fn node_status_updates_while_the_engine_pool_is_at_cap() { fn node_status_tracks_the_peer_gauges() { let base = TempDir::new().unwrap(); let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); - let mut tile = ClientServerTile { + let mut tile = ApplicationBoundaryTile { beacon: beacon(&Bind::parse("127.0.0.1:0")), engine: engine(no_el(), ["cs_peers_gossip", "cs_peers_rpc", "cs_peers_resp"]), }; diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index 11a264d0..25d58ee9 100644 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -6,10 +6,10 @@ rust-version.workspace = true version.workspace = true [dependencies] +silver_application_boundary.workspace = true silver_beacon_api.workspace = true silver_beacon_state.workspace = true silver_beacon_state_data.workspace = true -silver_client_server.workspace = true silver_columns.workspace = true silver_common.workspace = true silver_config.workspace = true diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 811523aa..6c71c362 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -7,10 +7,10 @@ use flux::{ use mimalloc::MiMalloc; use quinn_proto::{Endpoint, EndpointConfig}; use rand::RngCore; +use silver_application_boundary::ApplicationBoundaryTile; use silver_beacon_api::BeaconApi; use silver_beacon_state::{BeaconStateTile, SlotTicker}; use silver_beacon_state_data::{BeaconState, SLOTS_PER_EPOCH}; -use silver_client_server::ClientServerTile; use silver_columns::tile::DataColumnsTile; #[cfg(feature = "alloc-profile")] use silver_common::metrics::CountingAllocator; @@ -285,7 +285,8 @@ fn main() -> Result<(), Box> { incoming_rpc_consumer_eng, incoming_engine_resp_producer, ); - let client_server_tile = ClientServerTile { beacon: beacon_api, engine: engine_api }; + let application_boundary_tile = + ApplicationBoundaryTile { beacon: beacon_api, engine: engine_api }; // Spine let spine = SilverSpine::new(None); @@ -300,7 +301,7 @@ fn main() -> Result<(), Box> { ); attach_tile(storage_tile, scoped_spine, TileConfig::new(4, Some(ThreadNiceness::Highest))); attach_tile( - client_server_tile, + application_boundary_tile, scoped_spine, TileConfig::new(5, Some(ThreadNiceness::Highest)), ); diff --git a/docs/adr/0001-single-api-tile.md b/docs/adr/0001-single-api-tile.md index 425964a6..82b7b819 100644 --- a/docs/adr/0001-single-api-tile.md +++ b/docs/adr/0001-single-api-tile.md @@ -7,7 +7,7 @@ status: proposed 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 `client_server` tile hosting two +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 diff --git a/docs/spine-message-flow.md b/docs/spine-message-flow.md index ea1a937c..596173bd 100644 --- a/docs/spine-message-flow.md +++ b/docs/spine-message-flow.md @@ -8,7 +8,7 @@ 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), -**ClientServer** (hosting the `engine_api` client and the `beacon_api` server; the +**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). @@ -18,7 +18,7 @@ flowchart LR CTL["Control
PeerManager + SyncEngine + GossipHandler"] BS["BeaconState
state · fork choice"] ST["Storage
disk · backfill"] - EN["ClientServer
engine_api client · beacon_api server"] + EN["ApplicationBoundary
engine_api client · beacon_api server"] DC["DataColumns
column validation · DA · EL blobs"] %% ---- inbound ---- @@ -78,11 +78,11 @@ 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` (ClientServer +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↔ClientServer edges carry only the `GetBlobs` variants (EL-mempool blob +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. @@ -96,14 +96,14 @@ rest. | `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, ClientServer | 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, ClientServer | 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)_ | ClientServer | refs → `ssz_gossip` / `incoming_rpc`; GetBlobs inline | -| `engine_resps` | `EngineResp` | ClientServer | BeaconState, DataColumns _(GetBlobs)_ | ref → `incoming_engine_resp` | -| `engine_health` | `EngineHealthEvent` | ClientServer | _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 @@ -113,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), ClientServer | 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), ClientServer, 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` | ClientServer | 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 | --- From ef618e5774389eb905a7775ede92d6f58beb2558 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 21 Aug 2026 13:07:12 +0100 Subject: [PATCH 28/33] Linger on a refused request instead of resetting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request refused from its headers — a body declared past the read cap, or framing that was never established — is answered while the peer may still be sending. Closing then leaves those bytes unread, and the peer sees a reset where the answer should be. The two outcomes are not equivalent to the client. go-eth2-client turns a delivered 413 into an *api.Error, which its multi-client wrapper spares as a 4xx (multi/client.go:186); a connection that dies before a parseable response is a transport error that fails errors.As, falls through to the default failover (multi/client.go:200) and deactivates the node, with recovery gated on two unsynchronised 30 s tickers. Which one a deployment sees is decided by socket buffering and by how net/http interleaves its write and read loops — nothing the client can settle. Answering the same way every time is the server's job. So the connection now half-closes after the answer drains and keeps reading: the peer sees its response, marked Connection: close, terminated by FIN, and the body still in flight is read and discarded rather than left to reset the connection carrying it. The drain ends on the peer's own close, and is bounded by nginx's lingering_close caps — 5 s between reads, 30 s in total — so a slot cannot be held by a client that keeps sending or by one that neither sends nor hangs up. Unix sockets take the same half-close: an unread body breaks the peer's send there too. Limits: the drain costs a refused connection up to 5 s of a slot where it previously closed at once, and the discarded bytes are read at whatever rate the peer sends them — the per-event drain is bounded by scheduling, not structurally. Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/src/server.rs | 253 +++++++++++++++++++++++++++++--- crates/httpcore/src/server.rs | 129 +++++++++++----- crates/httpcore/src/stream.rs | 34 ++++- 3 files changed, 353 insertions(+), 63 deletions(-) diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index 7b66141f..a7e81001 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -17,10 +17,54 @@ use crate::{ 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, + } + } } /// Schedules the idle scan so that `pump` walks the connection map at most @@ -52,6 +96,7 @@ pub struct BeaconApi { listeners: Vec, max_connections: usize, idle: IdleSweep, + linger: Linger, current_token: Token, connections: HashMap, router: Router, @@ -88,6 +133,7 @@ impl BeaconApi { events: Events::with_capacity(1024), max_connections, idle: IdleSweep::new(idle_timeout), + linger: Linger::default(), current_token: Token(listeners.len()), listeners, connections: HashMap::new(), @@ -139,6 +185,7 @@ impl BeaconApi { stream, http: ServerConnection::new(), last_activity: now, + linger_since: None, }); }, None => { @@ -165,21 +212,29 @@ impl BeaconApi { } if self.idle.due(now) { - did_work |= self.close_idle(now); + did_work |= self.close_expired(now); } did_work } - fn close_idle(&mut self, now: Instant) -> bool { - let Self { connections, poll, idle, .. } = self; + fn close_expired(&mut self, now: Instant) -> bool { + let Self { connections, poll, idle, linger, .. } = self; let before = connections.len(); connections.retain(|_, conn| { - let idle_for = now.duration_since(conn.last_activity); - if idle_for <= idle.timeout { + if !conn.expired(now, idle.timeout, linger) { return true; } - tracing::warn!("beacon api connection idle for {idle_for:?}, closing"); + 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 _ = poll.registry().deregister(&mut conn.stream); false }); @@ -194,6 +249,10 @@ fn handle_event, &mut Vec)>( now: Instant, request_handler: &F, ) -> io::Result { + if conn.linger_since.is_some() { + return conn.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 @@ -248,6 +307,16 @@ fn handle_event, &mut Vec)>( } match conn.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. + conn.stream.shutdown_write()?; + conn.linger_since = Some(now); + registry.reregister(&mut conn.stream, event.token(), Interest::READABLE)?; + return conn.drain_discarded(now); + } AfterResponse::ResponsePending => { registry.reregister(&mut conn.stream, event.token(), Interest::WRITABLE)? } @@ -396,7 +465,7 @@ mod tests { assert!(text.contains("\"peer_id\""), "identity body missing: {text}"); } - fn read_to_eof(mut stream: TcpStream) -> Vec { + fn read_to_eof(mut stream: impl Read) -> Vec { let mut received = Vec::new(); let mut chunk = [0u8; 1024]; loop { @@ -408,6 +477,38 @@ mod tests { } } + 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() { @@ -605,9 +706,8 @@ mod tests { assert!(api.connections.is_empty(), "reaped connection must leave the map"); } - /// go-eth2-client and Prysm post a whole validator set unchunked, so an - /// operator large enough to declare more body than the read buffer holds - /// gets a status back rather than a connection that goes quiet. + /// 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 api = api_with(64, LONG_TIMEOUT); @@ -617,24 +717,131 @@ mod tests { &mut api, std::thread::spawn(move || { let mut stream = connect(addr); - 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(); + declare_oversized_body(&mut stream); read_to_eof(stream) }), - "oversized declaration answered", + "oversized declaration accepted", ); - assert!( - received.starts_with(b"HTTP/1.1 413 Payload Too Large\r\n"), - "unexpected response: {}", - String::from_utf8_lossy(&received) + assert_eq!(received, PAYLOAD_TOO_LARGE, "{}", String::from_utf8_lossy(&received)); + pump_until(&mut api, "answered connection closed on the peer's own close", |api| { + 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 api = api_with(64, LONG_TIMEOUT); + let addr = tcp_addr(&api); + + let received = serve( + &mut api, + std::thread::spawn(move || stream_body_past_the_answer(connect(addr))), + "413 delivered to a client still sending", ); - assert!(api.connections.is_empty(), "the answered connection must close"); + + assert_answer_survived(received); + pump_until(&mut api, "lingering connection closed once the peer went away", |api| { + 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 api = api_bound_to(&[Bind::Unix(socket.clone())], 64, LONG_TIMEOUT); + + let received = serve( + &mut api, + 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 api, "lingering uds connection closed once the peer went away", |api| { + 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 api = api_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. + api.linger = Linger { idle: Duration::from_millis(400), total: Duration::from_millis(200) }; + let addr = tcp_addr(&api); + + 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 api, "server pumped past the answer", |_| Instant::now() >= midway); + assert_eq!(api.connections.len(), 1, "the answered connection must drain, not close"); + + pump_until(&mut api, "flooding client dropped at the linger cap", |api| { + 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 api = api_with(64, idle_timeout); + api.linger = Linger { idle: Duration::from_millis(100), total: Duration::from_secs(30) }; + let addr = tcp_addr(&api); + + 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 api, "oversized declaration accepted", |api| api.connections.len() == 1); + let answered = Instant::now(); + pump_until(&mut api, "quiet lingering connection dropped at the idle cap", |api| { + 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] diff --git a/crates/httpcore/src/server.rs b/crates/httpcore/src/server.rs index 7098bf58..4a49b390 100644 --- a/crates/httpcore/src/server.rs +++ b/crates/httpcore/src/server.rs @@ -100,17 +100,28 @@ fn trimmed_utf8(value: &[u8]) -> Option<&str> { #[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, - keep_alive: bool, + continuation: Continuation, } impl ServerConnection { @@ -121,7 +132,7 @@ impl ServerConnection { read_end: 0, write_buf: Vec::with_capacity(WRITE_BUF_INIT), write_pos: 0, - keep_alive: true, + continuation: Continuation::KeepAlive, } } @@ -150,16 +161,30 @@ impl ServerConnection { /// 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 let - /// the caller close once the answer has drained. + /// 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.keep_alive = false; - frame_response(&mut self.write_buf, status, None, b""); + 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]) { @@ -170,10 +195,11 @@ impl ServerConnection { }; if req.version != 1 { tracing::warn!("rejecting HTTP/1.0 request"); - self.keep_alive = false; + self.continuation = Continuation::Close; frame_response(&mut self.write_buf, "505 HTTP Version Not Supported", None, b""); } else { - self.keep_alive = req.keep_alive; + self.continuation = + if req.keep_alive { Continuation::KeepAlive } else { Continuation::Close }; handler(&req, &mut self.write_buf); } self.read_pos += consumed; @@ -198,22 +224,26 @@ impl ServerConnection { handler: &F, ) -> AfterResponse { debug_assert!(self.write_pos == self.write_buf.len()); - if !self.keep_alive { - return AfterResponse::Close; - } - 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 + 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 + } + } } } } @@ -324,7 +354,7 @@ mod tests { .into_bytes() } - fn reject_and_close(request: &[u8], status: &str) { + fn reject_and_linger(request: &[u8], status: &str) -> ServerConnection { let mut conn = ServerConnection::new(); feed(&mut conn, request); @@ -333,11 +363,13 @@ mod tests { })); assert_eq!( conn.pending_write(), - format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\n\r\n").as_bytes() + 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::Close); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Linger); + conn } #[test] @@ -478,26 +510,26 @@ mod tests { } #[test] - fn dispatch_unparseable_request_line_writes_400_then_closes() { - reject_and_close(b"NOT A VALID REQUEST\r\n\r\n", "400 Bad Request"); + 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_closes() { - reject_and_close(&overlong_header_req(), "400 Bad Request"); + 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_closes() { - reject_and_close( + 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_closes() { - reject_and_close( + 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", ); @@ -507,12 +539,28 @@ mod tests { /// 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_closes() { + 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_close(&oversized_post(declared), "413 Payload Too Large"); + 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(); @@ -549,10 +597,13 @@ mod tests { 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\nContent-Length: 0\r\n\r\n"); + 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::Close); + assert_eq!(conn.after_response(&echo_path), AfterResponse::Linger); } #[test] diff --git a/crates/httpcore/src/stream.rs b/crates/httpcore/src/stream.rs index a87a843c..94e7c921 100644 --- a/crates/httpcore/src/stream.rs +++ b/crates/httpcore/src/stream.rs @@ -1,6 +1,6 @@ use std::{ io::{self, Read, Write}, - net::SocketAddr, + net::{Shutdown, SocketAddr}, path::{Path, PathBuf}, }; @@ -136,6 +136,15 @@ impl Stream { }, } } + + /// 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 { @@ -285,6 +294,29 @@ mod tests { } } + /// 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 { From bfed02b2321d6d9f647d6bc2abe28fff321adce1 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 21 Aug 2026 13:08:07 +0100 Subject: [PATCH 29/33] Derive the network's name from its genesis fork version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpecConfig carried two independent notions of network identity: CONFIG_NAME, defaulting to "mainnet" whenever a config file left it out, and network_name(), which read the same identity off genesis_fork_version. A sepolia config without CONFIG_NAME served "mainnet" from GET /eth/v1/config/spec while its fork versions said otherwise, every devnet served "mainnet" outright, and a file setting one and not the other split them silently. No validator client cross-checks the two, but they use them independently — 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 a client two different networks. CONFIG_NAME is now optional and network_name() is the one identity: the configured name when a file gives one, the name genesis_fork_version carries otherwise, and an empty name reads as absent. An unrecognised fork version yields devnet-, unprefixed, because go-eth2-client decodes every 0x-prefixed spec value into a byte slice and would hand its consumers bytes where they read a string. Loading a file whose CONFIG_NAME contradicts a fork version silver knows by name warns and continues — only the operator knows which half is the typo — and the comparison is exact: Teku's builtin lookup is, and upstream names are lowercase. Devnet telemetry vocabulary changes with it: meta_network_name goes from 0x to devnet-, so an already-running devnet deployment splits its identity at the deploy boundary. Assisted-by: Claude:claude-fable-5 --- Cargo.lock | 1 + crates/beacon_api/src/config.rs | 20 ++++- crates/config/Cargo.toml | 1 + crates/config/chain_spec/src/lib.rs | 135 +++++++++++++++++++++++----- crates/config/src/lib.rs | 42 ++++++++- 5 files changed, 176 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3e6928c..cc3ac3e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4596,6 +4596,7 @@ dependencies = [ "silver_chain_spec", "silver_common", "toml", + "tracing", ] [[package]] diff --git a/crates/beacon_api/src/config.rs b/crates/beacon_api/src/config.rs index c831c978..36f9d3d6 100644 --- a/crates/beacon_api/src/config.rs +++ b/crates/beacon_api/src/config.rs @@ -242,7 +242,7 @@ pub(crate) fn spec_body(spec: &SpecConfig) -> Vec { json.key("PRESET_BASE"); json.string(PRESET_BASE); json.key("CONFIG_NAME"); - json.string(&spec.config_name); + json.string(&spec.network_name()); for fork in ForkName::ALL { json.key(fork_version_key(fork)); @@ -579,6 +579,24 @@ mod tests { 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()); 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/src/lib.rs b/crates/config/chain_spec/src/lib.rs index 6ebfb347..6d8c22b7 100644 --- a/crates/config/chain_spec/src/lib.rs +++ b/crates/config/chain_spec/src/lib.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; const fn default_u64() -> u64 { V @@ -83,11 +83,13 @@ impl ForkName { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub struct SpecConfig { - /// The network's own name for itself. Silver derives nothing from it; it - /// is carried because a validator client builds its runtime config from - /// the spec this node serves and labels its logs with this string. - #[serde(default = "default_config_name")] - pub config_name: String, + /// 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. @@ -255,10 +257,6 @@ pub struct SpecConfig { pub ejection_balance: u64, } -fn default_config_name() -> String { - "mainnet".to_owned() -} - /// Mainnet's merge threshold, crossed 2022-09-15. const fn default_terminal_total_difficulty() -> u128 { 58_750_000_000_000_000_000_000 @@ -280,6 +278,12 @@ fn default_blob_schedule() -> Vec { }] } +/// `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 @@ -431,15 +435,42 @@ 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, } } + /// 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 @@ -450,7 +481,7 @@ impl SpecConfig { /// `BLOB_SCHEDULE`. pub fn hoodi() -> Self { Self { - config_name: "hoodi".to_owned(), + config_name: None, // Hoodi fork-version pattern is `0xN0000910`. genesis_fork_version: default_fork_version::<0x10000910>(), min_genesis_active_validator_count: 16_384, @@ -511,7 +542,7 @@ impl SpecConfig { pub fn mainnet() -> Self { Self { - config_name: default_config_name(), + config_name: None, genesis_fork_version: default_fork_version::<0x00000000>(), min_genesis_active_validator_count: 16_384, min_genesis_time: 1_606_824_000, @@ -595,7 +626,7 @@ mod tests { 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.config_name, "mainnet"); + 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); @@ -619,7 +650,7 @@ mod tests { #[test] fn hoodi_matches_its_upstream_config_file() { let spec = SpecConfig::hoodi(); - assert_eq!(spec.config_name, "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); @@ -797,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_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_fork_version() { - let devnet = + 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/lib.rs b/crates/config/src/lib.rs index 7d07057c..19326e80 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -185,7 +185,19 @@ impl Config { /// external IP, ports, secret key) here, so no source edits are needed. pub fn from_file>(path: P) -> Result { let text = std::fs::read_to_string(path)?; - Ok(toml::from_str(&text)?) + let config: Self = toml::from_str(&text)?; + + let spec = &config.chain_config.spec; + if let Some(network) = spec.misnamed_network() { + tracing::warn!( + config_name = %spec.network_name(), + genesis_fork_version = %hex::encode(spec.genesis_fork_version), + network, + "CONFIG_NAME disagrees with the network GENESIS_FORK_VERSION names" + ); + } + + Ok(config) } pub fn with_discovery_port(mut self, port: u16) -> Self { @@ -418,6 +430,34 @@ mod tests { 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#" From b8f688fd94a4a3e44a113a73bd1b543d09c70376 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 21 Aug 2026 13:22:54 +0100 Subject: [PATCH 30/33] Correct client-behaviour claims and the v2 dependent root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comments motivating the 415 on registerValidator said Teku posts the registrations as application/octet-stream first and downgrades to JSON inside its 415 handler. Teku's production call site constructs the request with SSZ preference hardcoded off, so the registrations it sends are always JSON; the SSZ-first path its request class carries is reached only from tests. Its block publish does go SSZ-first for real. The 415 is still owed: the schema declares it for the endpoint's octet-stream body variant, and a client sending SSZ keys its downgrade on that code alone — the hardcoded flag flipping, or another client adopting SSZ-first, is what the branch protects against. proposer.v2.yaml names a single dependent root for every epoch, get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch - 1) - 1): beacon-APIs #590 superseded the fork split #563 introduced, the head_v2 event having made it unnecessary by supplying the matching root directly. Silver holds Fulu states and later ones only, so the activation-boundary epoch that split's pre-Fulu branch existed for is out of contract here. v2 answers one epoch back unconditionally, and the fork epoch it consulted leaves ApiCtx with it. Assisted-by: Claude:claude-fable-5 --- crates/beacon_api/src/duties/proposer.rs | 27 ++++++---------- crates/beacon_api/src/duties/tests.rs | 41 +++--------------------- crates/beacon_api/src/receipts.rs | 7 ++-- crates/beacon_api/src/router.rs | 7 ++-- crates/beacon_api/src/routes.rs | 9 +----- 5 files changed, 22 insertions(+), 69 deletions(-) diff --git a/crates/beacon_api/src/duties/proposer.rs b/crates/beacon_api/src/duties/proposer.rs index d0b91743..57408dbf 100644 --- a/crates/beacon_api/src/duties/proposer.rs +++ b/crates/beacon_api/src/duties/proposer.rs @@ -20,8 +20,7 @@ pub(crate) fn get_proposer_duties(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Re } pub(crate) fn get_proposer_duties_v2(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { - let dependent = DependentEpoch::PrecedingSinceFulu { fulu_fork_epoch: ctx.fulu_fork_epoch }; - respond_with_proposers(req, ctx, resp, dependent); + respond_with_proposers(req, ctx, resp, DependentEpoch::Preceding); } fn respond_with_proposers( @@ -100,13 +99,11 @@ impl EpochProposers { enum DependentEpoch { /// The epoch asked about (`apis/validator/duties/proposer.yaml`). Requested, - /// The epoch before it (`proposer.v2.yaml`), which is where the lookahead - /// an epoch's proposers come from was seeded — the deterministic lookahead - /// of EIP-7917, and the reason a v2 exists at all. Fulu's own activation - /// epoch is the exception: its lookahead is seeded by the fork transition, - /// at its own boundary, so it and every epoch before it fall back to - /// [`Self::Requested`]. - PrecedingSinceFulu { fulu_fork_epoch: Epoch }, + /// The epoch before it (`proposer.v2.yaml`), which is where EIP-7917's + /// deterministic lookahead seeded that epoch's proposers — and the reason + /// a v2 exists at all. Silver holds Fulu states and later ones only, so no + /// epoch it answers for had its proposers seeded at a different boundary. + Preceding, } impl DependentEpoch { @@ -118,20 +115,16 @@ impl DependentEpoch { /// head is in the epoch it asked for, so the answer is the head block's /// root. fn root(self, view: &StateReadView<'_>, head_root: B256, epoch: Epoch) -> Option { - let dependent = self.of(epoch); + let dependent = match self { + Self::Requested => epoch, + Self::Preceding => epoch.saturating_sub(1), + }; if dependent > view.slot.current_epoch() { return Some(head_root); } view.block_roots .recorded_at((dependent * SLOTS_PER_EPOCH).saturating_sub(1), view.slot.slot_number()) } - - fn of(self, epoch: Epoch) -> Epoch { - match self { - Self::PrecedingSinceFulu { fulu_fork_epoch } if epoch > fulu_fork_epoch => epoch - 1, - Self::Requested | Self::PrecedingSinceFulu { .. } => epoch, - } - } } enum ProposerError { diff --git a/crates/beacon_api/src/duties/tests.rs b/crates/beacon_api/src/duties/tests.rs index 129a5229..c1d141a3 100644 --- a/crates/beacon_api/src/duties/tests.rs +++ b/crates/beacon_api/src/duties/tests.rs @@ -137,10 +137,6 @@ struct Fixture { empty_slots: Vec, validator_count: usize, sync_committee_indices: [u32; SYNC_COMMITTEE_SIZE], - /// Silver holds Fulu states and later ones only — it has no Electra state - /// transition and no upgrade into Fulu — so the default fixture is a chain - /// that has been Fulu throughout. - fulu_fork_epoch: Epoch, } impl Default for Fixture { @@ -150,7 +146,6 @@ impl Default for Fixture { empty_slots: Vec::new(), validator_count: VALIDATOR_COUNT, sync_committee_indices: seated_indices(), - fulu_fork_epoch: 0, } } } @@ -193,8 +188,7 @@ impl Fixture { owner.set_head_block_root(head_root); owner.publish_state_id(head); - let spec = SpecConfig { fulu_fork_epoch: self.fulu_fork_epoch, ..SpecConfig::mainnet() }; - let mut ctx = test_ctx(&spec, owner.reader()); + let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); ctx.node_status = synced_at(self.state_slot); ctx } @@ -346,9 +340,9 @@ fn the_next_epoch_reads_the_far_half_of_the_lookahead() { ); } -/// `proposer.v2.yaml` names `compute_start_slot_at_epoch(epoch - 1) - 1`, one -/// epoch further back than v1: the boundary the lookahead an epoch's -/// proposers came from was seeded at. Both epochs the window serves therefore +/// `proposer.v2.yaml` names `compute_start_slot_at_epoch(epoch - 1) - 1` for +/// every epoch, one further back than v1: the boundary the lookahead an +/// epoch's proposers came from was seeded at. Both epochs the window serves /// depend on a slot `block_roots` already records, so v2 never falls back to /// the head block's root the way v1 does for the next epoch — and the duties /// themselves are the same list. @@ -362,33 +356,6 @@ fn v2_depends_on_the_epoch_before_the_one_asked_for() { } } -/// v2's rule is the one EIP-7917 introduced, so it holds from Fulu on and no -/// earlier: the activation epoch's own lookahead is seeded by the fork -/// transition at its own boundary, and no epoch before it has a lookahead at -/// all. Both fall back to v1's dependent root — which is what a client -/// calling v2 on a chain that schedules Fulu ahead of it must be answered -/// with. -#[test] -fn v2_falls_back_to_v1s_dependent_root_up_to_the_fulu_activation_epoch() { - let activating = Fixture { fulu_fork_epoch: HEAD_EPOCH, ..Default::default() }.published(); - for epoch in [HEAD_EPOCH, NEXT_EPOCH] { - assert_eq!( - ok_body(&get_proposers_v(&activating, 2, &epoch.to_string())), - expected_proposers(epoch, block_root_of(DEPENDENT_SLOT)), - "epoch {epoch}" - ); - } - - let unreached = Fixture { fulu_fork_epoch: NEXT_EPOCH, ..Default::default() }.published(); - for epoch in [HEAD_EPOCH, NEXT_EPOCH] { - assert_eq!( - ok_body(&get_proposers_v(&unreached, 2, &epoch.to_string())), - ok_body(&get_proposers(&unreached, &epoch.to_string())), - "epoch {epoch}" - ); - } -} - /// An empty slot records the last block's root, which is what the spec's /// own accessor answers there — the dependent root of an epoch whose /// predecessor's last slot carried no block. diff --git a/crates/beacon_api/src/receipts.rs b/crates/beacon_api/src/receipts.rs index 002f7c5d..8431f90c 100644 --- a/crates/beacon_api/src/receipts.rs +++ b/crates/beacon_api/src/receipts.rs @@ -330,9 +330,10 @@ mod tests { } } - /// Teku posts `registerValidator` as SSZ first and turns SSZ off for the - /// session on a 415; under any other code the registrations are lost with - /// no retry. + /// 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()); diff --git a/crates/beacon_api/src/router.rs b/crates/beacon_api/src/router.rs index 1627382c..0bf90050 100644 --- a/crates/beacon_api/src/router.rs +++ b/crates/beacon_api/src/router.rs @@ -39,10 +39,9 @@ pub(crate) struct Request<'a> { 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: Teku - /// posts `registerValidator` as `application/octet-stream` first and turns - /// SSZ off for the session on the 415, so any other code there loses the - /// registrations with no retry. + /// 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; diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index 665101ea..24e9d8cb 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -3,9 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; #[cfg(test)] use silver_beacon_state_data::BeaconStateOwner; -use silver_beacon_state_data::{ - B256, BeaconStateReader, Epoch, ForkName, SpecConfig, StateReadView, -}; +use silver_beacon_state_data::{B256, BeaconStateReader, SpecConfig, StateReadView}; use silver_common::{Enr, Identify, Keypair}; use silver_httpcore::Query; @@ -80,10 +78,6 @@ pub(crate) struct ApiCtx { pub(crate) statics: StaticBodies, pub(crate) state: BeaconStateReader, pub(crate) node_status: NodeStatus, - /// Read per request rather than baked into `statics`: the proposer duties' - /// v2 dependent root is defined against the epoch from which EIP-7917's - /// deterministic lookahead schedules an epoch a boundary in advance. - pub(crate) fulu_fork_epoch: Epoch, } impl ApiCtx { @@ -98,7 +92,6 @@ impl ApiCtx { statics: StaticBodies::new(keypair, local_enr, identify, spec), state, node_status: NodeStatus::default(), - fulu_fork_epoch: spec.fork_epoch(ForkName::Fulu), } } From d5e32b0e7c467c74635c8c75d285bbab8c4d6619 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 21 Aug 2026 15:05:05 +0100 Subject: [PATCH 31/33] Amend ADRs, mark as accepted --- docs/adr/0001-single-api-tile.md | 6 ++---- docs/adr/0002-hand-rolled-http.md | 2 +- docs/adr/0003-dispatch-asymmetry.md | 2 +- docs/adr/0004-sync-materialized-api.md | 15 ++++++++++++--- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/adr/0001-single-api-tile.md b/docs/adr/0001-single-api-tile.md index 82b7b819..1cf11b6d 100644 --- a/docs/adr/0001-single-api-tile.md +++ b/docs/adr/0001-single-api-tile.md @@ -1,5 +1,5 @@ --- -status: proposed +status: accepted --- # One tile hosts all API access @@ -21,6 +21,4 @@ cheap cost. The spine contract is unchanged: producers and consumers of 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). Four independent designs were produced and -compared; see `.local/client-server-design.md` (untracked design notes) for -the full comparison. +hosted crate's signatures). diff --git a/docs/adr/0002-hand-rolled-http.md b/docs/adr/0002-hand-rolled-http.md index 10a9a49e..8f3e34fd 100644 --- a/docs/adr/0002-hand-rolled-http.md +++ b/docs/adr/0002-hand-rolled-http.md @@ -1,5 +1,5 @@ --- -status: proposed +status: accepted --- # Hand-rolled HTTP over mio; no async runtime, no TLS diff --git a/docs/adr/0003-dispatch-asymmetry.md b/docs/adr/0003-dispatch-asymmetry.md index 39b87de5..2286253b 100644 --- a/docs/adr/0003-dispatch-asymmetry.md +++ b/docs/adr/0003-dispatch-asymmetry.md @@ -1,5 +1,5 @@ --- -status: proposed +status: accepted --- # Dispatch: table for server routes, enum match for client methods diff --git a/docs/adr/0004-sync-materialized-api.md b/docs/adr/0004-sync-materialized-api.md index 0e267f9b..c4cf5e74 100644 --- a/docs/adr/0004-sync-materialized-api.md +++ b/docs/adr/0004-sync-materialized-api.md @@ -1,5 +1,5 @@ --- -status: proposed +status: accepted --- # Synchronous handlers, materialized responses, no streaming @@ -12,8 +12,8 @@ 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 (see -`.local/beacon-api-vc-surface.md`, untracked), nothing a validator client +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 @@ -41,3 +41,12 @@ 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 wants the waker and a non-zero timeout, which in turn wants +this tile's two `Poll`s — the beacon-api server's and the engine-api client's — to +become one readiness loop, since blocking in either starves the other. The +interleaving above follows from that one loop, not from the timeout being zero. From 9c25c257e7143f04f645544fa0eee31a2587ba25 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 21 Aug 2026 16:23:31 +0100 Subject: [PATCH 32/33] Share one readiness loop between the tile's two HTTP tenants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The beacon-api server and the engine-api client each owned an mio `Poll`, so every `loop_body` of the application-boundary tile made two `epoll_wait` calls to learn about sockets on one thread. Both now register through one `Readiness`, and a single wait feeds both dispatches. Sharing a loop shares a token space, and a token both tenants could allocate would deliver one tenant's socket readiness into the other's dispatch — where an API client hanging up fails the engine call that happens to share its number. `TokenRange` partitions the space instead: each tenant takes `share(index, TENANTS)`, allocates only inside its own range, and skips events whose token falls outside it. Both halves are enforced rather than documented: `at` asserts the offset is inside the span, and each tenant asserts at construction that its range holds every socket it can register at once — listeners plus the connection cap for the server, the pool cap plus the first-run healthcheck's overshoot for the client. The server's connection offsets recycle over its range above the listeners. Connections close in any order while the cursor only advances, so the offset it wraps onto may still be held; the allocator probes forward past live offsets, and the construction assert is what guarantees it lands on a free one rather than replacing a live connection's map entry. Because a tenant now sees only the batch the shared wait produced, the order inside `loop_body` decides latency: taking a request off the spine flips its pooled connection's interest to WRITABLE, so the wait runs after that intake and the request goes on the wire in the iteration that took it. Measured on the tile's own tests, produce-to-wire is 1 iteration per request where waiting first cost 2, and 1000 iterations make exactly 1000 `epoll_wait` calls against one epoll instance, down from 2000 against two. The engine's dispatch walks that batch once, indexing connections by token offset, instead of scanning every event once per connection — the batch carries the server's events too, so the old shape cost O(connections x events) over both tenants' sockets. Limits: the timeout stays zero, so the loop still busy-polls; a blocking wait needs an `mio::Waker` on the flux work signal, which is separate. The healthcheck is enqueued inside the engine's spin, after the wait, so it alone still reaches the EL an iteration late. And `share` truncates: `usize::MAX / count` leaves the tokens above the last share owned by nobody, which costs nothing while nothing allocates there. ADR 0004's amendment described this loop as outstanding work; it now describes the loop, leaving the waker and the timeout as what remains. Assisted-by: Claude:claude-opus-5 --- Cargo.lock | 2 - crates/application_boundary/Cargo.toml | 6 +- crates/application_boundary/src/lib.rs | 67 +- crates/application_boundary/tests/tile.rs | 357 ++++++++-- crates/beacon_api/examples/srv.rs | 8 +- crates/beacon_api/src/server.rs | 684 ++++++++++++-------- crates/bin/Cargo.toml | 2 - crates/bin/src/main.rs | 29 +- crates/engine_api/src/api.rs | 19 +- crates/engine_api/src/client.rs | 71 +- crates/engine_api/src/lib.rs | 2 +- crates/engine_api/src/pool.rs | 302 +++++---- crates/engine_api/tests/newpayload_alloc.rs | 30 +- crates/httpcore/src/lib.rs | 4 + crates/httpcore/src/readiness.rs | 38 ++ crates/httpcore/src/token_range.rs | 137 ++++ docs/adr/0004-sync-materialized-api.md | 10 +- 17 files changed, 1252 insertions(+), 516 deletions(-) create mode 100644 crates/httpcore/src/readiness.rs create mode 100644 crates/httpcore/src/token_range.rs diff --git a/Cargo.lock b/Cargo.lock index cc3ac3e9..b3641c08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4428,7 +4428,6 @@ dependencies = [ "quinn-proto", "rand 0.8.6", "silver_application_boundary", - "silver_beacon_api", "silver_beacon_state", "silver_beacon_state_data", "silver_columns", @@ -4436,7 +4435,6 @@ dependencies = [ "silver_config", "silver_control", "silver_discovery", - "silver_engine_api", "silver_gossip", "silver_httpcore", "silver_network", diff --git a/crates/application_boundary/Cargo.toml b/crates/application_boundary/Cargo.toml index 5459f471..eaf168d9 100644 --- a/crates/application_boundary/Cargo.toml +++ b/crates/application_boundary/Cargo.toml @@ -8,17 +8,17 @@ version.workspace = true [dependencies] flux.workspace = true silver_beacon_api.workspace = true +silver_beacon_state_data.workspace = true silver_common.workspace = true +silver_config.workspace = true silver_engine_api.workspace = true +silver_httpcore.workspace = true silver_peer.workspace = true [dev-dependencies] hex.workspace = true serde_json.workspace = true -silver_beacon_state_data.workspace = true -silver_config.workspace = true silver_engine_api = { workspace = true, features = ["test-el"] } -silver_httpcore.workspace = true tempfile = "3" [lints] diff --git a/crates/application_boundary/src/lib.rs b/crates/application_boundary/src/lib.rs index df110432..a463c732 100644 --- a/crates/application_boundary/src/lib.rs +++ b/crates/application_boundary/src/lib.rs @@ -1,26 +1,85 @@ +use std::time::Duration; + use flux::{spine::SpineAdapter, tile::Tile}; use silver_beacon_api::{BeaconApi, PeerCounts, SlotStatus}; -use silver_common::{BeaconStateEvent, SilverSpine, SyncUpdate}; +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}; use silver_peer::PeerCounters; +/// A tenant added here takes the next share of a raised `TENANTS`, which keeps +/// every share disjoint without a base to compute. +const TENANTS: usize = 2; +const BEACON_TOKENS: TokenRange = TokenRange::share(0, TENANTS); +const ENGINE_TOKENS: TokenRange = TokenRange::share(1, TENANTS); + pub struct ApplicationBoundaryTile { + readiness: Readiness, pub beacon: BeaconApi, - pub engine: EngineApi, + engine: EngineApi, } impl Tile for ApplicationBoundaryTile { fn loop_body(&mut self, adapter: &mut SpineAdapter) { self.engine.intake(adapter); - self.engine.spin(adapter); + self.readiness.wait(Duration::ZERO); + self.engine.spin(adapter, self.readiness.events()); self.refresh_node_status(adapter); - if self.beacon.pump() { + 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(); diff --git a/crates/application_boundary/tests/tile.rs b/crates/application_boundary/tests/tile.rs index 9804a0b3..9753c8ea 100644 --- a/crates/application_boundary/tests/tile.rs +++ b/crates/application_boundary/tests/tile.rs @@ -1,23 +1,22 @@ use std::{ io::{Read, Write}, - net::TcpStream, + 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::{BeaconApi, PeerCounts, SlotStatus}; +use silver_beacon_api::{PeerCounts, SlotStatus}; use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; use silver_common::{ BeaconStateEvent, ELSyncStatus, EngineFcuReq, EngineReq, EngineResp, Enr, Identify, Keypair, - SilverSpine, SyncUpdate, TCache, TCacheProducer, ssz_view::STATUS_V2_SIZE, + PayloadValidationStatus, SilverSpine, SyncUpdate, TCache, TCacheProducer, + ssz_view::STATUS_V2_SIZE, }; use silver_config::EngineConfig; -use silver_engine_api::{ - EngineApi, - test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}, -}; +use silver_engine_api::test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}; use silver_httpcore::Bind; use silver_peer::PeerCounters; use tempfile::TempDir; @@ -27,7 +26,11 @@ impl Tile for Injector { fn loop_body(&mut self, _: &mut SpineAdapter) {} } -fn beacon(bind: &Bind) -> BeaconApi { +fn boundary_tile( + bind: &Bind, + engine_config: EngineConfig, + tcache_names: [&'static str; 3], +) -> ApplicationBoundaryTile { // Every `loop_body` below samples the peer gauges; left at the default // base that is the counter file a node running on this machine serves. PeerCounters::init_with_base( @@ -39,7 +42,10 @@ fn beacon(bind: &Bind) -> BeaconApi { let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); - BeaconApi::new( + 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), @@ -48,21 +54,51 @@ fn beacon(bind: &Bind) -> BeaconApi { &Identify::default(), &SpecConfig::mainnet(), BeaconStateOwner::empty_test(0).reader(), - ) -} - -fn engine(config: EngineConfig, tcache_names: [&'static str; 3]) -> EngineApi { - 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); - EngineApi::new( - config, + 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() } } @@ -97,6 +133,17 @@ 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], @@ -111,20 +158,17 @@ fn status_event(head_slot: u64, wall_slot: u64, head_optimistic: bool) -> Beacon 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 = ApplicationBoundaryTile { - beacon: beacon(&Bind::parse("127.0.0.1:0")), - engine: engine(no_el(), ["cs_tcp_gossip", "cs_tcp_rpc", "cs_tcp_resp"]), - }; + 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 = 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") - }); + let client = identity_client(addr); let deadline = Instant::now() + Duration::from_secs(10); while !client.is_finished() { @@ -140,10 +184,11 @@ 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 = ApplicationBoundaryTile { - beacon: beacon(&Bind::Unix(socket.clone())), - engine: engine(no_el(), ["cs_uds_gossip", "cs_uds_rpc", "cs_uds_resp"]), - }; + 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())]); @@ -178,10 +223,11 @@ fn serves_beacon_api_while_engine_call_in_flight() { jwt_secret: jwt_path.to_str().unwrap().to_string(), ..EngineConfig::default() }; - let mut tile = ApplicationBoundaryTile { - beacon: beacon(&Bind::parse("127.0.0.1:0")), - engine: engine(config, ["cs_flight_gossip", "cs_flight_rpc", "cs_flight_resp"]), - }; + 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, _| {}); @@ -212,11 +258,7 @@ fn serves_beacon_api_while_engine_call_in_flight() { // 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 = 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") - }); + let client = identity_client(addr); while !client.is_finished() { crank(&mut tile, &mut el, "identity served while fcu in flight"); } @@ -258,10 +300,11 @@ fn pool_cap_gates_spine_intake() { max_connections: 3, ..EngineConfig::default() }; - let mut tile = ApplicationBoundaryTile { - beacon: beacon(&Bind::parse("127.0.0.1:0")), - engine: engine(config, ["cs_cap_gossip", "cs_cap_rpc", "cs_cap_resp"]), - }; + 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, _| {}); @@ -321,6 +364,84 @@ fn pool_cap_gates_spine_intake() { 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 @@ -329,10 +450,11 @@ fn pool_cap_gates_spine_intake() { 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 = ApplicationBoundaryTile { - beacon: beacon(&Bind::parse("127.0.0.1:0")), - engine: engine(no_el(), ["cs_status_gossip", "cs_status_rpc", "cs_status_resp"]), - }; + 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); @@ -383,10 +505,11 @@ fn node_status_updates_while_the_engine_pool_is_at_cap() { max_connections: 3, ..EngineConfig::default() }; - let mut tile = ApplicationBoundaryTile { - beacon: beacon(&Bind::parse("127.0.0.1:0")), - engine: engine(config, ["cs_sat_gossip", "cs_sat_rpc", "cs_sat_resp"]), - }; + 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, _| {}); @@ -443,10 +566,11 @@ fn node_status_updates_while_the_engine_pool_is_at_cap() { fn node_status_tracks_the_peer_gauges() { let base = TempDir::new().unwrap(); let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); - let mut tile = ApplicationBoundaryTile { - beacon: beacon(&Bind::parse("127.0.0.1:0")), - engine: engine(no_el(), ["cs_peers_gossip", "cs_peers_rpc", "cs_peers_resp"]), - }; + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ + "cs_peers_gossip", + "cs_peers_rpc", + "cs_peers_resp", + ]); let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); PeerCounters::PeersConnected.set(56); @@ -458,3 +582,124 @@ fn node_status_tracks_the_peer_gauges() { tile.loop_body(&mut adapter); assert_eq!(tile.beacon.node_status_mut().peers.connected, 55, "refreshed every iteration"); } + +/// Both tenants register into one readiness loop, where a token either could +/// allocate would deliver one's socket to the other's dispatch. Every socket +/// here is well past its tenant's first token, and every one of them is live +/// at the same time. +#[test] +fn concurrent_clients_and_engine_calls_keep_their_own_sockets() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let (mut el, endpoint) = FakeEl::tcp(); + let jwt_path = write_jwt(base.path()); + + let config = EngineConfig { + execution_endpoint: endpoint, + jwt_secret: jwt_path.to_str().unwrap().to_string(), + max_connections: 4, + ..EngineConfig::default() + }; + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), config, [ + "cs_alias_gossip", + "cs_alias_rpc", + "cs_alias_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + inj.consume(|_: EngineResp, _| {}); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ApplicationBoundaryTile, el: &mut FakeEl, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + el.pump(); + std::thread::sleep(Duration::from_millis(1)); + }; + + // The startup healthcheck trio takes three pooled connections; answering + // it leaves all three registered and free for the FCUs below. + while el.requests.len() < 3 { + crank(&mut tile, &mut el, "startup healthcheck trio"); + } + for i in 0..3 { + el.respond(i, "false"); + } + + let roots = [21u8, 22, 23, 24]; + for byte in roots { + inj.produce(fcu_req(byte)); + } + let fcu_count = |el: &FakeEl| { + el.requests.iter().filter(|r| r.method == "engine_forkchoiceUpdatedV3").count() + }; + while fcu_count(&el) < roots.len() { + crank(&mut tile, &mut el, "four engine calls on the wire"); + } + + // Each client hangs up on its own connection while the engine calls are + // still in flight: a shared token would deliver that hangup to the engine + // pool, which would fail the call it is waiting on. + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; + let clients = roots.map(|_| identity_client_that_hangs_up(addr)); + while !clients.iter().all(JoinHandle::is_finished) { + crank(&mut tile, &mut el, "four api clients served while the engine calls wait"); + } + for client in clients { + assert_identity_ok(&client.join().unwrap()); + } + for _ in 0..10 { + crank(&mut tile, &mut el, "hangups delivered"); + } + + let mut completed = Vec::new(); + drain_fcu_completions(&mut inj, &mut completed); + assert!(completed.is_empty(), "a client hanging up must not complete an engine call"); + + for byte in roots { + let on_wire = el + .requests + .iter() + .position(|r| r.body.contains(&head_block_hash_json(byte))) + .expect("fcu on the wire"); + el.respond(on_wire, FCU_VALID_RESULT); + } + while completed.len() < roots.len() { + crank(&mut tile, &mut el, "every engine completion on the spine"); + drain_fcu_completions(&mut inj, &mut completed); + } + completed.sort_by_key(|(root, _)| *root); + assert_eq!( + completed, + roots.map(|byte| ([byte; 32], PayloadValidationStatus::Valid)), + "each call must carry its own EL answer, not a transport failure" + ); +} + +/// In unsafe no-EL mode the engine has no client and registers nothing, so the +/// beacon-api server is the only tenant of the loop and must serve as if it +/// had one to itself. +#[test] +fn serves_concurrent_clients_with_no_engine_registered() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ + "cs_noel_gossip", + "cs_noel_rpc", + "cs_noel_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; + let clients = [(); 3].map(|()| identity_client(addr)); + + let deadline = Instant::now() + Duration::from_secs(10); + while !clients.iter().all(JoinHandle::is_finished) { + assert!(Instant::now() < deadline, "timeout: three clients served with no engine"); + tile.loop_body(&mut adapter); + std::thread::sleep(Duration::from_millis(1)); + } + for client in clients { + assert_identity_ok(&client.join().unwrap()); + } +} diff --git a/crates/beacon_api/examples/srv.rs b/crates/beacon_api/examples/srv.rs index c46c2449..d2f96a00 100644 --- a/crates/beacon_api/examples/srv.rs +++ b/crates/beacon_api/examples/srv.rs @@ -3,7 +3,7 @@ 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; +use silver_httpcore::{Bind, Readiness, TokenRange}; fn main() { let arg = std::env::args().nth(1).unwrap_or_else(|| "0.0.0.0:5051".into()); @@ -13,7 +13,10 @@ fn main() { // 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), @@ -25,7 +28,8 @@ fn main() { ); println!("serving on {:?}", api.local_addrs()); loop { - api.pump(); + readiness.wait(Duration::ZERO); + api.pump(readiness.events()); std::thread::sleep(Duration::from_millis(1)); } } diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index a7e81001..4158bce9 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -4,10 +4,12 @@ use std::{ time::{Duration, Instant}, }; -use mio::{Events, Interest, Poll, Token}; +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}; +use silver_httpcore::{ + AfterResponse, Bind, Listener, ParsedRequest, ServerConnection, Stream, TokenRange, +}; use crate::{ NodeStatus, @@ -65,6 +67,95 @@ impl Connection { 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 @@ -91,13 +182,13 @@ impl IdleSweep { } pub struct BeaconApi { - poll: Poll, - events: Events, + registry: Registry, + tokens: TokenRange, listeners: Vec, max_connections: usize, idle: IdleSweep, linger: Linger, - current_token: Token, + next_connection_offset: usize, connections: HashMap, router: Router, ctx: ApiCtx, @@ -106,6 +197,8 @@ pub struct BeaconApi { impl BeaconApi { #[allow(clippy::too_many_arguments)] pub fn new( + registry: &Registry, + tokens: TokenRange, binds: &[Bind], max_connections: usize, idle_timeout: Duration, @@ -116,25 +209,34 @@ impl BeaconApi { state: BeaconStateReader, ) -> Self { assert!(!binds.is_empty(), "beacon api needs at least one bind"); - let poll = Poll::new().unwrap(); + 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}")); - poll.registry().register(&mut listener, Token(index), Interest::READABLE).unwrap(); + registry.register(&mut listener, tokens.at(index), Interest::READABLE).unwrap(); listener }) .collect::>(); Self { - poll, - events: Events::with_capacity(1024), + registry, + tokens, max_connections, idle: IdleSweep::new(idle_timeout), linger: Linger::default(), - current_token: Token(listeners.len()), + next_connection_offset: listeners.len(), listeners, connections: HashMap::new(), router: Router::new(ROUTES), @@ -151,64 +253,19 @@ impl BeaconApi { &mut self.ctx.node_status } - pub fn pump(&mut self) -> bool { - self.poll.poll(&mut self.events, Some(Duration::ZERO)).unwrap(); + pub fn pump(&mut self, events: &Events) -> bool { let now = Instant::now(); let mut did_work = false; - for event in &self.events { - match self.listeners.get(event.token().0) { - Some(listener) => loop { - let mut stream = match listener.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 = next(&mut self.current_token, self.listeners.len()); - self.poll.registry().register(&mut stream, token, Interest::READABLE).unwrap(); - self.connections.insert(token, Connection { - stream, - http: ServerConnection::new(), - last_activity: now, - linger_since: None, - }); - }, - None => { - let token = event.token(); - if let Some(conn) = self.connections.get_mut(&token) { - did_work = true; - match handle_event(self.poll.registry(), conn, event, now, &|req, out| { - self.router.dispatch(req, &self.ctx, out) - }) { - Ok(true) => { - let _ = self.poll.registry().deregister(&mut conn.stream); - self.connections.remove(&token); - } - Ok(false) => {} - Err(e) => { - tracing::warn!("connection error: {e}"); - let _ = self.poll.registry().deregister(&mut conn.stream); - self.connections.remove(&token); - } - }; - } - } - } + 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) { @@ -218,8 +275,83 @@ impl BeaconApi { 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, poll, idle, linger, .. } = self; + let Self { connections, registry, idle, linger, .. } = self; let before = connections.len(); connections.retain(|_, conn| { if !conn.expired(now, idle.timeout, linger) { @@ -235,111 +367,13 @@ impl BeaconApi { now.duration_since(conn.last_activity) ), } - let _ = poll.registry().deregister(&mut conn.stream); + let _ = registry.deregister(&mut conn.stream); false }); connections.len() != before } } -fn handle_event, &mut Vec)>( - registry: &mio::Registry, - conn: &mut Connection, - event: &mio::event::Event, - now: Instant, - request_handler: &F, -) -> io::Result { - if conn.linger_since.is_some() { - return conn.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 conn.http.read_space() { - Ok(space) => space, - Err(e) => { - exhausted = Some(e); - break; - } - }; - match conn.stream.read(space) { - Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), - Ok(n) => { - conn.last_activity = now; - conn.http.commit_read(n); - } - Err(e) if would_block(&e) => break, - Err(e) if interrupted(&e) => continue, - Err(e) => return Err(e), - } - } - - if conn.http.dispatch(request_handler) { - registry.reregister(&mut conn.stream, event.token(), Interest::WRITABLE)?; - } else if let Some(e) = exhausted { - return Err(e); - } - return Ok(false); - } - - if event.is_writable() { - if !conn.http.pending_write().is_empty() { - loop { - match conn.stream.write(conn.http.pending_write()) { - Ok(0) => { - return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")) - } - Ok(n) => { - conn.last_activity = now; - conn.http.commit_write(n); - if conn.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 conn.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. - conn.stream.shutdown_write()?; - conn.linger_since = Some(now); - registry.reregister(&mut conn.stream, event.token(), Interest::READABLE)?; - return conn.drain_discarded(now); - } - AfterResponse::ResponsePending => { - registry.reregister(&mut conn.stream, event.token(), Interest::WRITABLE)? - } - AfterResponse::AwaitRequest => { - registry.reregister(&mut conn.stream, event.token(), Interest::READABLE)? - } - } - } - return Ok(false); - } - - Ok(false) -} - -/// Connection tokens sit above the listener range `0..reserved`, which the -/// wrap must skip to avoid aliasing an accept socket. -fn next(current: &mut Token, reserved: usize) -> Token { - let tok = Token(current.0); - let n = current.0.wrapping_add(1); - current.0 = if n < reserved { reserved } else { n }; - tok -} - fn would_block(err: &io::Error) -> bool { err.kind() == io::ErrorKind::WouldBlock } @@ -359,47 +393,63 @@ mod tests { }; use silver_beacon_state_data::BeaconStateOwner; + use silver_httpcore::Readiness; use super::*; - #[test] - fn token_wrap_skips_the_listener_range() { - let reserved = 3; - - let mut cur = Token(usize::MAX); - let assigned = next(&mut cur, reserved); - assert!(assigned.0 >= reserved, "returned token must not alias a listener"); - assert_eq!(cur, Token(reserved), "the wrap must land above the listener range"); + /// Longer than any test's 10 s spin deadline: the idle sweep never reaps. + const LONG_TIMEOUT: Duration = Duration::from_secs(60); - let mut cur = Token(reserved); - assert_eq!(next(&mut cur, reserved), Token(reserved)); - assert_eq!(cur, Token(reserved + 1)); + /// 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, } - /// Longer than any test's 10 s spin deadline: the idle sweep never reaps. - const LONG_TIMEOUT: Duration = Duration::from_secs(60); + 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 api_bound_to(binds: &[Bind], max_connections: usize, idle_timeout: Duration) -> BeaconApi { - let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); - let local_enr = Enr::empty(keypair.secret_key()).unwrap(); - BeaconApi::new( - binds, - max_connections, - idle_timeout, - &keypair, - local_enr, - &Identify::default(), - &SpecConfig::mainnet(), - BeaconStateOwner::empty_test(0).reader(), - ) + fn pump(&mut self) -> bool { + self.readiness.wait(Duration::ZERO); + self.api.pump(self.readiness.events()) + } } - fn api_with(max_connections: usize, idle_timeout: Duration) -> BeaconApi { - api_bound_to(&[Bind::parse("127.0.0.1:0")], max_connections, idle_timeout) + fn server_bound_to(binds: &[Bind], max_connections: usize, idle_timeout: Duration) -> Server { + Server::new(TokenRange::whole(), binds, max_connections, idle_timeout) } - fn tcp_addrs(api: &BeaconApi) -> Vec { - api.local_addrs() + 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") }; @@ -408,34 +458,108 @@ mod tests { .collect() } - fn tcp_addr(api: &BeaconApi) -> SocketAddr { - tcp_addrs(api)[0] + fn tcp_addr(server: &Server) -> SocketAddr { + tcp_addrs(server)[0] } - fn pump_until(api: &mut BeaconApi, msg: &str, mut done: impl FnMut(&BeaconApi) -> bool) { + fn pump_until(server: &mut Server, msg: &str, mut done: impl FnMut(&Server) -> bool) { let deadline = Instant::now() + Duration::from_secs(10); - while !done(api) { + while !done(server) { assert!(Instant::now() < deadline, "timeout: {msg}"); - api.pump(); + server.pump(); std::thread::sleep(Duration::from_millis(1)); } } - fn serve(api: &mut BeaconApi, client: JoinHandle, msg: &str) -> T { - pump_until(api, msg, |_| client.is_finished()); + fn serve(server: &mut Server, client: JoinHandle, msg: &str) -> T { + pump_until(server, msg, |_| client.is_finished()); client.join().unwrap() } fn serve_both( - api: &mut BeaconApi, + server: &mut Server, first: JoinHandle, second: JoinHandle, msg: &str, ) -> (T, T) { - pump_until(api, msg, |_| first.is_finished() && second.is_finished()); + 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(); @@ -512,25 +636,25 @@ mod tests { #[test] #[should_panic(expected = "at least one bind")] fn an_empty_bind_list_is_rejected() { - api_bound_to(&[], 64, LONG_TIMEOUT); + server_bound_to(&[], 64, LONG_TIMEOUT); } #[test] fn every_tcp_listener_serves_the_api() { - let mut api = api_bound_to( + 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(&api); + 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 api, + &mut server, std::thread::spawn(move || get_identity(connect(first_addr))), std::thread::spawn(move || get_identity(connect(second_addr))), "both tcp listeners served", @@ -543,13 +667,13 @@ mod tests { fn tcp_and_uds_listeners_serve_side_by_side() { let dir = tempfile::tempdir().unwrap(); let socket = dir.path().join("api.sock"); - let mut api = api_bound_to( + let mut server = server_bound_to( &[Bind::parse("127.0.0.1:0"), Bind::Unix(socket.clone())], 64, LONG_TIMEOUT, ); - let addrs = api.local_addrs(); + 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:?}") }; @@ -557,7 +681,7 @@ mod tests { let tcp_addr = *tcp_addr; let (over_tcp, over_uds) = serve_both( - &mut api, + &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", @@ -570,16 +694,16 @@ mod tests { /// listener refuses clients arriving on any other. #[test] fn connection_cap_is_shared_across_listeners() { - let mut api = api_bound_to( + 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(&api); + let addrs = tcp_addrs(&server); let (held, other) = (addrs[0], addrs[1]); let held_open = serve( - &mut api, + &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(); @@ -595,15 +719,15 @@ mod tests { "first listener's client took the only slot", ); - assert_eq!(api.connections.len(), 1); + assert_eq!(server.api.connections.len(), 1); assert!( - api.connections.keys().all(|token| token.0 >= 2), + server.api.connections.keys().all(|token| token.0 >= 2), "connection tokens must clear the listener range: {:?}", - api.connections.keys().collect::>() + server.api.connections.keys().collect::>() ); let denied = serve( - &mut api, + &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"); @@ -618,10 +742,12 @@ mod tests { ); drop(held_open); - pump_until(&mut api, "closed connection reaped", |api| api.connections.is_empty()); + pump_until(&mut server, "closed connection reaped", |server| { + server.api.connections.is_empty() + }); let response = serve( - &mut api, + &mut server, std::thread::spawn(move || get_identity(connect(other))), "second listener served once the slot freed", ); @@ -630,11 +756,11 @@ mod tests { #[test] fn connection_cap_drops_excess_then_recovers() { - let mut api = api_with(1, LONG_TIMEOUT); - let addr = tcp_addr(&api); + let mut server = server_with(1, LONG_TIMEOUT); + let addr = tcp_addr(&server); let held_open = serve( - &mut api, + &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(); @@ -652,7 +778,7 @@ mod tests { ); let denied = serve( - &mut api, + &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"); @@ -667,10 +793,12 @@ mod tests { ); drop(held_open); - pump_until(&mut api, "closed connection reaped", |api| api.connections.is_empty()); + pump_until(&mut server, "closed connection reaped", |server| { + server.api.connections.is_empty() + }); let response = serve( - &mut api, + &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") @@ -689,11 +817,11 @@ mod tests { /// at parse time. #[test] fn partial_request_is_reaped_after_the_idle_deadline() { - let mut api = api_with(64, Duration::from_millis(200)); - let addr = tcp_addr(&api); + let mut server = server_with(64, Duration::from_millis(200)); + let addr = tcp_addr(&server); let received = serve( - &mut api, + &mut server, std::thread::spawn(move || { let mut stream = connect(addr); write!(stream, "GET /metrics HTTP/1.1\r\nHost: x\r\n").unwrap(); @@ -703,18 +831,18 @@ mod tests { ); assert!(received.is_empty(), "half a request must not be answered: {received:?}"); - assert!(api.connections.is_empty(), "reaped connection must leave the map"); + 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 api = api_with(64, LONG_TIMEOUT); - let addr = tcp_addr(&api); + let mut server = server_with(64, LONG_TIMEOUT); + let addr = tcp_addr(&server); let received = serve( - &mut api, + &mut server, std::thread::spawn(move || { let mut stream = connect(addr); declare_oversized_body(&mut stream); @@ -724,8 +852,8 @@ mod tests { ); assert_eq!(received, PAYLOAD_TOO_LARGE, "{}", String::from_utf8_lossy(&received)); - pump_until(&mut api, "answered connection closed on the peer's own close", |api| { - api.connections.is_empty() + pump_until(&mut server, "answered connection closed on the peer's own close", |server| { + server.api.connections.is_empty() }); } @@ -736,18 +864,18 @@ mod tests { /// nothing. #[test] fn a_client_still_streaming_when_the_413_is_framed_reads_all_of_it() { - let mut api = api_with(64, LONG_TIMEOUT); - let addr = tcp_addr(&api); + let mut server = server_with(64, LONG_TIMEOUT); + let addr = tcp_addr(&server); let received = serve( - &mut api, + &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 api, "lingering connection closed once the peer went away", |api| { - api.connections.is_empty() + pump_until(&mut server, "lingering connection closed once the peer went away", |server| { + server.api.connections.is_empty() }); } @@ -757,18 +885,20 @@ mod tests { 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 api = api_bound_to(&[Bind::Unix(socket.clone())], 64, LONG_TIMEOUT); + let mut server = server_bound_to(&[Bind::Unix(socket.clone())], 64, LONG_TIMEOUT); let received = serve( - &mut api, + &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 api, "lingering uds connection closed once the peer went away", |api| { - api.connections.is_empty() - }); + 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>) { @@ -784,11 +914,12 @@ mod tests { /// 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 api = api_with(64, Duration::from_millis(800)); + 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. - api.linger = Linger { idle: Duration::from_millis(400), total: Duration::from_millis(200) }; - let addr = tcp_addr(&api); + 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); @@ -804,11 +935,15 @@ mod tests { }); let midway = Instant::now() + Duration::from_millis(100); - pump_until(&mut api, "server pumped past the answer", |_| Instant::now() >= midway); - assert_eq!(api.connections.len(), 1, "the answered connection must drain, not close"); + 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 api, "flooding client dropped at the linger cap", |api| { - api.connections.is_empty() + 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"); } @@ -819,9 +954,10 @@ mod tests { #[test] fn a_lingering_connection_that_goes_quiet_is_dropped_at_the_idle_cap() { let idle_timeout = Duration::from_secs(2); - let mut api = api_with(64, idle_timeout); - api.linger = Linger { idle: Duration::from_millis(100), total: Duration::from_secs(30) }; - let addr = tcp_addr(&api); + 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 || { @@ -832,10 +968,12 @@ mod tests { answer }); - pump_until(&mut api, "oversized declaration accepted", |api| api.connections.len() == 1); + pump_until(&mut server, "oversized declaration accepted", |server| { + server.api.connections.len() == 1 + }); let answered = Instant::now(); - pump_until(&mut api, "quiet lingering connection dropped at the idle cap", |api| { - api.connections.is_empty() + 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"); @@ -847,11 +985,11 @@ mod tests { #[test] fn idle_keep_alive_connection_is_reaped_after_the_idle_deadline() { let idle_timeout = Duration::from_millis(200); - let mut api = api_with(64, idle_timeout); - let addr = tcp_addr(&api); + let mut server = server_with(64, idle_timeout); + let addr = tcp_addr(&server); let (received, alive_for) = serve( - &mut api, + &mut server, std::thread::spawn(move || { let mut stream = connect(addr); // Timed from before the request: the server's activity stamp @@ -866,19 +1004,19 @@ mod tests { 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!(api.connections.is_empty(), "reaped connection must leave the map"); + 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 api = api_with(64, idle_timeout); - let addr = tcp_addr(&api); + 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 api, + &mut server, std::thread::spawn(move || { let mut stream = connect(addr); let mut chunk = [0u8; 1024]; @@ -893,25 +1031,27 @@ mod tests { "keep-alive client kept alive by its own traffic", ); - assert_eq!(api.connections.len(), 1, "an active connection must survive the sweep"); + 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 api = api_with(1, Duration::from_millis(800)); - let addr = tcp_addr(&api); + 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 api, "hung client holds the only slot", |api| api.connections.len() == 1); + pump_until(&mut server, "hung client holds the only slot", |server| { + server.api.connections.len() == 1 + }); let denied = serve( - &mut api, + &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"); @@ -925,10 +1065,10 @@ mod tests { "the held slot must refuse other clients: {denied:?}" ); - assert!(serve(&mut api, hung, "hung client reaped").is_empty()); + assert!(serve(&mut server, hung, "hung client reaped").is_empty()); let response = serve( - &mut api, + &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") diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index 25d58ee9..ad3d91b2 100644 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -7,7 +7,6 @@ version.workspace = true [dependencies] silver_application_boundary.workspace = true -silver_beacon_api.workspace = true silver_beacon_state.workspace = true silver_beacon_state_data.workspace = true silver_columns.workspace = true @@ -19,7 +18,6 @@ silver_gossip.workspace = true silver_network.workspace = true silver_peer.workspace = true silver_storage.workspace = true -silver_engine_api.workspace = true silver_httpcore.workspace = true clap.workspace = true diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 6c71c362..6d89aa62 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -8,7 +8,6 @@ use mimalloc::MiMalloc; use quinn_proto::{Endpoint, EndpointConfig}; use rand::RngCore; use silver_application_boundary::ApplicationBoundaryTile; -use silver_beacon_api::BeaconApi; use silver_beacon_state::{BeaconStateTile, SlotTicker}; use silver_beacon_state_data::{BeaconState, SLOTS_PER_EPOCH}; use silver_columns::tile::DataColumnsTile; @@ -21,7 +20,6 @@ use silver_common::{ use silver_config::Config; use silver_control::Controller; use silver_discovery::{DiscV5, Discovery}; -use silver_engine_api::EngineApi; use silver_gossip::GossipHandler; use silver_httpcore::Bind; use silver_network::{Context, NetworkTile, P2p}; @@ -237,19 +235,6 @@ fn main() -> Result<(), Box> { !config.disable_weak_subjectivity_check(), state, ); - let beacon_api_binds = - config.beacon_api_bind().iter().map(String::as_str).map(Bind::parse).collect::>(); - let beacon_api = BeaconApi::new( - &beacon_api_binds, - config.beacon_api_max_connections(), - config.beacon_api_idle_timeout(), - &keypair, - local_enr, - &identify, - &spec, - beacon_state_tile.reader(), - ); - let state_reader = beacon_state_tile.reader(); let storage_tile = StorageTile::new( @@ -279,14 +264,22 @@ fn main() -> Result<(), Box> { el_producer, ); - let engine_api = EngineApi::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, incoming_engine_resp_producer, ); - let application_boundary_tile = - ApplicationBoundaryTile { beacon: beacon_api, engine: engine_api }; // Spine let spine = SilverSpine::new(None); diff --git a/crates/engine_api/src/api.rs b/crates/engine_api/src/api.rs index 8da1caf3..6c12ce49 100644 --- a/crates/engine_api/src/api.rs +++ b/crates/engine_api/src/api.rs @@ -1,14 +1,17 @@ use std::time::{Duration, Instant}; 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::*, }; @@ -33,7 +36,15 @@ pub struct EngineApi { } 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 + } + pub fn new( + registry: &Registry, + tokens: TokenRange, config: EngineConfig, gossip_consumer: TRandomAccess, rpc_consumer: TRandomAccess, @@ -44,6 +55,8 @@ impl EngineApi { None } else { Some(EngineClient::new( + registry, + tokens, &config.execution_endpoint, &config.jwt_secret, config.max_connections, @@ -107,7 +120,7 @@ impl EngineApi { } } - pub fn spin(&mut self, adapter: &mut SpineAdapter) { + pub fn spin(&mut self, adapter: &mut SpineAdapter, events: &Events) { let mut negotiated_get_payload_method: Option<&'static str> = None; { @@ -130,7 +143,7 @@ impl EngineApi { 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_api/src/client.rs b/crates/engine_api/src/client.rs index a155f7d7..6321ed41 100644 --- a/crates/engine_api/src/client.rs +++ b/crates/engine_api/src/client.rs @@ -1,8 +1,9 @@ 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, @@ -13,8 +14,6 @@ use crate::{ }, }; -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; @@ -46,8 +45,7 @@ pub enum ReqKind { pub struct EngineClient { pool: HttpPool, - poll: Poll, - events: Events, + registry: Registry, id: u64, pending_requests: FxHashMap, pub get_payload_method: &'static str, @@ -56,24 +54,44 @@ pub struct EngineClient { impl EngineClient { pub fn new( + registry: &Registry, + tokens: TokenRange, endpoint: &str, jwt: &str, max_connections: usize, request_timeout: Duration, ) -> Self { - Self::with_endpoint(parse_endpoint(endpoint), jwt, max_connections, request_timeout) + 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(Endpoint::Uds(path.into()), jwt, max_connections, request_timeout) + 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, @@ -81,9 +99,8 @@ impl EngineClient { ) -> Self { let jwt = JwtSecret::from_file(jwt).unwrap_or_else(|e| panic!("invalid JWT secret: {e}")); Self { - pool: HttpPool::new(endpoint, jwt, max_connections, request_timeout), - 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", @@ -94,6 +111,21 @@ impl EngineClient { 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 { @@ -136,7 +168,7 @@ fn enqueue(c: &mut EngineClient, rpc_id: u64, body: &simd_json::OwnedValue) { tracing::warn!("failed to serialize RPC body: {e}"); return; } - c.pool.enqueue(rpc_id, &c.scratch, &mut c.poll); + c.pool.enqueue(rpc_id, &c.scratch, &c.registry); } pub fn send_fcu( @@ -187,7 +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'}'); - c.pool.enqueue(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(()) } @@ -271,21 +303,6 @@ 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 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 { pool, events, poll, pending_requests, .. } = c; - pool.poll_events(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; diff --git a/crates/engine_api/src/lib.rs b/crates/engine_api/src/lib.rs index c72f0708..bf0a9940 100644 --- a/crates/engine_api/src/lib.rs +++ b/crates/engine_api/src/lib.rs @@ -12,6 +12,6 @@ mod types; pub use api::EngineApi; pub use client::EngineClient; #[cfg(feature = "test-el")] -pub use client::{ReqKind, poll, send_new_payload}; +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 index 9c03a400..e1e62393 100644 --- a/crates/engine_api/src/pool.rs +++ b/crates/engine_api/src/pool.rs @@ -5,8 +5,8 @@ use std::{ time::{Duration, Instant}, }; -use mio::{Events, Interest, Poll, Token}; -use silver_httpcore::{ClientConnection, Stream, frame_request}; +use mio::{Events, Interest, Registry, Token, event::Event}; +use silver_httpcore::{ClientConnection, Stream, TokenRange, frame_request}; use crate::{EngineError, JwtSecret}; @@ -19,6 +19,11 @@ const READ_BUF_CAPACITY: usize = 10 * 1024 * 1024; // 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), @@ -85,7 +90,7 @@ impl PooledConnection { self.request_started.is_some_and(|started| now.duration_since(started) > timeout) } - fn enqueue(&mut self, rpc_id: u64, body: &[u8], poll: &mut Poll) { + 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); @@ -93,79 +98,73 @@ impl PooledConnection { self.request_started = Some(Instant::now()); match self.conn { - Conn::Disconnected => self.connect(poll), - Conn::Connected(_) => self.update_interest(poll), + Conn::Disconnected => self.connect(registry), + Conn::Connected(_) => self.update_interest(registry), Conn::Connecting(_) => {} } } - fn handle_events(&mut self, events: &Events, poll: &mut Poll, on_complete: &mut F) + fn handle_event(&mut self, event: &Event, registry: &Registry, on_complete: &mut F) where F: FnMut(u64, Result<&mut [u8], EngineError>), { - for event in events.iter() { - if event.token() != self.token { - continue; - } - match &self.conn { - Conn::Disconnected => {} - Conn::Connecting(stream) => { - if event.is_error() || event.is_read_closed() || event.is_write_closed() { - self.fail(poll, on_complete, "connect failed"); - break; - } - 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(poll); - } else { - self.fail(poll, on_complete, "connect failed"); - break; - } - } + 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; } - Conn::Connected(_) => { - if event.is_error() { - self.fail(poll, on_complete, "connection error"); - break; + 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"); } - if event.is_writable() { - if let Err(e) = self.do_write() { - let msg = e.to_string(); - self.fail(poll, on_complete, &msg); - break; - } - self.update_interest(poll); - } - 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 break - // below covers that close path too. - if let Err(e) = self.do_read(on_complete) { - let msg = e.to_string(); - self.fail(poll, on_complete, &msg); - break; - } + } + } + 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; } - if event.is_read_closed() { - // Remote closed with no (more) data — in_flight will - // never get a response. - self.fail(poll, on_complete, "connection closed"); - break; + 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, poll: &mut Poll) { + fn connect(&mut self, registry: &Registry) { let stream = match &self.endpoint { Endpoint::Http(endpoint) => { let addr = if let Some(a) = self.addr { @@ -188,7 +187,7 @@ impl PooledConnection { }; match stream { Ok(mut stream) => { - if poll.registry().register(&mut stream, self.token, Interest::WRITABLE).is_ok() { + if registry.register(&mut stream, self.token, Interest::WRITABLE).is_ok() { self.conn = Conn::Connecting(stream); } } @@ -242,7 +241,7 @@ impl PooledConnection { Ok(()) } - fn fail(&mut self, poll: &mut Poll, on_complete: &mut F, msg: &str) + fn fail(&mut self, registry: &Registry, on_complete: &mut F, msg: &str) where F: FnMut(u64, Result<&mut [u8], EngineError>), { @@ -258,11 +257,11 @@ impl PooledConnection { 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 _ = poll.registry().deregister(&mut stream); + let _ = registry.deregister(&mut stream); } } - fn update_interest(&mut self, poll: &mut Poll) { + fn update_interest(&mut self, registry: &Registry) { let interest = if self.pending_id.is_none() { Interest::READABLE } else { @@ -272,7 +271,7 @@ impl PooledConnection { Conn::Connecting(s) | Conn::Connected(s) => s, Conn::Disconnected => return, }; - let _ = poll.registry().reregister(stream, self.token, interest); + let _ = registry.reregister(stream, self.token, interest); } } @@ -288,6 +287,7 @@ pub(crate) struct HttpPool { connections: Vec, endpoint: Endpoint, jwt: JwtSecret, + tokens: TokenRange, max_connections: usize, request_timeout: Duration, } @@ -296,37 +296,63 @@ impl HttpPool { pub(crate) fn new( endpoint: Endpoint, jwt: JwtSecret, + tokens: TokenRange, max_connections: usize, request_timeout: Duration, ) -> Self { - let connections = vec![PooledConnection::new(endpoint.clone(), jwt.clone(), Token(0))]; - Self { connections, endpoint, jwt, max_connections, request_timeout } + 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. The first-run healthcheck trio issues three requests - /// against one gate check, so the pool can overshoot `max_connections` - /// by at most two connections, once. + /// 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], poll: &mut Poll) { + 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, poll); + conn.enqueue(rpc_id, body, registry); } else { let mut new_conn = PooledConnection::new( self.endpoint.clone(), self.jwt.clone(), - Token(self.connections.len()), + self.tokens.at(self.connections.len()), ); - new_conn.enqueue(rpc_id, body, poll); + new_conn.enqueue(rpc_id, body, registry); self.connections.push(new_conn); } } - pub(crate) fn poll_events(&mut self, events: &Events, poll: &mut Poll, on_complete: &mut F) + 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>), { @@ -336,25 +362,25 @@ impl HttpPool { // 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(poll, on_complete, "connect failed to start"); + conn.fail(registry, on_complete, "connect failed to start"); } else if conn.expired(now, self.request_timeout) { - conn.fail(poll, on_complete, "request timed out"); + conn.fail(registry, on_complete, "request timed out"); } - conn.handle_events(events, poll, on_complete); } } } #[cfg(test)] mod tests { - use std::os::unix::net::UnixListener; + use std::{os::unix::net::UnixListener, path::Path}; + use silver_httpcore::Readiness; use tempfile::TempDir; use super::*; use crate::{ EngineClient, - client::{ReqKind, poll, send_fcu}, + client::{ReqKind, send_fcu}, test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}, types::ForkchoiceState, }; @@ -362,6 +388,41 @@ mod tests { /// 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], @@ -385,15 +446,14 @@ mod tests { let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = - EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32, LONG_TIMEOUT); + let mut client = Client::uds(&socket, &jwt_path, 32, LONG_TIMEOUT); let block_root = [7u8; 32]; - send_fcu(&mut client, block_root, fcu_state(1), None); + 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", || { - poll(&mut client, |kind, response| { + client.poll(|kind, response| { let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; completed = Some((root, response.expect("fcu response").to_vec())); }); @@ -426,15 +486,14 @@ mod tests { // max_connections = 1: after the failure, has_capacity() can only be // true again if the zombie connection was actually freed. - let mut client = - EngineClient::new_uds(&missing_socket, jwt_path.to_str().unwrap(), 1, LONG_TIMEOUT); + let mut client = Client::uds(&missing_socket, &jwt_path, 1, LONG_TIMEOUT); let block_root = [3u8; 32]; - send_fcu(&mut client, block_root, fcu_state(3), None); - assert!(!client.has_capacity(), "request occupies the only connection"); + 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", || { - poll(&mut client, |kind, response| { + 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); @@ -443,7 +502,7 @@ mod tests { }); assert_eq!(failed.unwrap(), block_root); - assert!(client.has_capacity(), "failed connection must be reusable"); + assert!(client.engine.has_capacity(), "failed connection must be reusable"); } #[test] @@ -453,15 +512,14 @@ mod tests { let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = - EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 32, LONG_TIMEOUT); + let mut client = Client::uds(&socket, &jwt_path, 32, LONG_TIMEOUT); let block_root = [9u8; 32]; - send_fcu(&mut client, block_root, fcu_state(2), None); + 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", || { - poll(&mut client, |kind, response| { + 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); @@ -484,17 +542,12 @@ mod tests { let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = EngineClient::new_uds( - &socket, - jwt_path.to_str().unwrap(), - 1, - Duration::from_millis(200), - ); - send_fcu(&mut client, [1u8; 32], fcu_state(1), None); + 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", || { - poll(&mut client, |kind, response| { + 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); @@ -505,13 +558,13 @@ mod tests { assert_eq!(timed_out.unwrap(), [1u8; 32]); assert_eq!(el.requests.len(), 1, "the EL received the request it never answered"); - assert!(client.has_capacity(), "timed-out connection must be reusable"); + assert!(client.engine.has_capacity(), "timed-out connection must be reusable"); - send_fcu(&mut client, [2u8; 32], fcu_state(2), None); + 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", || { - poll(&mut client, |kind, response| { + client.poll(|kind, response| { let ReqKind::Fcu(root) = kind else { panic!("unexpected completion") }; assert!(response.is_ok(), "answered request must succeed"); completed = Some(root); @@ -533,15 +586,14 @@ mod tests { let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = - EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 1, Duration::from_secs(2)); - send_fcu(&mut client, [4u8; 32], fcu_state(4), None); + 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", || { - poll(&mut client, |kind, response| { + 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); @@ -556,6 +608,23 @@ mod tests { 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 @@ -568,17 +637,22 @@ mod tests { 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, 1, Duration::from_millis(100)); - let mut poll = Poll::new().unwrap(); - let events = Events::with_capacity(1); + 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"{}", &mut poll); + 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.poll_events(&events, &mut poll, &mut |rpc_id, response| { + pool.dispatch_events(readiness.events(), readiness.registry(), &mut |rpc_id, response| { failed = Some((rpc_id, response.is_err())); }); diff --git a/crates/engine_api/tests/newpayload_alloc.rs b/crates/engine_api/tests/newpayload_alloc.rs index 2bf8dbdd..eaa32c0d 100644 --- a/crates/engine_api/tests/newpayload_alloc.rs +++ b/crates/engine_api/tests/newpayload_alloc.rs @@ -9,9 +9,10 @@ use std::{ }; use silver_engine_api::{ - EngineClient, ReqKind, poll, send_new_payload, + 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) }; @@ -55,7 +56,12 @@ fn unix_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() } -fn complete_round_trip(client: &mut EngineClient, el: &mut FakeEl, request_index: usize) { +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; @@ -67,7 +73,8 @@ fn complete_round_trip(client: &mut EngineClient, el: &mut FakeEl, request_index el.respond(request_index, NEW_PAYLOAD_VALID); responded = true; } - poll(client, |kind, response| { + readiness.wait(Duration::ZERO); + client.dispatch(readiness.events(), |kind, response| { assert!(matches!(kind, ReqKind::NewPayload(_))); response.expect("newPayload response"); done = true; @@ -82,11 +89,18 @@ fn warm_new_payload_send_allocates_nothing() { let jwt_path = write_jwt(dir.path()); let socket = dir.path().join("engine.sock"); let mut el = FakeEl::uds(&socket); - let mut client = - EngineClient::new_uds(&socket, jwt_path.to_str().unwrap(), 4, Duration::from_secs(60)); + 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 client, &mut el, 0); + 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"); @@ -96,14 +110,14 @@ fn warm_new_payload_send_allocates_nothing() { for _ in 0..5 { let second = unix_secs(); send_new_payload(&mut client, SIGNED_BLOCK_SSZ, [1u8; 32]).unwrap(); - complete_round_trip(&mut client, &mut el, request_index); + 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 client, &mut el, request_index); + 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"); diff --git a/crates/httpcore/src/lib.rs b/crates/httpcore/src/lib.rs index 1365b477..f37a4269 100644 --- a/crates/httpcore/src/lib.rs +++ b/crates/httpcore/src/lib.rs @@ -1,11 +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/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/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/0004-sync-materialized-api.md b/docs/adr/0004-sync-materialized-api.md index c4cf5e74..b5fb1c22 100644 --- a/docs/adr/0004-sync-materialized-api.md +++ b/docs/adr/0004-sync-materialized-api.md @@ -46,7 +46,9 @@ Amended 2026-08-21: `poll(Duration::ZERO)` is the busy-spin build's mechanism, n 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 wants the waker and a non-zero timeout, which in turn wants -this tile's two `Poll`s — the beacon-api server's and the engine-api client's — to -become one readiness loop, since blocking in either starves the other. The -interleaving above follows from that one loop, not from the timeout being zero. +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. From d2cea25c6574b39b768fe4dcf9982562474c45c1 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 21 Aug 2026 18:41:28 +0100 Subject: [PATCH 33/33] Name the head from fork choice, not the published state The beacon API's `head` means fork choice's head. The seqlock control word carried a different block: the one the published state was applied from. The two part whenever an import lands on a branch fork choice passes over. No state can name fork choice's head at all. So the control word drops the root, and `read_head` folds back into `read`. The head now arrives on the Status payload, which the boundary tile already consumes for `head_optimistic`. That tile lifts `head_root` and `head_slot` into `SlotStatus`. Neither the state tile nor the event changes. `SlotStatus` names the two blocks apart. `latest_block_slot` is the highest block imported, on whichever branch it landed. `sync_distance` has always measured that one. `ChainHead` carries fork choice's root beside its slot. `resolve` reads the state's own block from `latest_block_header` and the ring, then serves the header for that root alone. One `process_slot` fills the header's `state_root` and writes the `block_roots` entry naming the block. A filled `state_root` therefore proves the ring can name it. Root and header now come from one snapshot, so the pairing is proved rather than assumed. `canonical` follows from comparing the two roots. It replaces a hardcoded `true`, which lied about a header requested by root after fork choice had left that branch. Carrying the head's slot lets a slot request answer with a block of that slot, or with nothing. The head stands in for the one block the ring cannot name: its own, between arrival and the `process_slot` that records it. That substitution now applies only at the head's own slot. A node that has published a state but heard no status still has a head. It answers with the block its own state was applied from. That window is no startup blip. The boundary tile's first consume snaps its cursor past the initial status. The slot-tick status fires only once the tile follows. A node that finds no peer imports nothing. Neither block schema declares a 503, so the status code cannot be traded down. Nimbus reads a 404 from `blocks/head/root` as an incompatible node and deselects it. Limits. `canonical` is read only beside a header, and the header is served for the state's own block alone. One case can still misreport: that block is an ancestor of a head the state has not caught up to. It is canonical without being the head, and answers `false`. Older blocks still answer by root alone, because serving their headers needs the block store. Assisted-by: Claude:claude-opus-5 --- crates/application_boundary/src/lib.rs | 20 +- crates/application_boundary/tests/tile.rs | 69 ++++-- crates/beacon_api/src/blocks.rs | 232 +++++++++++++++------ crates/beacon_api/src/duties/proposer.rs | 29 +-- crates/beacon_api/src/duties/sync.rs | 2 +- crates/beacon_api/src/duties/tests.rs | 83 ++++---- crates/beacon_api/src/lib.rs | 2 +- crates/beacon_api/src/node_status.rs | 36 +++- crates/beacon_api/src/routes.rs | 68 +++--- crates/beacon_api/src/validators/mod.rs | 11 +- crates/beacon_state/data/src/view.rs | 49 +---- crates/beacon_state/tile/src/tile.rs | 1 - crates/beacon_state/tile/src/tile/block.rs | 1 - crates/beacon_state/tile/src/tile/tests.rs | 28 ++- 14 files changed, 388 insertions(+), 243 deletions(-) diff --git a/crates/application_boundary/src/lib.rs b/crates/application_boundary/src/lib.rs index a463c732..86ffb3d5 100644 --- a/crates/application_boundary/src/lib.rs +++ b/crates/application_boundary/src/lib.rs @@ -1,10 +1,11 @@ use std::time::Duration; use flux::{spine::SpineAdapter, tile::Tile}; -use silver_beacon_api::{BeaconApi, PeerCounts, SlotStatus}; +use silver_beacon_api::{BeaconApi, ChainHead, PeerCounts, SlotStatus}; use silver_beacon_state_data::{BeaconStateReader, SpecConfig}; use silver_common::{ BeaconStateEvent, Enr, Identify, Keypair, SilverSpine, SyncUpdate, TProducer, TRandomAccess, + ssz_view::StatusView, }; use silver_config::EngineConfig; use silver_engine_api::EngineApi; @@ -89,11 +90,22 @@ impl ApplicationBoundaryTile { // saturated loses everything published in the meantime. adapter.consume(|event: BeaconStateEvent, _| { if let BeaconStateEvent::Status { - latest_block_slot, wall_slot, head_optimistic, .. + ssz, + latest_block_slot, + wall_slot, + head_optimistic, + .. } = event { - status.slots = - Some(SlotStatus { head_slot: latest_block_slot, wall_slot, head_optimistic }); + status.slots = Some(SlotStatus { + latest_block_slot, + head: ChainHead { + root: *StatusView::head_root(&ssz), + slot: StatusView::head_slot(&ssz), + }, + wall_slot, + head_optimistic, + }); } }); adapter.consume(|update: SyncUpdate, _| { diff --git a/crates/application_boundary/tests/tile.rs b/crates/application_boundary/tests/tile.rs index 9753c8ea..09bfa63c 100644 --- a/crates/application_boundary/tests/tile.rs +++ b/crates/application_boundary/tests/tile.rs @@ -8,12 +8,12 @@ use std::{ use flux::{spine::SpineAdapter, tile::Tile}; use silver_application_boundary::ApplicationBoundaryTile; -use silver_beacon_api::{PeerCounts, SlotStatus}; +use silver_beacon_api::{ChainHead, PeerCounts, SlotStatus}; use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; use silver_common::{ BeaconStateEvent, ELSyncStatus, EngineFcuReq, EngineReq, EngineResp, Enr, Identify, Keypair, PayloadValidationStatus, SilverSpine, SyncUpdate, TCache, TCacheProducer, - ssz_view::STATUS_V2_SIZE, + ssz_view::{STATUS_V2_SIZE, StatusView}, }; use silver_config::EngineConfig; use silver_engine_api::test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}; @@ -144,12 +144,20 @@ fn drain_fcu_completions( }); } -fn status_event(head_slot: u64, wall_slot: u64, head_optimistic: bool) -> BeaconStateEvent { +/// Builds the event the beacon-state tile publishes for a given status. Fork +/// choice's head rides the Status payload that the p2p tiles send verbatim, +/// at the offsets `StatusView` reads. The other fields are the event's own. +fn status_event(status: SlotStatus) -> BeaconStateEvent { + let mut ssz = [0u8; STATUS_V2_SIZE]; + ssz[44..76].copy_from_slice(&status.head.root); + ssz[76..84].copy_from_slice(&status.head.slot.to_le_bytes()); + assert_eq!(StatusView::head_root(&ssz), &status.head.root); + assert_eq!(StatusView::head_slot(&ssz), status.head.slot); BeaconStateEvent::Status { - ssz: [0u8; STATUS_V2_SIZE], - head_optimistic, - latest_block_slot: head_slot, - wall_slot, + ssz, + head_optimistic: status.head_optimistic, + latest_block_slot: status.latest_block_slot, + wall_slot: status.wall_slot, enr_fork_id: [0u8; 16], } } @@ -458,33 +466,49 @@ fn node_status_tracks_the_spine_once_the_cursor_snaps() { 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)); + inj.produce(status_event(SlotStatus { + latest_block_slot: 1, + head: ChainHead { root: [1u8; 32], slot: 1 }, + wall_slot: 1, + head_optimistic: true, + })); tile.loop_body(&mut adapter); assert!( tile.beacon.node_status_mut().slots.is_none(), "a status published before the first consume is skipped, not delivered" ); - inj.produce(status_event(7, 9, true)); + // The import at slot 7 landed on a branch fork choice passed over. That + // block is not the head. The head stays the slot-6 block. + let passed_over = SlotStatus { + latest_block_slot: 7, + head: ChainHead { root: [6u8; 32], slot: 6 }, + wall_slot: 9, + head_optimistic: true, + }; + inj.produce(status_event(passed_over)); inj.produce(SyncUpdate::SyncingHead { head_root: [3u8; 32], head_slot: 9 }); tile.loop_body(&mut adapter); let status = *tile.beacon.node_status_mut(); - assert_eq!( - status.slots, - Some(SlotStatus { head_slot: 7, wall_slot: 9, head_optimistic: true }) - ); + assert_eq!(status.slots, Some(passed_over)); assert_eq!(status.slots.unwrap().sync_distance(), 2); assert!(status.syncing); - inj.produce(status_event(9, 9, false)); + let caught_up = SlotStatus { + latest_block_slot: 9, + head: ChainHead { root: [9u8; 32], slot: 9 }, + wall_slot: 9, + head_optimistic: false, + }; + inj.produce(status_event(caught_up)); inj.produce(SyncUpdate::Following); tile.loop_body(&mut adapter); let status = *tile.beacon.node_status_mut(); assert_eq!( status.slots, - Some(SlotStatus { head_slot: 9, wall_slot: 9, head_optimistic: false }), - "each status replaces the last, execution status included" + Some(caught_up), + "each status replaces the last, head and execution status included" ); assert!(!status.syncing, "reaching the target clears the syncing flag"); } @@ -544,7 +568,13 @@ fn node_status_updates_while_the_engine_pool_is_at_cap() { crank(&mut tile, &mut el, "pool saturated with unanswered FCUs"); } - inj.produce(status_event(7, 9, false)); + let published = SlotStatus { + latest_block_slot: 7, + head: ChainHead { root: [7u8; 32], slot: 7 }, + wall_slot: 9, + head_optimistic: false, + }; + inj.produce(status_event(published)); inj.produce(SyncUpdate::Following); while tile.beacon.node_status_mut().slots.is_none() { crank(&mut tile, &mut el, "status consumed while the pool is at cap"); @@ -552,10 +582,7 @@ fn node_status_updates_while_the_engine_pool_is_at_cap() { } let status = *tile.beacon.node_status_mut(); - assert_eq!( - status.slots, - Some(SlotStatus { head_slot: 7, wall_slot: 9, head_optimistic: false }) - ); + assert_eq!(status.slots, Some(published)); assert!(!status.syncing); assert_eq!(status.el, ELSyncStatus::Synced); } diff --git a/crates/beacon_api/src/blocks.rs b/crates/beacon_api/src/blocks.rs index 3207fb58..03315eba 100644 --- a/crates/beacon_api/src/blocks.rs +++ b/crates/beacon_api/src/blocks.rs @@ -1,6 +1,7 @@ use silver_beacon_state_data::{B256, BLSSignature, BeaconBlockHeader, Slot, StateReadView}; use crate::{ + ChainHead, ids::{parse_root, parse_uint64}, json::ReadFlags, response::Response, @@ -21,11 +22,6 @@ const ZERO_ROOT: B256 = [0u8; 32]; /// requires the field. const UNAVAILABLE_SIGNATURE: BLSSignature = [0u8; 96]; -/// Required beside `root` by the header schema. Asserted, not proved: these -/// endpoints read the last state applied, and fork choice — which re-heads on -/// attestations, between publishes — may have passed that branch over. -const CANONICAL: bool = true; - pub(crate) fn get_block_root(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { let Some(block) = read_block(req, ctx, resp) else { return; @@ -45,7 +41,7 @@ pub(crate) fn get_block_header(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Respo let flags = block.flags(ctx.node_status.execution_optimistic()); resp.json_body(|json| { json.flagged_envelope(flags, |json| { - json.block_header_data(&block.root, CANONICAL, &header, &UNAVAILABLE_SIGNATURE) + json.block_header_data(&block.root, block.canonical, &header, &UNAVAILABLE_SIGNATURE) }) }); } @@ -56,9 +52,8 @@ fn read_block(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) -> Optio resp.error(400, "invalid block_id"); return None; }; - let block = ctx.read_state_or(resp, 404, NOT_FOUND, |view, head_root| { - block_id.resolve(&view, head_root) - })?; + let head = ctx.node_status.head(); + let block = ctx.read_state_or(resp, 404, NOT_FOUND, |view| block_id.resolve(&view, head))?; if block.is_none() { resp.error(404, NOT_FOUND); } @@ -67,9 +62,11 @@ fn read_block(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) -> Optio struct Block { root: B256, - /// The head block's header, and only while the state carries it whole — - /// every other block this crate can name, it can name only by root. + /// The header of the block the state came from, present only once the + /// state carries that header whole. This crate names every other block by + /// root alone. header: Option, + canonical: bool, finalized: bool, } @@ -103,27 +100,63 @@ impl BlockId { }) } - /// `head_root` names the block the published state was applied from, which - /// the state itself cannot: between that block's arrival and the next slot - /// its header is the state's own with `state_root` still zero, and the - /// `block_roots` entry naming it is written by the `process_slot` that - /// fills it. - fn resolve(self, view: &StateReadView<'_>, head_root: B256) -> Option { - let head = view.slot.state().latest_block_header; + /// One `process_slot` fills `latest_block_header.state_root` and writes + /// the `block_roots` entry naming that block. A filled `state_root` + /// therefore proves the ring can name the state's own block. The header + /// becomes that block's only once `state_root` is in. Only that root + /// carries the header. + /// + /// `head` is fork choice's head, which the state cannot name at all. It + /// is not always the block the state came from. An import can land on a + /// branch that fork choice passes over. Before the first status names a + /// head, the state's own block is the only head this node has. + fn resolve(self, view: &StateReadView<'_>, head: Option) -> Option { + let own_header = view.slot.state().latest_block_header; + let state_slot = view.slot.slot_number(); + let own_block_root = (own_header.state_root != ZERO_ROOT) + .then(|| view.block_roots.proposed_at(own_header.slot, state_slot)) + .flatten() + .filter(|root| *root != ZERO_ROOT); + let head = + head.or_else(|| own_block_root.map(|root| ChainHead { root, slot: own_header.slot })); + let (root, finalized) = match self { - Self::Head => (head_root, view.epoch.finalizes_slot(head.slot)), - Self::Slot(slot) if slot == head.slot => (head_root, view.epoch.finalizes_slot(slot)), - Self::Slot(slot) => ( - view.block_roots.proposed_at(slot, view.slot.slot_number())?, - view.epoch.finalizes_slot(slot), - ), + Self::Head => { + let head = head?; + (head.root, view.epoch.finalizes_slot(head.slot)) + } + Self::Slot(slot) => { + let root = match view.block_roots.proposed_at(slot, state_slot) { + Some(root) => root, + // The ring cannot name one block: the head, between its + // arrival and the `process_slot` that records it. The + // head stands in only at its own slot. A slot that + // carried no block falls through to `None`. + None => head.filter(|head| head.slot == slot)?.root, + }; + (root, view.epoch.finalizes_slot(slot)) + } Self::Finalized => (view.epoch.finalized_block_root()?, true), - Self::Root(root) if root == head_root => (root, view.epoch.finalizes_slot(head.slot)), - // Every other root is a block this crate has no read path to. - Self::Root(_) => return None, + Self::Root(root) => { + // Every other root is a block this crate has no read path to. + let slot = head + .filter(|head| head.root == root) + .map(|head| head.slot) + .or_else(|| (Some(root) == own_block_root).then_some(own_header.slot))?; + (root, view.epoch.finalizes_slot(slot)) + } }; - let header = (root == head_root && head.state_root != ZERO_ROOT).then_some(head); - Some(Block { root, header, finalized }) + Some(Block { + root, + header: (Some(root) == own_block_root).then_some(own_header), + // Only a header response carries this flag. Only the state's own + // block gets a header. That block is canonical when fork choice + // heads on it. A block canonical without being the head answers + // `false`. That happens for an ancestor of a head this state has + // not caught up to. + canonical: Some(root) == head.map(|head| head.root), + finalized, + }) } } @@ -133,14 +166,13 @@ mod tests { BeaconState, BeaconStateOwner, Checkpoint, EpochState, EpochStateFinalized, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT, SpecConfig, ValSeed, }; - use silver_common::ELSyncStatus; use silver_httpcore::ParsedRequest; use super::*; use crate::{ - NodeStatus, PeerCounts, SlotStatus, + NodeStatus, SlotStatus, router::Router, - routes::{ROUTES, preboot_ctx, test_ctx}, + routes::{ROUTES, preboot_ctx, synced_status, test_ctx}, }; const FINALIZED_EPOCH: u64 = 280; @@ -174,6 +206,12 @@ mod tests { tagged(0xd0, slot) } + /// A block on another branch, at the head block's own slot. Fork choice + /// heads on one of these after a late or equivocating proposal. + fn sibling_root_of(slot: Slot) -> B256 { + tagged(0xb1, slot) + } + fn tagged(kind: u8, slot: Slot) -> B256 { let mut root = [kind; 32]; root[24..].copy_from_slice(&slot.to_be_bytes()); @@ -197,13 +235,11 @@ mod tests { }) } - /// A published head state grown a slot at a time the way the tile grows - /// one: every slot carries a block bar `empty_slots`, and the - /// `process_slot` that leaves a slot fills the previous header's - /// `state_root` and records the latest block's root. The walk stops with - /// the state at `state_slot`, so a block there is one no slot has followed - /// — and the newest block's root is published beside the state, as the - /// tile publishes it. + /// A published head state, grown a slot at a time the way the tile grows + /// one. Every slot carries a block except those in `empty_slots`. The + /// walk stops with the state at `state_slot`. No slot has followed a + /// block at that slot. The node's cached status names the newest block, + /// because fork choice heads on it. fn published_chain(finalized: Checkpoint, empty_slots: &[Slot], state_slot: Slot) -> ApiCtx { let base_slot = state_slot.saturating_sub(RECORDED_SLOTS); let mut owner = BeaconStateOwner::new(BeaconState::for_test( @@ -213,17 +249,17 @@ mod tests { )); let anchor = owner.roll_fresh(); let (mut writer, _, _) = owner.apply_block_view(anchor); - let mut head_root = ZERO_ROOT; + let mut head = ChainHead { root: ZERO_ROOT, slot: base_slot }; for slot in base_slot..=state_slot { if !empty_slots.contains(&slot) { writer.slot.state_mut().latest_block_header = BeaconBlockHeader { slot, proposer_index: proposer_of(slot), - parent_root: head_root, + parent_root: head.root, state_root: ZERO_ROOT, body_root: body_root_of(slot), }; - head_root = block_root_of(slot); + head = ChainHead { root: block_root_of(slot), slot }; } if slot == state_slot { break; @@ -234,21 +270,17 @@ mod tests { writer.block_roots.set(bucket, block_root_of(latest_block)); writer.slot.advance_slot(); } - let head = writer.commit(None, None); - owner.set_head_block_root(head_root); - owner.publish_state_id(head); + let published = writer.commit(None, None); + owner.publish_state_id(published); let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); - ctx.node_status = NodeStatus { - slots: Some(SlotStatus { - head_slot: state_slot, - wall_slot: state_slot, - head_optimistic: false, - }), - syncing: false, - el: ELSyncStatus::Synced, - peers: PeerCounts::default(), - }; + ctx.node_status = synced_status(head.slot, head.root); + ctx + } + + /// The same published state, with fork choice heading on another block. + fn heading_elsewhere(mut ctx: ApiCtx, head: ChainHead) -> ApiCtx { + ctx.node_status.slots = Some(SlotStatus { head, ..ctx.node_status.slots.unwrap() }); ctx } @@ -454,8 +486,9 @@ mod tests { assert_not_found(&young_ctx(), "finalized"); } - /// The published head block root is the one root this crate can confirm; - /// every other is a block it has no read path to. + /// This crate can confirm two roots: fork choice's head, and the block + /// the state came from. They are the same block here. This crate has no + /// read path to any other block. #[test] fn a_root_is_answered_only_for_the_head_block() { let ctx = head_ctx(); @@ -472,12 +505,12 @@ mod tests { } } - /// The window this pair of endpoints exists for: between a block's arrival - /// and the next slot the state cannot name it — `latest_block_header` - /// still carries the zero `state_root`, and no ring entry names the block - /// — but the root published beside the state can, under all three of its - /// names. The header is what waits: it is not the block's until the next - /// `process_slot` fills it. + /// This pair of endpoints exists for one window. Between a block's + /// arrival and the next slot, the state cannot name that block. + /// `latest_block_header` still carries the zero `state_root`. No ring + /// entry names the block either. The node's cached status names it + /// anyway, under all three of its names. The header is what waits. It + /// becomes the block's only when the next `process_slot` fills it. #[test] fn the_head_is_named_before_its_own_state_can_name_it() { let ctx = just_applied_ctx(); @@ -493,9 +526,9 @@ mod tests { ); } - /// A served header is served with the root the state was published with, - /// not with one derived from the header itself — the two agree only - /// because the header is complete. + /// A served header is always a complete one. The `state_root` it carries + /// is its own block's post-state. Filling that field is what lets the ring + /// name that block. #[test] fn a_served_header_carries_the_state_root_of_its_own_post_state() { let header = &parsed(&head_ctx(), &header_path("head"))["data"]["header"]["message"]; @@ -503,6 +536,32 @@ mod tests { assert_ne!(header["state_root"], root_text(ZERO_ROOT)); } + /// Each header goes out under the root of the block it belongs to, and no + /// other. Fork choice's head is not always the block the state came from. + /// The state carries that one block's header alone. A head naming another + /// block answers by root, with no header beside it. The state's own block + /// answers under its own root, header and all. + #[test] + fn a_header_is_never_served_beside_another_block_s_root() { + let elsewhere = ChainHead { root: sibling_root_of(HEAD_BLOCK_SLOT), slot: HEAD_BLOCK_SLOT }; + let ctx = heading_elsewhere(head_ctx(), elsewhere); + + assert_eq!(served_root(&ctx, "head"), root_text(elsewhere.root)); + assert_no_header(&ctx, "head"); + assert_no_header(&ctx, &root_text(elsewhere.root)); + + let own = root_text(block_root_of(HEAD_BLOCK_SLOT)); + assert_eq!(served_root(&ctx, &own), own); + let served = &parsed(&ctx, &header_path(&own))["data"]; + assert_eq!(served["root"], own); + assert_eq!(served["canonical"], false, "fork choice has passed this branch over"); + assert_eq!( + served_root(&ctx, &HEAD_BLOCK_SLOT.to_string()), + own, + "the ring names the state's own block, whatever fork choice heads on" + ); + } + /// `finalized` describes the block served, not the state read: the same /// state answers both ways either side of the finalized checkpoint's slot. #[test] @@ -557,6 +616,47 @@ mod tests { } } + /// A node can publish a state and still hear no status. That window runs + /// from bootstrap to the first imported block. A node that finds no peer + /// never leaves it. Such a node still has a head to name: the block its + /// own state came from. Fork choice can only have headed on that block. + /// Nimbus treats a 404 here as an incompatible beacon node, and deselects + /// it. + #[test] + fn the_state_s_own_block_is_the_head_until_the_first_status() { + let mut ctx = head_ctx(); + ctx.node_status = NodeStatus::default(); + let root = root_text(block_root_of(HEAD_BLOCK_SLOT)); + + for block_id in ["head", &HEAD_BLOCK_SLOT.to_string(), &root] { + assert_eq!(served_root(&ctx, block_id), root, "{block_id}"); + let served = &parsed(&ctx, &header_path(block_id))["data"]; + assert_eq!(served["root"], root, "{block_id}"); + assert_eq!(served["canonical"], true, "{block_id}"); + } + } + + /// A request naming a slot gets a block of that slot, or nothing. The + /// head stands in for the one block the ring cannot name. It stands in + /// only at the slot the head sits at. So `head` and the head's own slot + /// never disagree. + #[test] + fn a_slot_is_never_answered_with_a_block_of_another_slot() { + let behind = ChainHead { root: block_root_of(HEAD_BLOCK_SLOT), slot: HEAD_BLOCK_SLOT }; + let ctx = heading_elsewhere(just_applied_ctx(), behind); + assert_eq!(served_root(&ctx, &HEAD_BLOCK_SLOT.to_string()), root_text(behind.root)); + assert_not_found(&ctx, &STATE_SLOT.to_string()); + + let ahead = ChainHead { root: sibling_root_of(STATE_SLOT), slot: STATE_SLOT }; + let ctx = heading_elsewhere(head_ctx(), ahead); + assert_eq!(served_root(&ctx, "head"), root_text(ahead.root)); + assert_eq!( + served_root(&ctx, &STATE_SLOT.to_string()), + root_text(ahead.root), + "a head the published state has not caught up to answers under its own slot too" + ); + } + /// Neither schema declares a 503, so a node with no state published /// answers the only way it can — and the `block_id` verdict does not wait /// on a state to read. diff --git a/crates/beacon_api/src/duties/proposer.rs b/crates/beacon_api/src/duties/proposer.rs index 57408dbf..5d8152c3 100644 --- a/crates/beacon_api/src/duties/proposer.rs +++ b/crates/beacon_api/src/duties/proposer.rs @@ -1,6 +1,7 @@ use silver_beacon_state_data::{B256, BLSPubkey, Epoch, SLOTS_PER_EPOCH, Slot, StateReadView}; use crate::{ + ChainHead, duties::{CURRENTLY_SYNCING, DutyWindow, OutOfWindow, RequestedEpoch}, response::Response, router::Request, @@ -32,9 +33,8 @@ fn respond_with_proposers( let Some(requested) = RequestedEpoch::parse(req, ctx, resp) else { return; }; - let read = |view: StateReadView<'_>, head_root| { - EpochProposers::read(&view, head_root, requested, dependent) - }; + let head = ctx.node_status.head(); + let read = |view: StateReadView<'_>| EpochProposers::read(&view, head, requested, dependent); let Some(answer) = ctx.read_state_or(resp, 503, CURRENTLY_SYNCING, read) else { return; }; @@ -61,15 +61,14 @@ struct EpochProposers { impl EpochProposers { fn read( view: &StateReadView<'_>, - head_root: B256, + head: Option, requested: RequestedEpoch, dependent: DependentEpoch, ) -> Result { let epochs_ahead = DutyWindow::ProposerLookahead.units_ahead(requested, view.slot.current_epoch())?; - let dependent_root = dependent - .root(view, head_root, requested.epoch) - .ok_or(ProposerError::NoDependentRoot)?; + let dependent_root = + dependent.root(view, head, requested.epoch).ok_or(ProposerError::NoDependentRoot)?; let first_slot = requested.epoch * SLOTS_PER_EPOCH; let first_entry = epochs_ahead * SLOTS_PER_EPOCH; @@ -110,17 +109,17 @@ impl DependentEpoch { /// `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch) - 1)`, /// or the genesis block root on underflow — the entry slot zero records. /// A [`Self::Requested`] epoch above the head state's own names a slot - /// that has not happened, and the endpoint's head-event rule applies - /// instead: a client matches the answer against `event.block` unless the - /// head is in the epoch it asked for, so the answer is the head block's - /// root. - fn root(self, view: &StateReadView<'_>, head_root: B256, epoch: Epoch) -> Option { + /// that has not happened. The endpoint's head-event rule applies instead. + /// A client matches the answer against `event.block`, unless the head is + /// in the epoch it asked for. So the answer is fork choice's head, the + /// block root on the canonical chain that the rule compares against. + fn root(self, view: &StateReadView<'_>, head: Option, epoch: Epoch) -> Option { let dependent = match self { Self::Requested => epoch, Self::Preceding => epoch.saturating_sub(1), }; if dependent > view.slot.current_epoch() { - return Some(head_root); + return head.map(|head| head.root); } view.block_roots .recorded_at((dependent * SLOTS_PER_EPOCH).saturating_sub(1), view.slot.slot_number()) @@ -131,7 +130,9 @@ enum ProposerError { OutOfWindow(OutOfWindow), /// `block_roots` holds no entry for the slot the dependent root names, /// which past genesis it always does — only a state still on slot zero - /// depends on a block root the state itself does not carry. + /// depends on a block root the state itself does not carry. An epoch that + /// answers from the head also lands here, until the first status names a + /// head. NoDependentRoot, /// The lookahead names a validator this state's own registry does not /// hold. Nothing a request can send causes it, and leaving the slot out of diff --git a/crates/beacon_api/src/duties/sync.rs b/crates/beacon_api/src/duties/sync.rs index 22eb35fa..3e514bcd 100644 --- a/crates/beacon_api/src/duties/sync.rs +++ b/crates/beacon_api/src/duties/sync.rs @@ -30,7 +30,7 @@ pub(crate) fn post_sync_duties(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Respo return; } }; - let read = |view: StateReadView<'_>, _| { + let read = |view: StateReadView<'_>| { CommitteeSeats::serving(&view, requested) .map(|seats| seats.duties(&view.validators, &indices)) }; diff --git a/crates/beacon_api/src/duties/tests.rs b/crates/beacon_api/src/duties/tests.rs index c1d141a3..fdbf8905 100644 --- a/crates/beacon_api/src/duties/tests.rs +++ b/crates/beacon_api/src/duties/tests.rs @@ -3,15 +3,14 @@ use silver_beacon_state_data::{ EpochStateFinalized, LongtailGroup, LongtailState, SLOTS_PER_HISTORICAL_ROOT, SYNC_COMMITTEE_SIZE, Slot, SpecConfig, SyncCommittee, ValSeed, }; -use silver_common::ELSyncStatus; use silver_httpcore::ParsedRequest; use super::*; use crate::{ - NodeStatus, PeerCounts, SlotStatus, + ChainHead, NodeStatus, SlotStatus, ids::MAX_BODY_IDS, router::Router, - routes::{ROUTES, preboot_ctx, test_ctx}, + routes::{ROUTES, preboot_ctx, synced_status, test_ctx}, }; /// Mid-epoch and mid-period: the head sits in epoch 300, which is in sync @@ -151,12 +150,10 @@ impl Default for Fixture { } impl Fixture { - /// A published head state grown a slot at a time the way the tile - /// grows one: every slot carries a block bar `empty_slots`, and the - /// `process_slot` that leaves a slot records the latest block's root - /// — so an empty slot's entry repeats its predecessor's. The newest - /// block's root is published beside the state, as the tile publishes - /// it. + /// A published head state, grown a slot at a time the way the tile + /// grows one. Every slot carries a block except those in `empty_slots`, + /// whose ring entry repeats its predecessor's. The node's cached status + /// names the newest block, because fork choice heads on it. fn published(self) -> ApiCtx { let seeds: Vec = (0..self.validator_count) .map(|index| ValSeed { pubkey: pubkey_of(index), ..Default::default() }) @@ -168,12 +165,12 @@ impl Fixture { let mut owner = BeaconStateOwner::new(state); let anchor = owner.roll_fresh(); let (mut writer, _, _) = owner.apply_block_view(anchor); - let mut head_root = [0u8; 32]; + let mut head = ChainHead { root: [0u8; 32], slot: base_slot }; for slot in base_slot..=self.state_slot { if !self.empty_slots.contains(&slot) { writer.slot.state_mut().latest_block_header = - BeaconBlockHeader { slot, parent_root: head_root, ..Default::default() }; - head_root = block_root_of(slot); + BeaconBlockHeader { slot, parent_root: head.root, ..Default::default() }; + head = ChainHead { root: block_root_of(slot), slot }; } if slot == self.state_slot { break; @@ -184,29 +181,15 @@ impl Fixture { writer.block_roots.set(bucket, block_root_of(latest_block)); writer.slot.advance_slot(); } - let head = writer.commit(None, None); - owner.set_head_block_root(head_root); - owner.publish_state_id(head); + let published = writer.commit(None, None); + owner.publish_state_id(published); let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); - ctx.node_status = synced_at(self.state_slot); + ctx.node_status = synced_status(head.slot, head.root); ctx } } -fn at_wall_slot(head_slot: Slot, wall_slot: Slot) -> NodeStatus { - NodeStatus { - slots: Some(SlotStatus { head_slot, wall_slot, head_optimistic: false }), - syncing: false, - el: ELSyncStatus::Synced, - peers: PeerCounts::default(), - } -} - -fn synced_at(head_slot: Slot) -> NodeStatus { - at_wall_slot(head_slot, head_slot) -} - fn head_ctx() -> ApiCtx { Fixture::default().published() } @@ -216,7 +199,10 @@ fn head_ctx() -> ApiCtx { fn head_behind_wall(head_epoch: Epoch, wall_epoch: Epoch) -> ApiCtx { let state_slot = head_epoch * SLOTS_PER_EPOCH + 1; let mut ctx = Fixture { state_slot, ..Default::default() }.published(); - ctx.node_status = at_wall_slot(state_slot, wall_epoch * SLOTS_PER_EPOCH); + ctx.node_status.slots = Some(SlotStatus { + wall_slot: wall_epoch * SLOTS_PER_EPOCH, + ..ctx.node_status.slots.unwrap() + }); ctx } @@ -340,6 +326,35 @@ fn the_next_epoch_reads_the_far_half_of_the_lookahead() { ); } +/// A dependent slot that has not happened answers with fork choice's head, +/// not with the block the published state came from. The spec's dependent +/// root is a block root on the canonical chain. A client matches the answer +/// against the head event. +#[test] +fn a_dependent_root_past_the_state_is_fork_choice_s_head() { + let mut ctx = head_ctx(); + let elsewhere = ChainHead { root: block_root_of(HEAD_SLOT + 1), slot: HEAD_SLOT + 1 }; + ctx.node_status.slots = Some(SlotStatus { head: elsewhere, ..ctx.node_status.slots.unwrap() }); + assert_eq!( + ok_body(&get_proposers(&ctx, &NEXT_EPOCH.to_string())), + expected_proposers(NEXT_EPOCH, elsewhere.root) + ); +} + +/// An epoch whose dependent slot has not happened stays unanswerable until +/// the first status names a head. An epoch the state's own `block_roots` +/// covers answers throughout. +#[test] +fn a_dependent_root_that_needs_the_head_waits_for_the_first_status() { + let mut ctx = head_ctx(); + ctx.node_status = NodeStatus::default(); + assert_syncing(&get_proposers(&ctx, &NEXT_EPOCH.to_string())); + let answered = ok_body(&get_proposers(&ctx, &HEAD_EPOCH.to_string())); + let dependent = + format!("{{\"dependent_root\":\"{}\"", root_text(block_root_of(DEPENDENT_SLOT))); + assert!(answered.starts_with(&dependent), "{answered}"); +} + /// `proposer.v2.yaml` names `compute_start_slot_at_epoch(epoch - 1) - 1` for /// every epoch, one further back than v1: the boundary the lookahead an /// epoch's proposers came from was seeded at. Both epochs the window serves @@ -423,7 +438,7 @@ fn an_epoch_the_wall_clock_has_scheduled_is_503_while_the_head_is_behind() { #[test] fn an_unscheduled_epoch_is_400_even_while_syncing() { let mut ctx = head_ctx(); - ctx.node_status = NodeStatus { syncing: true, ..synced_at(HEAD_SLOT) }; + ctx.node_status.syncing = true; for version in [1, 2] { assert_bad_request( &get_proposers_v(&ctx, version, &(HEAD_EPOCH + 2).to_string()), @@ -547,10 +562,8 @@ fn a_proposer_the_registry_does_not_hold_is_500() { #[test] fn execution_optimistic_follows_the_head() { let mut ctx = head_ctx(); - ctx.node_status = NodeStatus { - slots: Some(SlotStatus { head_optimistic: true, ..synced_at(HEAD_SLOT).slots.unwrap() }), - ..synced_at(HEAD_SLOT) - }; + ctx.node_status.slots = + Some(SlotStatus { head_optimistic: true, ..ctx.node_status.slots.unwrap() }); for version in [1, 2] { assert!( ok_body(&get_proposers_v(&ctx, version, &HEAD_EPOCH.to_string())) diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index cdbd5b36..94bb4e03 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -14,5 +14,5 @@ mod server; mod statics; mod validators; -pub use node_status::{NodeStatus, PeerCounts, SlotStatus}; +pub use node_status::{ChainHead, NodeStatus, PeerCounts, SlotStatus}; pub use server::BeaconApi; diff --git a/crates/beacon_api/src/node_status.rs b/crates/beacon_api/src/node_status.rs index 8bdab79d..f113da6e 100644 --- a/crates/beacon_api/src/node_status.rs +++ b/crates/beacon_api/src/node_status.rs @@ -1,4 +1,4 @@ -use silver_beacon_state_data::{Epoch, SLOTS_PER_EPOCH}; +use silver_beacon_state_data::{B256, Epoch, SLOTS_PER_EPOCH}; use silver_common::ELSyncStatus; use crate::json::{PeerCountData, SyncingData}; @@ -36,6 +36,12 @@ impl NodeStatus { self.slots.is_none_or(|slots| slots.head_optimistic) } + /// Returns `None` until the first status arrives. This node has no head + /// to name before then. + pub(crate) fn head(&self) -> Option { + self.slots.map(|slots| slots.head) + } + /// The spec puts an optimistic or offline execution layer on the same /// footing as a syncing beacon node — both mean "data served may be /// incorrect" — and an EL we have not heard from yet is no better @@ -55,7 +61,7 @@ impl NodeStatus { /// to attest against nothing. pub(crate) fn syncing_data(&self) -> SyncingData { SyncingData { - head_slot: self.slots.map_or(0, |slots| slots.head_slot), + head_slot: self.slots.map_or(0, |slots| slots.latest_block_slot), sync_distance: self.sync_distance(), is_syncing: self.is_syncing(), is_optimistic: self.execution_optimistic(), @@ -105,20 +111,32 @@ pub struct PeerCounts { pub connecting: u64, } -/// `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, + /// Holds the slot of the highest block this node imported, on whichever + /// branch it landed. A `sync_distance` of one is ordinary on a synced + /// node. The current slot's block lands partway into that slot. An empty + /// slot carries no block at all. + pub latest_block_slot: u64, + pub head: ChainHead, pub wall_slot: u64, pub head_optimistic: bool, } impl SlotStatus { - /// Saturating: a head ahead of the wall clock (a peer's block accepted - /// early in the slot) is zero distance, not an underflow. + /// Saturates instead of underflowing. A block can arrive ahead of the + /// wall clock, when a peer's block lands early in the slot. That case + /// reports zero distance. pub fn sync_distance(&self) -> u64 { - self.wall_slot.saturating_sub(self.head_slot) + self.wall_slot.saturating_sub(self.latest_block_slot) } } + +/// The block fork choice has settled on. This is not always the block the +/// published state came from. An import can land on a branch that fork choice +/// passes over. The two then name different blocks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ChainHead { + pub root: B256, + pub slot: u64, +} diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index 24e9d8cb..3efe8229 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -2,11 +2,15 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; #[cfg(test)] -use silver_beacon_state_data::BeaconStateOwner; -use silver_beacon_state_data::{B256, BeaconStateReader, SpecConfig, StateReadView}; +use silver_beacon_state_data::{B256, BeaconStateOwner, Slot}; +use silver_beacon_state_data::{BeaconStateReader, SpecConfig, StateReadView}; +#[cfg(test)] +use silver_common::ELSyncStatus; use silver_common::{Enr, Identify, Keypair}; use silver_httpcore::Query; +#[cfg(test)] +use crate::{ChainHead, PeerCounts, SlotStatus}; use crate::{ NodeStatus, blocks::{get_block_header, get_block_root}, @@ -95,18 +99,17 @@ impl ApiCtx { } } - /// The published state and the root of the block it was applied from, or - /// `code`/`message` while the node has published none. Which code that is - /// belongs to the endpoint: the state reads' schemas declare no 503, the - /// duties' no 404. + /// Reads the published state, or answers `code`/`message` while the node + /// has published none. The endpoint picks that code. The state reads' + /// schemas declare no 503, while the duties' schemas declare no 404. pub(crate) fn read_state_or( &self, resp: &mut Response<'_>, code: u16, message: &str, - read: impl Fn(StateReadView<'_>, B256) -> R, + read: impl Fn(StateReadView<'_>) -> R, ) -> Option { - let result = self.state.read_head(&read); + let result = self.state.read(&read); if result.is_none() { resp.error(code, message); } @@ -134,7 +137,7 @@ impl ApiCtx { } let execution_optimistic = self.node_status.execution_optimistic(); - let read = |view: StateReadView<'_>, _| StateRead { + let read = |view: StateReadView<'_>| StateRead { flags: ReadFlags { execution_optimistic, // Genesis is the only state that is its own finalized history: @@ -181,12 +184,10 @@ fn is_recognized_state_id(state_id: &str) -> bool { 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, - } + 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; @@ -284,6 +285,23 @@ pub(crate) fn preboot_ctx() -> ApiCtx { test_ctx(&SpecConfig::mainnet(), BeaconStateOwner::empty_test(0).reader()) } +/// A node fully caught up with the chain. Its head is the block `head_root` +/// names, at `head_slot`. +#[cfg(test)] +pub(crate) fn synced_status(head_slot: Slot, head_root: B256) -> NodeStatus { + NodeStatus { + slots: Some(SlotStatus { + latest_block_slot: head_slot, + head: ChainHead { root: head_root, slot: head_slot }, + wall_slot: head_slot, + head_optimistic: false, + }), + syncing: false, + el: ELSyncStatus::Synced, + peers: PeerCounts::default(), + } +} + #[cfg(test)] pub(crate) fn test_ctx(spec: &SpecConfig, state: BeaconStateReader) -> ApiCtx { let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); @@ -298,11 +316,11 @@ mod tests { use silver_beacon_state_data::{ BeaconState, Checkpoint, EpochState, EpochStateFinalized, Fork, SLOTS_PER_EPOCH, }; - use silver_common::{AGENT_VERSION, ELSyncStatus}; + use silver_common::AGENT_VERSION; use silver_httpcore::ParsedRequest; use super::*; - use crate::{PeerCounts, SlotStatus, router::Router}; + use crate::router::Router; /// Wire bytes the pre-table implementation produced for these exact /// inputs (captured before the table dispatch landed). @@ -394,19 +412,13 @@ mod tests { } fn ready() -> NodeStatus { - NodeStatus { - slots: Some(SlotStatus { head_slot: 100, wall_slot: 100, head_optimistic: false }), - syncing: false, - el: ELSyncStatus::Synced, - peers: PeerCounts::default(), - } + synced_status(100, [0xb0; 32]) } - fn head_at(head_slot: u64, wall_slot: u64) -> NodeStatus { - NodeStatus { - slots: Some(SlotStatus { head_slot, wall_slot, head_optimistic: false }), - ..ready() - } + fn head_at(latest_block_slot: Slot, wall_slot: Slot) -> NodeStatus { + let mut status = synced_status(latest_block_slot, [0xb0; 32]); + status.slots = Some(SlotStatus { wall_slot, ..status.slots.unwrap() }); + status } fn health_response(status: NodeStatus, query: &str) -> Vec { diff --git a/crates/beacon_api/src/validators/mod.rs b/crates/beacon_api/src/validators/mod.rs index 50166725..b23924b6 100644 --- a/crates/beacon_api/src/validators/mod.rs +++ b/crates/beacon_api/src/validators/mod.rs @@ -67,15 +67,13 @@ mod tests { BeaconState, BeaconStateOwner, Epoch, EpochStateFinalized, FAR_FUTURE_EPOCH, SLOTS_PER_EPOCH, SpecConfig, StateId, ValSeed, Withdrawals, }; - use silver_common::ELSyncStatus; use silver_httpcore::ParsedRequest; use super::*; use crate::{ - NodeStatus, PeerCounts, SlotStatus, json::Json, router::Router, - routes::{ROUTES, preboot_ctx, test_ctx}, + routes::{ROUTES, preboot_ctx, synced_status, test_ctx}, }; const HEAD_EPOCH: Epoch = 100; @@ -181,12 +179,7 @@ mod tests { fn published_ctx(seeds: &[ValSeed], extras: &[Extra], slot: u64) -> ApiCtx { let (owner, _) = published_state(seeds, extras, slot); let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); - ctx.node_status = NodeStatus { - slots: Some(SlotStatus { head_slot: slot, wall_slot: slot, head_optimistic: false }), - syncing: false, - el: ELSyncStatus::Synced, - peers: PeerCounts::default(), - }; + ctx.node_status = synced_status(slot, [0xb0; 32]); ctx } diff --git a/crates/beacon_state/data/src/view.rs b/crates/beacon_state/data/src/view.rs index db3336da..6828e06a 100644 --- a/crates/beacon_state/data/src/view.rs +++ b/crates/beacon_state/data/src/view.rs @@ -9,7 +9,7 @@ use flux::communication::Seqlock; use flux_profiler::timed; use crate::{ - B256, BeaconState, EpochGroup, LongtailGroup, StateId, StateReadView, StateWriterView, + BeaconState, EpochGroup, LongtailGroup, StateId, StateReadView, StateWriterView, encode::GLOAS_VAR_LEN_SECTIONS, }; @@ -42,7 +42,6 @@ impl StateCell { pub struct BeaconStateOwner { state: Arc, inner: Arc>, - head_block_root: B256, } impl BeaconStateOwner { @@ -53,7 +52,6 @@ impl BeaconStateOwner { Self { state: Arc::new(StateCell(UnsafeCell::new(state))), inner: Arc::new(Seqlock::default()), - head_block_root: [0u8; 32], } } @@ -119,14 +117,6 @@ impl BeaconStateOwner { self.inner.read_copy().map_or_else(|_| ControlInner::default(), |(value, _)| value) } - /// Name the block whose post-state the publishes from here on carry. Set - /// per applied block rather than per publish: the empty-slot advances and - /// the finalize window between two blocks publish states of that same - /// block, and each carries the root forward untouched. - pub fn set_head_block_root(&mut self, root: B256) { - self.head_block_root = root; - } - /// Publish the head's index bundle for cross-thread readers — call only /// once the per-tier slots it names will no longer be mutated. The first /// publish is what makes the state observable at all. Carries the @@ -136,7 +126,6 @@ impl BeaconStateOwner { let mut value = self.current_control(); debug_assert!(value.finalize_version & 1 == 0, "publish inside a write window"); value.state_id = Some(state_id); - value.head_block_root = self.head_block_root; // Single producer; `write` also handles the never-written 0→2 case. self.inner.write(&value); } @@ -195,24 +184,10 @@ impl BeaconStateReader { /// inactivity boxes) are safe to read optimistically. The pending / /// longtail bases are realloc-prone `Vec`s — reading their CONTENT here /// can race a finalize realloc; those reads need the lock-guarded path. + #[timed] pub fn read(&self, reader: &F) -> Option where F: Fn(StateReadView<'_>) -> R, - { - self.read_head(&|view, _| reader(view)) - } - - /// [`Self::read`], also naming the block whose post-state the snapshot is. - /// Both come off one control word, so no request can pair a root with a - /// state that was applied from another block. The state cannot supply the - /// root itself: between a block's arrival and the next slot its - /// `latest_block_header` still carries the zero `state_root` the STF left, - /// and the `block_roots` entry naming the block is written by the - /// `process_slot` that fills it. - #[timed] - pub fn read_head(&self, reader: &F) -> Option - where - F: Fn(StateReadView<'_>, B256) -> R, { loop { // `Err(Empty)` = never written; `state_id: None` = a pre-publish @@ -225,7 +200,7 @@ impl BeaconStateReader { } let state_id = control.state_id?; sync::atomic::fence(Ordering::Acquire); - let result = reader(self.state.get().read_view(state_id), control.head_block_root); + let result = reader(self.state.get().read_view(state_id)); // Validate: no finalize ran while we were reading the state. sync::atomic::fence(Ordering::Acquire); @@ -425,18 +400,16 @@ impl<'a> Drop for WriteGuard<'a> { } } -/// The control word: the published head's per-tier index bundle, the root of -/// the block it was applied from, plus the finalize counter (odd = finalize -/// window open). Publishes rewrite `state_id` but keep the counter — tiers are -/// append-only between finalizations, so a publish never invalidates an -/// in-flight read; only the finalize window (which rebases and frees tier -/// slots) does. `Default` exists only because `Seqlock::default()` requires -/// it; readers treat `state_id: None` (only reachable when a finalize window -/// closes before the first publish) as "no state yet" — every publish writes -/// `Some`. +/// The control word: the published head's per-tier index bundle plus the +/// finalize counter (odd = finalize window open). Publishes rewrite +/// `state_id` but keep the counter — tiers are append-only between +/// finalizations, so a publish never invalidates an in-flight read; only the +/// finalize window (which rebases and frees tier slots) does. `Default` +/// exists only because `Seqlock::default()` requires it; readers treat +/// `state_id: None` (only reachable when a finalize window closes before the +/// first publish) as "no state yet" — every publish writes `Some`. #[derive(Clone, Copy, Default)] struct ControlInner { state_id: Option, - head_block_root: B256, finalize_version: u64, } diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 49016867..5202ad12 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -346,7 +346,6 @@ impl BeaconStateTile { let trusted = Checkpoint { epoch: slot.div_ceil(SLOTS_PER_EPOCH), root: block_root }; self.last_applied_block_root = block_root; - self.state.set_head_block_root(block_root); let anchor_is_gloas = self.state.read_view(anchor).is_gloas(); self.fork_choice = ForkChoice::init( diff --git a/crates/beacon_state/tile/src/tile/block.rs b/crates/beacon_state/tile/src/tile/block.rs index 6789a4df..3e0346f5 100644 --- a/crates/beacon_state/tile/src/tile/block.rs +++ b/crates/beacon_state/tile/src/tile/block.rs @@ -340,7 +340,6 @@ impl BeaconStateTile { // advance lands this import, not one recompute later. self.last_applied = new_id; self.last_applied_block_root = parsed.block_root; - self.state.set_head_block_root(parsed.block_root); if is_gloas { self.notify_ptc_from_block(block_data); diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index 7a1b819e..ac2c894c 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -191,7 +191,6 @@ fn arm_tile_state( // epoch/longtail stay lazy. Rolled before the owner wraps the state. let anchor = bs.roll_fresh(); let mut owner = BeaconStateOwner::new(bs); - owner.set_head_block_root(ANCHOR_ROOT); owner.publish_state_id(anchor); tile.state = owner; @@ -357,24 +356,24 @@ fn status_event_carries_the_head_s_execution_status() { assert!(!head_optimistic(&mut tile)); } -fn published_head_block_root(tile: &BeaconStateTile) -> B256 { - tile.reader().read_head(&|_, root| root).expect("a state is published") +fn status_head_root(tile: &mut BeaconStateTile) -> B256 { + match tile.status_event() { + BeaconStateEvent::Status { ssz, .. } => *StatusView::head_root(&ssz), + ev => panic!("status_event produced {ev:?}"), + } } -/// The published state names the block it was applied from, from the publish -/// that first makes that state visible — which is the only way a reader can -/// name it mid-slot: the header `process_block_header` left still carries the -/// zero `state_root`, and the `block_roots` entry naming the block is written -/// by the `process_slot` that fills it, a slot later. The empty slots between -/// two blocks republish the state, and keep naming the same block. +/// The status event alone names a block just applied. The state that block +/// produced cannot name it until the next slot, as the assertions below spell +/// out. The empty slots between two blocks keep naming the same block. #[test] -fn the_published_state_names_the_block_it_was_applied_from() { +fn a_just_applied_block_is_named_by_the_status_event_alone() { const CHILD_SLOT: Slot = 11; const CHILD_ROOT: B256 = [0x0C; 32]; let mut tile = make_tile(); seed_tile(&mut tile, 4, 10); - assert_eq!(published_head_block_root(&tile), ANCHOR_ROOT); + assert_eq!(status_head_root(&mut tile), ANCHOR_ROOT); let header = BeaconBlockHeader { slot: CHILD_SLOT, @@ -425,7 +424,7 @@ fn the_published_state_names_the_block_it_was_applied_from() { }, ); - assert_eq!(published_head_block_root(&tile), CHILD_ROOT); + assert_eq!(status_head_root(&mut tile), CHILD_ROOT); let (state_root, recorded) = tile .reader() .read(&|v| { @@ -439,7 +438,7 @@ fn the_published_state_names_the_block_it_was_applied_from() { assert_eq!(recorded, None, "nor does the ring, until the next slot"); tile.on_slot_start(CHILD_SLOT + 1); - assert_eq!(published_head_block_root(&tile), CHILD_ROOT, "an empty slot changes no head"); + assert_eq!(status_head_root(&mut tile), CHILD_ROOT, "an empty slot changes no head"); } #[test] @@ -1802,8 +1801,7 @@ fn multi_fork_finalize_promotes_and_rebases() { tile.last_applied = d_id; tile.last_applied_block_root = D_ROOT; tile.fork_choice.finalized_checkpoint = f_cp; - // Republish so the seqlock control matches the new head. - tile.state.set_head_block_root(D_ROOT); + // Republish so the seqlock control names the new head's bundle. tile.state.publish_state_id(d_id); // Sanity: pre-finalize state.