feat(rpc): add GET /lean/v0/events SSE stream#517
Conversation
🤖 Kimi Code ReviewThe PR implements an SSE endpoint for chain events with proper best-effort semantics and clean separation between the blockchain actor (publisher) and RPC (subscriber). No critical issues found. Minor observations:
Security/Performance notes:
Architecture verification:
The implementation follows Rust async best practices and integrates cleanly with the existing axum router structure. Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
Otherwise the wiring looks clean, and the flat JSON framing test is a good regression check for the earlier double-tagging bug. I couldn’t run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview:
|
Greptile SummaryThis PR adds a live SSE stream for blockchain events. The main changes are:
Confidence Score: 5/5This looks safe to merge.
|
| Filename | Overview |
|---|---|
| crates/net/rpc/src/events.rs | Adds the SSE handler, event framing, keep-alives, and payload test. |
| crates/net/rpc/src/lib.rs | Registers the events route and provides the shared event bus. |
| bin/ethlambda/src/main.rs | Connects one event bus to the blockchain publisher and RPC server. |
| crates/blockchain/src/events.rs | Adds direct serialization of chain events as flat JSON objects. |
Reviews (4): Last reviewed commit: "Merge branch 'main' into feat/events-sse..." | Re-trigger Greptile
🤖 Kimi Code ReviewOverall Assessment: Clean, idiomatic implementation of SSE streaming for consensus events. Correctly handles backpressure via bounded broadcast channel semantics and implements best-effort delivery as specified. Minor Issues & Suggestions: 1. Rust Version Compatibility (Line 47,
// Alternative for older Rust versions:
.json_data(&ev)
.map_err(|err| tracing::warn!(%err, "Failed to serialize SSE chain event"))
.ok()?2. Test Race Condition Risk (Lines 67-97,
3. Connection Resource Limits
4. Serialization Failure Handling (Line 47)
5. Documentation Accuracy (Line 27,
Security & Consensus Considerations:
Performance:
Acknowledgments:
Verdict: LGTM. Address the Rust version check for Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
I didn’t find other correctness issues in the diff. This PR doesn’t alter fork choice, attestation validation, finalization, state transition, XMSS, or SSZ logic; it only exposes existing I couldn’t run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewConsistent with existing conventions. I have enough context to write the review now. Review:
|
23825f1 to
3a3c804
Compare
bb2acdd to
8bc49c0
Compare
🤖 Codex Code ReviewNo findings. The diff stays out of consensus-critical execution paths: it only wires the existing Residual risk: I couldn’t run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Kimi Code ReviewThe PR adds a clean, well-tested SSE endpoint for chain events. Implementation follows Axum best practices and correctly handles slow clients via lag detection. Minor observations:
Security & Performance:
Consensus considerations:
Verdict: LGTM. The lag-handling logic is correct for a best-effort consensus event stream. Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Claude Code ReviewReview:
|
3a3c804 to
a966632
Compare
8bc49c0 to
50050d2
Compare
First PR of the chain-events series (re-cut of #460), shipping only the pub-sub mechanism so the real design decisions (event shape, publisher model, back-pressure policy) are reviewable on their own. The SSE transport and topic filtering follow in separate PRs; the bus stays unconsumed until the endpoint lands. Emission is actor-layer only, not threaded through the store. Before each state-changing store call (store::on_tick, store::on_block) the actor snapshots (head, latest_justified, latest_finalized), runs the store function completely unchanged, then diffs the snapshot against the post-call state and emits in order block -> justified_checkpoint -> head -> finalized_checkpoint. This keeps store.rs, both spec-test files, and test_driver.rs byte-identical to main: no Option<&EventBus> parameter spreads across on_tick/on_block/update_head or any of their test call sites, and the sole-publisher property is structural (only BlockChainServer ever holds a live EventBus) rather than something every call site has to preserve by convention. Two consequences of diffing at this level, both accepted: - `block` needs a was-it-new guard: on_block returns Ok early for an already-imported root, so the actor checks block-known-ness (the same has_state check the store itself uses) before importing, and only emits `block` for a genuinely new root. - Several head moves inside a single tick or block import coalesce into one `head` event carrying the net move; fine for subscribers, since the beacon-API analog is also last-value-wins per slot. The bus itself is a facade over a single bounded tokio broadcast channel: emit() never blocks or fails, since a slow subscriber lags and drops instead of back-pressuring consensus, and a receiver-count guard makes emission free when nobody is listening. ChainEvent serializes untagged so payloads stay flat: the topic name travels out-of-band via ChainEvent::topic(), fixing #460's double-tagged SSE payload before any transport exists. The `head` event is additionally gated by HEAD_EVENT_RECENCY_SLOTS, adopted from a review of Lighthouse's canonical-head recompute path: a new head is only emitted when its slot is within that many slots of the wall clock, so startup catch-up and backfill don't spam subscribers with historical head moves on the way to the tip. `block`, justified_checkpoint, and finalized_checkpoint stay ungated; consumers tracking sync progress should watch `block` instead.
Second PR of the chain-events series: the transport for the event bus introduced in the previous PR. Kept separate from the mechanism so the axum plumbing reviews on its own; topic filtering (?topics=) follows in the next PR, so this stream is unfiltered. Each connection subscribes its own receiver and forwards events as SSE frames: the topic name goes on the event: line via ChainEvent::topic() (no hardcoded name match to drift from the enum) and the data: line carries the flat JSON payload, so the topic is never duplicated inside the body. A lagged client skips past the events the bounded channel overwrote (debug log, stream continues) rather than ending the stream: the stream is best-effort by contract and clients re-sync via the blocks endpoints. Keep-alive comments hold idle connections open through proxies.
50050d2 to
03118fc
Compare
a966632 to
60d9c2e
Compare
…nd_emit Reorder chain-event emission so the head event fires before justified_checkpoint, giving subscribers the head move ahead of the checkpoint updates that follow it. Drop the ordering assertion test, which only pinned the old sequence.
Colocate the pre-call snapshot type (and HEAD_EVENT_RECENCY_SLOTS) with the ChainEvent/EventBus definitions it depends on, keeping all chain-event machinery in events.rs. The actor references it via crate::events; the snapshot's diff/gate tests move alongside it. No behavior change.
The recency gate is already covered by chain_event_diff_gates_stale_head (gated) and chain_event_diff_emits_recent_head (within window); the explicit boundary case was redundant.
Eventless call paths (spec tests, test_driver.rs) never construct an EventBus at all, so the dormant-bus constructor had no production callers; its only user was its own test.
Shape ChainEvent payloads after the Ethereum beacon-API eventstream (v4.0.0): `block` is the block root, `state` the state root, and `slot` stands in for the beacon `epoch`. Drop the `#[serde(untagged)]` Serialize derive; the wire encoding (decimal-string ints, 0x-hex roots, out-of-band topic) returns with the SSE endpoint in a follow-up PR. This also removes the ambiguous untagged shape flagged in review. Since serde is no longer used in the crate, drop the serde/serde_json deps. `justified_checkpoint` has no beacon analog and is kept as an ethlambda extension mirroring `finalized_checkpoint`. Also fix a checkpoint event dropped on the proposer's own slots: the interval-0 catch-up in `get_proposal_head` advances head/finalized outside every snapshot window, so a finalization moved there was folded into the later block-import diff's baseline and never emitted. `propose_block` now snapshots around the build and emits those moves on both build success and failure, matching what a non-proposing node emits at its interval-0 tick.
#516 reshaped ChainEvent (root/parent_root -> block/state) and dropped its Serialize derive; move the wire encoding into an RPC-crate DTO.
#516 deferred the chain-event wire encoding to this SSE PR. Derive it on ChainEvent directly (untagged, flat body) rather than mirroring the enum in an RPC-crate DTO, so the handler stays a direct json_data(&ev) with no parallel type to keep in sync. Wire shape is unchanged.
Adopt #517's beacon-aligned ChainEvent ({slot, block, state}, Serialize derived on the type) while keeping #518's TopicSet/EventSubscription filtering. The snapshot tests moved from lib.rs into events.rs upstream; take that version and add TopicSet::ALL to their subscribe calls. Update the topic-filter tests to the new field names, and switch the "block filtered out" assertion to check the SSE event: line since every payload now carries a `block` field.
# Conflicts: # Cargo.lock # bin/ethlambda/src/main.rs # crates/blockchain/src/events.rs
🤖 Kimi Code ReviewOverall Assessment: Clean, well-documented implementation of the SSE endpoint. The use of Minor Issues & Suggestions: 1. Silent event dropping on serialization failure 2. H256 serialization assumption 3. Untagged enum ambiguity (acceptable but note) 4. Channel capacity visibility 5. Clone semantics of EventBus 6. Test fragility Security/DoS: No rate limiting on the SSE endpoint is visible. Consider adding a connection limit or maximum subscriber count to the Correctness: The lagged-client handling (skipping gaps) correctly implements the documented best-effort contract. The Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code ReviewFindings
No fork-choice, attestation, justification/finalization, STF, XMSS, or SSZ logic changed in this diff, so I did not find a consensus-path regression beyond the new RPC/event-surface concerns above. I couldn’t run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview:
|
Adds Beacon-API-style server-side filtering to `GET /lean/v0/events`: `?topics=` takes a **required** comma-separated list of topic names. As in the Beacon API `eventstream` endpoint, `topics` is mandatory: a missing, empty, or unknown value returns 400 (the body names the offending topic). There is no "subscribe to everything" default. See `docs/rpc.md`. Filtering lives in the SSE handler, the sole consumer that needs it, rather than in the bus. `EventBus::subscribe()` stays a plain fan-out (`broadcast::Receiver`); the handler parses the requested topics into a `Vec<Topic>` and skips unmatched events in its own `recv` loop. Lag is surfaced to the handler, never swallowed. Topic-name parsing (`FromStr for Topic`, `UnknownTopic`) stays in the blockchain crate, where the orphan rule requires it. The handler bridges `recv` to a stream via `futures_util::stream::unfold`, replacing the `tokio-stream`/`futures-core` pair with `futures-util` (already in the lock transitively). On `Lagged(n)` the stream also yields an `: error - dropped n messages` SSE comment frame (wire-compatible with Lighthouse's lagged-client marker) instead of only logging, so clients get an explicit re-sync trigger. Stacked on #517. Has unit and handler tests (including the missing/empty and unknown-topic 400 paths) and passes clippy with `-D warnings`.
Adds
GET /lean/v0/events, a Server-Sent Events stream of the chain events introduced in #516:head,block,justified_checkpoint,finalized_checkpoint. Every subscriber currently receives all events; server-side?topics=filtering lands in the next PR of the series.Frames carry the topic on the SSE
event:line and a flat JSON payload indata:(built fromChainEvent::topic(), no hardcoded name mapping). A client that reads too slowly skips the events it missed (debug-logged, best-effort; re-sync via the blocks endpoints) rather than back-pressuring the actor. Keep-alive comments hold idle connections open. Documented indocs/rpc.md.Stacked on #516.
Has handler tests and passes clippy with
-D warnings.