Skip to content

adds beacon api - #81

Open
0w3n-d wants to merge 38 commits into
mainfrom
od/beacon_api
Open

adds beacon api#81
0w3n-d wants to merge 38 commits into
mainfrom
od/beacon_api

Conversation

@0w3n-d

@0w3n-d 0w3n-d commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@vladimir-ea

Copy link
Copy Markdown
Collaborator

can we add the beacon api to the engine api tile? it seems overkill to have cores assigned to marshalling http requests and responses for each api?

we could make this a component that is not an actual Tile but has a spin or loop method that could be called from a tile? that would give some flexibility in how it is deployed.

@vladimir-ea

Copy link
Copy Markdown
Collaborator

can we add the beacon api to the engine api tile? it seems overkill to have cores assigned to marshalling http requests and responses for each api?

we could make this a component that is not an actual Tile but has a spin or loop method that could be called from a tile? that would give some flexibility in how it is deployed.

also - if we run the http server as a separate tile then we should just run Axum in a single thread runtime and not take on the maintenance risk of writing our own web-server.

@0w3n-d

0w3n-d commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

can we add the beacon api to the engine api tile? it seems overkill to have cores assigned to marshalling http requests and responses for each api?

we could make this a component that is not an actual Tile but has a spin or loop method that could be called from a tile? that would give some flexibility in how it is deployed.

I do think the current tile setup is likely not optimal but we should probably wait until everything is running and we've gathered a bunch of data before deciding to consolidate tiles? When feature complete the system will have 3 integration points. p2p, Beacon api, and engine api. Currently it looks like we have 3 tiles for p2p stuff. Putting the Beacon and engine api in the same tile seems premature.

@0w3n-d

0w3n-d commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

can we add the beacon api to the engine api tile? it seems overkill to have cores assigned to marshalling http requests and responses for each api?
we could make this a component that is not an actual Tile but has a spin or loop method that could be called from a tile? that would give some flexibility in how it is deployed.

also - if we run the http server as a separate tile then we should just run Axum in a single thread runtime and not take on the maintenance risk of writing our own web-server.

I don't see why we'd go in that direction. That would pull in all of tokio and so on. Seems like the opposite to our approach elsewhere. The Beacon api is the interface to the validator client and commit boost and so on. If our design goals center low level, high performance, and innovation we probably shouldn't outsource a key interface to the axum, hypr, tokio stack.

Axum is just a nice wrapper around hypr and hypr is just state machine that sits on the same two crates I'm using here. Mio and httparse. And unlike the p2p interface, this one is purely internal so minimal security risk.

@vladimir-ea

Copy link
Copy Markdown
Collaborator

can we add the beacon api to the engine api tile? it seems overkill to have cores assigned to marshalling http requests and responses for each api?
we could make this a component that is not an actual Tile but has a spin or loop method that could be called from a tile? that would give some flexibility in how it is deployed.

I do think the current tile setup is likely not optimal but we should probably wait until everything is running and we've gathered a bunch of data before deciding to consolidate tiles? When feature complete the system will have 3 integration points. p2p, Beacon api, and engine api. Currently it looks like we have 3 tiles for p2p stuff. Putting the Beacon and engine api in the same tile seems premature.

well yes to not predetermining the tiles - which is why it makes sense as much as possible to create components that are not tiles but can be called from tiles

@vladimir-ea

Copy link
Copy Markdown
Collaborator

can we add the beacon api to the engine api tile? it seems overkill to have cores assigned to marshalling http requests and responses for each api?
we could make this a component that is not an actual Tile but has a spin or loop method that could be called from a tile? that would give some flexibility in how it is deployed.

also - if we run the http server as a separate tile then we should just run Axum in a single thread runtime and not take on the maintenance risk of writing our own web-server.

I don't see why we'd go in that direction. That would pull in all of tokio and so on. Seems like the opposite to our approach elsewhere. The Beacon api is the interface to the validator client and commit boost and so on. If our design goals center low level, high performance, and innovation we probably shouldn't outsource a key interface to the axum, hypr, tokio stack.

Axum is just a nice wrapper around hypr and hypr is just state machine that sits on the same two crates I'm using here. Mio and httparse. And unlike the p2p interface, this one is purely internal so minimal security risk.

the concern is not security, its just maintaining code which implements something that is already available and widely used - I see no issue in running tokio in a separate tile - or separate process on the same machine - if its for non-hot-path things.

@Bronek

Bronek commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

I have worked with Claude.ai to prepare a new design for Beacon API - the basic principles have been added to this branch in d096b51. Comments below will provide more details.

@Bronek

Bronek commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Design notes from Claude.ai


Goal (from team, channeled by Bronek)

  • No separate tile (loop_body) for any API access — serving (beacon_api) or calling (engine).
  • One new spine-attached tile (working name client_server) hosting hardcoded crates: beacon_api (kept name, refactored mechanics, HTTP server) and engine_api (renamed from engine, HTTP client).
  • Open: seam location for HTTP machinery (host implements client+server and provides them to hosted crates, vs crates implement internally).
  • Wanted: table-based dispatch for HTTP request handling (server side). Open: same for engine_api client side — decide on pros/cons.
  • Principles: simplicity without performance compromise; for HTTP specifically, simplicity + consistency take precedence (unavoidable multi-ms latency tax).

Mapped facts the designs rest on

  • TileConfig::new(n, None): n is a CPU core id; each tile = one OS thread pinned to its own core, busy-polling. Cores 1–7 taken (Controller, Network, BeaconState, Storage, Engine, DataColumns, BeaconApi). Merging engine + beacon_api frees a core — the mechanical driver behind "one tile for APIs".
  • crates/beacon_api (prototype, 596 lines): mio+httparse server, hardcoded 0.0.0.0:5051, blocking poll(100ms) in loop_body (must die in a shared loop), exact-match routing via closure F: Fn(&ParsedRequest, &mut Vec<u8>), 2 endpoints, no live state, 16 MiB/conn read buffers, no conn cap, ignores spine adapter. In-code TODO: parameterised routing needed.
  • crates/engine (mature): non-blocking mio client (poll Duration::ZERO), HttpPool one-req-per-conn growing unboundedly (10 MB read + 10 MB write per conn), hand-rolled JWT HS256 (1 s token cache), simd_json. One free function per method; response correlation FxHashMap<u64, ReqKind>; dispatch = match on EngineReq (inbound) and ReqKind (completion). HOT PATH: newPayload transcodes SSZ→JSON straight into persistent 10 MB scratch (client.rs:156-177, types.rs:269,345) — must be preserved verbatim. Dead parallel UDS transport ipc.rs (474-line near-copy of http.rs).
  • Spine contract (unchanged by any design): queues engine_reqs / engine_resps / engine_health; requests carry TCacheRead handles; responses written to tile-owned TCache. Producers: BeaconState, DataColumns.
  • State access for future endpoints: BeaconStateReader::read(|rv| ..) seqlock; closures short + re-runnable; None pre-bootstrap (docs/beacon-state-architecture.md §4-5).
  • Test gap: neither mio event loop has any end-to-end test (test-or-broken rule).
  • SpineAdapter::connect_tile is public — tests can build a real spine and drive loop_body by hand (verified by design agent A).

Recommendation (Claude's, for team review)

  1. Crates: client_server (tile: single mio Poll, token slab, loop composition), httpcore (one connection state machine, both roles, byte interfaces — no Poll, no sockets in signatures), beacon_api (ROUTES table + handlers + Request/Response/ApiCtx, transport-free), engine_api (renamed engine: codecs, correlation, JWT, ReqKind match, transport-free; scratch handed in, hot path verbatim).
  2. No traits at host↔hosted seams: two hardcoded crates = plain fields + fn calls (one adapter = hypothetical seam). A future third crate edits the host tile — accepted, cheap, honest.
  3. Transport testability via byte-level interfaces (C/D convergence): httpcore machines tested on byte slices with deterministic chunking (partial reads, pipelining, keep-alive, oversize) — no Mux trait needed when the seam is already &[u8].
    3a. Two production transports (team decision): TCP + UDS. Because the transport set is closed and we control it, represent it as an enum, not a trait — enum Stream { Tcp(mio::net::TcpStream), Uds(mio::net::UnixStream) } with match-forwarding read/write/register (both impl Read+Write+Source), plus enum Bind { Tcp(SocketAddr), Unix(PathBuf) } in config (B's shape). Same principle as client dispatch: closed set → enum; open set → table. Monomorphic, no generics in hosted-crate signatures (D's TcpOrUds). UDS applies to the engine_api client pool first; the server side gets the same Bind enum for free. UnixStream::pair() doubles as a portless, deterministic test transport for full-machine tests (B's suggestion).
  4. Dispatch: server table as converged; client enum match as unanimous.
  5. Test plan (test-or-broken), three tiers:
    a. Unit (socket-free, fast): router+handler tests through the table with real seqlock fixture pairs (typos in patterns fail in tests, not prod); httpcore byte-machine tests with deterministic chunking; golden-byte engine tests incl. counting-allocator assertion that newPayload allocates zero (the hot-path invariant finally gets a failing test).
    b. Integration over REAL http://localhost (team-requested, 2026-08-14): cargo tests/ dir tests that construct the real tile (real mio Poll, real TCP listener) and hand-crank loop_body in a plain test loop — no flux threads/pinning needed since loop_body is a plain fn and SpineAdapter::connect_tile is public. Test issues real HTTP over loopback (std::net or minimal client). DESIGN REQUIREMENTS this imposes: (i) bind address configurable (kills hardcoded 0.0.0.0:5051), (ii) bind to port 0 in tests and EXPOSE the actual bound address (local_addr()) so tests discover the ephemeral port, (iii) everything non-blocking means the test loop spins deterministically — fast, no sleeps. Same technique covers the UDS bind (tempdir socket path).
    c. Full-loop: beacon-api request served over loopback WHILE an EL call is in flight against a canned local EL — the merged-loop interleaving invariant no prototype ever tested; one such smoke test per transport as the seatbelt.
  6. Bounded client pool + server conn cap + configurable bind (kills three prototype liabilities in passing).

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
@Bronek

Bronek commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Implementation plan from Claude.ai


Commit sequence

Strategy: build the new transport alongside the old, migrate consumers one by one, consolidate tiles last. No commit mixes a move with a behavior change.

C1 — crates/httpcore skeleton (server role). b977f93
New crate silver_httpcore: the server-side connection byte-machine extracted from beacon_api's tile.rs (parse → dispatch-closure → frame response; keep-alive + pipelining preserved). Interfaces are bytes/ frames — no Poll, no sockets in signatures. Migrate beacon_api's 14 parser tests + add deterministic-chunking tests (partial reads, pipelined split across pumps, oversize reject). beacon_api crate switches to httpcore for framing but its tile stays attached and behaviorally identical (still its own Poll; the 100 ms block dies in C5, not here).

C2 — httpcore client role; engine consumes it; delete ipc.rs. 42d86da
Client-side connection machine (one-request-per-conn, Content-Length framing, WouldBlock resumption) moves from engine http.rs into httpcore, generic over Stream. ipc.rs (474 dead lines) deleted — UDS is now a Stream variant. Engine crate keeps: pool policy, JSON-RPC, JWT, correlation, ReqKind — all protocol. Engine's 66 tests keep passing; add golden-byte round-trip test through the new machine incl. JWT header. Bounded pool cap introduced here (config, default generous).

C3 — beacon_api table dispatch + Request/Response/ApiCtx. e7cbd99
Const ROUTES table ((Method, pattern) → handler fn), pattern compiled to Lit/Param segments at init, zero-alloc Params (inline ≤4), router owns 404/405, handlers own 400; ApiCtx { state: BeaconStateReader, identity, outbox }. Rewrite the two existing endpoints as table rows; /metrics still stubbed. Router+handler tests through the table with fixture seqlock pairs; 503-before-bootstrap test. [AMENDED: Accept/content-type field deferred to the M2 SSZ-negotiation work — httpcore doesn't expose request headers yet, and a parsed-but-unread field is dead API. Outbox/NodeCommand deferred to its first consumer (same principle as the C1 Stream/Bind deferral). Reader-in-ApiCtx conditional on cheap fixture + srv.rs-example construction — implementer investigates and reports. New behavior sanctioned: 405 for known-path-wrong-method (today the method is ignored entirely).]

C4 — rename crates/enginecrates/engine_api. 96a900c
Purely mechanical, own commit: directory, package name silver_engine_api, workspace member + dep entries, imports, docs/spine-message-flow.md prose. "Names track current reality" — also rename lingering engine-named locals in touched files. No logic changes.

C5 — crates/client_server tile; consolidation. 36f291e
New tile crate constructs/holds the beacon_api server component and the engine_api client component; loop_body composes their pumps (all non-blocking). [AMENDED: TWO Polls, not the single shared Poll + token slab from the recommendation sketch — C2 landed the pool with its own Poll inside engine_api's verified event paths; unifying would churn them to save one epoll_wait(0)/iteration (noise per team latency ruling) at the cost of token-space partitioning. Design D's argument, now backed by C2's shape. Revisit only if measurement ever says otherwise. No outbox — deferred since C3.] beacon_api's own tile and EngineTile deleted; main.rs attaches client_server once (cores renumber, one core freed); srv.rs example updated. Config: beacon-api bind via Config builder + file + --beacon-api-bind; execution_endpoint accepts socket path; tile exposes local_addr(). Tests: tier-b integration (real localhost HTTP against hand-cranked loop_body via public SpineAdapter::connect_tile, port 0 + local_addr); tier-c full-loop (beacon-api request served WHILE canned-EL call in flight); UDS variants of both (tempdir socket path).

C6 — counting-allocator hot-path test + smoke. bd42255
Zero-allocation assertion on the newPayload transcode path (CountingAllocator exists in main.rs idiom); one real-socket smoke per transport per direction. Any leftover prototype liabilities: connection cap on server, 16 MiB→sane read cap, /eth/v1/events → clean 404.

Bronek added 4 commits August 17, 2026 12:03
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
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
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
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
@Bronek

Bronek commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

@vladimir-ea @0w3n-d note a limitation in the C5, we may want to revisit it:


Single beacon_api bind As of C5 the node binds exactly ONE listener — TCP or UDS, chosen by beacon_api_bind. If we want simultaneous surfaces (local VC over UDS + TCP on specific interfaces), the additive change is: BeaconApi holds a list of listeners (one reserved token each; accept loop per listener; connections already transport-uniform via Stream), config becomes a list of binds. Router, handlers, connection handling untouched.

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
@vladimir-ea

Copy link
Copy Markdown
Collaborator

Recommendation (Claude's, for team review)

  1. Crates: client_server (tile: single mio Poll, token slab, loop composition), httpcore (one connection state machine, both roles, byte interfaces — no Poll, no sockets in signatures), beacon_api (ROUTES table + handlers + Request/Response/ApiCtx, transport-free), engine_api (renamed engine: codecs, correlation, JWT, ReqKind match, transport-free; scratch handed in, hot path verbatim).

agree that a single mio::Poll should be used across both apis - note that you can have a blocking poll if we are using the Flux thread_park feature (which we should probably make the default for silver?) b/c that hooks the Poll into the signalling mechanism (via mio::Waker https://github.com/gattaca-com/flux/blob/c73a663f62111fd4466551e53790b2d2f576608f/crates/flux-communication/src/park.rs#L160).

@0w3n-d

0w3n-d commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

@vladimir-ea @0w3n-d note a limitation in the C5, we may want to revisit it:

Single beacon_api bind As of C5 the node binds exactly ONE listener — TCP or UDS, chosen by beacon_api_bind. If we want simultaneous surfaces (local VC over UDS + TCP on specific interfaces), the additive change is: BeaconApi holds a list of listeners (one reserved token each; accept loop per listener; connections already transport-uniform via Stream), config becomes a list of binds. Router, handlers, connection handling untouched.

Probably OK for now. UDS would most likely be used for out bound connections to the EL, as EL and CL often run on the same machine. But on the listener side normally people run a VC on a different machine for security, maybe used by solo stakers or for testing but TCP is probably fine for those cases too.

@Bronek

Bronek commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Proposed API scope by Claude.ai


Beacon API: the validator-client surface (scoping beacon_api v1)

Date: 2026-08-14. Method: primary sources only — ethereum/beacon-APIs OpenAPI at tag v4.0.0 (current stable, 2025-10-14, Fulu-era; v5.0.0-alpha.x = Gloas dev), plus source of five VCs: Lighthouse sigp/lighthouse@unstable (LH), Teku Consensys/teku@master (TK), Nimbus status-im/nimbus-eth2@unstable (NB), Prysm OffchainLabs/prysm@develop (PR), Vouch attestantio/vouch + attestantio/go-eth2-client (VO). Five parallel research agents, one per client; every claim below carries a file path.

Caveat: PR's REST mode is still experimental and flag-gated (--enable-beacon-rest-api, config/features/flags.go:128; gRPC stays default through v8). PR-only rows are weighted accordingly.

Spec baseline (v4.0.0): v1 variants of pool attestations, aggregate_attestation, aggregate_and_proofs, and block/blinded-block publish are REMOVED (release notes, PR #549); block production is v3-only. So the Electra/Fulu-era surface is v2/v3 where marked below — silver never needs the v1 variants. Gloas (v5-alpha) later adds: v4 blocks, v2 proposer duties, ptc duties, payload_attestation_data, pool/payload_attestations, execution_payload_envelopes/bids, proposer_preferences, v2 node/version, topics head_v2 / execution_payload_available. LH/TK/PR already carry client code for these — out of v1 scope but the routing table should expect them.

(a) Endpoint table

Spec link = path under github.com/ethereum/beacon-APIs/blob/v4.0.0/. Verdict: MUST = exercised by ≥1 surveyed VC in default-config normal operation; SHOULD = specific clients / common configs; OPT = niche, subcommand, or DVT.

MUST — core duty cycle (all five VCs unless noted)

Endpoint Method Spec (apis/…) VCs Verdict
/eth/v1/beacon/genesis GET beacon/genesis.yaml all MUST (startup poll loops: LH wait_for_genesis, PR 1 s retry)
/eth/v1/config/spec GET config/spec.yaml LH TK NB VO MUST (TK/VO hard-require at startup; LH/NB compat checks; PR never calls it)
/eth/v1/config/fork_schedule GET config/fork_schedule.yaml NB VO MUST (NB polls every epoch, fork_service.nim; VO computes domains from it, gec http/domain.go:95)
/eth/v1/node/version GET node/version.yaml LH NB PR VO MUST (trivial)
/eth/v1/node/syncing GET node/syncing.yaml all MUST (health/readiness polling in every client)
/eth/v1/beacon/states/{id}/validators POST + GET beacon/states/validators.yaml TK NB PR VO MUST both verbs (TK/PR POST-first with GET fallback; NB POST-only; VO POST w/ pubkeys)
/eth/v1/beacon/states/{id}/validators/{validator_id} GET beacon/states/validator.yaml LH MUST (LH resolves indices per-pubkey, duties_service.rs ~L822)
/eth/v1/validator/duties/attester/{epoch} POST validator/duties/attester.yaml all MUST
/eth/v1/validator/duties/proposer/{epoch} GET validator/duties/proposer.yaml all MUST (v1; LH-unstable already prefers Gloas v2 w/ v1 fallback flag)
/eth/v1/validator/duties/sync/{epoch} POST validator/duties/sync.yaml all MUST
/eth/v1/validator/attestation_data GET validator/attestation_data.yaml all MUST (latency-critical, ~1/3 slot deadline)
/eth/v2/beacon/pool/attestations POST beacon/pool/attestations.v2.yaml all MUST (v2 only post-Electra; carries Eth-Consensus-Version req header)
/eth/v2/validator/aggregate_attestation GET validator/aggregate_attestation.v2.yaml all MUST (v2 only)
/eth/v2/validator/aggregate_and_proofs POST validator/aggregate_and_proofs.v2.yaml all MUST (v2 only; version header)
/eth/v3/validator/blocks/{slot} GET validator/block.v3.yaml all MUST (params: randao_reveal, graffiti, skip_randao_verification, builder_boost_factor; resp headers: Eth-Consensus-Version, Eth-Execution-Payload-Blinded, Eth-Execution-Payload-Value, Eth-Consensus-Block-Value)
/eth/v2/beacon/blocks POST beacon/blocks/blocks.v2.yaml all MUST (must parse broadcast_validation query: TK always sends it, NB sends gossip, LH omits; version header)
/eth/v1/beacon/pool/sync_committees POST beacon/pool/sync_committees.yaml all MUST
/eth/v1/validator/sync_committee_contribution GET validator/sync_committee_contribution.yaml all MUST
/eth/v1/validator/contribution_and_proofs POST validator/sync_committee_contribution_and_proof.yaml all MUST
/eth/v1/beacon/blocks/{block_id}/root GET beacon/blocks/root.yaml LH NB PR VO MUST (sync-committee message path, block_id=head)
/eth/v1/validator/beacon_committee_subscriptions POST validator/beacon_committee_subscriptions.yaml all MUST
/eth/v1/validator/sync_committee_subscriptions POST validator/sync_committee_subscriptions.yaml LH TK NB VO MUST (PR derives subnets itself)
/eth/v1/validator/prepare_beacon_proposer POST validator/prepare_beacon_proposer.yaml all MUST (NB re-sends every slot)
/eth/v1/validator/liveness/{epoch} POST validator/liveness.yaml LH TK NB PR MUST (doppelganger; NB default-ON conf.nim; LH/TK opt-in)
/eth/v1/beacon/headers/{block_id} GET beacon/headers/header.yaml NB PR VO MUST (NB default paths: finalized-header slashing-db pruning duties_service.nim:747, poll block monitor; VO proposer path)
/eth/v1/events GET (SSE) eventstream/index.yaml all connect by default MUST for production quality; deferrable at bring-up — see (b)

SHOULD — client- or config-specific

Endpoint Method Spec (apis/…) VCs Verdict
/eth/v2/beacon/blinded_blocks POST beacon/blocks/blinded_blocks.v2.yaml LH TK NB PR SHOULD — only reachable once produceBlockV3 can return blinded (external builder); VO unblinds via relay instead
/eth/v1/validator/register_validator POST validator/register_validator.yaml all, builder-gated SHOULD — accept-and-ignore is valid until builder support (NB asserts --payload-builder; VO posts as secondary)
/eth/v1/node/health GET node/health.yaml PR SHOULD (trivial: 200/206/503 by sync state)
/eth/v1/node/peer_count GET node/peer_count.yaml TK SHOULD (TK BeaconNodeReadinessManager wants ≥50 peers)
/eth/v1/config/deposit_contract GET config/deposit_contract.yaml PR VO SHOULD (static data)
/eth/v1/beacon/states/{id}/fork GET beacon/states/fork.yaml PR SHOULD
/eth/v1/beacon/states/{id}/finality_checkpoints GET beacon/states/finality_checkpoints.yaml PR SHOULD
/eth/v1/beacon/states/{id}/committees GET beacon/states/committees.yaml PR SHOULD (PR duties.go Committees)
/eth/v1/beacon/headers GET (list) beacon/headers/headers.yaml PR SHOULD
/eth/v2/beacon/blocks/{block_id} GET beacon/blocks/block.v2.yaml VO SHOULD (sync-duty inclusion verification, cache)

OPT — niche / out of normal operation

Endpoint VCs Note
GET /eth/v2/beacon/pool/attestations VO multi-instance mode only
POST /eth/v1/beacon/pool/voluntary_exits TK PR exit subcommands, not duty flow
POST /eth/v1/validator/{beacon,sync}_committee_selections LH TK NB PR DVT-only (--distributed / Obol flag)
POST validator/persistent_subnets_subscription TK Teku-proprietary (non-/eth); 404 is tolerated
Gloas set (v4 blocks, ptc duties, payload attestations, envelopes, proposer_preferences) LH TK PR defer until Gloas fork scheduling

(b) /eth/v1/events per client — NO client hard-requires it

  • LH (new on unstable): head monitor ON by default (enable_beacon_head_monitor: true, validator_client/src/config.rs L139), topic head only (beacon_node_fallback/src/beacon_head_monitor.rs L109). Purpose: attest immediately on head arrival. Stream failure = warn + auto-restart; attestations fall back to the ~1/3-slot deadline poll (attestation_service.rs L297–327). --disable-beacon-head-monitor exists.
  • TK: EventSourceBeaconChainEventAdapter subscribes head always; + attester_slashing, proposer_slashing only with --shut-down-when-validator-slashed-enabled (default false). ForkAwareTimeBasedEventAdapter runs CONCURRENTLY from genesis — all duty timers fire with or without the stream; SSE only adds early attestation + reorg-aware duty refresh.
  • NB: block monitor --block-monitor-type ∈ {disabled, poll, event}, default event (conf.nim:1113), topic head only (block_service.nim:503). poll mode (3× GET headers/head per slot) is a fully supported alternative; SSE failure = debug log + retry.
  • PR: topics head_v2 + execution_payload_available (api/client/event/event_stream.go:34); auto-falls back to head if the BN 400s on head_v2 (multi_event_stream.go L145). Reconnects forever (1 s→16 s backoff); duties are polled regardless. Without events: attests at full deadline, no dependent-root duty refresh.
  • VO: topics head + block (services/controller/standard/service.go:187-188), SSE via r3labs/sse, reconnect every 1 s (gec http/events.go:62). Drives early attest/sync-msg (fastTrackJobs), reorg duty re-fetch, block-root cache. Attestation jobs are ALSO scheduled at slot+slotDuration/3 (attester.go:110) — stream absence slows, not breaks.

Conclusion: polling fallback exists everywhere; the penalty for omitting SSE is degraded attestation latency (no early attest), no reorg-triggered duty refresh, and constant reconnect hammering (VO every 1 s, PR ≤16 s, LH/NB retries) polluting logs on both sides. Union of topics to serve when implemented: head, block (head_v2 declined via 400 is handled by PR). Slashing topics only for TK's opt-in shutdown feature.

(c) SSZ vs JSON

MUST accept/serve SSZ (application/octet-stream):

  • POST /eth/v2/beacon/blocks + blinded: LH sends SSZ ONLY — no JSON fallback (post_beacon_blocks_v2_ssz, eth2 lib L525; block_service.rs L741–770). TK/PR/VO also post SSZ-first (TK sticky-flips to JSON on 415; PR caches 415 per host w/ TTL; VO submitproposal.go:80). NB posts JSON. => server must decode BOTH, keyed by Content-Type + Eth-Consensus-Version request header.
  • GET /eth/v3/validator/blocks/{slot}: all five send Accept preferring octet-stream (TK q=0.9 default-on flag; LH SSZ-first w/ JSON retry; NB preferSSZ; PR q=0.95; VO q=1.0). Serving JSON is legal (clients switch on response Content-Type) but SSZ is the expected fast path; version/blinded-ness MUST come via response headers either way.

JSON-only in practice (spec allows SSZ on some, no surveyed VC uses it): attestation_data, aggregate_attestation v2, aggregate_and_proofs v2, pool attestations v2 (NB batch mode --batch-attestations posts SSZ, default off), register_validator (TK hardcodes JSON, OkHttpValidatorTypeDefClient), all duties, subscriptions, sync-committee family, validators queries, liveness, all bootstrap. VO alone sends SSZ-preferring Accept on GET v2 blocks/{id} (gec http.go:270) — JSON response is accepted.

v1 rule of thumb: SSZ decode on block publish, SSZ encode on block produce; JSON everywhere (incl. those two, for NB publish and any 415/downgrade path). Everything else JSON-only is spec-conformant and matches observed client behaviour.

(d) Conclusion for the client_server tile design

  1. v1 must serve ~25 routes (MUST rows): 3 duties, attestation_data + 3 attestation submit/aggregate routes (v2), produce v3 + publish v2, 3 sync-committee routes + contribution pair, 2 subnet subscriptions, prepare_beacon_proposer, liveness, validators lookup (POST+GET+by-id), genesis/spec/fork_schedule/version/syncing, blocks/{id}/root, headers/{id}. The SHOULD tier adds ~10 mostly-trivial reads. Fits the const ROUTES table + linear scan design comfortably.
  2. SSE is avoidable at first: every surveyed VC degrades to polling. Acceptable for bring-up; not acceptable for production (latency + reconnect spam). It slots into the accepted design exactly as anticipated: an additive subscription mode on the connection state machine (long-lived conn, small appended writes on head/block from spine events, periodic : keep-alive comments), NOT big-body streaming. Return 400 for unsupported topics per spec (PR relies on this for head_v2 negotiation).
  3. Nothing else conflicts with the materialized-in-buffer model. Zero long-polling or chunked responses in any client path. Bounded worst cases: produceBlockV3 SSZ (single- digit MB), validators queries (VCs always pass their own id lists; only TK's GET fallback and VO's no-ids call could ask for more — cap/paginate is a policy choice, not a streaming need). Client call timeouts are tight (TK 10 s hard; attestation_data effectively sub-second useful window) — the sync-handler + short-seqlock-read model is the right fit.
  4. Content negotiation is part of the router contract: Accept parsing on produce-v3 (and optionally attestation_data/aggregate later), Content-Type + Eth-Consensus-Version on the three v2 publish routes, broadcast_validation query on block publish, and the four produce-v3 response headers. 415 responses are load-bearing (TK/PR downgrade logic); so is 400-on-bad-topic for /eth/v1/events.

@vladimir-ea

Copy link
Copy Markdown
Collaborator

@vladimir-ea @0w3n-d note a limitation in the C5, we may want to revisit it:
Single beacon_api bind As of C5 the node binds exactly ONE listener — TCP or UDS, chosen by beacon_api_bind. If we want simultaneous surfaces (local VC over UDS + TCP on specific interfaces), the additive change is: BeaconApi holds a list of listeners (one reserved token each; accept loop per listener; connections already transport-uniform via Stream), config becomes a list of binds. Router, handlers, connection handling untouched.

Probably OK for now. UDS would most likely be used for out bound connections to the EL, as EL and CL often run on the same machine. But on the listener side normally people run a VC on a different machine for security, maybe used by solo stakers or for testing but TCP is probably fine for those cases too.

note that you do not need an 'accept loop' for each listener - it is simply another token registered with the poll and you handle as an accept when it becomes readable - this is how the tcp accept currently works with mio.

@Bronek

Bronek commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

@vladimir-ea @0w3n-d note a limitation in the C5, we may want to revisit it:
Single beacon_api bind As of C5 the node binds exactly ONE listener — TCP or UDS, chosen by beacon_api_bind. If we want simultaneous surfaces (local VC over UDS + TCP on specific interfaces), the additive change is: BeaconApi holds a list of listeners (one reserved token each; accept loop per listener; connections already transport-uniform via Stream), config becomes a list of binds. Router, handlers, connection handling untouched.

Probably OK for now. UDS would most likely be used for out bound connections to the EL, as EL and CL often run on the same machine. But on the listener side normally people run a VC on a different machine for security, maybe used by solo stakers or for testing but TCP is probably fine for those cases too.

note that you do not need an 'accept loop' for each listener - it is simply another token registered with the poll and you handle as an accept when it becomes readable - this is how the tcp accept currently works with mio.

Yes, which is why I think this should be a cheap fix, so perhaps we just need to do it.

Bronek added 3 commits August 18, 2026 11:13
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
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
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
@Bronek

Bronek commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Updated docs/adr in 828ebdb to include streaming in scope, for future implementation of /eth/v1/events (polling is wasteful on resources)

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
Bronek added 11 commits August 19, 2026 17:22
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
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
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
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
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
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
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
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
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
@Bronek

Bronek commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Beacon API: endpoints implemented

All endpoints are JSON over HTTP/1.1 (TCP and Unix sockets), served synchronously from the node's published head state. Target compatibility is the union of the five mainstream validator clients (Lighthouse, Teku, Nimbus, Prysm-REST, Vouch); error-code and response-shape choices were verified against client source, not just the OpenAPI schema.

Node & configuration

Endpoint Status Notes
GET /eth/v1/node/version ✅ Complete
GET /eth/v1/node/identity ✅ Complete
GET /eth/v1/node/health ✅ Complete Shares its sync predicate with syncing
GET /eth/v1/node/syncing ✅ Complete Distance-based; honest pre-bootstrap answers
GET /eth/v1/node/peer_count ✅ Complete
GET /eth/v1/config/spec ✅ Complete Serves every key clients gate on; CONFIG_NAME derives from the genesis fork version when a config file leaves it unset
GET /eth/v1/config/fork_schedule ✅ Complete
GET /eth/v1/config/deposit_contract ✅ Complete
GET /metrics ✅ Complete Non-spec extra (Prometheus)

Chain & state reads

Endpoint Status Notes
GET /eth/v1/beacon/genesis ✅ Complete
GET /eth/v1/beacon/states/{state_id}/fork ✅ Complete
GET /eth/v1/beacon/states/{state_id}/finality_checkpoints ✅ Complete
GET /eth/v1/beacon/blocks/{block_id}/root 🟡 Partial head, finalized, the head block's own root, and any slot still recorded in the state's block_roots ring; every other root needs the block-store read path
GET /eth/v1/beacon/headers/{block_id} 🟡 Partial The head block's header only, under all three of its names (head, its slot, its root), and only from the next slot's process_slot — a just-arrived block's latest_block_header still carries a zero state_root, so the root endpoint can name it while the header endpoint cannot. Older slots and finalized name a root but have no header here; the arbitrary-root and list forms need the block-store read path

{state_id} is served for the head keyword alone: a slot number or a state root is answered 404 even when it names the published state itself, and historical state queries are out of scope until a storage read path exists.

Validator registry

Endpoint Status Notes
GET /eth/v1/beacon/states/{state_id}/validators ✅ Complete id/status filters; unfiltered full-registry reads served (measured at mainnet scale)
POST /eth/v1/beacon/states/{state_id}/validators ✅ Complete Unchunked full-keyset posts from all clients handled
GET /eth/v1/beacon/states/{state_id}/validators/{validator_id} ✅ Complete

Duties

Endpoint Status Notes
GET /eth/v1/validator/duties/proposer/{epoch} ✅ Complete
GET /eth/v2/validator/duties/proposer/{epoch} ✅ Complete Lighthouse calls v2 by default; both served
POST /eth/v1/validator/duties/sync/{epoch} ✅ Complete Both sync-committee periods within reach

Attester duties are not yet served — they need the per-epoch shuffling publication (tracked separately) and only make sense together with the rest of the attestation duty cycle.

Silver targets post-Fulu networks only, and the v2 duty-dependent root is served accordingly: beacon-APIs now defines it unconditionally as start_slot(epoch - 1) - 1 (ethereum/beacon-APIs#590 superseding the fork-conditional form of #563), which assumes a chain that is post-Fulu throughout. The physically different answer at a Fulu activation boundary on a pre-Fulu chain is deliberately out of contract.

Validator receipt endpoints

These accept and validate requests so validator clients run their duty loops against this node, but the node does not yet act on the submitted data. Each limitation is stated in the serving code and commit history.

Endpoint Status Notes
POST /eth/v1/validator/register_validator 🟡 Accepted, not acted on No builder-network integration yet; 415 content negotiation in place
POST /eth/v1/validator/prepare_beacon_proposer 🟡 Accepted, not acted on No block-production consumer yet
POST /eth/v1/validator/liveness/{epoch} 🟡 Interim Answers not-live for every index (spec-legal best-effort); warns on every call — doppelganger detection is not yet backed by observed data
POST /eth/v1/validator/beacon_committee_subscriptions 🟡 Stub Accepted and logged; no subnet control channel yet
POST /eth/v1/validator/sync_committee_subscriptions 🟡 Stub Accepted and logged; no subnet control channel yet

Transport & robustness

  • Hand-rolled non-blocking HTTP/1.1 server (mio), no TLS, JSON only for now (SSZ planned with the block/attestation publish family).
  • Oversized request bodies (beyond the 16 MiB read buffer) are answered with 413 at header-parse time, off the declared Content-Length, before a body byte arrives — large operators post entire validator sets unchunked. The connection then half-closes and drains the in-flight body (nginx-style lingering close), so the client reliably receives the status instead of a connection reset — the difference between a logged error and a failover in some client stacks. A request whose framing cannot be parsed at all takes the same path with a 400; HTTP/1.0 gets 505.
  • Request/response deadlines on both directions; event streams (SSE) are not yet served (404).

Not yet implemented (planned, currently blocked, another PR to come)

Attester duties and the attestation data/aggregation/publish family, block production and publish, historical block/state reads, SSZ request/response encoding, and event streams. Each is gated on a cross-component capability (shuffling publication, fork-choice head publication per block, a storage read path, a gossip-publish primitive) rather than on API-layer work.

Bronek added 4 commits August 21, 2026 13:28
Assisted-by: Claude:claude-fable-5
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
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-<hex>, 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<hex> to devnet-<hex>, so an already-running devnet deployment splits
its identity at the deploy boundary.

Assisted-by: Claude:claude-fable-5
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
@Bronek

Bronek commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Minor correction to the "Serve the validator client's receipt POSTs" 93cf03a . It justifies the 415 on register_validator by saying Teku posts the registrations as application/octet-stream first and turns SSZ off only inside its 415 handler. That is wrong for production Teku: the SSZ-first machinery in RegisterValidatorsRequest exists, but its only non-test call site constructs it with preferSszEncoding=false hardcoded (OkHttpValidatorTypeDefClient.java:179, at teku 8bf7e9ea81), so Teku sends these registrations as JSON and never exercises the 415. Teku's block publish does use genuine SSZ-first with a sticky 415 downgrade, which is where the claim was generalized from.

The code is unaffected — the 415 is correct because this endpoint's schema declares it, whoever triggers it — and the code comments repeating the Teku claim were already rewritten in "Correct client-behaviour claims and the v2 dependent root".

Comment thread docs/adr/0001-single-api-tile.md Outdated
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

references a machine local file

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, will remove. For reference - the useful output of these trial designs has been preserved in #81 (comment)

Comment thread docs/adr/0004-sync-materialized-api.md Outdated

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here also

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, will replace with actual VCs from the document.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More details on this preserved in #81 (comment)

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that when we run flux in 'park' mode (instead of busy spin) we can poll for non-zero duration if we register an mio Waker with the Flux work signal, e.g. in the network tile:

impl Tile<SilverSpine> for NetworkTile {
    fn loop_body(&mut self, adapter: &mut SpineAdapter<SilverSpine>) {
        self.body(adapter);
    }

    #[cfg(feature = "thread_park")]
    fn try_init(&mut self, adapter: &mut SpineAdapter<SilverSpine>) -> bool {
        const WAKER_TOKEN: Token = Token(3);

        let waker = mio::Waker::new(self.inner.poll.registry(), WAKER_TOKEN)
            .expect("failed to create network waker");
        adapter.register_waker(waker);
        true
    }
   ...

this is maybe worth doing b/c poll with duration ZERO can impact throughput negatively

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — and for this tile it's a correctness point rather than only throughput.

The flux work signal fires on spine publishes alone, so socket readiness can never reach it, and the tile loop parks whenever !worked && !waker_registered (flux/src/tile/mod.rs:151). The application-boundary tile registers no waker, so a park build would sleep through an inbound HTTP request until unrelated spine traffic happened to wake it — request latency bounded by the spine rather than by readiness.

Applying your pattern needs one change first: this tile drives two independent Polls (beacon_api/src/server.rs:119 and engine_api/src/client.rs:85), and blocking in either starves the other. So the tile will own a single Poll, both sides registering into that one registry over a partitioned token space, and then the waker plus a non-zero timeout drops in exactly as you wrote it. Its own commit with a measurement, after this PR.

ADR-0004 now records that Duration::ZERO is the busy-spin build's mechanism and not the decision.

Separately, and not this PR: silver/thread_park expands to ["flux/park", "silver_common/thread_park"] and never enables silver_network/thread_park. cargo tree -p silver --features thread_park resolves silver_network with no features, so NetworkTile::try_init isn't compiled and POLL_TIMEOUT stays Duration::ZERO — the network tile parks with no waker in a park build. The code itself is fine (cargo check -p silver_network --features thread_park passes), it's just unreachable from the binary's own feature. Happy to file it if you want it tracked.

Comment on lines +479 to 522
/// An entry differing from its predecessor is a block of the slot's own; a
/// repeated one is a slot that carried none. Reading the entry itself asks
/// only that the ring still cover the slot, so an empty slot answers with the
/// root it repeats.
#[test]
fn block_roots_name_the_slots_that_carried_a_block() {
let empty = RECORDED_STATE_SLOT - 2;
let (g, id) = recorded_ring(Some(empty));
let reader = g.view(id);
let state_slot = RECORDED_STATE_SLOT;

assert_eq!(reader.at_slot(empty), root_of(empty - 1), "the ring answers at the wrapped index");

assert_eq!(reader.proposed_at(empty - 1, state_slot), Some(root_of(empty - 1)));
assert_eq!(reader.proposed_at(empty, state_slot), None);
assert_eq!(reader.proposed_at(empty + 1, state_slot), Some(root_of(empty + 1)));

assert_eq!(reader.recorded_at(empty, state_slot), Some(root_of(empty - 1)));
assert_eq!(reader.recorded_at(empty + 1, state_slot), Some(root_of(empty + 1)));
}

/// The ring covers the `SLOTS_PER_HISTORICAL_ROOT` slots below the state's own,
/// and naming a block needs its predecessor's entry too — so the floor itself
/// cannot be named however distinct its entry is, and the state's own slot has
/// no entry until the `process_slot` that leaves it.
#[test]
fn block_roots_bound_which_slots_can_be_named() {
let (g, id) = recorded_ring(None);
let reader = g.view(id);
let state_slot = RECORDED_STATE_SLOT;
let floor = state_slot - SLOTS_PER_HISTORICAL_ROOT as u64;

assert_eq!(reader.proposed_at(floor + 1, state_slot), Some(root_of(floor + 1)));
assert_eq!(reader.proposed_at(floor, state_slot), None, "the floor has no predecessor");
assert_eq!(reader.proposed_at(floor - 1, state_slot), None, "below the floor");
assert_eq!(reader.proposed_at(state_slot, state_slot), None, "the state's own slot");
assert_eq!(reader.proposed_at(state_slot + 1, state_slot), None, "past it");

assert_eq!(reader.recorded_at(floor, state_slot), Some(root_of(floor)));
assert_eq!(reader.recorded_at(floor - 1, state_slot), None, "below the floor");
assert_eq!(reader.recorded_at(state_slot, state_slot), None, "the state's own slot");
}

/// A block's reveal accumulates into the current epoch's bucket; the boundary

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need these?

// 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same coupling from the writing side: this makes the block-import path maintain a third copy of the head root, beside last_applied_block_root and fork choice's own find_head(). It goes with the control-word field. Answered in full on the status_event thread: #81 (comment)

Comment on lines +467 to +474
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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you need a head root, ideally you derive it from this event and cache until a new update comes in

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and the strongest argument for your shape is that we already do it for the flag sitting right next to the root. head_optimistic travels on this same event, gets cached in NodeStatus by the boundary tile's refresh_node_status, and is read back as execution_optimistic(). So today one field of the same response envelope arrives by event-and-cache and the other by a 32-byte addition to the state's seqlock control word. That is indefensible as a pair, and the event is the side to unify on.

The coupling is worse than one field, too: set_head_block_root makes the state's block-import path maintain a third copy of something fork choice already owns, beside last_applied_block_root and find_head(), and ControlInner is the hottest read path in the state architecture carrying a value no state read uses.

One sequencing constraint before I rip it out. status_event is produced at startup, on ReplayComplete, and on SlotStart when the state advanced or the head moved (tile.rs:641, :713, :726) — nothing publishes it on block import. Blocks land ~4 s into a slot, so a cache fed by today's event would name the previous head for the rest of the slot, which is the exact window the control-word field was added to cover: the state's latest_block_header names the parent until the next process_slot, and the block_roots entry naming the new block is written by that same process_slot. Caching now would regress GET /eth/v1/beacon/blocks/head/root and headers/head to a wrong-but-plausible answer for roughly half of every slot.

That publish cadence is CL-118 item 1 ("head block root published per accepted block, not per slot"), which we already believe is in your branch's scope. So the order I would like is:

  1. BeaconStateEvent::Status carries head_block_root explicitly and is published per accepted block. Explicitly, please, not read out of the ssz Status payload — the API parsing the p2p wire format to find it would just trade this coupling for a worse one.
  2. The boundary tile caches it in NodeStatus beside head_optimistic.
  3. ControlInner::head_block_root, set_head_block_root, and read_head's root parameter all come out.

Steps 2 and 3 are mine and I will do them as soon as 1 lands — tell me if your branch already publishes per block and I will start now.

Residual worth naming: the cache is a separate observation from the seqlock read, so a state read can be one publish ahead of the cached root, where today they are atomic. For "what is the head root" an off-by-one names the previous head, which is what every other client's cached head does, and execution_optimistic already has exactly that property — so this makes the envelope internally consistent rather than less so. I do not think the atomicity is worth the coupling.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting myself: my cadence claim above was wrong. on_accept produces a status event unconditionally (orphan_pool.rs:197), and its only two callers are handle_gossip (gossip.rs:727) and handle_rpc_block (orphan_pool.rs:245) — both block-import paths. So every accepted block does publish a status event, and I was wrong that the head root would go stale mid-slot. I had grepped tile.rs alone and missed orphan_pool.rs. There is no CL-118 item 1 dependency here.

But the two mechanisms do not carry the same value, and that is what is left to settle.

set_head_block_root records the root of the block just appliedblock.rs:341-342 sets it beside last_applied_block_root, before recompute_head(). The status event computes head_root = self.fork_choice.find_head() (tile.rs:467). Those diverge whenever fork choice does not head the block that was just applied: a late or equivocating proposal, or an import onto a non-canonical branch.

That matters for headers/{block_id}, because it returns a root and a header that must describe the same block. The header comes from the published state's latest_block_header, i.e. from last_applied. Pair it with find_head()'s root and, exactly when the two disagree, we serve one block's root beside another block's header. Today the control word makes that pairing consistent by construction, since both sides come from last_applied — which is also why blocks.rs can only assert canonical: true rather than prove it.

So I still want the coupling gone, on the event, and what I need on the event is the root of the block the published state was applied from — not, or not only, the fork-choice head. Concretely: BeaconStateEvent::Status carrying head_block_root as an explicit field (it is currently only inside the ssz payload, and the API parsing the p2p wire format to dig it out would be a worse coupling than the one we are removing), with the applied-block root either as that field or beside it. CL-118 item 7 already asks for a purpose-built head-changed event carrying the head block root and head state root together, which is the natural home if you would rather not widen Status.

Tell me which of the two roots you would rather publish and I will do the API side — cache it beside head_optimistic and delete ControlInner::head_block_root, set_head_block_root and read_head's root parameter. If you publish the fork-choice head only, that is still workable; it just means headers/head has to stop serving a header whenever the published state's own block is not the head, rather than pairing them and hoping.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(actual @Bronek here): we (Claude and me) made a mistake of using grep rather than LSP. Corrected.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and my objection was worse than wrong — it was contradicted by the crate I was defending. beacon_api already imports from ssz_view (crates/beacon_api/src/config.rs:17), and ssz_view:: appears in around sixty files across nearly every crate in the workspace, including control, columns, peer, storage and engine_api. There is no p2p boundary being crossed; SSZ views are how this project reads bytes. Withdrawn.

It also makes the fix smaller than what I proposed, which I should have found before arguing. StatusView::head_root already exists (crates/ssz/src/ssz_view.rs:1292, offset 44), so the fork-choice head root is available to the API today from the event it already consumes — no new field on BeaconStateEvent::Status, no tile change at all. The API caches StatusView::head_root(&ssz) in NodeStatus beside head_optimistic, and ControlInner::head_block_root, set_head_block_root and read_head's root parameter come straight out.

So the only thing still open is the semantic question from my previous comment, which the SSZ point does not touch: the Status payload's head root is fork_choice.find_head() (tile.rs:467), while the control word carries the root of the block the published state was applied from (block.rs:341-342, before recompute_head()). GET /eth/v1/beacon/headers/{block_id} has to return a root and a header describing the same block, and the header comes from the published state's latest_block_header — so on a late or equivocating proposal, pairing find_head()'s root with that header names two different blocks.

I can handle that entirely on the API side without asking you for anything: the event already carries latest_block_slot, and StatusView::head_slot gives the head's slot, so when the published state's block is not the head I withhold the header rather than pairing them. That leaves one residual — a same-slot equivocation is invisible to a slot comparison — which I would rather accept than keep the control-word field for. Both endpoints' existing tests cover the divergence case, so I will pin that behaviour explicitly.

Unless you would rather the applied-block root be published, I will take that route: no tile change, no event change, coupling gone.

Comment on lines +36 to +41
Phase0,
Altair,
Bellatrix,
Capella,
Deneb,
Electra,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given that we aren't able to support these forks I would prefer to remove this

Comment on lines +111 to +118
#[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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need these?

Comment on lines +666 to +682
#[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");
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't this just test the parser?

Comment thread crates/beacon_state/data/src/view.rs Outdated
#[derive(Clone, Copy, Default)]
struct ControlInner {
state_id: Option<StateId>,
head_block_root: B256,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field is the coupling you flagged on the tile's status_event — a value no state read uses, in the control word every state read goes through. Agreed it should not be here; it comes out once the head root is published per accepted block on BeaconStateEvent::Status. Answered in full there: #81 (comment)

@Bronek

Bronek commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

@clanky-gattaca review

Bronek added 2 commits August 21, 2026 15:05
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
u64::MAX
/// Mainnet deposit contract, live since 2020-11-04. Hoodi reuses the very
/// same address.
fn default_deposit_contract_address() -> [u8; 20] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not a constant with array? Also from where we get that numbers?

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants