feat(beacon): serve the Beacon API eventstream, GET /eth/v1/events - #626
MegaRedHand wants to merge 5 commits into
Conversation
A validator client and most tooling follow a beacon node through /eth/v1/events, and ethlambda beacon served none: the chain actor already had an event bus, but only the lean surface read it, and what it emitted on beacon was lean-shaped (bare integers, slots standing in for epochs) and missed every head move, since a beacon head moves in the head recompute that follows the import cascade, after the import's own snapshot diff. One bus still carries both chains' events. Topic gains every Beacon API name, and each surface accepts its own set: /lean/v0/events keeps its seven exactly, and /eth/v1/events accepts every specification topic (repeated or comma-separated) while emitting head, block, block_gossip, finalized_checkpoint, chain_reorg, attestation and data_column_sidecar, in the specification's payloads with quoted integers. Head and finality events come from a view the actor keeps between calls and diffs at the end of recompute_beacon_head, the one place every beacon head and finality move ends: a view cannot be missed the way a snapshot window was, and several moves between two diffs coalesce into one event. Any head change counts, including one no block caused, as Prysm reports it; heads more than 32 slots behind the wall clock are not reported. Reorgs are detected through the new head's post-state block_roots, and their depth walks the previous head back to the common ancestor. block_gossip now waits for the gossip verdict: NewBlock carries a BlockAnnouncement its sender sets, so a gossip block forwarded on Queue or Ignore(Overloaded) still imports but is not announced. Aggregates this node's own validator client submits reached neither its fork choice nor any event stream, since gossip never delivers a node its own messages. publish_beacon_aggregate now takes the attesting indices the Beacon API's checks resolved and hands the aggregate to the chain actor after gossiping it, as publish_beacon_block already does for blocks. The finalized root is exempt from prune_beacon_optimistic_roots, like the execution-hash cache beside it, so its execution_optimistic flag survives a skipped epoch boundary until the event and the REST handlers read it. Payloads are built only while a client is connected, whatever topics it asked for; per-topic subscriber counts are left for later.
…vents struct The chain actor carried the bus and the beacon view as two fields, with lean's snapshot diffs and the chain-specific payload building spread over its handlers. ChainEvents now holds the EventBus and everything that decides what goes out on it: the subscriber guard, which chain's payload an event takes, lean's snapshot diff around a store call, and beacon's persistent view. The actor keeps one field and only says what happened. The EventBus inside stays the cloneable handle main gives the HTTP server, which only subscribes. ChainEvents is the actor's alone, so nothing outside it can move the beacon view or publish. ChainEventSnapshot and BeaconEventView keep their logic, now private behind it. No behavior change.
…every event in ChainEvents Lean still captured a snapshot before each store call and diffed after it, while beacon diffed a view kept between calls. Lean now diffs the same view, after the same three store calls (tick, block import, the proposer's pre-build catch-up), so both chains follow one rule: whatever moved since the last diff is reported by the next one, once. On lean that reports exactly what the snapshots did, since its head and checkpoints move only inside those calls, but a later path that moves them elsewhere can no longer be silently folded into the next window's baseline. With nobody subscribed, a lean diff now reads only the view's three roots, as a beacon one does. The actor's last direct emits (lean attestations and aggregates, beacon data columns) are ChainEvents methods as well, so the actor never builds a ChainEvent and the bus is only reachable through ChainEvents.
A lean subscriber saw a head move to another branch only as a plain head event, with nothing saying the previous head was abandoned. Lean now accepts and emits chain_reorg: beacon's fields less epoch and execution_optimistic, with lean's bare integers. It means what the beacon one does: the new head does not descend from the previous one, and depth is how many slots the previous head sat above the common ancestor. The lean diff finds that ancestor by walking both heads' parent links, bounded by the finalized checkpoint the previous diff saw. A lean header is cheap to read, and a head that just moved forward meets the old head in a step or two. Like beacon's, the event goes out ahead of the head it moved to, is not gated on recency, and costs nothing with nobody subscribed. The beacon payload is renamed BeaconChainReorg(BeaconChainReorgEvent) so the lean variant takes the unprefixed name, as every other lean variant does.
…eat/beacon-events-endpoint # Conflicts: # crates/blockchain/Cargo.toml
🤖 Codex Code ReviewI found 1 correctness issue worth fixing; otherwise the eventstream work looks solid. Findings
Why it matters
What looks good
Validation note
If you want, I can turn this into a ready-to-post GitHub review comment set. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview: PR 626 — Beacon API
|
🤖 Kimi Code ReviewI'll review this PR which adds Beacon API eventstream support ( Overall AssessmentThis is a substantial, well-structured PR that unifies lean and beacon event publishing. The architecture is sound: a persistent Critical Issues1. Potential Division by Zero / Modulo by Zero in
|
🗒️ Description / Motivation
ethlambda beaconserved no Beacon API eventstream. The chain actor already had an event bus, but only/lean/v0/eventsread it, and on beacon it had three problems:slotstanding in forepoch.headevent ever fired for a block import. A beacon head moves inrecompute_beacon_head, which runs after the import cascade. By then the import's own snapshot diff has already run, and the next tick's snapshot starts from the head that already moved.block_gossipfired for blocks forwarded onQueueorIgnore(Overloaded), before gossip validation had passed.This PR adds
GET /eth/v1/eventsfor validator clients and tooling.What Changed
Topics and payloads (
crates/blockchain/src/events.rs)Topicgains every Beacon API name. Each surface accepts its own set throughTopic::parse_accepted:Topic::LEAN: the same seven as before, pluschain_reorg.Topic::BEACON: the 24 names in the specification, plusblob_sidecarfrom its last release.ChainEventvariants carry the specification's payloads with quoted integers:BeaconHead,BeaconBlock,BeaconBlockGossip,BeaconFinalizedCheckpoint,ChainReorg,BeaconAttestation(boxed),DataColumnSidecar.EventBus::has_subscribers(). Payloads that cost something to build are skipped when no client is connected.One publisher struct (
ChainEvents,crates/blockchain/src/events.rs)events: ChainEvents. It holds theEventBusplus everything that decides what goes out on it: the subscriber guard, which chain's payload shape an event takes, and the view both chains' head and checkpoint events diff against (below).ChainEventsmethod (publish_block,publish_block_gossip,publish_lean_attestation, ...), so the actor never builds aChainEventitself.EventBusinside stays the cloneable handle the HTTP server subscribes through.ChainEventsbelongs to the actor alone.Head and finality events (
EventView)ChainEventskeeps a view (head root, justified and finalized checkpoints) between calls. Beacon diffs it at the end ofrecompute_beacon_head, where every beacon head or finality move ends: the tick, the import cascade, and theINVALIDforkchoiceUpdatedpath inside it.blockfor each block as it imports, thenchain_reorg,head,finalized_checkpoint.block_roots. The depth walks the previous head back to the common ancestor.headis gated to within 32 slots of the wall clock.chain_reorgandfinalized_checkpointare not gated.block_gossipwaits for the verdict (crates/net/api,crates/net/p2p)new_blockcarries a newBlockAnnouncement { Announce, Silent }, set by the sender.Announcefor three senders: a beacon gossipAccept, a block published through the Beacon API, and lean gossip (unchanged). Everything else isSilent.BlockSourceand its metric labels are unchanged.Own aggregates reach the chain actor (
crates/net/rpc/src/beacon/pool.rs,gossipsub/handler.rs)post_aggregate_and_proofskeeps the attesting indicesstateful_checksresolves.publish_beacon_aggregatetakes those indices and hands the aggregate to the actor after gossiping it, aspublish_beacon_blockdoes for blocks. Gossip never delivers a node its own messages, so before this these aggregates reached neither fork choice nor any event stream.HTTP (
crates/net/rpc/src/beacon/events.rs,crates/net/rpc/src/events.rs)topicsis accepted repeated (?topics=a&topics=b) or comma-separated. Duplicates collapse.{"code":400,"message":"Invalid topic: …"}. This needs a newApiError::BadRequestDetail(String).X-Accel-Buffering: no.BeaconApiHandles.events.Storage
prune_beacon_optimistic_rootsexempts the finalized root, likeprune_beacon_el_block_hashesbeside it. Without this, the finalized block'sexecution_optimisticwas lost when the epoch boundary slot was skipped.Docs
docs/rpc.md: a newGET /eth/v1/eventssection covering accepted vs emitted topics, payloads, order, gating and coalescing.CLAUDE.md: the HTTP-servers paragraph no longer says the beacon RPC takes no bus.Correctness / Behavior Guarantees
single_attestation: subnet votes never reach the actor.payload_attributes, the light-client topics andblob_sidecar: no producer.head_v2and the other gloas topics: this build stops at fulu.headfires on any head change, including one no block caused (Prysm does the same; Lighthouse sends onlychain_reorg). Optimistic heads are emitted with the flag set (Prysm does the same; Lighthouse suppresses them).on_gossip_beacon_aggregate: covered-bits dedupe, then the one-slot hold. They are also counted in that path's mailbox-wait and outcome metrics./lean/v0/eventsgainschain_reorg(beacon's fields lessepochandexecution_optimistic, bare integers). Its ancestor is found by walking both heads' parent links, bounded by the previous finalized checkpoint. Its other seven topics and payloads are unchanged, and any other beacon-only name (data_column_sidecar, say) is still refused as unknown.Tests Added / Run
apis/eventstream/index.yaml(beacon-APIsa3f0654).as_str/FromStr.headonly;chain_reorgwith the right depth, thenhead;epoch_transitionand the dependent roots;block_gossipfollows the sender's announcement; only an accepted gossip block is announced (p2p); a published aggregate reaches the chain actor with its indices (p2p); the RPC passesstateful_checks' indices on.Commands run:
make fmt,make lintcargo test --profile release-fast --libforethlambda-blockchain,-p2p,-rpc,-storagecargo test -p ethlambda --binscargo test -p ethlambda-rpc --test http_serversNot run: leanSpec spec tests, and a manual
curl -Nagainst a live follower.Related Issues / PRs
beacon-chain-integrationlives on this repo.beacon-chain-integration@c79fabd5, merged into the branch.✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(test-consensusplustest-node, atrelease-fast) — unit tests only (--lib --bins, plushttp_servers); spec tests left to CI