Skip to content

Commit a3dcb73

Browse files
committed
feat(rpc): add GET /lean/v0/events SSE stream (head/block/finalized)
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.
1 parent 90b4dd7 commit a3dcb73

6 files changed

Lines changed: 135 additions & 12 deletions

File tree

bin/ethlambda/src/main.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,12 +288,20 @@ async fn main() -> eyre::Result<()> {
288288
// and the API server (which exposes GET/POST admin endpoints).
289289
let aggregator = AggregatorController::new(options.is_aggregator);
290290

291+
// Chain-event broadcast channel: the blockchain actor is the sole sender;
292+
// each SSE client (`GET /lean/v0/events`) subscribes its own receiver. The
293+
// initial receiver is dropped — subscribers attach on demand and a fully
294+
// unsubscribed channel just drops events.
295+
let (chain_events, _) =
296+
tokio::sync::broadcast::channel(ethlambda_blockchain::CHAIN_EVENT_CHANNEL_CAPACITY);
297+
291298
let blockchain = BlockChain::spawn(
292299
store.clone(),
293300
validator_keys,
294301
aggregator.clone(),
295302
attestation_committee_count,
296303
!options.disable_duty_sync_gate,
304+
chain_events.clone(),
297305
);
298306

299307
// Note: SwarmConfig.is_aggregator is intentionally a plain bool, not the
@@ -333,9 +341,15 @@ async fn main() -> eyre::Result<()> {
333341
let rpc_shutdown = shutdown_token.clone();
334342

335343
let rpc_handle = tokio::spawn(async move {
336-
let _ = ethlambda_rpc::start_rpc_server(rpc_config, store, aggregator, rpc_shutdown)
337-
.await
338-
.inspect_err(|err| error!(%err, "RPC server failed"));
344+
let _ = ethlambda_rpc::start_rpc_server(
345+
rpc_config,
346+
store,
347+
aggregator,
348+
chain_events,
349+
rpc_shutdown,
350+
)
351+
.await
352+
.inspect_err(|err| error!(%err, "RPC server failed"));
339353
});
340354

341355
info!("Node initialized");

crates/blockchain/src/store.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,16 @@ pub fn update_head(store: &mut Store, log_tree: bool, events: Option<&ChainEvent
6767
store.update_checkpoints(ForkCheckpoints::new(new_head, None, finalized));
6868

6969
if let Some(events) = events {
70-
// Emit the new head whenever fork choice moved it.
70+
// Emit the new head whenever fork choice moved it. Read the header once
71+
// and reuse it for slot and parent_root so they stay consistent.
7172
if old_head != new_head {
72-
let parent_root = store
73+
let new_header = store
7374
.get_block_header(&new_head)
74-
.map(|h| h.parent_root)
75-
.unwrap_or(H256::ZERO);
75+
.expect("head block exists");
7676
let _ = events.send(ChainEvent::Head {
77-
slot: store.head_slot(),
77+
slot: new_header.slot,
7878
root: new_head,
79-
parent_root,
79+
parent_root: new_header.parent_root,
8080
});
8181
}
8282

crates/net/rpc/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ serde_json.workspace = true
2626
hex.workspace = true
2727
tracing.workspace = true
2828
jemalloc_pprof.workspace = true
29+
tokio-stream = { version = "0.1", features = ["sync"] }
30+
futures-core = "0.3"
2931

3032
[dev-dependencies]
3133
ethlambda-types.workspace = true

crates/net/rpc/src/events.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//! `GET /lean/v0/events` — Server-Sent Events stream of chain events.
2+
//!
3+
//! The [`ethlambda_blockchain::BlockChainServer`] actor publishes
4+
//! [`ChainEvent`]s on a broadcast channel; this read-only handler subscribes a
5+
//! new receiver per connection and forwards each event as an SSE message. The
6+
//! flow is strictly one-directional (actor → broadcast → SSE), so RPC never
7+
//! writes into the actor.
8+
9+
use std::convert::Infallible;
10+
11+
use axum::{
12+
Extension, Router,
13+
response::{Sse, sse::Event},
14+
routing::get,
15+
};
16+
use ethlambda_blockchain::ChainEvent;
17+
use ethlambda_storage::Store;
18+
use futures_core::Stream;
19+
use tokio::sync::broadcast;
20+
use tokio_stream::{
21+
StreamExt,
22+
wrappers::{BroadcastStream, errors::BroadcastStreamRecvError},
23+
};
24+
25+
async fn get_events(
26+
Extension(tx): Extension<broadcast::Sender<ChainEvent>>,
27+
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
28+
let stream = BroadcastStream::new(tx.subscribe()).filter_map(|res| {
29+
// A slow client falls behind and the broadcast channel overwrites
30+
// events it never read. Surface that rather than silently dropping.
31+
let ev = match res {
32+
Ok(ev) => ev,
33+
Err(BroadcastStreamRecvError::Lagged(skipped)) => {
34+
tracing::debug!(skipped, "SSE client lagged; dropped chain events");
35+
return None;
36+
}
37+
};
38+
let name = match &ev {
39+
ChainEvent::Head { .. } => "head",
40+
ChainEvent::Block { .. } => "block",
41+
ChainEvent::FinalizedCheckpoint { .. } => "finalized_checkpoint",
42+
};
43+
Some(Ok(Event::default().event(name).json_data(ev).ok()?))
44+
});
45+
Sse::new(stream)
46+
}
47+
48+
pub(crate) fn routes() -> Router<Store> {
49+
Router::new().route("/lean/v0/events", get(get_events))
50+
}
51+
52+
#[cfg(test)]
53+
mod tests {
54+
use super::*;
55+
use axum::{body::Body, http::Request};
56+
use ethlambda_storage::{Store, backend::InMemoryBackend};
57+
use std::sync::Arc;
58+
use tower::ServiceExt;
59+
60+
use crate::test_utils::create_test_state;
61+
62+
#[tokio::test]
63+
async fn events_streams_head() {
64+
let (tx, _) = broadcast::channel::<ChainEvent>(16);
65+
let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state());
66+
let app = crate::build_api_router(store).layer(Extension(tx.clone()));
67+
68+
// Issue the request first so the handler subscribes its receiver before
69+
// we publish — `broadcast::send` errors if there are no live receivers.
70+
let resp = app
71+
.oneshot(
72+
Request::builder()
73+
.uri("/lean/v0/events")
74+
.body(Body::empty())
75+
.unwrap(),
76+
)
77+
.await
78+
.unwrap();
79+
assert_eq!(resp.status(), axum::http::StatusCode::OK);
80+
81+
tx.send(ChainEvent::Head {
82+
slot: 3,
83+
root: Default::default(),
84+
parent_root: Default::default(),
85+
})
86+
.unwrap();
87+
88+
let mut body = resp.into_body().into_data_stream();
89+
let chunk = tokio_stream::StreamExt::next(&mut body)
90+
.await
91+
.unwrap()
92+
.unwrap();
93+
let text = String::from_utf8_lossy(&chunk);
94+
assert!(text.contains("event:head") || text.contains("event: head"));
95+
}
96+
}

crates/net/rpc/src/lib.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::net::{IpAddr, SocketAddr};
22

33
use axum::{Extension, Router};
4+
use ethlambda_blockchain::ChainEventTx;
45
use ethlambda_storage::Store;
56
use ethlambda_types::aggregator::AggregatorController;
67
use tokio_util::sync::CancellationToken;
@@ -11,6 +12,7 @@ pub(crate) const SSZ_CONTENT_TYPE: &str = "application/octet-stream";
1112
mod admin;
1213
mod blocks;
1314
mod core;
15+
mod events;
1416
mod fork_choice;
1517
mod heap_profiling;
1618
pub mod metrics;
@@ -51,9 +53,12 @@ pub async fn start_rpc_server(
5153
config: RpcConfig,
5254
store: Store,
5355
aggregator: AggregatorController,
56+
chain_events: ChainEventTx,
5457
shutdown: CancellationToken,
5558
) -> Result<(), std::io::Error> {
56-
let api_router = build_api_router(store).layer(Extension(aggregator));
59+
let api_router = build_api_router(store)
60+
.layer(Extension(aggregator))
61+
.layer(Extension(chain_events));
5762
let metrics_router = metrics::start_prometheus_metrics_api();
5863
let debug_router = build_debug_router();
5964

@@ -98,6 +103,7 @@ fn build_api_router(store: Store) -> Router {
98103
Router::new()
99104
.merge(core::routes())
100105
.merge(blocks::routes())
106+
.merge(events::routes())
101107
.merge(fork_choice::routes())
102108
.merge(admin::routes())
103109
.with_state(store)

crates/net/rpc/src/test_driver.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,12 @@ fn apply_step(store: &mut Store, step: ForkChoiceStep) -> Result<(), String> {
347347
}
348348
(None, None) => return Err("tick step missing time and interval".to_string()),
349349
};
350-
store::on_tick(store, timestamp_ms, step.has_proposal.unwrap_or(false));
350+
store::on_tick(
351+
store,
352+
timestamp_ms,
353+
step.has_proposal.unwrap_or(false),
354+
None,
355+
);
351356
Ok(())
352357
}
353358
"block" => {
@@ -361,7 +366,7 @@ fn apply_step(store: &mut Store, step: ForkChoiceStep) -> Result<(), String> {
361366
if step.tick_to_slot {
362367
let block_time_ms = store.config().genesis_time * 1000
363368
+ signed_block.message.slot * MILLISECONDS_PER_SLOT;
364-
store::on_tick(store, block_time_ms, true);
369+
store::on_tick(store, block_time_ms, true, None);
365370
}
366371
store::on_block_without_verification(store, signed_block).map_err(|e| e.to_string())
367372
}

0 commit comments

Comments
 (0)