Skip to content

feat(rpc): add GET /lean/v0/events SSE stream (head/block/finalized_checkpoint)#460

Open
MegaRedHand wants to merge 4 commits into
mainfrom
feat/lean-api-events
Open

feat(rpc): add GET /lean/v0/events SSE stream (head/block/finalized_checkpoint)#460
MegaRedHand wants to merge 4 commits into
mainfrom
feat/lean-api-events

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Adds a Server-Sent Events endpoint (GET /lean/v0/events) that pushes real-time chain events to subscribers. All subscribers receive every event; there is no server-side topic filtering.

The three event types are head, block, and finalized_checkpoint.

Also adds the ChainEvent broadcast channel in the blockchain actor and wires it through main.rs so the RPC server receives events without polling. Enables reactive explorer UIs and monitoring tools. Has unit tests and passed clippy.

Stacked on #454.

Split the monolithic lib.rs API router into focused modules: core.rs
holds the finalized-state/finalized-block/justified-checkpoint handlers
and shared response helpers; blocks.rs, fork_choice.rs, and admin.rs
each expose pub(crate) routes() -> Router<Store>. build_api_router
merges them with .with_state(store). No behavior change.
Add a ChainEvent enum and a broadcast channel owned by the BlockChainServer
actor. The store's update_head path emits Head and FinalizedCheckpoint when
fork choice moves the head or finalization advances; on_block_core emits Block
on import. The sender is threaded as Option<&ChainEventTx> so spec-test and
test-driver entry points pass None. Keeps the actor as the sole writer: the
flow is strictly one-directional (actor -> broadcast).
Subscribe a fresh broadcast receiver per connection and forward each ChainEvent
as a Server-Sent Event. start_rpc_server takes the broadcast sender and attaches
it via Extension; main.rs creates the channel and threads it into both
BlockChain::spawn and start_rpc_server. RPC stays read-only: it only subscribes,
never writes back to the actor.
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment:
Good architectural approach. The broadcast channel correctly isolates the consensus actor from RPC back-pressure (slow clients get dropped rather than stalling block processing). Event ordering (Block → Head → Finalized) is logically sound.


Critical Issues

1. Panic risk in fork choice head update
crates/blockchain/src/store.rs:74-76

let new_header = store
    .get_block_header(&new_head)
    .expect("head block exists");

Using expect in consensus code is dangerous. If the store ever enters an inconsistent state (e.g., corruption, bug in block storage), the node crashes instead of attempting recovery or graceful degradation.

Recommendation: Use if let Some(header) = store.get_block_header(&new_head) and log a warning/error if missing, skipping the event emission. The head update in the store has already occurred; only the notification should be skipped.


Security & Correctness

2. Silent event drops on serialization failure
crates/net/rpc/src/events.rs:47

Some(Ok(Event::default().event(name).json_data(ev).ok()?))

If json_data fails (e.g., custom serializer error), the event is silently dropped. For blockchain monitoring, silent data loss is worse than a broken connection.

Recommendation: Log the error at warn or error level before returning None.

3. H256 serialization format
crates/blockchain/src/events.rs:18-25
Ensure H256 serializes as 0x-prefixed hex strings for JSON. If it serializes as a byte array ([0,0,...]), SSE clients will receive unreadable binary data. Verify that the type implements serialize_as_hex or similar, or use a wrapper type.


Code Quality & Maintainability

4. Incorrect comment in test
crates/net/rpc/src/events.rs:78-80

// Issue the request first so the handler subscribes its receiver before
// we publish — `broadcast::send` errors if there are no live receivers.

broadcast::send does not error when there are zero receivers; it returns Ok(0). The comment describes a race condition that doesn't exist. While subscribing first is good practice for test determinism, the reasoning is wrong.

5. Redundant finalized checkpoint check
crates/blockchain/src/store.rs:84-85

if new_finalized.slot > old_finalized.slot || new_finalized.root != old_finalized.root

Finalization is strictly monotonic in slot; the root check is redundant. Not harmful, but suggests uncertainty about invariants. Consider asserting monotonicity instead.

6. Dependency placement
crates/blockchain/Cargo.toml:32
Moving serde to [dependencies] from [dev-dependencies] is correct since ChainEvent derives Serialize, but ensure serde feature flags are minimal (e.g., avoid derive feature if only using Serialize via macro).


Performance & Observability

7. Lagged client logging
crates/net/rpc/src/events.rs:33-36
Lagged clients are dropped silently at debug level. Consider info or warn since this indicates a client that can't keep up with chain speed (e.g., 256 events behind).

8. Channel capacity sizing
crates/blockchain/src/events.rs:35
Capacity 256 is roughly 50 slots of events (Head + Block + Finalized). During initial sync, event bursts could overflow this quickly. The lag-and-drop behavior is correct, but document that clients must handle Lagged errors by reconnecting and backfilling from the REST API.


Positive Notes

  • Good: The Option<&ChainEventTx> parameter allows tests to pass None, keeping spec tests focused on consensus logic without side effects.
  • Good: Event emission ordering in on_block_core (Block event before update_head) ensures subscribers see the causal relationship correctly.
  • Good: Explicit None for events in get_proposal_head with clear documentation prevents leaking internal proposer state transitions.
  • Good: The one-directional data flow (Actor → Broadcast → SSE) maintains clean separation between consensus and RPC layers.

Suggested Patch (Critical Fix)

// crates/blockchain/src/store.rs
if old_head != new_head {
    if let Some(header) = store.get_block_header(&new_head) {
        let _ = events.send(ChainEvent::Head {
            slot: header.slot,
            root: new_head,
            parent_root: header.parent_root,
        });
    } else {
        tracing::error!(?new_head, "Head block missing from store during fork choice update");
    }
}

Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

No material findings in the diff.

The event emission points are side-band only and are placed after the store commit points, so I don’t see a consensus-path regression in fork choice, attestation validation, justification/finalization, XMSS verification, or SSZ handling from these changes. The broadcast channel choice also avoids back-pressuring block/tick processing.

Open questions / residual risk:

  • crates/blockchain/src/events.rs says lagged clients “re-sync via backfill”, but I didn’t find a corresponding backfill path in this diff. If that exists elsewhere, fine; otherwise the comment is stronger than the implementation.
  • crates/net/rpc/src/events.rs only tests the happy-path head stream. I’d want one integration test for block -> head -> finalized_checkpoint ordering and one lagged-client case, since those are the semantics this endpoint now depends on.

I couldn’t run cargo test in this environment because Rustup tried to write/fetch toolchain 1.92.0 and the sandbox is read-only with no network.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a Server-Sent Events endpoint (GET /lean/v0/events) that pushes real-time chain events (Head, Block, FinalizedCheckpoint) to subscribers using a tokio::sync::broadcast channel owned by the blockchain actor and consumed read-only by the RPC handler.

  • Adds ChainEvent enum and ChainEventTx alias in a new blockchain::events module, emitted from store::on_block and store::update_head with correct ordering (block before head).
  • Wires the broadcast sender through main.rsBlockChain::spawnstart_rpc_server and layers it as an Axum Extension on the API router.
  • The topics query parameter advertised in the PR description (?topics=head,block,finalized) is not parsed or enforced by the handler — all subscribers always receive every event type; and FinalizedCheckpoint is emitted as "finalized_checkpoint" on the wire rather than "finalized" as documented.

Confidence Score: 3/5

The blockchain actor plumbing and event emission logic are solid, but the RPC handler does not implement the topics filter documented in the PR, meaning every subscriber receives every event type regardless of what they request.

The blockchain-side changes (event emission ordering, finalization condition, broadcast channel wiring) are well-reasoned and safe. The RPC handler ships without the topic-filtering behaviour the PR description advertises, creating an API contract mismatch from day one. Additionally, the emitted event name for finalized checkpoints differs from the documented topic name, and there is no SSE keep-alive configured, which would cause idle connections to be dropped by most proxies in production.

crates/net/rpc/src/events.rs needs the most attention — topics filtering, keep-alive, and event name alignment all require changes before the endpoint matches its specification.

Important Files Changed

Filename Overview
crates/net/rpc/src/events.rs New SSE handler for GET /lean/v0/events; missing topics filtering, no keep-alive, and a naming mismatch between the documented "finalized" topic and the emitted "finalized_checkpoint" event name.
crates/blockchain/src/events.rs New ChainEvent enum and broadcast channel type aliases; clean, well-documented, and correctly sized at 256-capacity.
crates/blockchain/src/store.rs Adds ChainEvent emission at head updates and block import; ordering (block before head) is intentional and correct; finalization condition is sound.
bin/ethlambda/src/main.rs Wires broadcast channel from blockchain actor to RPC server; initial receiver correctly dropped; clone semantics for Sender are appropriate.
crates/net/rpc/src/lib.rs Adds chain_events Extension layer to the API router and merges the new events routes; clean integration.
crates/blockchain/src/lib.rs Threads ChainEventTx into BlockChainServer and on_tick/on_block calls; straightforward plumbing with no logic changes.
crates/blockchain/tests/forkchoice_spectests.rs Updates test call sites to pass None for the new events parameter; mechanical change with no logic impact.
crates/blockchain/tests/signature_spectests.rs Same mechanical None-passing update to on_tick and on_block call sites in signature spec tests.
crates/net/rpc/src/test_driver.rs Updates test-driver on_tick call sites to pass None for events; no behavioral change.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant A as BlockChainServer
    participant B as store.rs
    participant C as broadcast channel
    participant D as SSE handler
    participant E as SSE Client

    A->>B: "on_block(signed_block, events)"
    B->>C: "send(Block{slot, root})"
    B->>B: "update_head(events)"
    B->>C: "send(Head{slot, root, parent_root})"
    B->>C: "send(FinalizedCheckpoint{slot, root})"
    E->>D: "GET /lean/v0/events"
    D->>C: "tx.subscribe()"
    C-->>D: "BroadcastStream events"
    D-->>E: "event:block data:{...}"
    D-->>E: "event:head data:{...}"
    D-->>E: "event:finalized_checkpoint data:{...}"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant A as BlockChainServer
    participant B as store.rs
    participant C as broadcast channel
    participant D as SSE handler
    participant E as SSE Client

    A->>B: "on_block(signed_block, events)"
    B->>C: "send(Block{slot, root})"
    B->>B: "update_head(events)"
    B->>C: "send(Head{slot, root, parent_root})"
    B->>C: "send(FinalizedCheckpoint{slot, root})"
    E->>D: "GET /lean/v0/events"
    D->>C: "tx.subscribe()"
    C-->>D: "BroadcastStream events"
    D-->>E: "event:block data:{...}"
    D-->>E: "event:head data:{...}"
    D-->>E: "event:finalized_checkpoint data:{...}"
Loading
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
crates/net/rpc/src/events.rs:25-46
**`topics` filter advertised but not implemented**

The PR description and endpoint URL document `GET /lean/v0/events?topics=head,block,finalized`, implying clients can selectively subscribe to a subset of event types. The handler ignores any query parameter entirely, so every subscriber receives every event type (`Head`, `Block`, and `FinalizedCheckpoint`) regardless of what they pass in `?topics=`. A monitoring tool subscribing only to `?topics=finalized` will receive `head` and `block` events it never asked for, and a future client that parses the query parameter would behave differently from what's deployed today.

### Issue 2 of 4
crates/net/rpc/src/events.rs:45-46
**Missing SSE keep-alive — idle connections will be dropped by proxies**

`Sse::new(stream)` with no `.keep_alive()` means the TCP connection sits completely idle between chain events. Most HTTP proxies, load balancers, and some browsers close idle connections after 30–60 s. Adding a keep-alive interval ensures the connection stays open during quiet periods.

```suggestion
    Sse::new(stream).keep_alive(
        axum::response::sse::KeepAlive::new()
            .interval(std::time::Duration::from_secs(15))
            .text("keep-alive"),
    )
}
```

### Issue 3 of 4
crates/net/rpc/src/events.rs:43
**Silently drops events when `json_data` fails, with no log**

`json_data(ev).ok()?` turns a serialization error into `None`, which `filter_map` silently skips. While `ChainEvent` is unlikely to fail serialization today, a future variant that does would produce an invisible event hole. A `tracing::warn!` here keeps the skip explicit rather than surprising.

```suggestion
        match Event::default().event(name).json_data(ev) {
            Ok(sse_event) => Some(Ok(sse_event)),
            Err(err) => {
                tracing::warn!(%err, "failed to serialize chain event; skipping");
                None
            }
        }
```

### Issue 4 of 4
crates/net/rpc/src/events.rs:41
**Event name `finalized_checkpoint` doesn't match the documented topic `finalized`**

The PR description documents the topic name as `finalized`, but the SSE `event:` field emitted on the wire is `"finalized_checkpoint"`. If `topics` filtering is ever implemented, a client passing `?topics=finalized` would receive no events for this type because the names won't match. Aligning the wire name to `"finalized"` now avoids a breaking change later.

Reviews (1): Last reviewed commit: "feat(rpc): add GET /lean/v0/events SSE s..." | Re-trigger Greptile

Comment on lines +25 to +46
async fn get_events(
Extension(tx): Extension<broadcast::Sender<ChainEvent>>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = BroadcastStream::new(tx.subscribe()).filter_map(|res| {
// A slow client falls behind and the broadcast channel overwrites
// events it never read. Surface that rather than silently dropping.
let ev = match res {
Ok(ev) => ev,
Err(BroadcastStreamRecvError::Lagged(skipped)) => {
tracing::debug!(skipped, "SSE client lagged; dropped chain events");
return None;
}
};
let name = match &ev {
ChainEvent::Head { .. } => "head",
ChainEvent::Block { .. } => "block",
ChainEvent::FinalizedCheckpoint { .. } => "finalized_checkpoint",
};
Some(Ok(Event::default().event(name).json_data(ev).ok()?))
});
Sse::new(stream)
}

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.

P1 topics filter advertised but not implemented

The PR description and endpoint URL document GET /lean/v0/events?topics=head,block,finalized, implying clients can selectively subscribe to a subset of event types. The handler ignores any query parameter entirely, so every subscriber receives every event type (Head, Block, and FinalizedCheckpoint) regardless of what they pass in ?topics=. A monitoring tool subscribing only to ?topics=finalized will receive head and block events it never asked for, and a future client that parses the query parameter would behave differently from what's deployed today.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/events.rs
Line: 25-46

Comment:
**`topics` filter advertised but not implemented**

The PR description and endpoint URL document `GET /lean/v0/events?topics=head,block,finalized`, implying clients can selectively subscribe to a subset of event types. The handler ignores any query parameter entirely, so every subscriber receives every event type (`Head`, `Block`, and `FinalizedCheckpoint`) regardless of what they pass in `?topics=`. A monitoring tool subscribing only to `?topics=finalized` will receive `head` and `block` events it never asked for, and a future client that parses the query parameter would behave differently from what's deployed today.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread crates/net/rpc/src/events.rs Outdated
Comment on lines +45 to +46
Sse::new(stream)
}

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.

P2 Missing SSE keep-alive — idle connections will be dropped by proxies

Sse::new(stream) with no .keep_alive() means the TCP connection sits completely idle between chain events. Most HTTP proxies, load balancers, and some browsers close idle connections after 30–60 s. Adding a keep-alive interval ensures the connection stays open during quiet periods.

Suggested change
Sse::new(stream)
}
Sse::new(stream).keep_alive(
axum::response::sse::KeepAlive::new()
.interval(std::time::Duration::from_secs(15))
.text("keep-alive"),
)
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/events.rs
Line: 45-46

Comment:
**Missing SSE keep-alive — idle connections will be dropped by proxies**

`Sse::new(stream)` with no `.keep_alive()` means the TCP connection sits completely idle between chain events. Most HTTP proxies, load balancers, and some browsers close idle connections after 30–60 s. Adding a keep-alive interval ensures the connection stays open during quiet periods.

```suggestion
    Sse::new(stream).keep_alive(
        axum::response::sse::KeepAlive::new()
            .interval(std::time::Duration::from_secs(15))
            .text("keep-alive"),
    )
}
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread crates/net/rpc/src/events.rs Outdated
ChainEvent::Block { .. } => "block",
ChainEvent::FinalizedCheckpoint { .. } => "finalized_checkpoint",
};
Some(Ok(Event::default().event(name).json_data(ev).ok()?))

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.

P2 Silently drops events when json_data fails, with no log

json_data(ev).ok()? turns a serialization error into None, which filter_map silently skips. While ChainEvent is unlikely to fail serialization today, a future variant that does would produce an invisible event hole. A tracing::warn! here keeps the skip explicit rather than surprising.

Suggested change
Some(Ok(Event::default().event(name).json_data(ev).ok()?))
match Event::default().event(name).json_data(ev) {
Ok(sse_event) => Some(Ok(sse_event)),
Err(err) => {
tracing::warn!(%err, "failed to serialize chain event; skipping");
None
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/events.rs
Line: 43

Comment:
**Silently drops events when `json_data` fails, with no log**

`json_data(ev).ok()?` turns a serialization error into `None`, which `filter_map` silently skips. While `ChainEvent` is unlikely to fail serialization today, a future variant that does would produce an invisible event hole. A `tracing::warn!` here keeps the skip explicit rather than surprising.

```suggestion
        match Event::default().event(name).json_data(ev) {
            Ok(sse_event) => Some(Ok(sse_event)),
            Err(err) => {
                tracing::warn!(%err, "failed to serialize chain event; skipping");
                None
            }
        }
```

How can I resolve this? If you propose a fix, please make it concise.

let name = match &ev {
ChainEvent::Head { .. } => "head",
ChainEvent::Block { .. } => "block",
ChainEvent::FinalizedCheckpoint { .. } => "finalized_checkpoint",

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.

P2 Event name finalized_checkpoint doesn't match the documented topic finalized

The PR description documents the topic name as finalized, but the SSE event: field emitted on the wire is "finalized_checkpoint". If topics filtering is ever implemented, a client passing ?topics=finalized would receive no events for this type because the names won't match. Aligning the wire name to "finalized" now avoids a breaking change later.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/events.rs
Line: 41

Comment:
**Event name `finalized_checkpoint` doesn't match the documented topic `finalized`**

The PR description documents the topic name as `finalized`, but the SSE `event:` field emitted on the wire is `"finalized_checkpoint"`. If `topics` filtering is ever implemented, a client passing `?topics=finalized` would receive no events for this type because the names won't match. Aligning the wire name to `"finalized"` now avoids a breaking change later.

How can I resolve this? If you propose a fix, please make it concise.

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

I have enough verified findings to write the review. Here it is:


PR #460GET /lean/v0/events SSE Stream: Review

This PR adds a broadcast-channel-backed SSE endpoint that streams head, block, and finalized_checkpoint events to subscribers. The architecture is clean — the blockchain actor is the sole publisher, RPC only subscribes, and the Option<&ChainEventTx> threading through update_head/on_block_core/on_tick is handled consistently with appropriate None for test paths and get_proposal_head. The signal-on-head-change and finalization-advance logic is correct.

Several issues need addressing before merge:


crates/net/rpc/src/events.rs:30-35 — Lag comment contradicts the implementation

// A slow client falls behind and the broadcast channel overwrites
// events it never read. Surface that rather than silently dropping.
let ev = match res {
    Err(BroadcastStreamRecvError::Lagged(skipped)) => {
        tracing::debug!(skipped, "SSE client lagged; dropped chain events");
        return None;    // ← filter_map None is invisible to the client
    }

The comment says "surface that rather than silently dropping", but return None inside filter_map removes the item from the stream. The SSE client receives no frame and has no way to detect the gap. A client rebuilding fork-choice state from the sequential event stream will silently construct incorrect state. Either close the stream on lag (so the client reconnects and fetches a fresh snapshot) or send a distinct error SSE event before terminating.


crates/blockchain/src/events.rs:35 — Backfill claim is false

/// Capacity chosen so a briefly-stalled SSE client is dropped (lagged) rather
/// than back-pressuring the actor. Lagged clients re-sync via backfill.

No backfill mechanism exists anywhere: no replay endpoint, no event storage, no state snapshot on reconnect. The other uses of "backfill" in the codebase (block/chain sync) are unrelated. Either implement the mechanism or correct the doc — as written it will mislead operators and clients who trust the docs and skip reconnect-and-refetch logic.


crates/net/rpc/src/events.rs:45 — Missing keep_alive on the SSE response

Sse::new(stream)   // no .keep_alive(...)

Axum's Sse supports .keep_alive(KeepAlive::default()), which emits periodic SSE comment frames. Without it, any proxy, load balancer, or cloud infrastructure with a 60–300 s idle timeout will silently terminate a connection that sees no chain events (common during slow-finalization periods). The client cannot distinguish a quiet chain from a broken connection. Fix:

use axum::response::sse::KeepAlive;
Sse::new(stream).keep_alive(KeepAlive::default())

crates/net/rpc/src/events.rs:25?topics= query parameter is advertised but not implemented

The PR description reads: GET /lean/v0/events?topics=head,block,finalized. The handler takes only Extension(tx) — no Query extractor, no filtering logic. Every subscriber receives all three event types regardless of the query string. Either drop the ?topics= from the description and docs, or add the filter before merge to avoid a breaking API change later.


crates/net/rpc/src/events.rs:38-42 — Variant name duplicated between match arm and serde rename

let name = match &ev {
    ChainEvent::Head { .. } => "head",
    ChainEvent::Block { .. } => "block",
    ChainEvent::FinalizedCheckpoint { .. } => "finalized_checkpoint",
};
Some(Ok(Event::default().event(name).json_data(ev).ok()?))

ChainEvent already carries #[serde(tag = "event", rename_all = "snake_case")], so json_data(ev) embeds the same tag in the JSON. The match arm hardcodes the same strings a second time; if a variant is renamed in the serde attribute the SSE event: field and the JSON "event" key silently diverge. Extract the name from the serialized value, or (simpler) use a fn event_name(&self) -> &'static str on ChainEvent as the single source of truth.


crates/net/rpc/src/events.rs:43 — JSON serialization failure silently drops the event (CLAUDE.md violation)

Some(Ok(Event::default().event(name).json_data(ev).ok()?))

json_data(ev) returns a Result; .ok()? converts a serialization error to None, which filter_map silently removes with no server-side log. Per CLAUDE.md: "Use inspect_err for side-effect-only error handling." Suggested fix:

Some(
    Event::default()
        .event(name)
        .json_data(ev)
        .inspect_err(|err| tracing::warn!(%err, "Failed to serialize chain event"))
        .ok()
        .map(Ok)?,
)

Automated review by Claude (Anthropic) · sonnet · custom prompt

@MegaRedHand MegaRedHand changed the title feat(rpc): add GET /lean/v0/events SSE stream (head/block/finalized) feat(rpc): add GET /lean/v0/events SSE stream (head/block/finalized_checkpoint) Jun 24, 2026
Base automatically changed from feat/lean-api-router-scaffold to main June 24, 2026 19:10
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.

1 participant