Skip to content

Commit 90b4dd7

Browse files
committed
feat(blockchain): emit chain events from actor on head/block/finalized
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).
1 parent 7f7205a commit 90b4dd7

7 files changed

Lines changed: 148 additions & 21 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/blockchain/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ tokio-util = { version = "0.7", default-features = false }
2929
rayon.workspace = true
3030
thiserror.workspace = true
3131
tracing.workspace = true
32+
serde.workspace = true
3233

3334
hex.workspace = true
3435

3536
[dev-dependencies]
3637
ethlambda-test-fixtures.workspace = true
37-
serde = { workspace = true }
3838
serde_json = { workspace = true }
3939
hex = { workspace = true }
4040
libssz.workspace = true

crates/blockchain/src/events.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//! Chain events emitted by the [`crate::BlockChainServer`] actor and streamed
2+
//! to RPC clients over Server-Sent Events (`GET /lean/v0/events`).
3+
//!
4+
//! The flow is strictly one-directional: the actor (the sole writer) publishes
5+
//! events on a [`broadcast`] channel, and the read-only RPC handler subscribes.
6+
//! RPC never writes back into the actor.
7+
8+
use ethlambda_types::primitives::H256;
9+
use serde::Serialize;
10+
use tokio::sync::broadcast;
11+
12+
/// A consensus event broadcast to SSE subscribers.
13+
///
14+
/// Serialized with an external `event`/`data` tag so the JSON payload mirrors
15+
/// the SSE framing (`event: head\ndata: {...}`).
16+
#[derive(Clone, Debug, Serialize)]
17+
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
18+
pub enum ChainEvent {
19+
/// Fork choice selected a new head.
20+
Head {
21+
slot: u64,
22+
root: H256,
23+
parent_root: H256,
24+
},
25+
/// A block was imported into the store.
26+
Block { slot: u64, root: H256 },
27+
/// The finalized checkpoint advanced.
28+
FinalizedCheckpoint { slot: u64, root: H256 },
29+
}
30+
31+
/// Sender half of the chain-event broadcast channel, owned by the actor.
32+
pub type ChainEventTx = broadcast::Sender<ChainEvent>;
33+
34+
/// Capacity chosen so a briefly-stalled SSE client is dropped (lagged) rather
35+
/// than back-pressuring the actor. Lagged clients re-sync via backfill.
36+
pub const CHAIN_EVENT_CHANNEL_CAPACITY: usize = 256;
37+
38+
#[cfg(test)]
39+
mod tests {
40+
use super::*;
41+
42+
#[tokio::test]
43+
async fn channel_delivers_head_event() {
44+
let (tx, mut rx) = broadcast::channel::<ChainEvent>(CHAIN_EVENT_CHANNEL_CAPACITY);
45+
tx.send(ChainEvent::Head {
46+
slot: 7,
47+
root: H256::ZERO,
48+
parent_root: H256::ZERO,
49+
})
50+
.unwrap();
51+
match rx.recv().await.unwrap() {
52+
ChainEvent::Head { slot, .. } => assert_eq!(slot, 7),
53+
other => panic!("unexpected: {other:?}"),
54+
}
55+
}
56+
}

crates/blockchain/src/lib.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ use tracing::{debug, error, info, trace, warn};
2828

2929
use crate::store::StoreError;
3030

31+
pub use events::{CHAIN_EVENT_CHANNEL_CAPACITY, ChainEvent, ChainEventTx};
32+
3133
pub mod aggregation;
3234
pub mod block_builder;
3335
pub(crate) mod coverage;
36+
pub mod events;
3437
pub(crate) mod fork_choice_tree;
3538
pub mod key_manager;
3639
pub mod metrics;
@@ -82,6 +85,7 @@ impl BlockChain {
8285
aggregator: AggregatorController,
8386
attestation_committee_count: u64,
8487
gate_duties: bool,
88+
chain_events: ChainEventTx,
8589
) -> BlockChain {
8690
metrics::set_is_aggregator(aggregator.is_enabled());
8791
metrics::set_node_sync_status(metrics::SyncStatus::Idle);
@@ -108,6 +112,7 @@ impl BlockChain {
108112
attestation_committee_count,
109113
pre_merge_coverage: None,
110114
sync_status: SyncStatusTracker::new(gate_duties),
115+
chain_events,
111116
}
112117
.start();
113118
let time_until_genesis = (SystemTime::UNIX_EPOCH + Duration::from_secs(genesis_time))
@@ -177,6 +182,11 @@ pub struct BlockChainServer {
177182
/// validator duties while syncing, unless that gating was disabled at
178183
/// startup via `--disable-duty-sync-gate` (then it is metric-only).
179184
sync_status: SyncStatusTracker,
185+
186+
/// Broadcast sender for chain events streamed to SSE subscribers
187+
/// (`GET /lean/v0/events`). The actor is the sole publisher; the RPC
188+
/// handler only subscribes, preserving the one-directional write flow.
189+
chain_events: ChainEventTx,
180190
}
181191

182192
impl BlockChainServer {
@@ -258,7 +268,12 @@ impl BlockChainServer {
258268
let is_proposer = scheduled_proposer.is_some();
259269

260270
// Tick the store first - this accepts attestations at interval 0 if we have a proposal
261-
store::on_tick(&mut self.store, timestamp_ms, is_proposer);
271+
store::on_tick(
272+
&mut self.store,
273+
timestamp_ms,
274+
is_proposer,
275+
Some(&self.chain_events),
276+
);
262277

263278
// ==== interval 0 ====
264279

@@ -593,7 +608,7 @@ impl BlockChainServer {
593608

594609
/// Run block import and refresh metrics.
595610
fn process_block(&mut self, signed_block: SignedBlock) -> Result<(), StoreError> {
596-
store::on_block(&mut self.store, signed_block)?;
611+
store::on_block(&mut self.store, signed_block, Some(&self.chain_events))?;
597612
metrics::update_head_slot(self.store.head_slot());
598613
metrics::update_latest_justified_slot(self.store.latest_justified().slot);
599614
metrics::update_latest_finalized_slot(self.store.latest_finalized().slot);

crates/blockchain/src/store.rs

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,33 @@ use crate::{
2020
GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT, MAX_ATTESTATIONS_DATA,
2121
MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT,
2222
block_builder::{PostBlockCheckpoints, build_block},
23+
events::{ChainEvent, ChainEventTx},
2324
metrics,
2425
};
2526

2627
const JUSTIFICATION_LOOKBACK_SLOTS: u64 = 3;
2728

2829
/// Accept new aggregated payloads, promoting them to known for fork choice.
29-
fn accept_new_attestations(store: &mut Store, log_tree: bool) {
30+
fn accept_new_attestations(store: &mut Store, log_tree: bool, events: Option<&ChainEventTx>) {
3031
store.promote_new_aggregated_payloads();
3132
metrics::update_latest_new_aggregated_payloads(store.new_aggregated_payloads_count());
3233
metrics::update_latest_known_aggregated_payloads(store.known_aggregated_payloads_count());
33-
update_head(store, log_tree);
34+
update_head(store, log_tree, events);
3435
}
3536

3637
/// Update the head based on the fork choice rule.
3738
///
3839
/// When `log_tree` is true, also computes block weights and logs an ASCII
3940
/// fork choice tree to the terminal.
40-
pub fn update_head(store: &mut Store, log_tree: bool) {
41+
///
42+
/// When `events` is `Some`, emits a [`ChainEvent::Head`] whenever the head
43+
/// changes and a [`ChainEvent::FinalizedCheckpoint`] whenever finalization
44+
/// advances. Send errors (no subscribers) are ignored.
45+
pub fn update_head(store: &mut Store, log_tree: bool, events: Option<&ChainEventTx>) {
4146
let blocks = store.get_live_chain();
4247
let attestations = store.extract_latest_known_attestations();
4348
let old_head = store.head();
49+
let old_finalized = store.latest_finalized();
4450
let (new_head, weights) = ethlambda_fork_choice::compute_lmd_ghost_head(
4551
store.latest_justified().root,
4652
&blocks,
@@ -60,6 +66,30 @@ pub fn update_head(store: &mut Store, log_tree: bool) {
6066
.filter(|finalized| store.get_block_header(&finalized.root).is_some());
6167
store.update_checkpoints(ForkCheckpoints::new(new_head, None, finalized));
6268

69+
if let Some(events) = events {
70+
// Emit the new head whenever fork choice moved it.
71+
if old_head != new_head {
72+
let parent_root = store
73+
.get_block_header(&new_head)
74+
.map(|h| h.parent_root)
75+
.unwrap_or(H256::ZERO);
76+
let _ = events.send(ChainEvent::Head {
77+
slot: store.head_slot(),
78+
root: new_head,
79+
parent_root,
80+
});
81+
}
82+
83+
// Emit a finalized-checkpoint event only when finalization advanced.
84+
let new_finalized = store.latest_finalized();
85+
if new_finalized.slot > old_finalized.slot || new_finalized.root != old_finalized.root {
86+
let _ = events.send(ChainEvent::FinalizedCheckpoint {
87+
slot: new_finalized.slot,
88+
root: new_finalized.root,
89+
});
90+
}
91+
}
92+
6393
if old_head != new_head {
6494
let old_slot = store
6595
.get_block_header(&old_head)
@@ -254,7 +284,12 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<()
254284
/// 800ms interval. Slot and interval-within-slot are derived as:
255285
/// slot = store.time() / INTERVALS_PER_SLOT
256286
/// interval = store.time() % INTERVALS_PER_SLOT
257-
pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) {
287+
pub fn on_tick(
288+
store: &mut Store,
289+
timestamp_ms: u64,
290+
has_proposal: bool,
291+
events: Option<&ChainEventTx>,
292+
) {
258293
// Convert UNIX timestamp (ms) to interval count since genesis
259294
let genesis_time_ms = store.config().genesis_time * 1000;
260295
let time_delta_ms = timestamp_ms.saturating_sub(genesis_time_ms);
@@ -287,7 +322,7 @@ pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) {
287322
0 => {
288323
// Start of slot - process attestations if proposal exists
289324
if should_signal_proposal {
290-
accept_new_attestations(store, false);
325+
accept_new_attestations(store, false, events);
291326
}
292327
}
293328
1 => {
@@ -302,7 +337,7 @@ pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) {
302337
}
303338
4 => {
304339
// End of slot - accept accumulated attestations and log tree
305-
accept_new_attestations(store, true);
340+
accept_new_attestations(store, true, events);
306341
}
307342
_ => unreachable!("slots only have 5 intervals"),
308343
}
@@ -481,8 +516,12 @@ fn on_gossip_aggregated_attestation_core(
481516
///
482517
/// This is the safe default: it always verifies cryptographic signatures
483518
/// and stores them for future block building. Use this for all production paths.
484-
pub fn on_block(store: &mut Store, signed_block: SignedBlock) -> Result<(), StoreError> {
485-
on_block_core(store, signed_block, true)
519+
pub fn on_block(
520+
store: &mut Store,
521+
signed_block: SignedBlock,
522+
events: Option<&ChainEventTx>,
523+
) -> Result<(), StoreError> {
524+
on_block_core(store, signed_block, true, events)
486525
}
487526

488527
/// Process a new block without signature verification.
@@ -493,7 +532,7 @@ pub fn on_block_without_verification(
493532
store: &mut Store,
494533
signed_block: SignedBlock,
495534
) -> Result<(), StoreError> {
496-
on_block_core(store, signed_block, false)
535+
on_block_core(store, signed_block, false, None)
497536
}
498537

499538
/// Core block processing logic.
@@ -504,6 +543,7 @@ fn on_block_core(
504543
store: &mut Store,
505544
signed_block: SignedBlock,
506545
verify: bool,
546+
events: Option<&ChainEventTx>,
507547
) -> Result<(), StoreError> {
508548
let _timing = metrics::time_fork_choice_block_processing();
509549
let block_start = std::time::Instant::now();
@@ -586,8 +626,17 @@ fn on_block_core(
586626
metrics::inc_attestations_valid(count);
587627
}
588628

629+
// Emit the imported block before fork choice runs, so subscribers see the
630+
// `block` event ahead of any `head` move it triggers.
631+
if let Some(events) = events {
632+
let _ = events.send(ChainEvent::Block {
633+
slot,
634+
root: block_root,
635+
});
636+
}
637+
589638
// Update forkchoice head based on new block and attestations
590-
update_head(store, false);
639+
update_head(store, false, events);
591640

592641
let block_total = block_start.elapsed();
593642
info!(
@@ -758,11 +807,16 @@ fn get_proposal_head(store: &mut Store, slot: u64) -> H256 {
758807
// Calculate time corresponding to this slot
759808
let slot_time_ms = store.config().genesis_time * 1000 + slot * MILLISECONDS_PER_SLOT;
760809

761-
// Advance time to current slot (ticking intervals)
762-
on_tick(store, slot_time_ms, true);
810+
// Advance time to current slot (ticking intervals).
811+
//
812+
// No event sender here: this is the proposer's pre-build catch-up, and the
813+
// block it produces is imported via `on_block` (which emits the resulting
814+
// `Block`/`Head`). Emitting from here would surface a head move before the
815+
// block exists.
816+
on_tick(store, slot_time_ms, true, None);
763817

764818
// Process any pending attestations before proposal
765-
accept_new_attestations(store, false);
819+
accept_new_attestations(store, false, None);
766820

767821
store.head()
768822
}

crates/blockchain/tests/forkchoice_spectests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ fn run(path: &Path) -> datatest_stable::Result<()> {
103103
if step.tick_to_slot {
104104
let block_time_ms =
105105
genesis_time * 1000 + signed_block.message.slot * MILLISECONDS_PER_SLOT;
106-
store::on_tick(&mut store, block_time_ms, true);
106+
store::on_tick(&mut store, block_time_ms, true, None);
107107
}
108108
let result = store::on_block_without_verification(&mut store, signed_block);
109109
let import_ok = result.is_ok();
@@ -137,7 +137,7 @@ fn run(path: &Path) -> datatest_stable::Result<()> {
137137
// on_block already ran the head update before these votes
138138
// existed; recompute so the head reflects the block's own
139139
// attestations, matching the proposer-view store.
140-
store::update_head(&mut store, false);
140+
store::update_head(&mut store, false, None);
141141
}
142142
}
143143
"tick" => {
@@ -152,7 +152,7 @@ fn run(path: &Path) -> datatest_stable::Result<()> {
152152
(None, None) => panic!("tick step missing both time and interval"),
153153
};
154154
let has_proposal = step.has_proposal.unwrap_or(false);
155-
store::on_tick(&mut store, timestamp_ms, has_proposal);
155+
store::on_tick(&mut store, timestamp_ms, has_proposal, None);
156156
}
157157
"attestation" => {
158158
let att_data = step

crates/blockchain/tests/signature_spectests.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ fn run(path: &Path) -> datatest_stable::Result<()> {
6161

6262
// Advance time to the block's slot
6363
let block_time_ms = genesis_time * 1000 + signed_block.message.slot * MILLISECONDS_PER_SLOT;
64-
store::on_tick(&mut st, block_time_ms, true);
64+
store::on_tick(&mut st, block_time_ms, true, None);
6565

6666
// Process the block (this includes signature verification)
67-
let result = store::on_block(&mut st, signed_block);
67+
let result = store::on_block(&mut st, signed_block, None);
6868

6969
// Step 3: Check that it succeeded or failed as expected
7070
match (result.is_ok(), test.expect_exception.as_ref()) {

0 commit comments

Comments
 (0)