From 3988fbfa03e12fed114b41b263dc4369a92e0b3d Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 8 Sep 2026 16:02:13 +0100 Subject: [PATCH 01/13] Report the anchor block's slot in Status A checkpoint state can advance through empty slots beyond its latest block. Initializing the fork-choice anchor with the state slot caused P2P Status to advertise that later slot as the head. Use the latest block header's slot for the anchor node. An EF checkpoint fixture covers the case where the state is ahead of its latest block. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:GPT-6 --- crates/beacon_state/tile/src/tile.rs | 7 +++++-- crates/beacon_state/tile/src/tile/tests.rs | 24 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 0b77c959..2eb13649 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -315,7 +315,7 @@ impl BeaconStateTile { // `latest_block_header.state_root` stays `[0;32]` — the first // post-bootstrap `process_slot` hashes that canonical state and a // patched value would shift the result. - let (block_root, execution_block_hash) = { + let (header, block_root, execution_block_hash) = { let rv = self.state.read_view(anchor); let state_root = ssz_hash::hash_tree_root_state(&rv); let mut header = rv.slot.state().latest_block_header; @@ -323,6 +323,7 @@ impl BeaconStateTile { header.state_root = state_root; } ( + header, ssz_hash::hash_tree_root_block_header(&header), rv.slot.state().latest_execution_payload_header.block_hash, ) @@ -333,10 +334,12 @@ impl BeaconStateTile { self.last_seen_head_root = block_root; let anchor_is_gloas = self.state.read_view(anchor).is_gloas(); + // A checkpoint state can be ahead of its latest block. Peers need + // the block's slot in Status. self.fork_choice = ForkChoice::init( trusted, trusted, - slot, + header.slot, block_root, execution_block_hash, anchor_is_gloas, diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index a8ffa9be..7e025e06 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -631,6 +631,30 @@ fn a_block_is_applied_once_and_already_known_on_repeat() { assert_eq!(block_stages(&mut sink), [(block_root, BlockStage::AlreadyKnown)]); } +#[cfg(feature = "ef_tests")] +#[test] +fn the_anchor_reports_its_block_slot_not_the_checkpoint_state_slot() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "consensus-spec-tests/tests/mainnet/fulu/sanity/slots/pyspec_tests/slots_2/post.ssz_snappy", + ); + let raw = fs::read(&path) + .unwrap_or_else(|e| panic!("{}: {e} (run `just ef-tests-download`)", path.display())); + let ssz = snap::Decoder::new().decompress_vec(&raw).expect("snappy post"); + let state = BeaconState::from_checkpoint(&ssz, &SpecConfig::mainnet(), &[]) + .unwrap_or_else(|e| panic!("decompose checkpoint: {e}")); + + let view = state.slot_states.finalized_view(); + let (state_slot, block_slot) = (view.slot_number(), view.state().latest_block_header.slot); + assert!(state_slot > block_slot, "fixture premise: state {state_slot}, block {block_slot}"); + + let (mut tile, _gp, _rp) = make_tile_with_gossip(state_slot, state); + let BeaconStateEvent::Status { ssz, .. } = tile.status_event() else { + panic!("status_event produces Status") + }; + assert_eq!(StatusView::head_slot(&ssz), block_slot, "p2p Status names the anchor block"); + assert_eq!(*StatusView::head_root(&ssz), tile.head_block_root()); +} + /// Fulu requires the execution timestamp and the active blob limit before a /// block is propagated. The STF checks both, but that runs after the relay. #[test] From b11a8bc1a8196e2def9633f08d5ba8a6953ea4f1 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 8 Sep 2026 16:11:04 +0100 Subject: [PATCH 02/13] Serve head events from complete Status snapshots Add the head topic to /eth/v1/events. Extend Status with the selected block's state root and both duty-dependent roots from its fork. Publish Status when the selected head or its execution optimism changes. An end-of-loop comparison covers changes not already reported by existing publication sites. Keep its marker separate from reorg detection. The application boundary filters repeated observations and computes epoch_transition. The first complete snapshot establishes a baseline. If checkpoint history has overwritten a required root, suppress the head event and retain that baseline. Node-status updates continue. Retaining the state root adds 32 bytes per fork-choice node. The larger Status increases the beacon-events ring allocation by 2 MiB. Tests cover snapshot construction, validation and head changes, history bounds, state remapping, baseline filtering, and topic-specific delivery. Fixture tests exercise import and replay metadata; a fork-choice test covers validation propagation through ancestors. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:GPT-6 --- crates/application_boundary/src/lib.rs | 30 +- .../application_boundary/src/observed_head.rs | 148 ++++ crates/application_boundary/tests/tile.rs | 140 +++- crates/beacon_api/src/events.rs | 42 +- crates/beacon_api/src/json.rs | 46 ++ crates/beacon_api/src/lib.rs | 1 + crates/beacon_api/src/server.rs | 72 +- crates/beacon_state/data/src/column/roots.rs | 31 +- crates/beacon_state/data/src/column/tests.rs | 99 +++ .../beacon_state/tile/src/fork_choice/mod.rs | 4 + .../beacon_state/tile/src/fork_choice/node.rs | 3 + .../tile/src/fork_choice/tests.rs | 362 +++++++++- crates/beacon_state/tile/src/tile.rs | 83 ++- crates/beacon_state/tile/src/tile/block.rs | 1 + .../beacon_state/tile/src/tile/orphan_pool.rs | 2 +- crates/beacon_state/tile/src/tile/tests.rs | 642 +++++++++++++++++- crates/common/src/lib.rs | 21 +- crates/common/src/spine.rs | 2 +- crates/common/src/spine/messages.rs | 25 + crates/e2e/src/bin/da_replay.rs | 5 +- docs/adr/0004-sync-materialized-api.md | 21 + 21 files changed, 1677 insertions(+), 103 deletions(-) create mode 100644 crates/application_boundary/src/observed_head.rs diff --git a/crates/application_boundary/src/lib.rs b/crates/application_boundary/src/lib.rs index b439b2cf..0f32d71c 100644 --- a/crates/application_boundary/src/lib.rs +++ b/crates/application_boundary/src/lib.rs @@ -5,12 +5,16 @@ use silver_beacon_api::{BeaconApi, SlotStatus}; use silver_beacon_state_data::{BeaconStateReader, SpecConfig}; use silver_common::{ BeaconStateEvent, BlockStage, Enr, Identify, Keypair, SilverSpine, SyncUpdate, TProducer, - TRandomAccess, + TRandomAccess, ssz_view::StatusView, }; use silver_config::EngineConfig; use silver_engine_api::EngineApi; use silver_httpcore::{Bind, Readiness, TokenRange}; +use crate::observed_head::ObservedHead; + +mod observed_head; + /// A tenant added here takes the next share of a raised `TENANTS`, which keeps /// every share disjoint without a base to compute. const TENANTS: usize = 2; @@ -21,6 +25,7 @@ pub struct ApplicationBoundaryTile { readiness: Readiness, pub beacon: BeaconApi, engine: EngineApi, + head: ObservedHead, } impl Tile for ApplicationBoundaryTile { @@ -77,20 +82,35 @@ impl ApplicationBoundaryTile { rpc_consumer, resp_producer, ); - Self { readiness, beacon, engine } + Self { readiness, beacon, engine, head: ObservedHead::default() } } fn consume_spine_events(&mut self, adapter: &mut SpineAdapter) { - let beacon = &mut self.beacon; + let Self { beacon, head, .. } = self; // Consumed every iteration, and never behind the engine's capacity // gate: a consumer's first `consume` jumps its cursor to the // producer's write head, so a queue left unread while the pool is // saturated loses everything published in the meantime. adapter.consume(|event: BeaconStateEvent, _| match event { - BeaconStateEvent::Status { latest_block_slot, wall_slot, head_optimistic, .. } => { + BeaconStateEvent::Status { + ssz, + latest_block_slot, + wall_slot, + head_optimistic, + head_roots, + .. + } => { beacon.node_status_mut().slots = Some(SlotStatus { head_slot: latest_block_slot, wall_slot, head_optimistic }); + if let Some(event) = head.observe( + StatusView::head_slot(&ssz), + *StatusView::head_root(&ssz), + head_optimistic, + head_roots, + ) { + beacon.publish_head(&event); + } } BeaconStateEvent::BlockReceived { slot, @@ -100,7 +120,7 @@ impl ApplicationBoundaryTile { } => beacon.publish_block(slot, &block_root), _ => {} }); - let status = beacon.node_status_mut(); + let status = self.beacon.node_status_mut(); adapter.consume(|update: SyncUpdate, _| { status.syncing = !matches!(update, SyncUpdate::Following); }); diff --git a/crates/application_boundary/src/observed_head.rs b/crates/application_boundary/src/observed_head.rs new file mode 100644 index 00000000..0b1229fb --- /dev/null +++ b/crates/application_boundary/src/observed_head.rs @@ -0,0 +1,148 @@ +use silver_beacon_api::HeadEvent; +use silver_beacon_state_data::{B256, Epoch, SLOTS_PER_EPOCH}; +use silver_common::HeadRoots; + +/// The last complete observation, including the initial unpublished baseline. +#[derive(Clone, Copy)] +struct Reported { + root: B256, + optimistic: bool, + epoch: Epoch, +} + +/// Tracks complete head observations even when no subscribers are connected. +#[derive(Default)] +pub(crate) struct ObservedHead { + reported: Option, +} + +impl ObservedHead { + /// Incomplete snapshots leave the baseline unchanged. The first complete + /// observation establishes it without producing an event. + pub(crate) fn observe( + &mut self, + slot: u64, + root: B256, + optimistic: bool, + roots: HeadRoots, + ) -> Option { + if !roots.is_complete() { + return None; + } + let epoch = slot / SLOTS_PER_EPOCH; + let previous = self.reported.replace(Reported { root, optimistic, epoch })?; + if previous.root == root && previous.optimistic == optimistic { + return None; + } + Some(HeadEvent { + slot, + block_root: root, + roots, + epoch_transition: epoch > previous.epoch, + execution_optimistic: optimistic, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEAD: B256 = [0x11; 32]; + const OTHER: B256 = [0x22; 32]; + + fn roots(tag: u8) -> HeadRoots { + HeadRoots { + state_root: [tag; 32], + previous_duty_dependent_root: [tag.wrapping_add(1); 32], + current_duty_dependent_root: [tag.wrapping_add(2); 32], + } + } + + fn reported(event: Option) -> Option<(u64, B256, HeadRoots, bool, bool)> { + event.map(|e| (e.slot, e.block_root, e.roots, e.epoch_transition, e.execution_optimistic)) + } + + fn observed_at(slot: u64, root: B256, optimistic: bool) -> ObservedHead { + let mut head = ObservedHead::default(); + assert!(head.observe(slot, root, optimistic, roots(0x30)).is_none(), "baseline only"); + assert!(head.observe(slot, root, optimistic, roots(0x30)).is_none(), "and its repeat"); + head + } + + /// An incomplete startup snapshot must not make the first real head appear + /// to advance from epoch zero. + #[test] + fn an_incomplete_status_neither_reports_nor_baselines() { + let mut head = ObservedHead::default(); + assert!(head.observe(0, [0u8; 32], true, HeadRoots::default()).is_none()); + assert!(head.observe(0, [0u8; 32], true, HeadRoots::default()).is_none(), "repeat"); + + assert!(head.observe(40, HEAD, true, roots(0x30)).is_none()); + assert_eq!( + reported(head.observe(72, OTHER, true, roots(0x40))), + Some((72, OTHER, roots(0x40), true, true)), + "epoch 2 against the epoch-1 baseline, not against epoch 0" + ); + } + + #[test] + fn a_status_repeating_the_same_head_reports_nothing() { + let mut head = observed_at(40, HEAD, true); + assert!(head.observe(40, HEAD, true, roots(0x50)).is_none(), "other fields changed"); + assert!(head.observe(40, HEAD, true, roots(0x50)).is_none()); + } + + #[test] + fn a_validated_head_reports_once_with_no_epoch_transition() { + let mut head = observed_at(40, HEAD, true); + assert_eq!( + reported(head.observe(40, HEAD, false, roots(0x30))), + Some((40, HEAD, roots(0x30), false, false)) + ); + assert!(head.observe(40, HEAD, false, roots(0x30)).is_none(), "repeat"); + } + + #[test] + fn a_head_change_inside_one_epoch_reports_no_transition() { + let mut head = observed_at(40, HEAD, true); + assert_eq!( + reported(head.observe(41, OTHER, true, roots(0x40))), + Some((41, OTHER, roots(0x40), false, true)) + ); + assert!(head.observe(41, OTHER, true, roots(0x40)).is_none(), "repeat"); + } + + #[test] + fn a_head_change_into_a_later_epoch_reports_the_transition() { + let mut head = observed_at(40, HEAD, true); + assert_eq!( + reported(head.observe(64, OTHER, true, roots(0x40))), + Some((64, OTHER, roots(0x40), true, true)) + ); + assert!(head.observe(64, OTHER, true, roots(0x40)).is_none(), "repeat"); + } + + #[test] + fn a_head_change_into_an_earlier_epoch_reports_no_transition() { + let mut head = observed_at(64, HEAD, true); + assert_eq!( + reported(head.observe(40, OTHER, true, roots(0x40))), + Some((40, OTHER, roots(0x40), false, true)) + ); + assert!(head.observe(40, OTHER, true, roots(0x40)).is_none(), "repeat"); + } + + #[test] + fn a_validation_right_after_an_epoch_change_reports_no_transition() { + let mut head = observed_at(40, HEAD, true); + assert_eq!( + reported(head.observe(64, OTHER, true, roots(0x40))), + Some((64, OTHER, roots(0x40), true, true)) + ); + assert_eq!( + reported(head.observe(64, OTHER, false, roots(0x40))), + Some((64, OTHER, roots(0x40), false, false)) + ); + } +} diff --git a/crates/application_boundary/tests/tile.rs b/crates/application_boundary/tests/tile.rs index 8fceb7ba..9fe12840 100644 --- a/crates/application_boundary/tests/tile.rs +++ b/crates/application_boundary/tests/tile.rs @@ -13,7 +13,7 @@ use silver_beacon_api::SlotStatus; use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; use silver_common::{ BeaconStateEvent, BlockSource, BlockStage, ELSyncStatus, EngineFcuReq, EngineReq, EngineResp, - Enr, Identify, Keypair, PayloadValidationStatus, SilverSpine, SyncUpdate, TCache, + Enr, HeadRoots, Identify, Keypair, PayloadValidationStatus, SilverSpine, SyncUpdate, TCache, TCacheProducer, ssz_view::STATUS_V2_SIZE, }; use silver_config::EngineConfig; @@ -160,20 +160,24 @@ fn block_frame(slot: u64, byte: u8) -> Vec { /// Signals after receiving the response head so events are not published /// before the subscription is active. -fn events_subscriber(addr: SocketAddr, frame_len: usize) -> (JoinHandle>, Receiver<()>) { +fn events_subscriber( + addr: SocketAddr, + topics: &str, + frame_len: usize, +) -> (JoinHandle>, Receiver<()>) { let (subscribed, on_subscribed) = mpsc::channel(); + let request = format!("GET /eth/v1/events?topics={topics} HTTP/1.1\r\nHost: localhost\r\n\r\n"); let client = std::thread::spawn(move || { let mut stream = TcpStream::connect(addr).unwrap(); stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); - write!(stream, "GET /eth/v1/events?topics=block HTTP/1.1\r\nHost: localhost\r\n\r\n") - .unwrap(); + stream.write_all(request.as_bytes()).unwrap(); let mut head = vec![0; SSE_HEAD.len()]; stream.read_exact(&mut head).unwrap(); assert_eq!(head, SSE_HEAD, "{}", String::from_utf8_lossy(&head)); subscribed.send(()).unwrap(); - let mut frame = vec![0; frame_len]; - stream.read_exact(&mut frame).unwrap(); - frame + let mut frames = vec![0; frame_len]; + stream.read_exact(&mut frames).unwrap(); + frames }); (client, on_subscribed) } @@ -185,9 +189,46 @@ fn status_event(head_slot: u64, wall_slot: u64, head_optimistic: bool) -> Beacon latest_block_slot: head_slot, wall_slot, enr_fork_id: [0u8; 16], + head_roots: HeadRoots::default(), } } +fn head_roots() -> HeadRoots { + HeadRoots { + state_root: [0x60; 32], + previous_duty_dependent_root: [0x5e; 32], + current_duty_dependent_root: [0x91; 32], + } +} + +fn head_status(slot: u64, block_root: u8, head_optimistic: bool) -> BeaconStateEvent { + let mut ssz = [0u8; STATUS_V2_SIZE]; + ssz[44..76].copy_from_slice(&[block_root; 32]); + ssz[76..84].copy_from_slice(&slot.to_le_bytes()); + BeaconStateEvent::Status { + ssz, + head_optimistic, + latest_block_slot: slot, + wall_slot: slot, + enr_fork_id: [0u8; 16], + head_roots: head_roots(), + } +} + +fn head_frame(slot: u64, block_root: u8, execution_optimistic: bool) -> Vec { + let data = format!( + "event: head\ndata: {{\"slot\":\"{slot}\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"epoch_transition\":false,\"previous_duty_dependent_root\":\"0x{}\",\"current_duty_dependent_root\":\"0x{}\",\"execution_optimistic\":{execution_optimistic}}}\n\n", + hex::encode([block_root; 32]), + "60".repeat(32), + "5e".repeat(32), + "91".repeat(32), + ); + let mut frame = format!("{:x}\r\n", data.len()).into_bytes(); + frame.extend_from_slice(data.as_bytes()); + frame.extend_from_slice(b"\r\n"); + frame +} + #[test] fn serves_identity_over_tcp() { let base = TempDir::new().unwrap(); @@ -732,7 +773,7 @@ fn an_applied_block_on_the_spine_reaches_an_events_subscriber() { let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; let expected = block_frame(10, 0xab); - let (client, on_subscribed) = events_subscriber(addr, expected.len()); + let (client, on_subscribed) = events_subscriber(addr, "block", expected.len()); let deadline = Instant::now() + Duration::from_secs(10); let mut crank = |tile: &mut ApplicationBoundaryTile, msg: &str| { @@ -759,3 +800,86 @@ fn an_applied_block_on_the_spine_reaches_an_events_subscriber() { String::from_utf8_lossy(&expected) ); } + +#[test] +fn an_optimistic_then_validated_head_reaches_an_events_subscriber() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ + "cs_head_gossip", + "cs_head_rpc", + "cs_head_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + tile.loop_body(&mut adapter); + + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; + let expected = [head_frame(40, 0xab, true), head_frame(40, 0xab, false)].concat(); + let (client, on_subscribed) = events_subscriber(addr, "head", expected.len()); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ApplicationBoundaryTile, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + std::thread::sleep(Duration::from_millis(1)); + }; + while on_subscribed.try_recv().is_err() { + crank(&mut tile, "stream head reaches the subscriber"); + } + + // Establish a baseline, then change the head and validate it. + // Repeated observations between those changes produce no frames. + inj.produce(head_status(33, 0x0a, true)); + inj.produce(head_status(33, 0x0a, true)); + inj.produce(head_status(40, 0xab, true)); + inj.produce(head_status(40, 0xab, true)); + inj.produce(head_status(40, 0xab, false)); + while !client.is_finished() { + crank(&mut tile, "both head frames reach the subscriber"); + } + let got = client.join().unwrap(); + assert!( + got == expected, + "\n got: {:?}\nexpected: {:?}", + String::from_utf8_lossy(&got), + String::from_utf8_lossy(&expected) + ); + + let status = *tile.beacon.node_status_mut(); + assert_eq!( + status.slots, + Some(SlotStatus { head_slot: 40, wall_slot: 40, head_optimistic: false }), + "node status follows every Status, including the ones the head filter drops" + ); +} + +/// The initial head observation emits no event but still updates node status. +#[test] +fn node_status_optimism_follows_a_status_that_publishes_no_head_event() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ + "cs_optimism_gossip", + "cs_optimism_rpc", + "cs_optimism_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + tile.loop_body(&mut adapter); + + inj.produce(head_status(32, 0x0a, true)); + tile.loop_body(&mut adapter); + assert_eq!( + tile.beacon.node_status_mut().slots, + Some(SlotStatus { head_slot: 32, wall_slot: 32, head_optimistic: true }) + ); + + inj.produce(head_status(32, 0x0a, false)); + tile.loop_body(&mut adapter); + assert_eq!( + tile.beacon.node_status_mut().slots, + Some(SlotStatus { head_slot: 32, wall_slot: 32, head_optimistic: false }), + "the verdict reaches node status whatever the head filter decides" + ); +} diff --git a/crates/beacon_api/src/events.rs b/crates/beacon_api/src/events.rs index 23db79e1..23cdf009 100644 --- a/crates/beacon_api/src/events.rs +++ b/crates/beacon_api/src/events.rs @@ -1,5 +1,7 @@ use std::io::Write; +use silver_beacon_state_data::B256; +use silver_common::HeadRoots; use silver_httpcore::Query; use crate::{response::Response, router::Request, routes::ApiCtx}; @@ -13,6 +15,17 @@ pub(crate) const KEEP_ALIVE: &[u8] = b": keep-alive\n\n"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Channel { Block, + Head, +} + +/// `epoch_transition` compares this head with the publisher's previous +/// complete observation. +pub struct HeadEvent { + pub slot: u64, + pub block_root: B256, + pub roots: HeadRoots, + pub epoch_transition: bool, + pub execution_optimistic: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -68,6 +81,7 @@ fn topics(query: &str) -> Result { fn channel(topic: &str) -> Option { match topic { "block" => Some(Channel::Block), + "head" => Some(Channel::Head), _ => None, } } @@ -89,12 +103,18 @@ mod tests { routes::{ROUTES, preboot_ctx}, }; - fn block_only() -> ChannelSet { + fn set(of: &[Channel]) -> ChannelSet { let mut channels = ChannelSet::default(); - channels.insert(Channel::Block); + for &channel in of { + channels.insert(channel); + } channels } + fn block_only() -> ChannelSet { + set(&[Channel::Block]) + } + fn dispatch(query: &str) -> (Served, Vec) { let req = ParsedRequest { method: "GET", @@ -129,12 +149,20 @@ mod tests { assert_eq!(topics("topics=block%2Cblock"), Ok(block_only()), "percent-encoded comma"); } + #[test] + fn head_is_served_alone_and_alongside_block() { + assert_eq!(topics("topics=head"), Ok(set(&[Channel::Head]))); + assert_eq!(topics("topics=block,head"), Ok(set(&[Channel::Block, Channel::Head]))); + assert_eq!(topics("topics=head,block"), Ok(set(&[Channel::Block, Channel::Head]))); + assert_eq!(topics("topics=head&topics=block"), Ok(set(&[Channel::Block, Channel::Head]))); + } + #[test] fn a_topic_silver_does_not_serve_refuses_the_whole_subscription_by_name() { let unknown = |topic: &str| Err(Refused::Unknown(topic.to_string())); - assert_eq!(topics("topics=head"), unknown("head")); - assert_eq!(topics("topics=block,head"), unknown("head")); - assert_eq!(topics("topics=block&topics=chain_reorg"), unknown("chain_reorg")); + assert_eq!(topics("topics=head_v2"), unknown("head_v2")); + assert_eq!(topics("topics=block,head_v2"), unknown("head_v2")); + assert_eq!(topics("topics=head&topics=chain_reorg"), unknown("chain_reorg")); } #[test] @@ -173,9 +201,9 @@ mod tests { #[test] fn an_unserved_topic_is_a_400_naming_it_on_an_ordinary_connection() { - let (served, out) = dispatch("topics=block,head"); + let (served, out) = dispatch("topics=block,chain_reorg"); assert_eq!(served, Served::Response); - assert_eq!(out, bad_request(r#"unknown topic \"head\""#)); + assert_eq!(out, bad_request(r#"unknown topic \"chain_reorg\""#)); } #[test] diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index 3e2a187e..d6b96ca3 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -5,6 +5,8 @@ use silver_beacon_state_data::{B256, Checkpoint, Fork, Version}; +use crate::events::HeadEvent; + const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; /// Appends JSON to a buffer the caller owns — fresh or reused is the caller's @@ -231,6 +233,25 @@ impl Json<'_> { self.end_object(); } + pub(crate) fn head_event(&mut self, head: &HeadEvent) { + self.begin_object(); + self.key("slot"); + self.quoted_u64(head.slot); + self.key("block"); + self.hex(&head.block_root); + self.key("state"); + self.hex(&head.roots.state_root); + self.key("epoch_transition"); + self.bool(head.epoch_transition); + self.key("previous_duty_dependent_root"); + self.hex(&head.roots.previous_duty_dependent_root); + self.key("current_duty_dependent_root"); + self.hex(&head.roots.current_duty_dependent_root); + self.key("execution_optimistic"); + self.bool(head.execution_optimistic); + self.end_object(); + } + pub(crate) fn finality_checkpoints(&mut self, checkpoints: &FinalityCheckpoints) { self.begin_object(); self.key("previous_justified"); @@ -253,6 +274,7 @@ pub(crate) fn json_safe(text: &str) -> bool { #[cfg(test)] mod tests { use silver_beacon_state_data::FAR_FUTURE_EPOCH; + use silver_common::HeadRoots; use super::*; @@ -481,6 +503,30 @@ mod tests { assert!(!json_safe("back\\slash")); } + #[test] + fn head_event_matches_the_specification_s_field_order() { + let mut out = Vec::new(); + Json::new(&mut out).head_event(&HeadEvent { + slot: 10, + block_root: [0x9a; 32], + roots: HeadRoots { + state_root: [0x60; 32], + previous_duty_dependent_root: [0x5e; 32], + current_duty_dependent_root: [0x91; 32], + }, + epoch_transition: true, + execution_optimistic: false, + }); + let expected = format!( + "{{\"slot\":\"10\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"epoch_transition\":true,\"previous_duty_dependent_root\":\"0x{}\",\"current_duty_dependent_root\":\"0x{}\",\"execution_optimistic\":false}}", + "9a".repeat(32), + "60".repeat(32), + "5e".repeat(32), + "91".repeat(32), + ); + assert_eq!(String::from_utf8(out).unwrap(), expected); + } + #[test] fn block_event_quotes_the_slot_and_hexes_the_root() { let mut out = Vec::new(); diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index e7475ad0..cc4c80b9 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -11,5 +11,6 @@ mod routes; mod server; mod statics; +pub use events::HeadEvent; pub use node_status::{NodeStatus, SlotStatus}; pub use server::BeaconApi; diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index 72a870eb..e68a0112 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -15,7 +15,7 @@ use silver_httpcore::{ use crate::{ NodeStatus, - events::{self, Channel, ChannelSet}, + events::{self, Channel, ChannelSet, HeadEvent}, json::Json, router::{Router, Served}, routes::{ApiCtx, ROUTES}, @@ -397,6 +397,13 @@ impl BeaconApi { self.publish(Channel::Block, "block", &data); } + /// Head-change detection belongs to the caller. + pub fn publish_head(&mut self, head: &HeadEvent) { + let mut data = Vec::new(); + Json::new(&mut data).head_event(head); + self.publish(Channel::Head, "head", &data); + } + fn publish(&mut self, channel: Channel, event: &str, data: &[u8]) { let mut frame = Vec::new(); events::frame(&mut frame, event, data); @@ -594,6 +601,7 @@ mod tests { }; use silver_beacon_state_data::BeaconStateOwner; + use silver_common::HeadRoots; use silver_httpcore::Readiness; use super::*; @@ -1394,6 +1402,64 @@ mod tests { assert_eq!(subscribers(&server), 1, "delivery keeps the subscription"); } + fn head_event(slot: u64, block_root: &[u8; 32], execution_optimistic: bool) -> HeadEvent { + HeadEvent { + slot, + block_root: *block_root, + roots: HeadRoots { + state_root: [0x60; 32], + previous_duty_dependent_root: [0x5e; 32], + current_duty_dependent_root: [0x91; 32], + }, + epoch_transition: false, + execution_optimistic, + } + } + + fn head_frame(slot: u64, block_root: &[u8; 32], execution_optimistic: bool) -> Vec { + let data = format!( + "event: head\ndata: {{\"slot\":\"{slot}\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"epoch_transition\":false,\"previous_duty_dependent_root\":\"0x{}\",\"current_duty_dependent_root\":\"0x{}\",\"execution_optimistic\":{execution_optimistic}}}\n\n", + hex::encode(block_root), + "60".repeat(32), + "5e".repeat(32), + "91".repeat(32), + ); + chunk(data.as_bytes()) + } + + #[test] + fn each_subscriber_receives_only_the_channels_it_asked_for() { + let mut server = server_with(64, LONG_TIMEOUT); + let addr = tcp_addr(&server); + let (mut blocks, mut heads, mut both) = (connect(addr), connect(addr), connect(addr)); + subscribe(&mut blocks, "block"); + subscribe(&mut heads, "head"); + subscribe(&mut both, "block,head"); + pump_until(&mut server, "three subscribed", |server| subscribers(server) == 3); + + server.api.publish_block(10, &[0xab; 32]); + server.api.publish_head(&head_event(10, &[0xab; 32], true)); + + let block_only = [SSE_HEAD, &block_frame(10, &[0xab; 32])].concat(); + let head_only = [SSE_HEAD, &head_frame(10, &[0xab; 32], true)].concat(); + let mixed = + [SSE_HEAD, &block_frame(10, &[0xab; 32]), &head_frame(10, &[0xab; 32], true)].concat(); + + let readers = [ + read_exactly(blocks, block_only.len()), + read_exactly(heads, head_only.len()), + read_exactly(both, mixed.len()), + ]; + pump_until(&mut server, "every subscriber served", |_| { + readers.iter().all(JoinHandle::is_finished) + }); + let [got_blocks, got_heads, got_both] = readers.map(|r| r.join().unwrap()); + + assert_same_bytes(&got_blocks, &block_only); + assert_same_bytes(&got_heads, &head_only); + assert_same_bytes(&got_both, &mixed); + } + #[test] fn a_topic_silver_does_not_serve_is_refused_on_an_ordinary_connection() { let mut server = server_with(64, LONG_TIMEOUT); @@ -1402,7 +1468,7 @@ mod tests { let mut stream = connect(addr); write!( stream, - "GET /eth/v1/events?topics=head HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + "GET /eth/v1/events?topics=head_v2 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" ) .unwrap(); read_to_eof(stream) @@ -1411,7 +1477,7 @@ mod tests { let got = serve(&mut server, client, "400 for an unserved topic"); assert_same_bytes( &got, - b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: 47\r\n\r\n{\"code\":400,\"message\":\"unknown topic \\\"head\\\"\"}", + b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: 50\r\n\r\n{\"code\":400,\"message\":\"unknown topic \\\"head_v2\\\"\"}", ); assert_eq!(subscribers(&server), 0); } diff --git a/crates/beacon_state/data/src/column/roots.rs b/crates/beacon_state/data/src/column/roots.rs index 3230878f..fbf694ac 100644 --- a/crates/beacon_state/data/src/column/roots.rs +++ b/crates/beacon_state/data/src/column/roots.rs @@ -1,7 +1,7 @@ use super::{ColumnGroup, ColumnReader, ColumnSpec, ColumnWriteView}; use crate::{ ring::Id, - types::{B256, SLOTS_PER_HISTORICAL_ROOT, Slot}, + types::{B256, Epoch, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT, Slot}, }; /// `Vector[Root, SLOTS_PER_HISTORICAL_ROOT]`, written pointwise: `process_slot` @@ -39,6 +39,35 @@ impl RootsView<'_, BlockRoots> { self.get(slot as usize % SLOTS_PER_HISTORICAL_ROOT) } + /// Root at the slot before `epoch` starts, saturating to slot zero. + /// + /// A head at the decision slot supplies its own root without a history + /// read. Otherwise, availability is measured from `state_slot`: a + /// checkpoint state can be ahead of its latest block. Returns `None` + /// for overwritten history. + pub fn duty_dependent_root( + &self, + epoch: Epoch, + head_slot: Slot, + head_root: B256, + state_slot: Slot, + ) -> Option { + let decision_slot = (epoch * SLOTS_PER_EPOCH).saturating_sub(1); + debug_assert!( + decision_slot <= head_slot, + "epoch {epoch} decides at slot {decision_slot}, past the head at {head_slot}" + ); + debug_assert!( + head_slot <= state_slot, + "the state at {state_slot} is behind its head at {head_slot}" + ); + if head_slot == decision_slot { + return Some(head_root); + } + (state_slot - decision_slot <= SLOTS_PER_HISTORICAL_ROOT as u64) + .then(|| self.at_slot(decision_slot)) + } + /// Slot of the block with `root`, if the ring holds it at or below /// `from_slot`. Fork choice is what writes a root here, so a hit means /// "seen and validated". diff --git a/crates/beacon_state/data/src/column/tests.rs b/crates/beacon_state/data/src/column/tests.rs index 1a0a8a1a..008e0fcc 100644 --- a/crates/beacon_state/data/src/column/tests.rs +++ b/crates/beacon_state/data/src/column/tests.rs @@ -458,6 +458,105 @@ fn block_roots_slot_of_scans_back_from_the_given_slot() { assert_eq!(reader.slot_of(&[0xCC; 32], 103), None); } +/// A head in epoch 2 uses decision slots 63 and 31. +#[test] +fn duty_dependent_roots_read_the_slots_below_the_two_epoch_starts() { + const HEAD: B256 = [0x46; 32]; + let mut g = BlockRootsGroup::zeroed_vector(); + let id = { + let mut wv = g.roll_fresh(); + wv.set(31, [0x1F; 32]); + wv.set(60, [0x3C; 32]); + // Empty slots 61..=63 carry slot 60's block root forward. + for slot in 61..=63 { + wv.set(slot, [0x3C; 32]); + } + wv.set(70, HEAD); + wv.commit() + }; + let reader = g.view(id); + + assert_eq!( + reader.duty_dependent_root(2, 70, HEAD, 70), + Some([0x3C; 32]), + "epoch 2 decides at slot 63, an empty slot holding slot 60's block" + ); + assert_eq!( + reader.duty_dependent_root(1, 70, HEAD, 70), + Some([0x1F; 32]), + "epoch 1 decides at slot 31" + ); +} + +/// Both lookups use slot zero in epoch 0; the previous lookup also does so +/// in epoch 1. +#[test] +fn early_epochs_saturate_the_decision_slot_to_genesis() { + const GENESIS: B256 = [0x6E; 32]; + const HEAD: B256 = [0x05; 32]; + let mut g = BlockRootsGroup::zeroed_vector(); + let id = { + let mut wv = g.roll_fresh(); + wv.set(0, GENESIS); + wv.set(31, [0x1F; 32]); + wv.commit() + }; + let reader = g.view(id); + + let at = |epoch, head_slot| reader.duty_dependent_root(epoch, head_slot, HEAD, head_slot); + assert_eq!(at(0, 5), Some(GENESIS), "epoch 0 decides at slot 0"); + assert_eq!(at(1, 40), Some([0x1F; 32]), "epoch 1 decides at slot 31"); + assert_eq!(at(0, 40), Some(GENESIS), "epoch 0 stays at slot 0"); +} + +#[test] +fn a_head_at_slot_zero_decides_its_own_shuffling() { + const HEAD: B256 = [0x11; 32]; + let mut g = BlockRootsGroup::zeroed_vector(); + let id = g.roll_fresh().commit(); + let reader = g.view(id); + + assert_eq!(reader.duty_dependent_root(0, 0, HEAD, 0), Some(HEAD)); + assert_ne!(HEAD, reader.at_slot(0), "the ring holds nothing at slot 0 yet"); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "past the head")] +fn a_decision_slot_above_the_head_is_a_bug() { + let mut g = BlockRootsGroup::zeroed_vector(); + let id = g.roll_fresh().commit(); + g.view(id).duty_dependent_root(3, 70, [0x46; 32], 70); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "behind its head")] +fn a_state_behind_its_head_is_a_bug() { + let mut g = BlockRootsGroup::zeroed_vector(); + let id = g.roll_fresh().commit(); + g.view(id).duty_dependent_root(2, 70, [0x46; 32], 69); +} + +/// At state slot S, the oldest retained slot is S − 8192, inclusive. +#[test] +fn a_decision_slot_is_available_until_the_state_moves_a_ring_past_it() { + const HEAD: B256 = [0x46; 32]; + let mut g = BlockRootsGroup::zeroed_vector(); + let id = { + let mut wv = g.roll_fresh(); + wv.set(31, [0x1F; 32]); + wv.commit() + }; + let reader = g.view(id); + // Epoch 1 decides at slot 31; a state at 8223 still holds it as its + // oldest slot. + let edge = SLOTS_PER_HISTORICAL_ROOT as u64 + 31; + + assert_eq!(reader.duty_dependent_root(1, 70, HEAD, edge), Some([0x1F; 32])); + assert_eq!(reader.duty_dependent_root(1, 70, HEAD, edge + 1), None, "overwritten a slot later"); +} + /// A block's reveal accumulates into the current epoch's bucket; the boundary /// copy seeds the next epoch from it, and the next epoch's reveals accumulate /// on top without disturbing the finished epoch. diff --git a/crates/beacon_state/tile/src/fork_choice/mod.rs b/crates/beacon_state/tile/src/fork_choice/mod.rs index 511226ad..d2384523 100644 --- a/crates/beacon_state/tile/src/fork_choice/mod.rs +++ b/crates/beacon_state/tile/src/fork_choice/mod.rs @@ -74,6 +74,7 @@ pub struct ForkChoice { pub struct BlockImport { pub slot: Slot, pub block_root: B256, + pub state_root: B256, pub parent_root: B256, pub execution_block_hash: B256, pub justified: Checkpoint, @@ -94,6 +95,7 @@ impl ForkChoice { justified_checkpoint: Checkpoint, finalized_slot: Slot, finalized_block_root: B256, + finalized_state_root: B256, finalized_execution_block_hash: B256, anchor_is_gloas: bool, state_id: StateId, @@ -105,6 +107,7 @@ impl ForkChoice { nodes.push(ForkChoiceNode { slot: finalized_slot, block_root: finalized_block_root, + state_root: finalized_state_root, execution_block_hash: finalized_execution_block_hash, parent_ix: NULL, execution_status: ExecutionStatus::Valid, @@ -172,6 +175,7 @@ impl ForkChoice { self.nodes.push(ForkChoiceNode { slot: b.slot, block_root: b.block_root, + state_root: b.state_root, execution_block_hash: b.execution_block_hash, parent_ix: parent, execution_status: ExecutionStatus::Optimistic, diff --git a/crates/beacon_state/tile/src/fork_choice/node.rs b/crates/beacon_state/tile/src/fork_choice/node.rs index 28db4159..0c242a85 100644 --- a/crates/beacon_state/tile/src/fork_choice/node.rs +++ b/crates/beacon_state/tile/src/fork_choice/node.rs @@ -22,6 +22,9 @@ pub enum PayloadStatus { pub struct ForkChoiceNode { pub slot: Slot, pub block_root: B256, + /// Retained because a reorg can select this block after its bytes have + /// left the cache. + pub state_root: B256, pub execution_block_hash: B256, pub parent_ix: usize, diff --git a/crates/beacon_state/tile/src/fork_choice/tests.rs b/crates/beacon_state/tile/src/fork_choice/tests.rs index 8143516f..0a187cc6 100644 --- a/crates/beacon_state/tile/src/fork_choice/tests.rs +++ b/crates/beacon_state/tile/src/fork_choice/tests.rs @@ -33,6 +33,13 @@ fn root(b: u8) -> B256 { r } +/// Use a non-zero state root distinct from the block root. +fn state_root_of(block_root: B256) -> B256 { + let mut r = block_root; + r[31] = 0xFF; + r +} + fn cp(epoch: Epoch, b: u8) -> Checkpoint { Checkpoint { epoch, root: root(b) } } @@ -49,6 +56,7 @@ fn block( BlockImport { slot, block_root, + state_root: state_root_of(block_root), parent_root, execution_block_hash: [0u8; 32], justified: jus, @@ -104,7 +112,17 @@ fn compute_deltas( fn single_chain_head() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); fc.on_block(block(1, root(2), root(1), jus, fin)); fc.on_block(block(2, root(3), root(2), jus, fin)); @@ -116,7 +134,17 @@ fn single_chain_head() { fn fork_heavier_wins() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); fc.on_block(block(1, root(2), root(1), jus, fin)); fc.on_block(block(1, root(3), root(1), jus, fin)); @@ -136,7 +164,17 @@ fn two_pass_weight_correctness() { // sibling loses weight. let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); // root(1) → root(2) [idx 1] and root(3) [idx 2] fc.on_block(block(1, root(2), root(1), jus, fin)); @@ -162,7 +200,17 @@ fn two_pass_weight_correctness() { fn prune_below_finalized() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); fc.on_block(block(1, root(2), root(1), jus, fin)); fc.on_block(block(2, root(3), root(2), jus, fin)); @@ -184,7 +232,17 @@ fn prune_drops_later_imported_siblings() { // the promoted delta. let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); fc.on_block(block(1, root(2), root(1), jus, fin)); // finalized-to-be fc.on_block(block(1, root(3), root(1), jus, fin)); // sibling, imported after @@ -207,7 +265,17 @@ fn prune_drops_later_imported_siblings() { fn deltas_moving_votes() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); fc.on_block(block(1, root(2), root(1), jus, fin)); let mut votes = vec![Vote::default(); 16]; @@ -240,7 +308,17 @@ fn deltas_different_votes() { // Each validator votes for a different block. let fin = cp(0, 100); let jus = cp(0, 100); - let mut fc = ForkChoice::init(fin, jus, 0, root(100), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(100), + state_root_of(root(100)), + [0u8; 32], + false, + test_state_id(), + 0, + ); for i in 1..=16u8 { fc.on_block(block(i as u64, root(i), root(100), jus, fin)); @@ -271,7 +349,17 @@ fn deltas_different_votes() { fn deltas_move_out_of_tree() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); let mut votes = vec![Vote::default(); 16]; let mut balances = vec![0u64; 16]; @@ -304,7 +392,17 @@ fn deltas_move_out_of_tree() { fn deltas_changing_balances() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); fc.on_block(block(1, root(2), root(1), jus, fin)); let mut votes = vec![Vote::default(); 16]; @@ -335,7 +433,17 @@ fn deltas_balance_change_no_vote_change() { // Balances change but votes don't — still need deltas. let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); let mut votes = vec![Vote::default(); 16]; let mut old_bal = vec![0u64; 16]; @@ -358,7 +466,17 @@ fn deltas_balance_change_no_vote_change() { fn split_tie_breaker_no_attestations() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); // Two blocks at slot 1 forking from genesis. root(2) < root(3). fc.on_block(block(1, root(2), root(1), jus, fin)); @@ -373,7 +491,17 @@ fn split_tie_breaker_no_attestations() { fn shorter_chain_but_heavier_weight() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); // Long chain: root(1) → root(2) → root(3) → root(4). fc.on_block(block(1, root(2), root(1), jus, fin)); @@ -398,7 +526,17 @@ fn shorter_chain_but_heavier_weight() { fn on_block_duplicate() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); fc.on_block(block(1, root(2), root(1), jus, fin)); assert_eq!(fc.nodes.len(), 2); @@ -412,7 +550,17 @@ fn on_block_duplicate() { fn on_block_unknown_parent() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); // root(99) is not known. fc.on_block(block(1, root(2), root(99), jus, fin)); @@ -426,7 +574,17 @@ fn on_block_unknown_parent() { fn node_lookup_equivalence_and_prune() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); for i in 2..=20u8 { fc.on_block(block(i as u64, root(i), root(i - 1), jus, fin)); } @@ -451,7 +609,17 @@ fn node_lookup_equivalence_and_prune() { fn proposer_boost_flips_then_expires() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); fc.on_block(block(1, root(2), root(1), jus, fin)); // idx 1 fc.on_block(block(1, root(3), root(1), jus, fin)); // idx 2 @@ -484,7 +652,17 @@ fn proposer_boost_flips_then_expires() { fn compute_deltas_dirty_matches_full() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); for i in 2..=5u8 { fc.on_block(block(1, root(i), root(1), jus, fin)); } @@ -527,7 +705,17 @@ fn compute_deltas_dirty_matches_full() { #[test] fn viability_genesis_exception() { let g = cp(0, 1); - let mut fc = ForkChoice::init(g, g, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); // Prior-epoch node with a stale (epoch 1) voting source. fc.on_block(block(64, root(2), root(1), cp(1, 9), g)); let a = fc.find_node_idx(&root(2)).unwrap(); @@ -546,7 +734,17 @@ fn viability_unrealized_justified_and_plus_two() { // Genesis finalized isolates the justified rule; store justified // promoted to epoch 2 (node root(2) at slot 64). let g = cp(0, 1); - let mut fc = ForkChoice::init(g, g, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); fc.on_block(block(64, root(2), root(1), cp(2, 2), g)); // A @ epoch 2 fc.justified_checkpoint = cp(2, 2); // C @ epoch 2, child of A, realized justified stale (epoch 1). @@ -583,7 +781,17 @@ fn viability_unrealized_justified_and_plus_two() { #[test] fn gloas_vote_branch_classification() { let g = cp(0, 1); - let mut fc = ForkChoice::init(g, g, 0, root(1), [0u8; 32], false, test_state_id(), 8); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 8, + ); fc.on_block(gloas_block(5, root(2), root(1), g, g, PayloadStatus::Full, true)); let gl = fc.node(fc.find_node_idx(&root(2)).unwrap()); assert_eq!(branch_voted_for(gl, 6, true), PayloadStatus::Full); @@ -601,7 +809,17 @@ fn gloas_vote_branch_classification() { #[test] fn gloas_resolves_heavier_payload_branch() { let g = cp(0, 1); - let mut fc = ForkChoice::init(g, g, 0, root(1), [0u8; 32], false, test_state_id(), 8); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 8, + ); // A (idx1), then a FULL-edge child C_f (idx2) and EMPTY-edge child C_e (idx3). fc.on_block(gloas_block(1, root(2), root(1), g, g, PayloadStatus::Full, true)); fc.on_block(gloas_block(2, root(3), root(2), g, g, PayloadStatus::Full, true)); @@ -629,7 +847,17 @@ fn gloas_resolves_heavier_payload_branch() { #[test] fn gloas_tie_broken_by_should_extend_payload() { let g = cp(0, 1); - let mut fc = ForkChoice::init(g, g, 0, root(1), [0u8; 32], false, test_state_id(), 8); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 8, + ); fc.on_block(gloas_block(1, root(2), root(1), g, g, PayloadStatus::Full, true)); fc.on_block(gloas_block(2, root(3), root(2), g, g, PayloadStatus::Full, true)); // C_f fc.on_block(gloas_block(2, root(4), root(2), g, g, PayloadStatus::Empty, true)); // C_e @@ -661,7 +889,17 @@ fn gloas_tie_broken_by_should_extend_payload() { fn shuffling_dependent_root_is_ancestor_at_dependent_slot() { let fin = cp(0, 1); let jus = cp(0, 1); - let mut fc = ForkChoice::init(fin, jus, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + fin, + jus, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); let last_of_epoch0 = SLOTS_PER_EPOCH - 1; // Branch A: genesis → 2 (slot 31) → 3 (slot 33) and 6 (slot 34). fc.on_block(block(last_of_epoch0, root(2), root(1), jus, fin)); @@ -693,7 +931,17 @@ fn shuffling_dependent_root_is_ancestor_at_dependent_slot() { #[test] fn gloas_unverified_payload_forces_empty() { let g = cp(0, 1); - let mut fc = ForkChoice::init(g, g, 0, root(1), [0u8; 32], false, test_state_id(), 8); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 8, + ); fc.on_block(gloas_block(1, root(2), root(1), g, g, PayloadStatus::Full, false)); fc.on_block(gloas_block(2, root(3), root(2), g, g, PayloadStatus::Full, true)); // C_f fc.on_block(gloas_block(2, root(4), root(2), g, g, PayloadStatus::Empty, true)); // C_e @@ -719,7 +967,17 @@ fn gloas_unverified_payload_forces_empty() { #[test] fn gloas_empty_survives_full_invalid() { let g = cp(0, 1); - let mut fc = ForkChoice::init(g, g, 0, root(1), [0u8; 32], false, test_state_id(), 8); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 8, + ); fc.on_block(gloas_block(1, root(2), root(1), g, g, PayloadStatus::Full, true)); fc.on_block(gloas_block(2, root(3), root(2), g, g, PayloadStatus::Full, true)); // C_f fc.on_block(gloas_block(2, root(4), root(2), g, g, PayloadStatus::Empty, true)); // C_e @@ -748,7 +1006,17 @@ fn gloas_empty_survives_full_invalid() { #[test] fn gloas_boost_is_pending_not_empty() { let g = cp(0, 1); - let mut fc = ForkChoice::init(g, g, 0, root(1), [0u8; 32], false, test_state_id(), 8); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 8, + ); // Boosted current-slot block, envelope not yet revealed. fc.on_block(gloas_block(1, root(2), root(1), g, g, PayloadStatus::Full, false)); fc.proposer_boost_root = root(2); @@ -776,7 +1044,17 @@ fn gloas_boost_is_pending_not_empty() { /// ``` fn skipped_slot_forks() -> ForkChoice { let g = cp(0, 1); - let mut fc = ForkChoice::init(g, g, 0, root(1), [0u8; 32], false, test_state_id(), 0); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 0, + ); fc.on_block(block(2, root(2), root(1), g, g)); fc.on_block(block(10, root(4), root(2), g, g)); fc.on_block(block(9, root(5), root(2), g, g)); @@ -813,3 +1091,33 @@ fn lca_of_a_pruned_head_falls_back_to_finalized() { assert_eq!(fc.lca_slot(root(99), root(6)), Some(SLOTS_PER_EPOCH), "unknown old head"); assert_eq!(fc.lca_slot(root(4), root(99)), None, "unknown new head says nothing"); } + +#[test] +fn a_valid_payload_validates_its_ancestors_and_no_sibling() { + let g = cp(0, 1); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 8, + ); + fc.on_block(block(1, root(2), root(1), g, g)); // A + fc.on_block(block(2, root(3), root(2), g, g)); // C, A's child + fc.on_block(block(1, root(4), root(1), g, g)); // B, A's sibling + let valid = |fc: &ForkChoice, b: u8| { + fc.nodes[fc.find_node_idx(&root(b)).unwrap()].execution_status == ExecutionStatus::Valid + }; + assert!(!valid(&fc, 2) && !valid(&fc, 3) && !valid(&fc, 4), "imports start optimistic"); + + fc.on_payload_valid(&root(3)); + + assert!(valid(&fc, 3)); + assert!(valid(&fc, 2), "A is validated through its child"); + assert!(!valid(&fc, 4), "a sibling is not on the walk"); + assert!(valid(&fc, 1), "the anchor was valid from init"); +} diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 2eb13649..db9a4b01 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -11,7 +11,7 @@ use silver_beacon_state_data::{ Slot, SlotState, SpecConfig, StateId, }; use silver_common::{ - BeaconStateEvent, BlockSource, DataColumnsEvent, DataKind, EngineResp, GossipTopic, + BeaconStateEvent, BlockSource, DataColumnsEvent, DataKind, EngineResp, GossipTopic, HeadRoots, NewGossipMsg, Origin, PayloadValidationStatus, ReplayBlock, RequestId, RpcInbound, RpcResponse, RpcResponseInbound, SilverSpine, SyncUpdate, TRandomAccess, TRead, hex32, ssz_view::STATUS_V2_SIZE, @@ -111,6 +111,21 @@ impl Debug for Feedback { } } +/// Resolved once so each Status uses one fork's head metadata. +#[derive(Clone, Copy)] +struct SelectedHead { + root: B256, + /// `None` only before the anchor is seeded, where no node is resident. + idx: Option, + optimistic: bool, +} + +impl SelectedHead { + fn reported(&self) -> (B256, bool) { + (self.root, self.optimistic) + } +} + pub struct BeaconStateTile { sync_target: SyncUpdate, ticker: SlotTicker, @@ -148,6 +163,9 @@ pub struct BeaconStateTile { precomputed_epochs: PrecomputedEpochs, last_seen_head_root: B256, + /// Kept separate from the reorg marker: an early Status must not hide a + /// reorg that the end-of-loop check has yet to report. + emitted_head: (B256, bool), initial_status_emitted: bool, cached_fork_digest: Option<(Epoch, [u8; 4])>, @@ -225,6 +243,7 @@ impl BeaconStateTile { last_applied_block_root: [0u8; 32], precomputed_epochs: PrecomputedEpochs::default(), last_seen_head_root: [0u8; 32], + emitted_head: ([0u8; 32], true), initial_status_emitted: false, cached_fork_digest: None, stf_scratch: stf::StfScratch::new(val_cap), @@ -341,6 +360,7 @@ impl BeaconStateTile { trusted, header.slot, block_root, + header.state_root, execution_block_hash, anchor_is_gloas, anchor, @@ -452,19 +472,61 @@ impl BeaconStateTile { self.slot_state_at(self.last_applied).latest_block_header.slot } - fn status_event(&mut self) -> BeaconStateEvent { - let head_root = self.fork_choice.find_head(); - let head_idx = self.fork_choice.find_node_idx(&head_root); - let head_optimistic = head_idx.is_none_or(|idx| { + fn selected_head(&self) -> SelectedHead { + let root = self.fork_choice.find_head(); + let idx = self.fork_choice.find_node_idx(&root); + let optimistic = idx.is_none_or(|idx| { self.fork_choice.node(idx).execution_status != ExecutionStatus::Valid }); + SelectedHead { root, idx, optimistic } + } + + /// A missing node or overwritten checkpoint history makes the whole + /// root bundle unavailable; partial metadata cannot describe the head. + fn head_roots(&self, head: SelectedHead) -> HeadRoots { + let Some(idx) = head.idx else { return HeadRoots::default() }; + let node = self.fork_choice.node(idx); + let epoch = node.slot / SLOTS_PER_EPOCH; + let view = self.state.read_view(node.state_id); + let state_slot = view.slot.state().slot; + let dependent = + |epoch| view.block_roots.duty_dependent_root(epoch, node.slot, head.root, state_slot); + match (dependent(epoch.saturating_sub(1)), dependent(epoch)) { + (Some(previous), Some(current)) => HeadRoots { + state_root: node.state_root, + previous_duty_dependent_root: previous, + current_duty_dependent_root: current, + }, + _ => HeadRoots::default(), + } + } + fn status_event(&mut self, head: SelectedHead) -> BeaconStateEvent { BeaconStateEvent::Status { - ssz: self.status_payload(head_root, head_idx), + ssz: self.status_payload(head.root, head.idx), latest_block_slot: self.last_applied_block_slot(), wall_slot: self.ticker.current_slot(), - head_optimistic, + head_optimistic: head.optimistic, enr_fork_id: self.enr_fork_id(), + head_roots: self.head_roots(head), + } + } + + pub(super) fn publish_status(&mut self, producers: &mut Producers) { + self.publish_selected_head(self.selected_head(), producers); + } + + fn publish_selected_head(&mut self, head: SelectedHead, producers: &mut Producers) { + self.emitted_head = head.reported(); + let event = self.status_event(head); + producers.produce(event); + } + + /// Covers changes since the last Status, including execution verdicts. + fn publish_status_on_head_change(&mut self, producers: &mut Producers) { + let head = self.selected_head(); + if head.reported() != self.emitted_head { + self.publish_selected_head(head, producers); } } @@ -614,7 +676,7 @@ impl BeaconStateTile { let prev_head = self.fork_choice.find_head(); let advanced = self.slot_tick(slot); if advanced || self.fork_choice.find_head() != prev_head { - adapter.produce(self.status_event()); + self.publish_status(&mut adapter.producers); } } TickEvent::StateAdvance(slot) => self.on_state_advance(slot), @@ -733,7 +795,7 @@ impl BeaconStateTile { } ReplayBlock::Done => { producers.produce(BeaconStateEvent::ReplayComplete); - producers.produce(self.status_event()); + self.publish_status(producers); } } } @@ -897,7 +959,7 @@ impl Tile for BeaconStateTile { fn loop_body(&mut self, adapter: &mut SpineAdapter) { if !self.initial_status_emitted { tracing::info!("producing initial status"); - adapter.produce(self.status_event()); + self.publish_status(&mut adapter.producers); self.initial_status_emitted = true; } @@ -909,6 +971,7 @@ impl Tile for BeaconStateTile { if self.fork_choice.take_head_moved() { self.try_detect_reorg(&mut adapter.producers); + self.publish_status_on_head_change(&mut adapter.producers); } } } diff --git a/crates/beacon_state/tile/src/tile/block.rs b/crates/beacon_state/tile/src/tile/block.rs index da497c14..ad0a04da 100644 --- a/crates/beacon_state/tile/src/tile/block.rs +++ b/crates/beacon_state/tile/src/tile/block.rs @@ -509,6 +509,7 @@ impl BeaconStateTile { self.fork_choice.on_block(BlockImport { slot: parsed.header.slot, block_root: parsed.block_root, + state_root: parsed.header.state_root, parent_root: parsed.header.parent_root, execution_block_hash, justified, diff --git a/crates/beacon_state/tile/src/tile/orphan_pool.rs b/crates/beacon_state/tile/src/tile/orphan_pool.rs index 8f670c7a..caa0c5f9 100644 --- a/crates/beacon_state/tile/src/tile/orphan_pool.rs +++ b/crates/beacon_state/tile/src/tile/orphan_pool.rs @@ -147,7 +147,7 @@ impl BeaconStateTile { self.replay_orphans(root, producers); self.drain_pending_envelope(root, producers); } - producers.produce(self.status_event()); + self.publish_status(producers); } pub(super) fn park_block( diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index 7e025e06..8f045332 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -11,7 +11,8 @@ use silver_beacon_state_data::{ }; use silver_common::{ BlockStage, EngineNewPayloadResp, GossipTopic, LOCAL_GOSSIP_STREAM_ID, MessageId, P2pStreamId, - PeerEvent, StreamProtocol, SyncNeed, TCache, TCacheProducer, TCacheRead, TProducer, + PayloadValidationStatus, PeerEvent, StreamProtocol, SyncNeed, TCache, TCacheProducer, + TCacheRead, TProducer, column_util::block_root_fulu, ssz_view::{ ATTESTATION_DATA_SIZE, AttestationView, BEACON_BLOCK_BODY_FIXED, BYTES_PER_KZG_COMMITMENT, @@ -39,6 +40,14 @@ use crate::{ const MAX_EFFECTIVE_BALANCE: u64 = 32_000_000_000; const TEST_RING_BYTES: usize = 1 << 20; const ANCHOR_ROOT: B256 = [0x01u8; 32]; +/// Non-zero so the anchor has complete head metadata. +const ANCHOR_STATE_ROOT: B256 = [0xA1u8; 32]; + +fn state_root_of(block_root: B256) -> B256 { + let mut r = block_root; + r[31] = 0xFF; + r +} /// Byte position of the body inside a `SignedBeaconBlock`. const BODY: usize = SIGNED_BEACON_BLOCK_MIN; @@ -124,6 +133,17 @@ fn make_tile_with_gossip( wall_slot: u64, state: BeaconState, ) -> (BeaconStateTile, TProducer, TProducer) { + let (tile, gossip, rpc, _replay) = + make_tile_with_producers(wall_slot, state, SpecConfig::mainnet()); + (tile, gossip, rpc) +} + +/// Keeps the replay producer alive for tests that feed cached block bytes. +fn make_tile_with_producers( + wall_slot: u64, + state: BeaconState, + spec: SpecConfig, +) -> (BeaconStateTile, TProducer, TProducer, TProducer) { let secs_per_slot = 12u64; let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); let genesis = now.saturating_sub(wall_slot * secs_per_slot + 1); @@ -138,7 +158,7 @@ fn make_tile_with_gossip( let replay_c = replay_p.cache_ref().random_access("test_replay_buf", true).unwrap(); let tile = BeaconStateTile::new( ticker, - Arc::new(SpecConfig::mainnet()), + Arc::new(spec), &SyncingConfig::default(), gossip_c, rpc_c, @@ -147,7 +167,7 @@ fn make_tile_with_gossip( true, state, ); - (tile, gossip_p, event_p) + (tile, gossip_p, event_p, replay_p) } /// Publish a minimal block (slot at offset 100) into `producer` and wrap it @@ -261,8 +281,17 @@ fn arm_tile_state( tile.sync_target = SyncUpdate::Following; let cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; - tile.fork_choice = - ForkChoice::init(cp, cp, start_slot, ANCHOR_ROOT, [0u8; 32], false, anchor, seeds.len()); + tile.fork_choice = ForkChoice::init( + cp, + cp, + start_slot, + ANCHOR_ROOT, + ANCHOR_STATE_ROOT, + [0u8; 32], + false, + anchor, + seeds.len(), + ); let view = tile.state.read_view(anchor); tile.shuffling_cache.ensure_window(&view, start_slot / SLOTS_PER_EPOCH); @@ -424,7 +453,8 @@ fn slot_advance_crosses_two_epoch_boundaries() { fn status_event_carries_the_head_s_execution_status() { const CHILD_ROOT: B256 = [0x0C; 32]; - let head_optimistic = |tile: &mut BeaconStateTile| match tile.status_event() { + let head_optimistic = |tile: &mut BeaconStateTile| match tile.status_event(tile.selected_head()) + { BeaconStateEvent::Status { ssz, head_optimistic, .. } => { assert_eq!(*StatusView::head_root(&ssz), tile.fork_choice.find_head()); head_optimistic @@ -436,17 +466,26 @@ fn status_event_carries_the_head_s_execution_status() { seed_tile(&mut tile, 4, 10); assert!(!head_optimistic(&mut tile), "the trusted anchor is valid"); + // The child's post-state sits at its own slot, as an import leaves it. + let child_state = { + let anchor = tile.last_applied; + let mut g = tile.state.write(); + let mut sw = g.slot_states.roll_from(anchor.slot_idx); + sw.state_mut().slot = 11; + StateId { slot_idx: sw.commit(), ..anchor } + }; let anchor_cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; tile.fork_choice.on_block(BlockImport { slot: 11, block_root: CHILD_ROOT, + state_root: state_root_of(CHILD_ROOT), parent_root: ANCHOR_ROOT, execution_block_hash: [0u8; 32], justified: anchor_cp, finalized: anchor_cp, unrealized_justified: anchor_cp, unrealized_finalized: anchor_cp, - state_id: tile.last_applied, + state_id: child_state, bid_block_hash: [0u8; 32], parent_payload_status: PayloadStatus::Full, payload_verified: true, @@ -459,6 +498,385 @@ fn status_event_carries_the_head_s_execution_status() { assert!(!head_optimistic(&mut tile)); } +/// A wins the equal-weight root tie-break, regardless of import order. +const A_ROOT: B256 = [0xAA; 32]; +const B_ROOT: B256 = [0x0B; 32]; +/// Decision slots for a head in epoch 2: `start(2) - 1` and `start(1) - 1`. +const CURRENT_DECISION_SLOT: Slot = 63; +const PREVIOUS_DECISION_SLOT: Slot = 31; + +#[derive(Debug, PartialEq, Eq)] +struct StatusHead { + root: B256, + slot: Slot, + optimistic: bool, + roots: HeadRoots, +} + +struct Published(Vec); + +impl Published { + fn drain(sink: &mut SpineAdapter) -> Self { + let mut events = Vec::new(); + sink.consume(|event: BeaconStateEvent, _| events.push(event)); + Self(events) + } + + fn heads(&self) -> Vec { + self.0 + .iter() + .filter_map(|event| match event { + BeaconStateEvent::Status { ssz, head_optimistic, head_roots, .. } => { + Some(StatusHead { + root: *StatusView::head_root(ssz), + slot: StatusView::head_slot(ssz), + optimistic: *head_optimistic, + roots: *head_roots, + }) + } + _ => None, + }) + .collect() + } + + fn reorgs(&self) -> Vec { + self.0 + .iter() + .filter_map(|event| match event { + BeaconStateEvent::Reorg { lca_slot } => Some(*lca_slot), + _ => None, + }) + .collect() + } +} + +/// Synthetic fork-choice setup with spine injection and publication capture. +/// `crank` runs the tile loop; setup helpers also call tile methods directly. +struct HeadRig { + sink: SpineAdapter, + adapter: SpineAdapter, + tile: BeaconStateTile, + anchor: StateId, + _gossip: TProducer, + _rpc: TProducer, + _spine: Box, +} + +impl HeadRig { + /// Anchored at slot 70 (epoch 2) and cranked once, so every cursor has + /// snapped and the startup Status is behind us. + fn new() -> Self { + const ANCHOR_SLOT: Slot = 70; + let (mut tile, gossip, rpc, mut spine, adapter) = tile_with_producers(ANCHOR_SLOT); + seed_tile(&mut tile, 8, ANCHOR_SLOT); + tile.sync_target = SyncUpdate::Following; + let mut sink = SpineAdapter::connect_tile(&Sink, &mut spine); + sink.consume(|_: BeaconStateEvent, _| {}); + + // Distinct anchor roots expose accidental reads from a child's history. + let anchor = { + let base = tile.last_applied; + let mut g = tile.state.write(); + let mut w = g.block_roots.roll_from(base.block_roots_idx); + w.set(PREVIOUS_DECISION_SLOT as u32, ANCHOR_PREVIOUS); + w.set(CURRENT_DECISION_SLOT as u32, ANCHOR_CURRENT); + StateId { block_roots_idx: w.commit(), ..base } + }; + tile.last_applied = anchor; + tile.state.publish_state_id(anchor); + let cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; + tile.fork_choice = ForkChoice::init( + cp, + cp, + ANCHOR_SLOT, + ANCHOR_ROOT, + ANCHOR_STATE_ROOT, + [0u8; 32], + false, + anchor, + 8, + ); + let mut rig = + Self { sink, adapter, tile, anchor, _gossip: gossip, _rpc: rpc, _spine: spine }; + assert_eq!( + rig.crank().heads().len(), + 1, + "the startup Status is the only one before a test acts" + ); + rig + } + + fn crank(&mut self) -> Published { + self.tile.loop_body(&mut self.adapter); + self.drain() + } + + fn drain(&mut self) -> Published { + Published::drain(&mut self.sink) + } + + /// Builds a synthetic state with distinct roots at the two decision slots. + fn post_state( + &mut self, + parent: StateId, + slot: Slot, + previous: B256, + current: B256, + ) -> StateId { + let mut g = self.tile.state.write(); + let mut sw = g.slot_states.roll_from(parent.slot_idx); + sw.state_mut().slot = slot; + let slot_idx = sw.commit(); + let mut w = g.block_roots.roll_from(parent.block_roots_idx); + w.set(PREVIOUS_DECISION_SLOT as u32, previous); + w.set(CURRENT_DECISION_SLOT as u32, current); + let block_roots_idx = w.commit(); + StateId { slot_idx, block_roots_idx, ..parent } + } + + /// Seeds fork choice and runs the accept notification without parsing a + /// block or executing its state transition. + fn import(&mut self, block_root: B256, slot: Slot, previous: B256, current: B256) -> StateId { + let anchor = self.anchor; + self.import_child(block_root, slot, ANCHOR_ROOT, anchor, previous, current) + } + + fn import_child( + &mut self, + block_root: B256, + slot: Slot, + parent_root: B256, + parent_state: StateId, + previous: B256, + current: B256, + ) -> StateId { + let state_id = self.post_state(parent_state, slot, previous, current); + let anchor_cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; + self.tile.fork_choice.on_block(BlockImport { + slot, + block_root, + state_root: state_root_of(block_root), + parent_root, + execution_block_hash: block_root, + justified: anchor_cp, + finalized: anchor_cp, + unrealized_justified: anchor_cp, + unrealized_finalized: anchor_cp, + state_id, + bid_block_hash: [0u8; 32], + parent_payload_status: PayloadStatus::Full, + payload_verified: true, + is_gloas: false, + }); + self.tile.last_applied = state_id; + self.tile.last_applied_block_root = block_root; + self.tile.recompute_head(); + self.tile.on_accept(Some(block_root), &mut self.adapter.producers); + state_id + } + + fn verdict(&mut self, block_root: B256, status: PayloadValidationStatus) { + self.sink.produce(EngineResp::NewPayload(EngineNewPayloadResp { + block_root, + status, + latest_valid_hash: [0u8; 32], + })); + } + + fn advance_to_slot(&mut self, slot: Slot) { + self.tile.ticker.set_since_genesis_ms(slot * 12_000); + } + + fn vote_for(&mut self, block_root: B256, validators: std::ops::Range) { + let n = self.tile.head_validator_count(); + for validator in validators { + self.tile.fork_choice.record_vote( + &AttestationVote { + validator, + block_root, + target_epoch: 2, + attestation_slot: 71, + payload_present: true, + }, + n, + ); + } + } +} + +/// Distinct roots expose snapshots that mix metadata from different forks. +const A_PREVIOUS: B256 = [0xA1; 32]; +const A_CURRENT: B256 = [0xA2; 32]; +const B_PREVIOUS: B256 = [0xB1; 32]; +const B_CURRENT: B256 = [0xB2; 32]; +const ANCHOR_PREVIOUS: B256 = [0x71; 32]; +const ANCHOR_CURRENT: B256 = [0x72; 32]; + +fn head_a(optimistic: bool) -> StatusHead { + StatusHead { + root: A_ROOT, + slot: 71, + optimistic, + roots: HeadRoots { + state_root: state_root_of(A_ROOT), + previous_duty_dependent_root: A_PREVIOUS, + current_duty_dependent_root: A_CURRENT, + }, + } +} + +fn head_b(optimistic: bool) -> StatusHead { + StatusHead { + root: B_ROOT, + slot: 71, + optimistic, + roots: HeadRoots { + state_root: state_root_of(B_ROOT), + previous_duty_dependent_root: B_PREVIOUS, + current_duty_dependent_root: B_CURRENT, + }, + } +} + +fn head_anchor() -> StatusHead { + StatusHead { + root: ANCHOR_ROOT, + slot: 70, + optimistic: false, + roots: HeadRoots { + state_root: ANCHOR_STATE_ROOT, + previous_duty_dependent_root: ANCHOR_PREVIOUS, + current_duty_dependent_root: ANCHOR_CURRENT, + }, + } +} + +#[test] +fn an_import_publishes_one_status_and_the_end_of_loop_check_adds_none() { + let mut rig = HeadRig::new(); + rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); + + assert_eq!(rig.drain().heads(), [head_a(true)], "the accept path published it"); + assert_eq!(rig.crank().heads(), [], "and the dirty check finds it current"); +} + +#[test] +fn a_non_head_import_publishes_a_status_naming_the_head_it_did_not_take() { + let mut rig = HeadRig::new(); + rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); + let _ = rig.crank(); + + rig.import(B_ROOT, 71, B_PREVIOUS, B_CURRENT); + assert_eq!(rig.tile.fork_choice.find_head(), A_ROOT, "B loses the weight tie-break"); + assert_eq!(rig.drain().heads(), [head_a(true)]); + assert_eq!(rig.crank().heads(), []); +} + +#[test] +fn a_valid_verdict_on_the_head_publishes_one_more_status_and_no_third() { + let mut rig = HeadRig::new(); + rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); + let _ = rig.crank(); + + rig.verdict(A_ROOT, PayloadValidationStatus::Valid); + assert_eq!(rig.crank().heads(), [head_a(false)], "the verdict is the whole change"); + + rig.verdict(A_ROOT, PayloadValidationStatus::Valid); + assert_eq!(rig.crank().heads(), [], "a repeat changes nothing to report"); +} + +#[test] +fn an_invalid_verdict_publishes_the_snapshot_of_the_head_it_moved_to() { + let mut rig = HeadRig::new(); + rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); + rig.import(B_ROOT, 71, B_PREVIOUS, B_CURRENT); + let _ = rig.crank(); + + rig.verdict(A_ROOT, PayloadValidationStatus::Invalid); + let events = rig.crank(); + assert_eq!(events.heads(), [head_b(true)]); + assert_eq!(events.reorgs(), [70], "the head left A's branch for its sibling"); +} + +/// Votes take effect when the next slot tick recomputes the head. +#[test] +fn a_vote_driven_reorg_publishes_the_new_head_and_reports_the_reorg() { + let mut rig = HeadRig::new(); + rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); + rig.import(B_ROOT, 71, B_PREVIOUS, B_CURRENT); + let _ = rig.crank(); + + rig.vote_for(B_ROOT, 0..8); + assert_eq!(rig.tile.fork_choice.find_head(), A_ROOT, "the votes are not folded yet"); + + rig.advance_to_slot(72); + let events = rig.crank(); + assert_eq!(events.heads(), [head_b(true)]); + assert_eq!(events.reorgs(), [70]); +} + +#[test] +fn a_status_that_already_named_the_new_head_does_not_hide_the_reorg() { + let mut rig = HeadRig::new(); + rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); + rig.import(B_ROOT, 71, B_PREVIOUS, B_CURRENT); + let _ = rig.crank(); + + rig.vote_for(B_ROOT, 0..8); + rig.tile.recompute_head(); + rig.tile.on_accept(None, &mut rig.adapter.producers); + assert_eq!(rig.drain().heads(), [head_b(true)], "an accept published the new head"); + + let events = rig.crank(); + assert_eq!(events.reorgs(), [70], "the reorg is reported anyway"); + assert_eq!(events.heads(), [], "and the head check adds no duplicate"); +} + +#[test] +fn a_head_change_after_an_earlier_status_in_the_same_iteration_is_not_lost() { + let mut rig = HeadRig::new(); + rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); + let _ = rig.crank(); + + rig.tile.on_accept(None, &mut rig.adapter.producers); + rig.tile.fork_choice.on_payload_valid(&A_ROOT); + rig.tile.publish_status_on_head_change(&mut rig.adapter.producers); + + assert_eq!(rig.drain().heads(), [head_a(true), head_a(false)]); +} + +#[test] +fn a_verdict_on_a_non_head_sibling_publishes_nothing_until_it_is_selected() { + let mut rig = HeadRig::new(); + rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); + rig.import(B_ROOT, 71, B_PREVIOUS, B_CURRENT); + let _ = rig.crank(); + + rig.verdict(B_ROOT, PayloadValidationStatus::Valid); + assert_eq!(rig.crank().heads(), [], "the head is A, and A is still optimistic"); + assert_eq!(rig.tile.fork_choice.find_head(), A_ROOT); + + rig.vote_for(B_ROOT, 0..8); + rig.advance_to_slot(72); + assert_eq!(rig.crank().heads(), [head_b(false)]); +} + +#[test] +fn a_return_to_the_anchor_publishes_the_anchor_s_own_snapshot() { + let mut rig = HeadRig::new(); + rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); + rig.import(B_ROOT, 71, B_PREVIOUS, B_CURRENT); + let _ = rig.crank(); + + rig.verdict(A_ROOT, PayloadValidationStatus::Invalid); + assert_eq!(rig.crank().heads(), [head_b(true)]); + + rig.verdict(B_ROOT, PayloadValidationStatus::Invalid); + let events = rig.crank(); + assert_eq!(events.heads(), [head_anchor()]); + assert_eq!(events.reorgs(), [70]); +} + #[test] fn block_unknown_parent_rejected() { let mut tile = make_tile(); @@ -550,6 +968,7 @@ fn anchor_child(block_root: B256, state_id: StateId) -> BlockImport { BlockImport { slot: 11, block_root, + state_root: state_root_of(block_root), parent_root: ANCHOR_ROOT, execution_block_hash: [0u8; 32], justified: anchor_cp, @@ -593,24 +1012,56 @@ fn a_block_already_in_fork_choice_is_reported_already_known() { assert_eq!(block_stages(&mut sink), [(block_root, BlockStage::AlreadyKnown)]); } +/// The fixtures sign under Fulu from genesis; mainnet's fork schedule would +/// put a Phase0 domain on their signatures. +#[cfg(feature = "ef_tests")] +fn fulu_from_genesis() -> SpecConfig { + SpecConfig { fulu_fork_epoch: 0, ..SpecConfig::mainnet() } +} + +/// `pre`, `blocks_0` and `post` of a mainnet Fulu sanity fixture, decompressed. +#[cfg(feature = "ef_tests")] +fn sanity_fixture(name: &str) -> (Vec, Vec, Vec) { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("consensus-spec-tests/tests/mainnet/fulu/sanity/blocks/pyspec_tests") + .join(name); + let read = |file: &str| { + let path = dir.join(file); + let raw = fs::read(&path) + .unwrap_or_else(|e| panic!("{}: {e} (run `just ef-tests-download`)", path.display())); + snap::Decoder::new() + .decompress_vec(&raw) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())) + }; + (read("pre.ssz_snappy"), read("blocks_0.ssz_snappy"), read("post.ssz_snappy")) +} + +/// Read expected roots from the block header and EF post-state, independently +/// of the duty-dependent lookup under test. The slot-33 block uses slots 0 and +/// 31. +#[cfg(feature = "ef_tests")] +fn attestation_fixture_head_roots(block_ssz: &[u8], post_ssz: &[u8]) -> HeadRoots { + assert_eq!(SignedBeaconBlockView::slot(block_ssz), 33, "fixture premise"); + let mut post = BeaconState::from_checkpoint(post_ssz, &fulu_from_genesis(), &[]) + .unwrap_or_else(|e| panic!("decompose post: {e}")); + let id = post.roll_fresh(); + let ring = post.block_roots.view(id.block_roots_idx); + HeadRoots { + state_root: *SignedBeaconBlockView::state_root(block_ssz), + previous_duty_dependent_root: ring.at_slot(0), + current_duty_dependent_root: ring.at_slot(31), + } +} + #[cfg(feature = "ef_tests")] #[test] fn a_block_is_applied_once_and_already_known_on_repeat() { - let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("consensus-spec-tests/tests/mainnet/fulu/sanity/blocks/pyspec_tests/attestation"); - let read = |name: &str| { - let path = fixture.join(name); - fs::read(&path) - .unwrap_or_else(|e| panic!("{}: {e} (run `just ef-tests-download`)", path.display())) - }; - let (pre, block) = (read("pre.ssz_snappy"), read("blocks_0.ssz_snappy")); - let pre_ssz = snap::Decoder::new().decompress_vec(&pre).expect("snappy pre"); - let block_ssz = snap::Decoder::new().decompress_vec(&block).expect("snappy block"); + let (pre_ssz, block_ssz, _) = sanity_fixture("attestation"); let state = BeaconState::from_checkpoint(&pre_ssz, &SpecConfig::mainnet(), &[]) .unwrap_or_else(|e| panic!("decompose checkpoint: {e}")); let block_slot = SignedBeaconBlockView::slot(&block_ssz); let (mut tile, mut gp, _rp, mut spine, mut adapter) = - tile_with_producers_on(block_slot + 1, state); + tile_with_producers_on(block_slot + 1, state, SpecConfig::mainnet()); let mut sink = SpineAdapter::connect_tile(&Sink, &mut spine); sink.consume(|_: BeaconStateEvent, _| {}); @@ -631,6 +1082,61 @@ fn a_block_is_applied_once_and_already_known_on_repeat() { assert_eq!(block_stages(&mut sink), [(block_root, BlockStage::AlreadyKnown)]); } +/// Exercises block parsing, signature verification, and state transition +/// before invoking the accept notification. +#[cfg(feature = "ef_tests")] +#[test] +fn an_imported_fixture_block_s_status_carries_its_own_roots() { + let (pre_ssz, block_ssz, post_ssz) = sanity_fixture("attestation"); + let state = BeaconState::from_checkpoint(&pre_ssz, &fulu_from_genesis(), &[]) + .unwrap_or_else(|e| panic!("decompose checkpoint: {e}")); + let expected = attestation_fixture_head_roots(&block_ssz, &post_ssz); + let (mut tile, mut gp, _rp, mut spine, mut adapter) = + tile_with_producers_on(34, state, fulu_from_genesis()); + let mut sink = SpineAdapter::connect_tile(&Sink, &mut spine); + sink.consume(|_: BeaconStateEvent, _| {}); + + let (data, read) = publish_block_bytes(&mut gp, &block_ssz); + let feedback = + tile.apply_block(&data, read, BlockSource::Gossip, false, &mut adapter.producers, |_| {}); + let Feedback::Accept(Some(block_root)) = feedback else { panic!("{feedback:?}") }; + tile.on_accept(Some(block_root), &mut adapter.producers); + + assert_eq!(Published::drain(&mut sink).heads(), [StatusHead { + root: block_root, + slot: 33, + optimistic: true, + roots: expected, + }]); +} + +/// Checks replay metadata and dirty marking. Status is constructed directly; +/// this test does not exercise its end-of-loop publication. +#[cfg(feature = "ef_tests")] +#[test] +fn a_replayed_fixture_block_moves_the_head_and_its_status_names_its_roots() { + let (pre_ssz, block_ssz, post_ssz) = sanity_fixture("attestation"); + let state = BeaconState::from_checkpoint(&pre_ssz, &fulu_from_genesis(), &[]) + .unwrap_or_else(|e| panic!("decompose checkpoint: {e}")); + let expected = attestation_fixture_head_roots(&block_ssz, &post_ssz); + let (mut tile, _gp, _rp, mut replay) = make_tile_with_producers(34, state, fulu_from_genesis()); + assert!(!tile.fork_choice.take_head_moved(), "nothing has moved before the replay"); + + let (_, read) = publish_block_bytes(&mut replay, &block_ssz); + tile.replay_block(read); + + assert!(tile.fork_choice.take_head_moved(), "the replayed block is the head to publish"); + let BeaconStateEvent::Status { ssz, head_roots, head_optimistic, .. } = + tile.status_event(tile.selected_head()) + else { + panic!("status_event produces Status") + }; + assert_eq!(*StatusView::head_root(&ssz), tile.head_block_root()); + assert_eq!(StatusView::head_slot(&ssz), 33); + assert!(head_optimistic, "replay asks the execution layer nothing"); + assert_eq!(head_roots, expected); +} + #[cfg(feature = "ef_tests")] #[test] fn the_anchor_reports_its_block_slot_not_the_checkpoint_state_slot() { @@ -643,16 +1149,61 @@ fn the_anchor_reports_its_block_slot_not_the_checkpoint_state_slot() { let state = BeaconState::from_checkpoint(&ssz, &SpecConfig::mainnet(), &[]) .unwrap_or_else(|e| panic!("decompose checkpoint: {e}")); - let view = state.slot_states.finalized_view(); - let (state_slot, block_slot) = (view.slot_number(), view.state().latest_block_header.slot); - assert!(state_slot > block_slot, "fixture premise: state {state_slot}, block {block_slot}"); + let header = state.slot_states.finalized_view().state().latest_block_header; + let state_slot = state.slot_states.finalized_view().slot_number(); + assert!(state_slot > header.slot, "fixture premise: state {state_slot}, block {}", header.slot); + assert_ne!(header.state_root, [0u8; 32], "fixture premise: the header names its state"); let (mut tile, _gp, _rp) = make_tile_with_gossip(state_slot, state); - let BeaconStateEvent::Status { ssz, .. } = tile.status_event() else { + let BeaconStateEvent::Status { ssz, head_roots, .. } = tile.status_event(tile.selected_head()) + else { panic!("status_event produces Status") }; - assert_eq!(StatusView::head_slot(&ssz), block_slot, "p2p Status names the anchor block"); + assert_eq!(StatusView::head_slot(&ssz), header.slot, "p2p Status names the anchor block"); assert_eq!(*StatusView::head_root(&ssz), tile.head_block_root()); + assert_eq!(head_roots.state_root, header.state_root, "the anchor block's declared state"); + assert_eq!(header.slot, 0, "fixture premise: the anchor is the genesis block"); + assert_eq!( + (head_roots.previous_duty_dependent_root, head_roots.current_duty_dependent_root), + (tile.head_block_root(), tile.head_block_root()), + "a slot-zero head decides its own shuffling, whatever slot its state reached" + ); +} + +#[test] +fn an_anchor_whose_state_outran_the_ring_reports_no_head_metadata() { + let anchor_at = |state_slot: Slot| { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, state_slot); + let cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; + tile.fork_choice = ForkChoice::init( + cp, + cp, + 70, + ANCHOR_ROOT, + ANCHOR_STATE_ROOT, + [0u8; 32], + false, + tile.last_applied, + 4, + ); + tile + }; + // The previous decision slot for a head in epoch 2 is slot 31. + let edge = SLOTS_PER_HISTORICAL_ROOT as u64 + 31; + + let tile = anchor_at(edge); + assert!( + tile.head_roots(tile.selected_head()).is_complete(), + "a state at {edge} still holds slot 31, the oldest slot in its ring" + ); + + let tile = anchor_at(edge + 1); + assert_eq!( + tile.head_roots(tile.selected_head()), + HeadRoots::default(), + "one slot later that root is gone, and so is the whole snapshot" + ); } /// Fulu requires the execution timestamp and the active blob limit before a @@ -868,14 +1419,15 @@ fn pending_admission_window_bounds() { fn tile_with_producers( wall_slot: u64, ) -> (BeaconStateTile, TProducer, TProducer, Box, SpineAdapter) { - tile_with_producers_on(wall_slot, BeaconState::empty_test(0)) + tile_with_producers_on(wall_slot, BeaconState::empty_test(0), SpecConfig::mainnet()) } fn tile_with_producers_on( wall_slot: u64, state: BeaconState, + spec: SpecConfig, ) -> (BeaconStateTile, TProducer, TProducer, Box, SpineAdapter) { - let (tile, gp, rp) = make_tile_with_gossip(wall_slot, state); + let (tile, gp, rp, _replay) = make_tile_with_producers(wall_slot, state, spec); let (spine, adapter) = spine_adapter(&tile); (tile, gp, rp, spine, adapter) } @@ -1388,8 +1940,17 @@ fn block_known_parent_bad_sig_rejected() { let parent_root = ssz_hash::hash_tree_root_block_header(&genesis_header); let cp = Checkpoint { epoch: 0, root: parent_root }; - tile.fork_choice = - ForkChoice::init(cp, cp, 10, parent_root, [0u8; 32], false, tile.last_applied, 0); + tile.fork_choice = ForkChoice::init( + cp, + cp, + 10, + parent_root, + state_root_of(parent_root), + [0u8; 32], + false, + tile.last_applied, + 0, + ); // Valid structure, zeroed BLS signature → precheck reaches and fails // signature verification, so no fork-choice node is added. @@ -2746,9 +3307,11 @@ impl ThreeForks { let mut sw = g.slot_states.roll_from(parent.slot_idx); sw.state_mut().slot = slot; let slot_idx = sw.commit(); - // The block-roots column, written where `process_slot` writes it. + // Slot zero is a synthetic marker for detecting reads from the + // wrong bundle after finalization. let mut w = g.block_roots.roll_from(parent.block_roots_idx); w.set((slot % SLOTS_PER_HISTORICAL_ROOT as u64) as u32, root); + w.set(0, root); let block_roots_idx = w.commit(); StateId { balances_idx, slot_idx, block_roots_idx, ..parent } } @@ -2764,6 +3327,7 @@ impl ThreeForks { self.tile.fork_choice.on_block(BlockImport { slot, block_root, + state_root: state_root_of(block_root), parent_root, execution_block_hash: [0u8; 32], justified: cp, @@ -2882,6 +3446,27 @@ fn multi_fork_finalize_promotes_and_rebases() { assert_ne!(tile.last_applied, d_id, "stale head bundle replaced"); } +/// Distinct slot-zero markers identify which state bundle supplies the roots +/// after finalization remaps the surviving head. +#[test] +fn head_roots_read_the_survivor_s_re_anchored_bundle_after_finalization() { + let mut forks = ThreeForks::new(); + // Justification follows finality here, as `lift_checkpoints` would have it. + forks.tile.fork_choice.justified_checkpoint = Checkpoint { epoch: 0, root: F_ROOT }; + forks.tile.maybe_finalize(); + assert_eq!(forks.tile.fork_choice.find_head(), D_ROOT); + + assert_eq!( + forks.tile.head_roots(forks.tile.selected_head()), + HeadRoots { + state_root: state_root_of(D_ROOT), + previous_duty_dependent_root: D_ROOT, + current_duty_dependent_root: D_ROOT, + }, + "epoch 0 decides at slot 0, where each bundle carries its own root" + ); +} + /// A block staged on its data columns holds a committed state off a live /// parent: finalization re-anchors it like a node when its parent survives /// and drops it when its parent is pruned. @@ -3147,6 +3732,7 @@ fn finalize_promotes_every_tier_into_checkpoint_encode() { tile.fork_choice.on_block(BlockImport { slot: 1, block_root: F_ROOT, + state_root: state_root_of(F_ROOT), parent_root: ANCHOR_ROOT, execution_block_hash: [0u8; 32], justified: f_cp, diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ccf10fc0..9d9314f9 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -23,16 +23,17 @@ pub use crate::{ EngineGetPayloadBodiesByRangeReq, EngineGetPayloadBodiesResp, EngineGetPayloadReq, EngineGetPayloadResp, EngineHealthEvent, EngineNewPayloadEnvelopeReq, EngineNewPayloadReq, EngineNewPayloadResp, EnginePreparePayloadReq, EngineReq, EngineResp, Error as TCacheError, - GossipMsgIn, GossipMsgOut, IpBytes, LOCAL_GOSSIP_STREAM_ID, LocalAttestationFailure, - LocalAttestationResult, MAX_BLOBS_PER_BLOCK, MAX_PAYLOAD_BODIES_PER_REQ, MULTISTREAM_V1, - MultiProducer as TMultiProducer, NewGossipMsg, P2pConnectionStats, P2pSend, P2pStreamId, - PREFILL_SLOTS, PayloadValidationStatus, PeerControl, PeerEvent, PeerScores, PeerStats, - PeerStatus, PeerTopicScores, Prefill, Producer as TProducer, REJECT_RESPONSE, - RPC_PROTOCOLS, RandomAccessConsumer as TRandomAccess, ReplayBlock, - Reservation as TReservation, RpcInbound, RpcOutbound, RpcRequest, RpcRequestInbound, - RpcRequestOutbound, RpcResponse, RpcResponseInbound, RpcResponseOutbound, RpcSeverity, - SilverSpine, SilverSpineProducers, StreamProtocol, SyncNeed, SyncUpdate, SyncingStrategy, - TCache, TCacheProducer, TCacheRead, TCacheRef, WithdrawalInline, + GossipMsgIn, GossipMsgOut, HeadRoots, IpBytes, LOCAL_GOSSIP_STREAM_ID, + LocalAttestationFailure, LocalAttestationResult, MAX_BLOBS_PER_BLOCK, + MAX_PAYLOAD_BODIES_PER_REQ, MULTISTREAM_V1, MultiProducer as TMultiProducer, NewGossipMsg, + P2pConnectionStats, P2pSend, P2pStreamId, PREFILL_SLOTS, PayloadValidationStatus, + PeerControl, PeerEvent, PeerScores, PeerStats, PeerStatus, PeerTopicScores, Prefill, + Producer as TProducer, REJECT_RESPONSE, RPC_PROTOCOLS, + RandomAccessConsumer as TRandomAccess, ReplayBlock, Reservation as TReservation, + RpcInbound, RpcOutbound, RpcRequest, RpcRequestInbound, RpcRequestOutbound, RpcResponse, + RpcResponseInbound, RpcResponseOutbound, RpcSeverity, SilverSpine, SilverSpineProducers, + StreamProtocol, SyncNeed, SyncUpdate, SyncingStrategy, TCache, TCacheProducer, TCacheRead, + TCacheRef, WithdrawalInline, }, util::{create_self_signed_certificate, decode_varint, encode_varint, hex32}, wheel::Wheel, diff --git a/crates/common/src/spine.rs b/crates/common/src/spine.rs index 543084ab..e3be9de4 100644 --- a/crates/common/src/spine.rs +++ b/crates/common/src/spine.rs @@ -7,7 +7,7 @@ pub use messages::{ EngineGetBlobsResp, EngineGetPayloadBodiesByHashReq, EngineGetPayloadBodiesByRangeReq, EngineGetPayloadBodiesResp, EngineGetPayloadReq, EngineGetPayloadResp, EngineHealthEvent, EngineNewPayloadEnvelopeReq, EngineNewPayloadReq, EngineNewPayloadResp, - EnginePreparePayloadReq, EngineReq, EngineResp, GossipMsgIn, GossipMsgOut, IpBytes, + EnginePreparePayloadReq, EngineReq, EngineResp, GossipMsgIn, GossipMsgOut, HeadRoots, IpBytes, LocalAttestationFailure, LocalAttestationResult, MAX_BLOBS_PER_BLOCK, MAX_PAYLOAD_BODIES_PER_REQ, NewGossipMsg, P2pConnectionStats, P2pSend, PREFILL_SLOTS, PayloadValidationStatus, PeerControl, PeerEvent, PeerScores, PeerStats, PeerStatus, diff --git a/crates/common/src/spine/messages.rs b/crates/common/src/spine/messages.rs index 44e82fa1..6b44e09d 100644 --- a/crates/common/src/spine/messages.rs +++ b/crates/common/src/spine/messages.rs @@ -827,17 +827,42 @@ pub enum ColumnSource { El, } +/// A zero `state_root` marks the whole bundle unavailable. This can occur +/// before seeding or when checkpoint history has overwritten a dependent +/// root. Consumers tracking head changes must ignore incomplete bundles. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(C)] +pub struct HeadRoots { + pub state_root: [u8; 32], + /// Root at the slot before the head block's previous epoch starts, + /// saturating to slot zero. + pub previous_duty_dependent_root: [u8; 32], + /// Root at the slot before the head block's epoch starts, saturating to + /// slot zero. + pub current_duty_dependent_root: [u8; 32], +} + +impl HeadRoots { + pub fn is_complete(&self) -> bool { + self.state_root != [0u8; 32] + } +} + #[allow(clippy::large_enum_variant)] #[derive(Clone, Copy, Debug)] #[repr(C)] pub enum BeaconStateEvent { ReplayComplete, + /// An observation that may repeat unchanged. Consumers decide which + /// fields require action. `latest_block_slot` tracks import progress; + /// the selected head's slot in `ssz` can differ. Status { ssz: [u8; STATUS_V2_SIZE], latest_block_slot: u64, wall_slot: u64, head_optimistic: bool, enr_fork_id: [u8; 16], + head_roots: HeadRoots, }, EnvelopeAvailable { ssz: TCacheRead, diff --git a/crates/e2e/src/bin/da_replay.rs b/crates/e2e/src/bin/da_replay.rs index d2aad66b..940da629 100644 --- a/crates/e2e/src/bin/da_replay.rs +++ b/crates/e2e/src/bin/da_replay.rs @@ -29,8 +29,8 @@ use silver_columns::tile::{ColumnConsumers, DataColumnsTile}; #[cfg(feature = "alloc-profile")] use silver_common::metrics::CountingAllocator; use silver_common::{ - BeaconStateEvent, DataColumnsEvent, DataKind, EngineReq, GossipTopic, MessageId, Nanos, - NewGossipMsg, P2pStreamId, PeerEvent, SilverSpine, StreamProtocol, SyncNeed, SyncUpdate, + BeaconStateEvent, DataColumnsEvent, DataKind, EngineReq, GossipTopic, HeadRoots, MessageId, + Nanos, NewGossipMsg, P2pStreamId, PeerEvent, SilverSpine, StreamProtocol, SyncNeed, SyncUpdate, TCache, TCacheProducer, TProducer, profiler::InProcessReader, ssz_view::{DataColumnSidecarFuluView, NUMBER_OF_COLUMNS, STATUS_V2_SIZE}, @@ -165,6 +165,7 @@ impl Node { wall_slot: slot, head_optimistic: false, enr_fork_id: [0u8; 16], + head_roots: HeadRoots::default(), }); self.turn(); } diff --git a/docs/adr/0004-sync-materialized-api.md b/docs/adr/0004-sync-materialized-api.md index 3ee52e91..27225982 100644 --- a/docs/adr/0004-sync-materialized-api.md +++ b/docs/adr/0004-sync-materialized-api.md @@ -78,3 +78,24 @@ are published. The tile calls `publish_block` without accessing connections. `/eth/v1/events` serves `block` and rejects other topics with 400. Further topics and silver-specific SSE routes can use the same subscription mechanism. + +Amended 2026-09-08: `/eth/v1/events` also serves the legacy `head` topic. +`BeaconStateEvent::Status` carries the selected block's declared state root +and both duty-dependent roots from its fork's history. + +Status describes an observation. Each consumer decides which fields require +action. Existing publications remain, and an end-of-loop check covers changes +to the selected head or its execution optimism since the last Status. +The publication marker is separate from the reorg marker, so an earlier +Status cannot hide a reorg notification. Replay can emit intermediate +observations; completion still requires `ReplayComplete`. + +The application boundary publishes a head event when a complete observation +changes the head root or optimism. The first complete observation establishes +a baseline. Incomplete metadata, including overwritten checkpoint history, +leaves that baseline unchanged. Node-status updates continue independently. + +`epoch_transition` compares consecutive complete observations and is true +only when the head epoch advances. Same-block validation updates and backward +reorgs report false. New subscriptions receive future changes without an +initial snapshot. `head_v2` remains unsupported. From 49e9f95fb06f284c36e0d7658a3246e5ec75c654 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 8 Sep 2026 16:33:11 +0100 Subject: [PATCH 03/13] Test consumer behavior under repeated Status observations Cover replay request gating, sync progress, checkpoint scheduling, column-buffer draining, and ENR stability when Status repeats. Verify that a deferred checkpoint remains eligible after catching up, and that a repeated head root drains columns buffered since the previous observation. Repeating an unchanged fork ID preserves the signed ENR and its sequence number. Extract StorageTile::on_status from the loop body to test checkpoint scheduling directly. These tests cover scheduling and buffer removal, not completed checkpoint writes or successful column validation. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:GPT-6 --- crates/columns/src/tile.rs | 50 ++++++++++- crates/control/src/sync_engine/tests.rs | 39 ++++++++ crates/discovery/src/discv5.rs | 23 +++++ crates/storage/src/store/tests.rs | 24 +++++ crates/storage/src/tile.rs | 114 ++++++++++++++++++++---- 5 files changed, 230 insertions(+), 20 deletions(-) diff --git a/crates/columns/src/tile.rs b/crates/columns/src/tile.rs index 09409c41..3c6c2021 100644 --- a/crates/columns/src/tile.rs +++ b/crates/columns/src/tile.rs @@ -773,8 +773,8 @@ mod tests { use silver_beacon_state_data::{BeaconState, BeaconStateOwner}; use silver_common::{ - BlockSource, BlockStage, EngineReq, P2pStreamId, StreamProtocol, TCache, TCacheProducer, - TCacheRead, + BlockSource, BlockStage, EngineReq, HeadRoots, P2pStreamId, StreamProtocol, TCache, + TCacheProducer, TCacheRead, ssz_view::{ DATA_COLUMN_SIDECAR_MIN, DataColumnSidecarFuluView, NUMBER_OF_COLUMNS, SIGNED_BEACON_BLOCK_MIN, @@ -1050,6 +1050,52 @@ mod tests { } } + fn head_status(head_root: BlockRoot) -> BeaconStateEvent { + let mut ssz = status_ssz(0); + ssz[44..76].copy_from_slice(&head_root); + BeaconStateEvent::Status { + ssz, + latest_block_slot: 7, + wall_slot: 7, + head_optimistic: false, + enr_fork_id: [0u8; 16], + head_roots: HeadRoots::default(), + } + } + + /// A repeated head root can unblock newly buffered columns. This test + /// checks buffer removal; its synthetic sidecar does not pass + /// validation. + #[test] + fn each_status_drains_whatever_is_buffered_on_its_head() { + const PARENT: BlockRoot = [0x77; 32]; + let mut rig = Rig::new(CUSTODY_COLUMNS); + let (mut consumer, ssz) = produce_block(&synth_fulu_sidecar(3, 7), "drain_once"); + let buffer = |rig: &mut Rig, read| { + rig.tile.parent_pending_columns.entry(PARENT).or_default().push(PendingColumn { + stream_id: P2pStreamId::new(2, 2, StreamProtocol::DataColumnSidecarsByRange, true), + sidecar: read, + gossip_subnet: None, + recv_ts: IngestionTime::now(), + }); + }; + let pending = |rig: &Rig| rig.tile.parent_pending_columns.get(&PARENT).map(Vec::len); + + buffer(&mut rig, consumer.acquire(ssz)); + assert_eq!(pending(&rig), Some(1), "one column is waiting on that root"); + + rig.tile.handle_beacon_state_event(head_status(PARENT), &mut rig.conn.producers); + assert_eq!(pending(&rig), None, "the first Status drains the buffer"); + + rig.tile.handle_beacon_state_event(head_status(PARENT), &mut rig.conn.producers); + assert_eq!(pending(&rig), None, "the repeat has nothing to find or re-buffer"); + + buffer(&mut rig, consumer.acquire(ssz)); + assert_eq!(pending(&rig), Some(1), "a column buffered after the drain waits again"); + rig.tile.handle_beacon_state_event(head_status(PARENT), &mut rig.conn.producers); + assert_eq!(pending(&rig), None, "and the next Status naming that root takes it"); + } + #[test] fn block_reports_its_missing_custody_columns() { let block_bytes = blob_block_bytes(42); diff --git a/crates/control/src/sync_engine/tests.rs b/crates/control/src/sync_engine/tests.rs index fd884d79..eb3db217 100644 --- a/crates/control/src/sync_engine/tests.rs +++ b/crates/control/src/sync_engine/tests.rs @@ -845,6 +845,45 @@ fn the_replay_gate_holds_every_request_until_replay_reports_complete() { assert!(drive(&mut e, now).is_some(), "and released by the report, not by a clock"); } +/// Intermediate observations do not complete replay. The Status following +/// ReplayComplete establishes where network requests resume. +#[test] +fn replay_completion_determines_where_requests_resume() { + let now = Instant::now(); + let mut e = engine_awaiting_replay(); + peer_status(&mut e, PEER, HEAD_ROOT, 200); + local_status(&mut e, 0, 200); + advance(&mut e); + + local_status(&mut e, 60, 200); + assert!(actions(&mut e, now, true).is_empty(), "the gate holds every request"); + + e.on_replay_complete(); + local_status(&mut e, 100, 200); + + let (_, start, _) = drive(&mut e, now).expect("requests open after replay"); + assert_eq!(tail(&e), 100, "the completion Status is the floor"); + assert_eq!(start, 101, "so fetching resumes above it"); +} + +/// While syncing, import progress and range coverage advance independently. +/// Following uses a different policy: Status can move the tail to the head. +#[test] +fn a_repeated_local_status_while_syncing_leaves_the_window_where_it_is() { + let mut e = engine(); + peer_status(&mut e, PEER, HEAD_ROOT, 200); + local_status(&mut e, 50, 200); + advance(&mut e); + let before = (tail(&e), e.ctx.local.head_imported_slot); + + local_status(&mut e, 50, 200); + assert_eq!((tail(&e), e.ctx.local.head_imported_slot), before); + + local_status(&mut e, 60, 200); + assert_eq!(e.ctx.local.head_imported_slot, 60, "a moved head moves the watermark"); + assert_eq!(tail(&e), before.0, "with nothing covered, the tail stays"); +} + /// The columns tile refuses to acknowledge data availability at or below /// what finalization already settles, so the engine must not ask for it /// there: the columns would arrive, go unacknowledged, and hold the tail on diff --git a/crates/discovery/src/discv5.rs b/crates/discovery/src/discv5.rs index d863c96c..21586383 100644 --- a/crates/discovery/src/discv5.rs +++ b/crates/discovery/src/discv5.rs @@ -1633,6 +1633,29 @@ mod tests { assert_eq!(d.local_enr.seq(), seq_after); } + /// A sequence bump advertises a changed ENR to peers. Repeating the same + /// fork ID should leave the record and its sequence unchanged. + #[test] + fn update_enr_fork_id_leaves_the_enr_untouched_when_it_already_says_that() { + let digest = [0x01, 0x02, 0x03, 0x04u8]; + let sk = SecretKey::new(&mut rand::thread_rng()); + let mut eth2 = [0u8; 16]; + eth2[..4].copy_from_slice(&digest); + eth2[4..8].copy_from_slice(&digest); + eth2[8..].copy_from_slice(&u64::MAX.to_le_bytes()); + let mut enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(20100u16).build(&sk).unwrap(); + enr.set_eth2(eth2, &sk).unwrap(); + let mut d = DiscV5::new(DiscoveryConfig::default(), sk, enr, digest); + + let (seq, raw) = (d.local_enr.seq(), d.local_enr_raw.clone()); + d.update_enr_fork_id(eth2); + + assert_eq!(d.local_enr.seq(), seq, "no sequence bump"); + assert_eq!(d.local_enr_raw, raw, "the signed record is byte-identical"); + assert_eq!(d.fork_digest, digest); + assert!(d.previous_fork_digest.is_none(), "nothing was superseded"); + } + #[test] fn test_nodes_fork_digest_filter() { let now = Instant::now(); diff --git a/crates/storage/src/store/tests.rs b/crates/storage/src/store/tests.rs index 5878c5c7..0835a2a2 100644 --- a/crates/storage/src/store/tests.rs +++ b/crates/storage/src/store/tests.rs @@ -86,6 +86,30 @@ fn concurrent_read_write() { } } +#[test] +fn update_head_assigns_the_head_but_advances_finalization_only_forward() { + let store_path = format!("/tmp/test_store_update_head_{}", rand::random::()); + let _ = std::fs::remove_dir_all(&store_path); + let mut store = load_fulu(store_path.clone()); + + store.update_head(64, [0xAA; 32], 32, [0x11; 32]); + assert_eq!((store.head.slot, store.head.root), (64, [0xAA; 32])); + assert_eq!((store.head.finalized_slot, store.head.finalized_root), (32, [0x11; 32])); + + store.update_head(65, [0xBB; 32], 32, [0x11; 32]); + assert_eq!((store.head.slot, store.head.root), (65, [0xBB; 32]), "the head still moves"); + assert_eq!( + (store.head.finalized_slot, store.head.finalized_root), + (32, [0x11; 32]), + "a repeated finalization changes nothing" + ); + + store.update_head(96, [0xCC; 32], 64, [0x33; 32]); + assert_eq!((store.head.finalized_slot, store.head.finalized_root), (64, [0x33; 32])); + + let _ = std::fs::remove_dir_all(&store_path); +} + #[test] fn fork_tree_persist_serve_promote() { use silver_common::{ diff --git a/crates/storage/src/tile.rs b/crates/storage/src/tile.rs index 9d6fcd5a..e8f933e6 100644 --- a/crates/storage/src/tile.rs +++ b/crates/storage/src/tile.rs @@ -217,6 +217,28 @@ impl StorageTile { } impl StorageTile { + /// Defer checkpoint scheduling until the head is near the wall clock. + /// Record the scheduled epoch so repeated observations do not schedule it + /// again. + fn on_status(&mut self, ssz: &[u8; 92], wall_slot: u64) { + self.wall_slot = wall_slot; + let head_slot = StatusView::head_slot(ssz); + let finalized_epoch = StatusView::finalized_epoch(ssz); + self.store.update_head( + head_slot, + *StatusView::head_root(ssz), + finalized_epoch * SLOTS_PER_EPOCH, + *StatusView::finalized_root(ssz), + ); + + if finalized_epoch > self.checkpointed_epoch && + head_slot + CAUGHT_UP_SLACK_SLOTS >= wall_slot + { + self.checkpointed_epoch = finalized_epoch; + self.persist_pending = true; + } + } + #[timed] fn handle_beacon_state_event( &mut self, @@ -389,24 +411,7 @@ impl Tile for StorageTile { }); if let Some((ssz, wall_slot)) = latest_status_event { - self.wall_slot = wall_slot; - let head_slot = StatusView::head_slot(&ssz); - let head_root = *StatusView::head_root(&ssz); - let finalized_epoch = StatusView::finalized_epoch(&ssz); - let finalized_root = *StatusView::finalized_root(&ssz); - self.store.update_head( - head_slot, - head_root, - finalized_epoch * SLOTS_PER_EPOCH, - finalized_root, - ); - - if finalized_epoch > self.checkpointed_epoch && - head_slot + CAUGHT_UP_SLACK_SLOTS >= wall_slot - { - self.checkpointed_epoch = finalized_epoch; - self.persist_pending = true; - } + self.on_status(&ssz, wall_slot); } adapter.consume(|sync_update: SyncUpdate, _| self.store.sync_update(sync_update)); @@ -498,6 +503,79 @@ mod tests { b } + fn status_ssz(head_slot: u64, finalized_epoch: u64) -> [u8; 92] { + let mut ssz = [0u8; 92]; + ssz[36..44].copy_from_slice(&finalized_epoch.to_le_bytes()); + ssz[44..76].copy_from_slice(&[0xAB; 32]); + ssz[76..84].copy_from_slice(&head_slot.to_le_bytes()); + ssz + } + + fn empty_tile(store_dir: &str) -> StorageTile { + let pg = TCache::producer("st_pg", 1 << 16); + let rpc = TCache::producer("st_rpc", 1 << 16); + let pr = TCache::producer("st_pr", 1 << 16); + let el = TCache::producer("st_el", 1 << 16); + StorageTile::new( + pg.cache_ref().random_access("st_pg", true).unwrap(), + rpc.cache_ref().random_access("st_rpc", true).unwrap(), + pr.cache_ref().random_access("st_pr", true).unwrap(), + el.cache_ref().random_access("st_el", true).unwrap(), + TCache::multi_producer("st_rpc_out", 1 << 16), + TCache::producer("st_replay_out", 1 << 16), + BeaconStateOwner::empty_test(0).reader(), + 0, + Arc::new(SpecConfig::mainnet()), + store_dir.to_string(), + true, + ) + } + + /// Checks scheduling deduplication; no checkpoint write is performed here. + #[test] + fn a_repeated_status_arms_no_second_checkpoint_persist() { + let store_dir = format!("/tmp/test_storage_status_{}", rand::random::()); + let _ = std::fs::remove_dir_all(&store_dir); + let mut tile = empty_tile(&store_dir); + + tile.on_status(&status_ssz(96, 2), 96); + assert!(tile.persist_pending, "a finalization not yet scheduled"); + assert_eq!(tile.checkpointed_epoch, 2); + + tile.persist_pending = false; + tile.on_status(&status_ssz(96, 2), 96); + assert!(!tile.persist_pending, "the same finalization was already scheduled"); + assert_eq!(tile.checkpointed_epoch, 2); + + tile.on_status(&status_ssz(128, 3), 128); + assert!(tile.persist_pending, "an advanced finalization arms the next one"); + assert_eq!(tile.checkpointed_epoch, 3); + + let _ = std::fs::remove_dir_all(&store_dir); + } + + /// Deferring a checkpoint must leave it eligible when the head catches up, + /// even if finalization has not advanced again. + #[test] + fn a_status_whose_head_lags_the_wall_clock_arms_no_persist() { + let store_dir = format!("/tmp/test_storage_lag_{}", rand::random::()); + let _ = std::fs::remove_dir_all(&store_dir); + let mut tile = empty_tile(&store_dir); + + tile.on_status(&status_ssz(96, 2), 96 + CAUGHT_UP_SLACK_SLOTS + 1); + assert!(!tile.persist_pending); + assert_eq!(tile.checkpointed_epoch, 0, "the deferred epoch is not recorded as scheduled"); + + tile.on_status(&status_ssz(96, 2), 96); + assert!( + tile.persist_pending, + "caught up at the same finalization, the checkpoint is not lost" + ); + assert_eq!(tile.checkpointed_epoch, 2); + + let _ = std::fs::remove_dir_all(&store_dir); + } + #[test] fn replay_skips_blocks_missing_custody_columns() { // Checkpoint at slot 32 plus three unfinalized blocks above it. Custody From c6420a74f9e78783cc46b4b3c4f79a8f9e6ba1d5 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 9 Sep 2026 11:27:53 +0100 Subject: [PATCH 04/13] Serve head_v2 events over SSE Add head_v2 alongside head and block. Status carries fork choice's empty/full resolution of the selected block's payload, and publishes an updated observation when that resolution changes. The application boundary publishes head_v2 when the root, optimism or payload resolution changes, including both empty-to-full and full-to-empty transitions. Legacy head retains its existing filter. Render the versioned response using the configured fork at the head block's slot. Reuse the existing dependent roots under the v2 field names. The added resolution field fits existing padding, leaving BeaconStateEvent at 240 bytes. Tests cover resolution and validation changes, slot and PTC effects, repeat suppression, fork-version selection, JSON mapping, and topic isolation through socket delivery. The slot test injects votes early to isolate the previous-slot rule; envelope and PTC tests bypass gossip validation. Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:GPT-6 --- crates/application_boundary/src/lib.rs | 11 +- .../application_boundary/src/observed_head.rs | 174 ++++++++++---- crates/application_boundary/tests/tile.rs | 115 ++++++++-- crates/beacon_api/src/events.rs | 24 +- crates/beacon_api/src/json.rs | 59 ++++- crates/beacon_api/src/routes.rs | 2 + crates/beacon_api/src/server.rs | 105 ++++++++- .../beacon_state/tile/src/fork_choice/head.rs | 6 + .../beacon_state/tile/src/fork_choice/mod.rs | 4 +- .../tile/src/fork_choice/tests.rs | 90 ++++++++ crates/beacon_state/tile/src/tile.rs | 35 ++- crates/beacon_state/tile/src/tile/tests.rs | 215 ++++++++++++++++-- crates/columns/src/tile.rs | 5 +- crates/common/src/lib.rs | 6 +- crates/common/src/spine.rs | 8 +- crates/common/src/spine/messages.rs | 20 ++ crates/e2e/src/bin/da_replay.rs | 5 +- docs/adr/0004-sync-materialized-api.md | 13 +- 18 files changed, 789 insertions(+), 108 deletions(-) diff --git a/crates/application_boundary/src/lib.rs b/crates/application_boundary/src/lib.rs index 0f32d71c..56f06f9b 100644 --- a/crates/application_boundary/src/lib.rs +++ b/crates/application_boundary/src/lib.rs @@ -11,7 +11,7 @@ use silver_config::EngineConfig; use silver_engine_api::EngineApi; use silver_httpcore::{Bind, Readiness, TokenRange}; -use crate::observed_head::ObservedHead; +use crate::observed_head::{HeadChange, ObservedHead}; mod observed_head; @@ -99,17 +99,22 @@ impl ApplicationBoundaryTile { wall_slot, head_optimistic, head_roots, + head_payload, .. } => { beacon.node_status_mut().slots = Some(SlotStatus { head_slot: latest_block_slot, wall_slot, head_optimistic }); - if let Some(event) = head.observe( + if let Some(HeadChange { event, legacy }) = head.observe( StatusView::head_slot(&ssz), *StatusView::head_root(&ssz), head_optimistic, + head_payload, head_roots, ) { - beacon.publish_head(&event); + if legacy { + beacon.publish_head(&event); + } + beacon.publish_head_v2(&event); } } BeaconStateEvent::BlockReceived { diff --git a/crates/application_boundary/src/observed_head.rs b/crates/application_boundary/src/observed_head.rs index 0b1229fb..5a74168e 100644 --- a/crates/application_boundary/src/observed_head.rs +++ b/crates/application_boundary/src/observed_head.rs @@ -1,15 +1,24 @@ use silver_beacon_api::HeadEvent; use silver_beacon_state_data::{B256, Epoch, SLOTS_PER_EPOCH}; -use silver_common::HeadRoots; +use silver_common::{HeadRoots, PayloadResolution}; /// The last complete observation, including the initial unpublished baseline. #[derive(Clone, Copy)] struct Reported { root: B256, optimistic: bool, + payload: PayloadResolution, epoch: Epoch, } +/// Every change publishes to `head_v2`; `legacy` also selects `head` when +/// the root or optimism changed. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct HeadChange { + pub(crate) event: HeadEvent, + pub(crate) legacy: bool, +} + /// Tracks complete head observations even when no subscribers are connected. #[derive(Default)] pub(crate) struct ObservedHead { @@ -24,28 +33,34 @@ impl ObservedHead { slot: u64, root: B256, optimistic: bool, + payload: PayloadResolution, roots: HeadRoots, - ) -> Option { + ) -> Option { if !roots.is_complete() { return None; } let epoch = slot / SLOTS_PER_EPOCH; - let previous = self.reported.replace(Reported { root, optimistic, epoch })?; - if previous.root == root && previous.optimistic == optimistic { + let previous = self.reported.replace(Reported { root, optimistic, payload, epoch })?; + let legacy = previous.root != root || previous.optimistic != optimistic; + if !legacy && previous.payload == payload { return None; } - Some(HeadEvent { + let event = HeadEvent { slot, block_root: root, roots, + payload, epoch_transition: epoch > previous.epoch, execution_optimistic: optimistic, - }) + }; + Some(HeadChange { event, legacy }) } } #[cfg(test)] mod tests { + use PayloadResolution::{Empty, Full}; + use super::*; const HEAD: B256 = [0x11; 32]; @@ -59,14 +74,47 @@ mod tests { } } - fn reported(event: Option) -> Option<(u64, B256, HeadRoots, bool, bool)> { - event.map(|e| (e.slot, e.block_root, e.roots, e.epoch_transition, e.execution_optimistic)) + fn event( + slot: u64, + root: B256, + tag: u8, + payload: PayloadResolution, + epoch_transition: bool, + optimistic: bool, + ) -> HeadEvent { + HeadEvent { + slot, + block_root: root, + roots: roots(tag), + payload, + epoch_transition, + execution_optimistic: optimistic, + } + } + + fn both_topics(event: HeadEvent) -> Option { + Some(HeadChange { event, legacy: true }) } - fn observed_at(slot: u64, root: B256, optimistic: bool) -> ObservedHead { + fn v2_only(event: HeadEvent) -> Option { + Some(HeadChange { event, legacy: false }) + } + + fn observed_at( + slot: u64, + root: B256, + optimistic: bool, + payload: PayloadResolution, + ) -> ObservedHead { let mut head = ObservedHead::default(); - assert!(head.observe(slot, root, optimistic, roots(0x30)).is_none(), "baseline only"); - assert!(head.observe(slot, root, optimistic, roots(0x30)).is_none(), "and its repeat"); + assert!( + head.observe(slot, root, optimistic, payload, roots(0x30)).is_none(), + "baseline only" + ); + assert!( + head.observe(slot, root, optimistic, payload, roots(0x30)).is_none(), + "and its repeat" + ); head } @@ -75,74 +123,118 @@ mod tests { #[test] fn an_incomplete_status_neither_reports_nor_baselines() { let mut head = ObservedHead::default(); - assert!(head.observe(0, [0u8; 32], true, HeadRoots::default()).is_none()); - assert!(head.observe(0, [0u8; 32], true, HeadRoots::default()).is_none(), "repeat"); + assert!(head.observe(0, [0u8; 32], true, Empty, HeadRoots::default()).is_none()); + assert!(head.observe(0, [0u8; 32], true, Empty, HeadRoots::default()).is_none(), "repeat"); - assert!(head.observe(40, HEAD, true, roots(0x30)).is_none()); + assert!(head.observe(40, HEAD, true, Full, roots(0x30)).is_none()); assert_eq!( - reported(head.observe(72, OTHER, true, roots(0x40))), - Some((72, OTHER, roots(0x40), true, true)), + head.observe(72, OTHER, true, Full, roots(0x40)), + both_topics(event(72, OTHER, 0x40, Full, true, true)), "epoch 2 against the epoch-1 baseline, not against epoch 0" ); } #[test] fn a_status_repeating_the_same_head_reports_nothing() { - let mut head = observed_at(40, HEAD, true); - assert!(head.observe(40, HEAD, true, roots(0x50)).is_none(), "other fields changed"); - assert!(head.observe(40, HEAD, true, roots(0x50)).is_none()); + let mut head = observed_at(40, HEAD, true, Full); + assert!(head.observe(40, HEAD, true, Full, roots(0x50)).is_none(), "other fields changed"); + assert!(head.observe(40, HEAD, true, Full, roots(0x50)).is_none()); } #[test] fn a_validated_head_reports_once_with_no_epoch_transition() { - let mut head = observed_at(40, HEAD, true); + let mut head = observed_at(40, HEAD, true, Full); assert_eq!( - reported(head.observe(40, HEAD, false, roots(0x30))), - Some((40, HEAD, roots(0x30), false, false)) + head.observe(40, HEAD, false, Full, roots(0x30)), + both_topics(event(40, HEAD, 0x30, Full, false, false)) ); - assert!(head.observe(40, HEAD, false, roots(0x30)).is_none(), "repeat"); + assert!(head.observe(40, HEAD, false, Full, roots(0x30)).is_none(), "repeat"); + } + + #[test] + fn a_payload_resolution_change_alone_reaches_only_head_v2() { + let mut head = observed_at(40, HEAD, true, Empty); + assert_eq!( + head.observe(40, HEAD, true, Full, roots(0x30)), + v2_only(event(40, HEAD, 0x30, Full, false, true)) + ); + assert!(head.observe(40, HEAD, true, Full, roots(0x30)).is_none(), "repeat"); + + assert_eq!( + head.observe(40, HEAD, true, Empty, roots(0x30)), + v2_only(event(40, HEAD, 0x30, Empty, false, true)), + "the reverse transition is published too" + ); + assert!(head.observe(40, HEAD, true, Empty, roots(0x30)).is_none(), "repeat"); + } + + #[test] + fn a_change_in_every_dimension_is_one_event_for_both_topics() { + let mut head = observed_at(40, HEAD, true, Empty); + assert_eq!( + head.observe(41, OTHER, false, Full, roots(0x40)), + both_topics(event(41, OTHER, 0x40, Full, false, false)) + ); + assert!(head.observe(41, OTHER, false, Full, roots(0x40)).is_none(), "repeat"); } #[test] fn a_head_change_inside_one_epoch_reports_no_transition() { - let mut head = observed_at(40, HEAD, true); + let mut head = observed_at(40, HEAD, true, Full); assert_eq!( - reported(head.observe(41, OTHER, true, roots(0x40))), - Some((41, OTHER, roots(0x40), false, true)) + head.observe(41, OTHER, true, Full, roots(0x40)), + both_topics(event(41, OTHER, 0x40, Full, false, true)) ); - assert!(head.observe(41, OTHER, true, roots(0x40)).is_none(), "repeat"); + assert!(head.observe(41, OTHER, true, Full, roots(0x40)).is_none(), "repeat"); } #[test] fn a_head_change_into_a_later_epoch_reports_the_transition() { - let mut head = observed_at(40, HEAD, true); + let mut head = observed_at(40, HEAD, true, Full); assert_eq!( - reported(head.observe(64, OTHER, true, roots(0x40))), - Some((64, OTHER, roots(0x40), true, true)) + head.observe(64, OTHER, true, Full, roots(0x40)), + both_topics(event(64, OTHER, 0x40, Full, true, true)) ); - assert!(head.observe(64, OTHER, true, roots(0x40)).is_none(), "repeat"); + assert!(head.observe(64, OTHER, true, Full, roots(0x40)).is_none(), "repeat"); } #[test] fn a_head_change_into_an_earlier_epoch_reports_no_transition() { - let mut head = observed_at(64, HEAD, true); + let mut head = observed_at(64, HEAD, true, Full); assert_eq!( - reported(head.observe(40, OTHER, true, roots(0x40))), - Some((40, OTHER, roots(0x40), false, true)) + head.observe(40, OTHER, true, Full, roots(0x40)), + both_topics(event(40, OTHER, 0x40, Full, false, true)) ); - assert!(head.observe(40, OTHER, true, roots(0x40)).is_none(), "repeat"); + assert!(head.observe(40, OTHER, true, Full, roots(0x40)).is_none(), "repeat"); } + /// The transition flag belongs to the observation that crossed the epoch, + /// not to later updates of the same head. #[test] - fn a_validation_right_after_an_epoch_change_reports_no_transition() { - let mut head = observed_at(40, HEAD, true); + fn updates_right_after_an_epoch_change_report_no_transition() { + let mut head = observed_at(40, HEAD, true, Empty); assert_eq!( - reported(head.observe(64, OTHER, true, roots(0x40))), - Some((64, OTHER, roots(0x40), true, true)) + head.observe(64, OTHER, true, Empty, roots(0x40)), + both_topics(event(64, OTHER, 0x40, Empty, true, true)) ); assert_eq!( - reported(head.observe(64, OTHER, false, roots(0x40))), - Some((64, OTHER, roots(0x40), false, false)) + head.observe(64, OTHER, true, Full, roots(0x40)), + v2_only(event(64, OTHER, 0x40, Full, false, true)) + ); + assert_eq!( + head.observe(64, OTHER, false, Full, roots(0x40)), + both_topics(event(64, OTHER, 0x40, Full, false, false)) + ); + } + + #[test] + fn an_incomplete_snapshot_between_complete_ones_keeps_the_earlier_baseline() { + let mut head = observed_at(40, HEAD, true, Empty); + assert!(head.observe(41, OTHER, false, Full, HeadRoots::default()).is_none()); + assert_eq!( + head.observe(40, HEAD, true, Full, roots(0x30)), + v2_only(event(40, HEAD, 0x30, Full, false, true)), + "only the payload differs from the last complete observation" ); } } diff --git a/crates/application_boundary/tests/tile.rs b/crates/application_boundary/tests/tile.rs index 9fe12840..8bb6beb3 100644 --- a/crates/application_boundary/tests/tile.rs +++ b/crates/application_boundary/tests/tile.rs @@ -13,8 +13,8 @@ use silver_beacon_api::SlotStatus; use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; use silver_common::{ BeaconStateEvent, BlockSource, BlockStage, ELSyncStatus, EngineFcuReq, EngineReq, EngineResp, - Enr, HeadRoots, Identify, Keypair, PayloadValidationStatus, SilverSpine, SyncUpdate, TCache, - TCacheProducer, ssz_view::STATUS_V2_SIZE, + Enr, HeadRoots, Identify, Keypair, PayloadResolution, PayloadValidationStatus, SilverSpine, + SyncUpdate, TCache, TCacheProducer, ssz_view::STATUS_V2_SIZE, }; use silver_config::EngineConfig; use silver_engine_api::test_el::{FCU_VALID_RESULT, FakeEl, write_jwt}; @@ -190,6 +190,7 @@ fn status_event(head_slot: u64, wall_slot: u64, head_optimistic: bool) -> Beacon wall_slot, enr_fork_id: [0u8; 16], head_roots: HeadRoots::default(), + head_payload: PayloadResolution::Full, } } @@ -201,7 +202,12 @@ fn head_roots() -> HeadRoots { } } -fn head_status(slot: u64, block_root: u8, head_optimistic: bool) -> BeaconStateEvent { +fn head_status( + slot: u64, + block_root: u8, + head_optimistic: bool, + head_payload: PayloadResolution, +) -> BeaconStateEvent { let mut ssz = [0u8; STATUS_V2_SIZE]; ssz[44..76].copy_from_slice(&[block_root; 32]); ssz[76..84].copy_from_slice(&slot.to_le_bytes()); @@ -212,9 +218,17 @@ fn head_status(slot: u64, block_root: u8, head_optimistic: bool) -> BeaconStateE wall_slot: slot, enr_fork_id: [0u8; 16], head_roots: head_roots(), + head_payload, } } +fn chunked(data: &str) -> Vec { + let mut frame = format!("{:x}\r\n", data.len()).into_bytes(); + frame.extend_from_slice(data.as_bytes()); + frame.extend_from_slice(b"\r\n"); + frame +} + fn head_frame(slot: u64, block_root: u8, execution_optimistic: bool) -> Vec { let data = format!( "event: head\ndata: {{\"slot\":\"{slot}\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"epoch_transition\":false,\"previous_duty_dependent_root\":\"0x{}\",\"current_duty_dependent_root\":\"0x{}\",\"execution_optimistic\":{execution_optimistic}}}\n\n", @@ -223,10 +237,24 @@ fn head_frame(slot: u64, block_root: u8, execution_optimistic: bool) -> Vec "5e".repeat(32), "91".repeat(32), ); - let mut frame = format!("{:x}\r\n", data.len()).into_bytes(); - frame.extend_from_slice(data.as_bytes()); - frame.extend_from_slice(b"\r\n"); - frame + chunked(&data) +} + +fn head_v2_frame( + slot: u64, + block_root: u8, + version: &str, + payload_status: &str, + execution_optimistic: bool, +) -> Vec { + let data = format!( + "event: head_v2\ndata: {{\"version\":\"{version}\",\"data\":{{\"slot\":\"{slot}\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"payload_status\":\"{payload_status}\",\"epoch_transition\":false,\"current_epoch_dependent_root\":\"0x{}\",\"next_epoch_dependent_root\":\"0x{}\",\"execution_optimistic\":{execution_optimistic}}}}}\n\n", + hex::encode([block_root; 32]), + "60".repeat(32), + "5e".repeat(32), + "91".repeat(32), + ); + chunked(&data) } #[test] @@ -830,11 +858,11 @@ fn an_optimistic_then_validated_head_reaches_an_events_subscriber() { // Establish a baseline, then change the head and validate it. // Repeated observations between those changes produce no frames. - inj.produce(head_status(33, 0x0a, true)); - inj.produce(head_status(33, 0x0a, true)); - inj.produce(head_status(40, 0xab, true)); - inj.produce(head_status(40, 0xab, true)); - inj.produce(head_status(40, 0xab, false)); + inj.produce(head_status(33, 0x0a, true, PayloadResolution::Full)); + inj.produce(head_status(33, 0x0a, true, PayloadResolution::Full)); + inj.produce(head_status(40, 0xab, true, PayloadResolution::Full)); + inj.produce(head_status(40, 0xab, true, PayloadResolution::Full)); + inj.produce(head_status(40, 0xab, false, PayloadResolution::Full)); while !client.is_finished() { crank(&mut tile, "both head frames reach the subscriber"); } @@ -854,6 +882,65 @@ fn an_optimistic_then_validated_head_reaches_an_events_subscriber() { ); } +/// A payload resolution change is visible to `head_v2` alone; the later +/// validation reaches both topics. +#[test] +fn a_payload_resolution_change_reaches_head_v2_but_not_head() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ + "cs_head_v2_gossip", + "cs_head_v2_rpc", + "cs_head_v2_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + tile.loop_body(&mut adapter); + + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; + let fulu = SpecConfig::mainnet().fulu_fork_epoch * 32; + let legacy_expected = + [head_frame(fulu + 8, 0xab, true), head_frame(fulu + 8, 0xab, false)].concat(); + let v2_expected = [ + head_v2_frame(fulu + 8, 0xab, "fulu", "empty", true), + head_v2_frame(fulu + 8, 0xab, "fulu", "full", true), + head_v2_frame(fulu + 8, 0xab, "fulu", "full", false), + ] + .concat(); + let (legacy, legacy_subscribed) = events_subscriber(addr, "head", legacy_expected.len()); + let (v2, v2_subscribed) = events_subscriber(addr, "head_v2", v2_expected.len()); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ApplicationBoundaryTile, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + std::thread::sleep(Duration::from_millis(1)); + }; + for subscribed in [legacy_subscribed, v2_subscribed] { + while subscribed.try_recv().is_err() { + crank(&mut tile, "both stream heads reach their subscribers"); + } + } + + inj.produce(head_status(fulu + 1, 0x0a, true, PayloadResolution::Full)); + inj.produce(head_status(fulu + 8, 0xab, true, PayloadResolution::Empty)); + inj.produce(head_status(fulu + 8, 0xab, true, PayloadResolution::Full)); + inj.produce(head_status(fulu + 8, 0xab, false, PayloadResolution::Full)); + while !legacy.is_finished() || !v2.is_finished() { + crank(&mut tile, "every frame reaches its subscriber"); + } + for (got, expected) in + [(legacy.join().unwrap(), legacy_expected), (v2.join().unwrap(), v2_expected)] + { + assert!( + got == expected, + "\n got: {:?}\nexpected: {:?}", + String::from_utf8_lossy(&got), + String::from_utf8_lossy(&expected) + ); + } +} + /// The initial head observation emits no event but still updates node status. #[test] fn node_status_optimism_follows_a_status_that_publishes_no_head_event() { @@ -868,14 +955,14 @@ fn node_status_optimism_follows_a_status_that_publishes_no_head_event() { let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); tile.loop_body(&mut adapter); - inj.produce(head_status(32, 0x0a, true)); + inj.produce(head_status(32, 0x0a, true, PayloadResolution::Full)); tile.loop_body(&mut adapter); assert_eq!( tile.beacon.node_status_mut().slots, Some(SlotStatus { head_slot: 32, wall_slot: 32, head_optimistic: true }) ); - inj.produce(head_status(32, 0x0a, false)); + inj.produce(head_status(32, 0x0a, false, PayloadResolution::Full)); tile.loop_body(&mut adapter); assert_eq!( tile.beacon.node_status_mut().slots, diff --git a/crates/beacon_api/src/events.rs b/crates/beacon_api/src/events.rs index 23cdf009..84050d67 100644 --- a/crates/beacon_api/src/events.rs +++ b/crates/beacon_api/src/events.rs @@ -1,7 +1,7 @@ use std::io::Write; use silver_beacon_state_data::B256; -use silver_common::HeadRoots; +use silver_common::{HeadRoots, PayloadResolution}; use silver_httpcore::Query; use crate::{response::Response, router::Request, routes::ApiCtx}; @@ -16,14 +16,17 @@ pub(crate) const KEEP_ALIVE: &[u8] = b": keep-alive\n\n"; pub(crate) enum Channel { Block, Head, + HeadV2, } /// `epoch_transition` compares this head with the publisher's previous -/// complete observation. +/// complete observation. Only `head_v2` renders `payload`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct HeadEvent { pub slot: u64, pub block_root: B256, pub roots: HeadRoots, + pub payload: PayloadResolution, pub epoch_transition: bool, pub execution_optimistic: bool, } @@ -82,6 +85,7 @@ fn channel(topic: &str) -> Option { match topic { "block" => Some(Channel::Block), "head" => Some(Channel::Head), + "head_v2" => Some(Channel::HeadV2), _ => None, } } @@ -157,12 +161,22 @@ mod tests { assert_eq!(topics("topics=head&topics=block"), Ok(set(&[Channel::Block, Channel::Head]))); } + #[test] + fn head_v2_is_served_alone_and_alongside_the_other_topics() { + let all = set(&[Channel::Block, Channel::Head, Channel::HeadV2]); + assert_eq!(topics("topics=head_v2"), Ok(set(&[Channel::HeadV2]))); + assert_eq!(topics("topics=head_v2,head_v2"), Ok(set(&[Channel::HeadV2]))); + assert_eq!(topics("topics=head,head_v2"), Ok(set(&[Channel::Head, Channel::HeadV2]))); + assert_eq!(topics("topics=block,head,head_v2"), Ok(all)); + assert_eq!(topics("topics=head_v2&topics=block&topics=head"), Ok(all)); + } + #[test] fn a_topic_silver_does_not_serve_refuses_the_whole_subscription_by_name() { let unknown = |topic: &str| Err(Refused::Unknown(topic.to_string())); - assert_eq!(topics("topics=head_v2"), unknown("head_v2")); - assert_eq!(topics("topics=block,head_v2"), unknown("head_v2")); - assert_eq!(topics("topics=head&topics=chain_reorg"), unknown("chain_reorg")); + assert_eq!(topics("topics=finalized_checkpoint"), unknown("finalized_checkpoint")); + assert_eq!(topics("topics=block,finalized_checkpoint"), unknown("finalized_checkpoint")); + assert_eq!(topics("topics=head_v2&topics=chain_reorg"), unknown("chain_reorg")); } #[test] diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index d6b96ca3..bf8fe3b0 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -252,6 +252,33 @@ impl Json<'_> { self.end_object(); } + /// `version` names the fork in force at the head block's slot. + pub(crate) fn head_v2_event(&mut self, head: &HeadEvent, version: &str) { + self.begin_object(); + self.key("version"); + self.string(version); + self.key("data"); + self.begin_object(); + self.key("slot"); + self.quoted_u64(head.slot); + self.key("block"); + self.hex(&head.block_root); + self.key("state"); + self.hex(&head.roots.state_root); + self.key("payload_status"); + self.string(head.payload.name()); + self.key("epoch_transition"); + self.bool(head.epoch_transition); + self.key("current_epoch_dependent_root"); + self.hex(&head.roots.previous_duty_dependent_root); + self.key("next_epoch_dependent_root"); + self.hex(&head.roots.current_duty_dependent_root); + self.key("execution_optimistic"); + self.bool(head.execution_optimistic); + self.end_object(); + self.end_object(); + } + pub(crate) fn finality_checkpoints(&mut self, checkpoints: &FinalityCheckpoints) { self.begin_object(); self.key("previous_justified"); @@ -274,7 +301,7 @@ pub(crate) fn json_safe(text: &str) -> bool { #[cfg(test)] mod tests { use silver_beacon_state_data::FAR_FUTURE_EPOCH; - use silver_common::HeadRoots; + use silver_common::{HeadRoots, PayloadResolution}; use super::*; @@ -514,6 +541,7 @@ mod tests { previous_duty_dependent_root: [0x5e; 32], current_duty_dependent_root: [0x91; 32], }, + payload: PayloadResolution::Full, epoch_transition: true, execution_optimistic: false, }); @@ -527,6 +555,35 @@ mod tests { assert_eq!(String::from_utf8(out).unwrap(), expected); } + /// Distinct dependent roots catch a swap in the renamed fields. + #[test] + fn head_v2_event_wraps_the_versioned_data_and_maps_the_dependent_roots() { + let mut out = Vec::new(); + Json::new(&mut out).head_v2_event( + &HeadEvent { + slot: 10, + block_root: [0x9a; 32], + roots: HeadRoots { + state_root: [0x60; 32], + previous_duty_dependent_root: [0x5e; 32], + current_duty_dependent_root: [0x91; 32], + }, + payload: PayloadResolution::Empty, + epoch_transition: false, + execution_optimistic: true, + }, + "gloas", + ); + let expected = format!( + "{{\"version\":\"gloas\",\"data\":{{\"slot\":\"10\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"payload_status\":\"empty\",\"epoch_transition\":false,\"current_epoch_dependent_root\":\"0x{}\",\"next_epoch_dependent_root\":\"0x{}\",\"execution_optimistic\":true}}}}", + "9a".repeat(32), + "60".repeat(32), + "5e".repeat(32), + "91".repeat(32), + ); + assert_eq!(String::from_utf8(out).unwrap(), expected); + } + #[test] fn block_event_quotes_the_slot_and_hexes_the_root() { let mut out = Vec::new(); diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index 8222fd54..2bfaffa2 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -70,6 +70,7 @@ pub(crate) const ROUTES: &[(Method, &str, Handler)] = &[ pub(crate) struct ApiCtx { pub(crate) statics: StaticBodies, + pub(crate) spec: SpecConfig, pub(crate) state: BeaconStateReader, pub(crate) node_status: NodeStatus, } @@ -84,6 +85,7 @@ impl ApiCtx { ) -> Self { Self { statics: StaticBodies::new(keypair, local_enr, identify, spec), + spec: spec.clone(), state, node_status: NodeStatus::default(), } diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index e68a0112..2eff9e1f 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -404,6 +404,13 @@ impl BeaconApi { self.publish(Channel::Head, "head", &data); } + pub fn publish_head_v2(&mut self, head: &HeadEvent) { + let version = self.ctx.spec.fork_at_slot(head.slot).name(); + let mut data = Vec::new(); + Json::new(&mut data).head_v2_event(head, version); + self.publish(Channel::HeadV2, "head_v2", &data); + } + fn publish(&mut self, channel: Channel, event: &str, data: &[u8]) { let mut frame = Vec::new(); events::frame(&mut frame, event, data); @@ -600,8 +607,8 @@ mod tests { time::Instant, }; - use silver_beacon_state_data::BeaconStateOwner; - use silver_common::HeadRoots; + use silver_beacon_state_data::{BeaconStateOwner, SLOTS_PER_EPOCH}; + use silver_common::{HeadRoots, PayloadResolution}; use silver_httpcore::Readiness; use super::*; @@ -1411,11 +1418,28 @@ mod tests { previous_duty_dependent_root: [0x5e; 32], current_duty_dependent_root: [0x91; 32], }, + payload: PayloadResolution::Full, epoch_transition: false, execution_optimistic, } } + fn head_v2_frame( + slot: u64, + block_root: &[u8; 32], + version: &str, + execution_optimistic: bool, + ) -> Vec { + let data = format!( + "event: head_v2\ndata: {{\"version\":\"{version}\",\"data\":{{\"slot\":\"{slot}\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"payload_status\":\"full\",\"epoch_transition\":false,\"current_epoch_dependent_root\":\"0x{}\",\"next_epoch_dependent_root\":\"0x{}\",\"execution_optimistic\":{execution_optimistic}}}}}\n\n", + hex::encode(block_root), + "60".repeat(32), + "5e".repeat(32), + "91".repeat(32), + ); + chunk(data.as_bytes()) + } + fn head_frame(slot: u64, block_root: &[u8; 32], execution_optimistic: bool) -> Vec { let data = format!( "event: head\ndata: {{\"slot\":\"{slot}\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"epoch_transition\":false,\"previous_duty_dependent_root\":\"0x{}\",\"current_duty_dependent_root\":\"0x{}\",\"execution_optimistic\":{execution_optimistic}}}\n\n", @@ -1460,6 +1484,79 @@ mod tests { assert_same_bytes(&got_both, &mixed); } + #[test] + fn head_and_head_v2_are_separate_channels() { + let mut server = server_with(64, LONG_TIMEOUT); + let addr = tcp_addr(&server); + let (mut legacy, mut v2, mut heads, mut all) = + (connect(addr), connect(addr), connect(addr), connect(addr)); + subscribe(&mut legacy, "head"); + subscribe(&mut v2, "head_v2"); + subscribe(&mut heads, "head,head_v2"); + subscribe(&mut all, "block,head,head_v2"); + pump_until(&mut server, "four subscribed", |server| subscribers(server) == 4); + + let slot = SpecConfig::mainnet().fulu_fork_epoch * SLOTS_PER_EPOCH; + let root = [0xab; 32]; + server.api.publish_block(slot, &root); + server.api.publish_head(&head_event(slot, &root, true)); + server.api.publish_head_v2(&head_event(slot, &root, true)); + + let legacy_frames = [SSE_HEAD, &head_frame(slot, &root, true)].concat(); + let v2_frames = [SSE_HEAD, &head_v2_frame(slot, &root, "fulu", true)].concat(); + let head_frames = + [SSE_HEAD, &head_frame(slot, &root, true), &head_v2_frame(slot, &root, "fulu", true)] + .concat(); + let all_frames = [ + SSE_HEAD, + &block_frame(slot, &root), + &head_frame(slot, &root, true), + &head_v2_frame(slot, &root, "fulu", true), + ] + .concat(); + + let readers = [ + read_exactly(legacy, legacy_frames.len()), + read_exactly(v2, v2_frames.len()), + read_exactly(heads, head_frames.len()), + read_exactly(all, all_frames.len()), + ]; + pump_until(&mut server, "every subscriber served", |_| { + readers.iter().all(JoinHandle::is_finished) + }); + let [got_legacy, got_v2, got_heads, got_all] = readers.map(|r| r.join().unwrap()); + + assert_same_bytes(&got_legacy, &legacy_frames); + assert_same_bytes(&got_v2, &v2_frames); + assert_same_bytes(&got_heads, &head_frames); + assert_same_bytes(&got_all, &all_frames); + } + + #[test] + fn head_v2_names_the_fork_at_the_head_slot() { + let mut server = server_with(64, LONG_TIMEOUT); + let mut client = connect(tcp_addr(&server)); + subscribe(&mut client, "head_v2"); + pump_until(&mut server, "subscribed", |server| subscribers(server) == 1); + + let fulu = SpecConfig::mainnet().fulu_fork_epoch * SLOTS_PER_EPOCH; + let root = [0xab; 32]; + for slot in [fulu - 1, fulu, fulu + 1, fulu - 1] { + server.api.publish_head_v2(&head_event(slot, &root, false)); + } + + let expected = [ + SSE_HEAD, + &head_v2_frame(fulu - 1, &root, "electra", false), + &head_v2_frame(fulu, &root, "fulu", false), + &head_v2_frame(fulu + 1, &root, "fulu", false), + &head_v2_frame(fulu - 1, &root, "electra", false), + ] + .concat(); + let got = serve(&mut server, read_exactly(client, expected.len()), "four versioned frames"); + assert_same_bytes(&got, &expected); + } + #[test] fn a_topic_silver_does_not_serve_is_refused_on_an_ordinary_connection() { let mut server = server_with(64, LONG_TIMEOUT); @@ -1468,7 +1565,7 @@ mod tests { let mut stream = connect(addr); write!( stream, - "GET /eth/v1/events?topics=head_v2 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + "GET /eth/v1/events?topics=chain_reorg HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" ) .unwrap(); read_to_eof(stream) @@ -1477,7 +1574,7 @@ mod tests { let got = serve(&mut server, client, "400 for an unserved topic"); assert_same_bytes( &got, - b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: 50\r\n\r\n{\"code\":400,\"message\":\"unknown topic \\\"head_v2\\\"\"}", + b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: 54\r\n\r\n{\"code\":400,\"message\":\"unknown topic \\\"chain_reorg\\\"\"}", ); assert_eq!(subscribers(&server), 0); } diff --git a/crates/beacon_state/tile/src/fork_choice/head.rs b/crates/beacon_state/tile/src/fork_choice/head.rs index 5b4bc5d0..2cff563f 100644 --- a/crates/beacon_state/tile/src/fork_choice/head.rs +++ b/crates/beacon_state/tile/src/fork_choice/head.rs @@ -1,5 +1,6 @@ use flux_profiler::timed; use silver_beacon_state_data::{B256, Epoch, MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH, Slot}; +use silver_common::PayloadResolution; use super::{ExecutionStatus, ForkChoice, GENESIS_EPOCH, NULL, PayloadStatus, node::PTC_SIZE}; @@ -107,6 +108,11 @@ impl ForkChoice { } } + /// Resolves the node's own payload using the same rule as head selection. + pub fn payload_resolution(&self, idx: usize) -> PayloadResolution { + if self.resolves_to_full(idx) { PayloadResolution::Full } else { PayloadResolution::Empty } + } + #[cfg(any(test, feature = "ef_tests"))] pub fn head_payload_present(&self) -> bool { let Some(ji) = self.find_node_idx(&self.justified_checkpoint.root) else { diff --git a/crates/beacon_state/tile/src/fork_choice/mod.rs b/crates/beacon_state/tile/src/fork_choice/mod.rs index d2384523..423376f8 100644 --- a/crates/beacon_state/tile/src/fork_choice/mod.rs +++ b/crates/beacon_state/tile/src/fork_choice/mod.rs @@ -12,8 +12,8 @@ mod tests; mod vote; pub use lookup::NodeLookup; -use node::{Branch, NodeCheckpoints, PayloadAxis, PtcVotes}; -pub use node::{ExecutionStatus, ForkChoiceNode, PayloadStatus}; +use node::{Branch, NodeCheckpoints, PtcVotes}; +pub use node::{ExecutionStatus, ForkChoiceNode, PayloadAxis, PayloadStatus}; pub use vote::{VoteTracker, WeightDelta}; /// Pre-allocation hint only — the node table and the state rings both grow diff --git a/crates/beacon_state/tile/src/fork_choice/tests.rs b/crates/beacon_state/tile/src/fork_choice/tests.rs index 0a187cc6..574800a9 100644 --- a/crates/beacon_state/tile/src/fork_choice/tests.rs +++ b/crates/beacon_state/tile/src/fork_choice/tests.rs @@ -1,4 +1,5 @@ use silver_beacon_state_data::MIN_SEED_LOOKAHEAD; +use silver_common::PayloadResolution; use super::{ vote::{Vote, branch_voted_for}, @@ -1035,6 +1036,95 @@ fn gloas_boost_is_pending_not_empty() { assert!(fc.head_payload_present()); } +fn head_idx(fc: &ForkChoice) -> usize { + fc.find_node_idx(&fc.find_head()).unwrap() +} + +#[test] +fn payload_resolution_follows_the_selected_node() { + let g = cp(0, 1); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 8, + ); + assert_eq!(fc.payload_resolution(head_idx(&fc)), PayloadResolution::Full, "pre-Gloas anchor"); + + fc.on_block(block(1, root(2), root(1), g, g)); + assert_eq!(fc.find_head(), root(2)); + assert_eq!(fc.payload_resolution(head_idx(&fc)), PayloadResolution::Full, "pre-Gloas block"); + + fc.on_block(gloas_block(2, root(3), root(2), g, g, PayloadStatus::Full, false)); + assert_eq!(fc.find_head(), root(3)); + assert_eq!(fc.payload_resolution(head_idx(&fc)), PayloadResolution::Empty, "no envelope yet"); + + fc.mark_payload_verified(&root(3)); + assert_eq!(fc.find_head(), root(3)); + assert_eq!(fc.payload_resolution(head_idx(&fc)), PayloadResolution::Full); +} + +#[test] +fn a_gloas_anchor_resolves_empty_until_its_envelope_is_verified() { + let g = cp(0, 1); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + true, + test_state_id(), + 8, + ); + assert_eq!(fc.payload_resolution(head_idx(&fc)), PayloadResolution::Empty); + + fc.mark_payload_verified(&root(1)); + assert_eq!(fc.payload_resolution(head_idx(&fc)), PayloadResolution::Full); +} + +/// Verification alone does not determine the selected payload resolution. +#[test] +fn a_verified_payload_resolves_empty_when_its_empty_branch_is_heavier() { + let g = cp(0, 1); + let mut fc = ForkChoice::init( + g, + g, + 0, + root(1), + state_root_of(root(1)), + [0u8; 32], + false, + test_state_id(), + 8, + ); + fc.on_block(gloas_block(1, root(2), root(1), g, g, PayloadStatus::Full, true)); + fc.on_block(gloas_block(2, root(3), root(2), g, g, PayloadStatus::Full, true)); + fc.on_block(gloas_block(2, root(4), root(2), g, g, PayloadStatus::Empty, true)); + + let mut d = vec![WeightDelta::default(); fc.nodes.len()]; + d[2].pending = 50; + d[3].pending = 100; + fc.weight_deltas = d; + fc.apply_score_changes(); + assert_eq!(fc.find_head(), root(4)); + + let a = fc.find_node_idx(&root(2)).unwrap(); + assert!(fc.nodes[a].payload.verified); + assert_eq!(fc.payload_resolution(a), PayloadResolution::Empty); + assert_eq!( + fc.payload_resolution(head_idx(&fc)), + PayloadResolution::Full, + "the selected child's own payload is full despite its empty parent edge" + ); +} + /// Two branches meeting at 2@slot 2, with heads level at slot 10 but at /// *different depths* — the left one skips slots 3..9 outright: /// diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index db9a4b01..0a4c6b86 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -12,8 +12,9 @@ use silver_beacon_state_data::{ }; use silver_common::{ BeaconStateEvent, BlockSource, DataColumnsEvent, DataKind, EngineResp, GossipTopic, HeadRoots, - NewGossipMsg, Origin, PayloadValidationStatus, ReplayBlock, RequestId, RpcInbound, RpcResponse, - RpcResponseInbound, SilverSpine, SyncUpdate, TRandomAccess, TRead, hex32, + NewGossipMsg, Origin, PayloadResolution, PayloadValidationStatus, ReplayBlock, RequestId, + RpcInbound, RpcResponse, RpcResponseInbound, SilverSpine, SyncUpdate, TRandomAccess, TRead, + hex32, ssz_view::STATUS_V2_SIZE, ticker::{MAXIMUM_GOSSIP_CLOCK_DISPARITY, SlotTicker, TickEvent}, }; @@ -118,11 +119,20 @@ struct SelectedHead { /// `None` only before the anchor is seeded, where no node is resident. idx: Option, optimistic: bool, + payload: PayloadResolution, +} + +/// Head changes that require a Status even without an import or slot tick. +#[derive(Clone, Copy, PartialEq, Eq)] +struct HeadObservation { + root: B256, + optimistic: bool, + payload: PayloadResolution, } impl SelectedHead { - fn reported(&self) -> (B256, bool) { - (self.root, self.optimistic) + fn observation(&self) -> HeadObservation { + HeadObservation { root: self.root, optimistic: self.optimistic, payload: self.payload } } } @@ -165,7 +175,7 @@ pub struct BeaconStateTile { last_seen_head_root: B256, /// Kept separate from the reorg marker: an early Status must not hide a /// reorg that the end-of-loop check has yet to report. - emitted_head: (B256, bool), + emitted_head: HeadObservation, initial_status_emitted: bool, cached_fork_digest: Option<(Epoch, [u8; 4])>, @@ -243,7 +253,11 @@ impl BeaconStateTile { last_applied_block_root: [0u8; 32], precomputed_epochs: PrecomputedEpochs::default(), last_seen_head_root: [0u8; 32], - emitted_head: ([0u8; 32], true), + emitted_head: HeadObservation { + root: [0u8; 32], + optimistic: true, + payload: PayloadResolution::Empty, + }, initial_status_emitted: false, cached_fork_digest: None, stf_scratch: stf::StfScratch::new(val_cap), @@ -478,7 +492,9 @@ impl BeaconStateTile { let optimistic = idx.is_none_or(|idx| { self.fork_choice.node(idx).execution_status != ExecutionStatus::Valid }); - SelectedHead { root, idx, optimistic } + let payload = + idx.map_or(PayloadResolution::Empty, |idx| self.fork_choice.payload_resolution(idx)); + SelectedHead { root, idx, optimistic, payload } } /// A missing node or overwritten checkpoint history makes the whole @@ -509,6 +525,7 @@ impl BeaconStateTile { head_optimistic: head.optimistic, enr_fork_id: self.enr_fork_id(), head_roots: self.head_roots(head), + head_payload: head.payload, } } @@ -517,7 +534,7 @@ impl BeaconStateTile { } fn publish_selected_head(&mut self, head: SelectedHead, producers: &mut Producers) { - self.emitted_head = head.reported(); + self.emitted_head = head.observation(); let event = self.status_event(head); producers.produce(event); } @@ -525,7 +542,7 @@ impl BeaconStateTile { /// Covers changes since the last Status, including execution verdicts. fn publish_status_on_head_change(&mut self, producers: &mut Producers) { let head = self.selected_head(); - if head.reported() != self.emitted_head { + if head.observation() != self.emitted_head { self.publish_selected_head(head, producers); } } diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index 8f045332..5f3b1cfd 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -11,8 +11,8 @@ use silver_beacon_state_data::{ }; use silver_common::{ BlockStage, EngineNewPayloadResp, GossipTopic, LOCAL_GOSSIP_STREAM_ID, MessageId, P2pStreamId, - PayloadValidationStatus, PeerEvent, StreamProtocol, SyncNeed, TCache, TCacheProducer, - TCacheRead, TProducer, + PayloadResolution, PayloadValidationStatus, PeerEvent, StreamProtocol, SyncNeed, TCache, + TCacheProducer, TCacheRead, TProducer, column_util::block_root_fulu, ssz_view::{ ATTESTATION_DATA_SIZE, AttestationView, BEACON_BLOCK_BODY_FIXED, BYTES_PER_KZG_COMMITMENT, @@ -31,7 +31,7 @@ use super::{ }; use crate::{ error::{PrecheckError, RejectReason}, - fork_choice::{BlockImport, PayloadStatus}, + fork_choice::{BlockImport, PayloadAxis, PayloadStatus}, merkle, ssz_hash, stf::AttestationVote, test_signing, @@ -511,6 +511,7 @@ struct StatusHead { slot: Slot, optimistic: bool, roots: HeadRoots, + payload: PayloadResolution, } struct Published(Vec); @@ -526,14 +527,15 @@ impl Published { self.0 .iter() .filter_map(|event| match event { - BeaconStateEvent::Status { ssz, head_optimistic, head_roots, .. } => { - Some(StatusHead { - root: *StatusView::head_root(ssz), - slot: StatusView::head_slot(ssz), - optimistic: *head_optimistic, - roots: *head_roots, - }) - } + BeaconStateEvent::Status { + ssz, head_optimistic, head_roots, head_payload, .. + } => Some(StatusHead { + root: *StatusView::head_root(ssz), + slot: StatusView::head_slot(ssz), + optimistic: *head_optimistic, + roots: *head_roots, + payload: *head_payload, + }), _ => None, }) .collect() @@ -649,6 +651,62 @@ impl HeadRig { parent_state: StateId, previous: B256, current: B256, + ) -> StateId { + let payload = PayloadAxis { + bid_block_hash: [0u8; 32], + parent_status: PayloadStatus::Full, + verified: true, + is_gloas: false, + }; + self.import_node(block_root, slot, parent_root, parent_state, previous, current, payload) + } + + fn import_gloas( + &mut self, + block_root: B256, + slot: Slot, + previous: B256, + current: B256, + payload_verified: bool, + ) -> StateId { + let payload = PayloadAxis { + bid_block_hash: block_root, + parent_status: PayloadStatus::Full, + verified: payload_verified, + is_gloas: true, + }; + let anchor = self.anchor; + self.import_node(block_root, slot, ANCHOR_ROOT, anchor, previous, current, payload) + } + + /// A verified Gloas block extending `parent_root`'s empty payload. + fn import_empty_child( + &mut self, + block_root: B256, + slot: Slot, + parent_root: B256, + parent_state: StateId, + previous: B256, + current: B256, + ) -> StateId { + let payload = PayloadAxis { + bid_block_hash: block_root, + parent_status: PayloadStatus::Empty, + verified: true, + is_gloas: true, + }; + self.import_node(block_root, slot, parent_root, parent_state, previous, current, payload) + } + + fn import_node( + &mut self, + block_root: B256, + slot: Slot, + parent_root: B256, + parent_state: StateId, + previous: B256, + current: B256, + payload: PayloadAxis, ) -> StateId { let state_id = self.post_state(parent_state, slot, previous, current); let anchor_cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; @@ -663,10 +721,10 @@ impl HeadRig { unrealized_justified: anchor_cp, unrealized_finalized: anchor_cp, state_id, - bid_block_hash: [0u8; 32], - parent_payload_status: PayloadStatus::Full, - payload_verified: true, - is_gloas: false, + bid_block_hash: payload.bid_block_hash, + parent_payload_status: payload.parent_status, + payload_verified: payload.verified, + is_gloas: payload.is_gloas, }); self.tile.last_applied = state_id; self.tile.last_applied_block_root = block_root; @@ -687,7 +745,34 @@ impl HeadRig { self.tile.ticker.set_since_genesis_ms(slot * 12_000); } + /// Bypasses gossip validation and supplies the recomputation normally + /// performed after committing PTC votes. + fn ptc_majority(&mut self, block_root: B256) { + self.tile.fork_choice.record_ptc_votes(&block_root, &[u64::MAX; 8], true, true); + self.tile.recompute_head(); + } + fn vote_for(&mut self, block_root: B256, validators: std::ops::Range) { + self.vote(block_root, validators, 71, true); + } + + /// Slot-72 votes can distinguish the slot-71 block's empty/full payload. + fn vote_on_payload( + &mut self, + block_root: B256, + validators: std::ops::Range, + present: bool, + ) { + self.vote(block_root, validators, 72, present); + } + + fn vote( + &mut self, + block_root: B256, + validators: std::ops::Range, + attestation_slot: Slot, + payload_present: bool, + ) { let n = self.tile.head_validator_count(); for validator in validators { self.tile.fork_choice.record_vote( @@ -695,8 +780,8 @@ impl HeadRig { validator, block_root, target_epoch: 2, - attestation_slot: 71, - payload_present: true, + attestation_slot, + payload_present, }, n, ); @@ -722,6 +807,7 @@ fn head_a(optimistic: bool) -> StatusHead { previous_duty_dependent_root: A_PREVIOUS, current_duty_dependent_root: A_CURRENT, }, + payload: PayloadResolution::Full, } } @@ -735,6 +821,7 @@ fn head_b(optimistic: bool) -> StatusHead { previous_duty_dependent_root: B_PREVIOUS, current_duty_dependent_root: B_CURRENT, }, + payload: PayloadResolution::Full, } } @@ -748,6 +835,7 @@ fn head_anchor() -> StatusHead { previous_duty_dependent_root: ANCHOR_PREVIOUS, current_duty_dependent_root: ANCHOR_CURRENT, }, + payload: PayloadResolution::Full, } } @@ -798,6 +886,98 @@ fn an_invalid_verdict_publishes_the_snapshot_of_the_head_it_moved_to() { assert_eq!(events.reorgs(), [70], "the head left A's branch for its sibling"); } +fn head_a_empty(optimistic: bool) -> StatusHead { + StatusHead { payload: PayloadResolution::Empty, ..head_a(optimistic) } +} + +/// Mark verification directly to isolate publication from envelope validation. +#[test] +fn verifying_the_gloas_head_s_envelope_publishes_the_full_resolution_once() { + let mut rig = HeadRig::new(); + rig.import_gloas(A_ROOT, 71, A_PREVIOUS, A_CURRENT, false); + assert_eq!(rig.drain().heads(), [head_a_empty(true)], "the unverified payload resolves empty"); + assert_eq!(rig.crank().heads(), []); + + rig.tile.fork_choice.mark_payload_verified(&A_ROOT); + let events = rig.crank(); + assert_eq!(events.heads(), [head_a(true)]); + assert!(events.reorgs().is_empty(), "the head block did not move"); + assert_eq!(rig.crank().heads(), [], "the full resolution is now the emitted one"); +} + +/// An invalid Gloas payload can leave its block selected with an empty +/// resolution. +#[test] +fn an_invalid_verdict_on_a_gloas_head_publishes_the_empty_resolution_without_a_reorg() { + let mut rig = HeadRig::new(); + rig.import_gloas(A_ROOT, 71, A_PREVIOUS, A_CURRENT, true); + assert_eq!(rig.drain().heads(), [head_a(true)]); + + rig.verdict(A_ROOT, PayloadValidationStatus::Invalid); + let events = rig.crank(); + assert_eq!(events.heads(), [head_a_empty(true)]); + assert!(events.reorgs().is_empty()); + + rig.verdict(A_ROOT, PayloadValidationStatus::Invalid); + assert_eq!(rig.crank().heads(), [], "a repeat changes nothing to report"); +} + +/// Inject slot-72 votes early so their weight is already folded while the +/// previous-slot rule still applies. This isolates the rule's expiry and +/// Status publication; normal gossip would defer these votes until slot 73. +#[test] +fn the_tick_closing_the_previous_slot_window_publishes_the_vote_weighted_resolution() { + let mut rig = HeadRig::new(); + rig.import_gloas(A_ROOT, 71, A_PREVIOUS, A_CURRENT, true); + assert_eq!(rig.drain().heads(), [head_a(true)]); + + rig.vote_on_payload(A_ROOT, 0..8, false); + rig.advance_to_slot(72); + assert_eq!(rig.crank().heads(), [head_a(true)], "the slot-start Status is the only one"); + + rig.advance_to_slot(73); + let events = rig.crank(); + assert_eq!(events.heads(), [head_a_empty(true)]); + assert!(events.reorgs().is_empty()); + assert_eq!(rig.crank().heads(), []); +} + +/// The boosted empty-edge child makes the parent's resolution depend on PTC +/// votes. Here the child is viable, so the majority also changes the head root. +#[test] +fn a_ptc_majority_returns_the_head_to_the_parent_with_its_full_payload() { + let mut rig = HeadRig::new(); + let a = rig.import_gloas(A_ROOT, 71, A_PREVIOUS, A_CURRENT, true); + rig.advance_to_slot(72); + let _ = rig.crank(); + + rig.tile.fork_choice.set_proposer_boost(B_ROOT); + rig.import_empty_child(B_ROOT, 72, A_ROOT, a, B_PREVIOUS, B_CURRENT); + let b = StatusHead { slot: 72, ..head_b(true) }; + assert_eq!(rig.drain().heads(), [b], "boost on the empty child resolves A empty"); + assert_eq!(rig.crank().heads(), []); + + rig.ptc_majority(A_ROOT); + let events = rig.crank(); + assert_eq!(events.heads(), [head_a(true)]); + assert_eq!(events.reorgs(), [71], "the head left B for its parent"); + + rig.ptc_majority(A_ROOT); + assert_eq!(rig.crank().heads(), [], "a repeated majority changes nothing to report"); +} + +#[test] +fn a_verification_and_a_valid_verdict_in_one_iteration_publish_one_snapshot() { + let mut rig = HeadRig::new(); + rig.import_gloas(A_ROOT, 71, A_PREVIOUS, A_CURRENT, false); + assert_eq!(rig.drain().heads(), [head_a_empty(true)]); + + rig.tile.fork_choice.mark_payload_verified(&A_ROOT); + rig.verdict(A_ROOT, PayloadValidationStatus::Valid); + assert_eq!(rig.crank().heads(), [head_a(false)]); + assert_eq!(rig.crank().heads(), []); +} + /// Votes take effect when the next slot tick recomputes the head. #[test] fn a_vote_driven_reorg_publishes_the_new_head_and_reports_the_reorg() { @@ -1107,6 +1287,7 @@ fn an_imported_fixture_block_s_status_carries_its_own_roots() { slot: 33, optimistic: true, roots: expected, + payload: PayloadResolution::Full, }]); } diff --git a/crates/columns/src/tile.rs b/crates/columns/src/tile.rs index 3c6c2021..4738522b 100644 --- a/crates/columns/src/tile.rs +++ b/crates/columns/src/tile.rs @@ -773,8 +773,8 @@ mod tests { use silver_beacon_state_data::{BeaconState, BeaconStateOwner}; use silver_common::{ - BlockSource, BlockStage, EngineReq, HeadRoots, P2pStreamId, StreamProtocol, TCache, - TCacheProducer, TCacheRead, + BlockSource, BlockStage, EngineReq, HeadRoots, P2pStreamId, PayloadResolution, + StreamProtocol, TCache, TCacheProducer, TCacheRead, ssz_view::{ DATA_COLUMN_SIDECAR_MIN, DataColumnSidecarFuluView, NUMBER_OF_COLUMNS, SIGNED_BEACON_BLOCK_MIN, @@ -1060,6 +1060,7 @@ mod tests { head_optimistic: false, enr_fork_id: [0u8; 16], head_roots: HeadRoots::default(), + head_payload: PayloadResolution::Full, } } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 9d9314f9..b936e50d 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -26,9 +26,9 @@ pub use crate::{ GossipMsgIn, GossipMsgOut, HeadRoots, IpBytes, LOCAL_GOSSIP_STREAM_ID, LocalAttestationFailure, LocalAttestationResult, MAX_BLOBS_PER_BLOCK, MAX_PAYLOAD_BODIES_PER_REQ, MULTISTREAM_V1, MultiProducer as TMultiProducer, NewGossipMsg, - P2pConnectionStats, P2pSend, P2pStreamId, PREFILL_SLOTS, PayloadValidationStatus, - PeerControl, PeerEvent, PeerScores, PeerStats, PeerStatus, PeerTopicScores, Prefill, - Producer as TProducer, REJECT_RESPONSE, RPC_PROTOCOLS, + P2pConnectionStats, P2pSend, P2pStreamId, PREFILL_SLOTS, PayloadResolution, + PayloadValidationStatus, PeerControl, PeerEvent, PeerScores, PeerStats, PeerStatus, + PeerTopicScores, Prefill, Producer as TProducer, REJECT_RESPONSE, RPC_PROTOCOLS, RandomAccessConsumer as TRandomAccess, ReplayBlock, Reservation as TReservation, RpcInbound, RpcOutbound, RpcRequest, RpcRequestInbound, RpcRequestOutbound, RpcResponse, RpcResponseInbound, RpcResponseOutbound, RpcSeverity, SilverSpine, SilverSpineProducers, diff --git a/crates/common/src/spine.rs b/crates/common/src/spine.rs index e3be9de4..7867be13 100644 --- a/crates/common/src/spine.rs +++ b/crates/common/src/spine.rs @@ -10,10 +10,10 @@ pub use messages::{ EnginePreparePayloadReq, EngineReq, EngineResp, GossipMsgIn, GossipMsgOut, HeadRoots, IpBytes, LocalAttestationFailure, LocalAttestationResult, MAX_BLOBS_PER_BLOCK, MAX_PAYLOAD_BODIES_PER_REQ, NewGossipMsg, P2pConnectionStats, P2pSend, PREFILL_SLOTS, - PayloadValidationStatus, PeerControl, PeerEvent, PeerScores, PeerStats, PeerStatus, - PeerTopicScores, Prefill, ReplayBlock, RpcInbound, RpcOutbound, RpcRequest, RpcRequestInbound, - RpcRequestOutbound, RpcResponse, RpcResponseInbound, RpcResponseOutbound, RpcSeverity, - SyncNeed, SyncUpdate, SyncingStrategy, WithdrawalInline, + PayloadResolution, PayloadValidationStatus, PeerControl, PeerEvent, PeerScores, PeerStats, + PeerStatus, PeerTopicScores, Prefill, ReplayBlock, RpcInbound, RpcOutbound, RpcRequest, + RpcRequestInbound, RpcRequestOutbound, RpcResponse, RpcResponseInbound, RpcResponseOutbound, + RpcSeverity, SyncNeed, SyncUpdate, SyncingStrategy, WithdrawalInline, }; pub use stream_id::{LOCAL_GOSSIP_STREAM_ID, P2pStreamId}; pub use stream_protocol::{ diff --git a/crates/common/src/spine/messages.rs b/crates/common/src/spine/messages.rs index 6b44e09d..f6e7b531 100644 --- a/crates/common/src/spine/messages.rs +++ b/crates/common/src/spine/messages.rs @@ -848,6 +848,25 @@ impl HeadRoots { } } +/// Fork choice's selection of the block's own payload, independent of its +/// execution validation status. Selected pre-Gloas blocks resolve `Full`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum PayloadResolution { + Empty, + Full, +} + +impl PayloadResolution { + /// The beacon-API `payload_status` spelling. + pub fn name(self) -> &'static str { + match self { + Self::Empty => "empty", + Self::Full => "full", + } + } +} + #[allow(clippy::large_enum_variant)] #[derive(Clone, Copy, Debug)] #[repr(C)] @@ -863,6 +882,7 @@ pub enum BeaconStateEvent { head_optimistic: bool, enr_fork_id: [u8; 16], head_roots: HeadRoots, + head_payload: PayloadResolution, }, EnvelopeAvailable { ssz: TCacheRead, diff --git a/crates/e2e/src/bin/da_replay.rs b/crates/e2e/src/bin/da_replay.rs index 940da629..5a742d93 100644 --- a/crates/e2e/src/bin/da_replay.rs +++ b/crates/e2e/src/bin/da_replay.rs @@ -30,8 +30,8 @@ use silver_columns::tile::{ColumnConsumers, DataColumnsTile}; use silver_common::metrics::CountingAllocator; use silver_common::{ BeaconStateEvent, DataColumnsEvent, DataKind, EngineReq, GossipTopic, HeadRoots, MessageId, - Nanos, NewGossipMsg, P2pStreamId, PeerEvent, SilverSpine, StreamProtocol, SyncNeed, SyncUpdate, - TCache, TCacheProducer, TProducer, + Nanos, NewGossipMsg, P2pStreamId, PayloadResolution, PeerEvent, SilverSpine, StreamProtocol, + SyncNeed, SyncUpdate, TCache, TCacheProducer, TProducer, profiler::InProcessReader, ssz_view::{DataColumnSidecarFuluView, NUMBER_OF_COLUMNS, STATUS_V2_SIZE}, ticker::SlotTicker, @@ -166,6 +166,7 @@ impl Node { head_optimistic: false, enr_fork_id: [0u8; 16], head_roots: HeadRoots::default(), + head_payload: PayloadResolution::Full, }); self.turn(); } diff --git a/docs/adr/0004-sync-materialized-api.md b/docs/adr/0004-sync-materialized-api.md index 27225982..c31dc40b 100644 --- a/docs/adr/0004-sync-materialized-api.md +++ b/docs/adr/0004-sync-materialized-api.md @@ -98,4 +98,15 @@ leaves that baseline unchanged. Node-status updates continue independently. `epoch_transition` compares consecutive complete observations and is true only when the head epoch advances. Same-block validation updates and backward reorgs report false. New subscriptions receive future changes without an -initial snapshot. `head_v2` remains unsupported. +initial snapshot. + +Amended 2026-09-09: `/eth/v1/events` also serves `head_v2`. Status carries +fork choice's empty/full resolution of the selected block's own payload. +The end-of-loop check publishes an updated Status when this resolution +changes, even if the root and optimism stay the same. + +The boundary publishes `head_v2` when the root, optimism or resolution +changes, including both empty-to-full and full-to-empty transitions. Legacy +`head` retains its root-and-optimism filter. Both topics use the same +complete observation. The v2 `version` names the configured fork at the head +block's slot; selected pre-Gloas blocks report `full`. From 1ca62fa4d1089a481bcf652c063581c819185117 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 9 Sep 2026 12:05:59 +0100 Subject: [PATCH 05/13] Use B256 for HeadRoots fields and the unavailable-root check Assisted-by: Codex:GPT-6 --- crates/common/src/spine/messages.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/common/src/spine/messages.rs b/crates/common/src/spine/messages.rs index f6e7b531..7feea87e 100644 --- a/crates/common/src/spine/messages.rs +++ b/crates/common/src/spine/messages.rs @@ -5,7 +5,7 @@ use std::{ }; use flux::timing::Nanos; -use silver_beacon_state_data::SLOTS_PER_EPOCH; +use silver_beacon_state_data::{B256, SLOTS_PER_EPOCH}; use crate::{ DataKind, Enr, GossipTopic, Identify, MessageId, Origin, P2pStreamId, PeerId, StreamProtocol, @@ -833,18 +833,18 @@ pub enum ColumnSource { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[repr(C)] pub struct HeadRoots { - pub state_root: [u8; 32], + pub state_root: B256, /// Root at the slot before the head block's previous epoch starts, /// saturating to slot zero. - pub previous_duty_dependent_root: [u8; 32], + pub previous_duty_dependent_root: B256, /// Root at the slot before the head block's epoch starts, saturating to /// slot zero. - pub current_duty_dependent_root: [u8; 32], + pub current_duty_dependent_root: B256, } impl HeadRoots { pub fn is_complete(&self) -> bool { - self.state_root != [0u8; 32] + self.state_root != B256::default() } } From f5cc651d313dc29fd73749ecdfc4f3fd61661d86 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 9 Sep 2026 12:12:06 +0100 Subject: [PATCH 06/13] Compose SelectedHead from HeadObservation and the node index Assisted-by: Codex:GPT-6 --- crates/beacon_state/tile/src/tile.rs | 32 +++++++++++++--------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 0a4c6b86..3132f7d2 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -115,11 +115,9 @@ impl Debug for Feedback { /// Resolved once so each Status uses one fork's head metadata. #[derive(Clone, Copy)] struct SelectedHead { - root: B256, + observation: HeadObservation, /// `None` only before the anchor is seeded, where no node is resident. idx: Option, - optimistic: bool, - payload: PayloadResolution, } /// Head changes that require a Status even without an import or slot tick. @@ -130,12 +128,6 @@ struct HeadObservation { payload: PayloadResolution, } -impl SelectedHead { - fn observation(&self) -> HeadObservation { - HeadObservation { root: self.root, optimistic: self.optimistic, payload: self.payload } - } -} - pub struct BeaconStateTile { sync_target: SyncUpdate, ticker: SlotTicker, @@ -494,7 +486,7 @@ impl BeaconStateTile { }); let payload = idx.map_or(PayloadResolution::Empty, |idx| self.fork_choice.payload_resolution(idx)); - SelectedHead { root, idx, optimistic, payload } + SelectedHead { observation: HeadObservation { root, optimistic, payload }, idx } } /// A missing node or overwritten checkpoint history makes the whole @@ -505,8 +497,14 @@ impl BeaconStateTile { let epoch = node.slot / SLOTS_PER_EPOCH; let view = self.state.read_view(node.state_id); let state_slot = view.slot.state().slot; - let dependent = - |epoch| view.block_roots.duty_dependent_root(epoch, node.slot, head.root, state_slot); + let dependent = |epoch| { + view.block_roots.duty_dependent_root( + epoch, + node.slot, + head.observation.root, + state_slot, + ) + }; match (dependent(epoch.saturating_sub(1)), dependent(epoch)) { (Some(previous), Some(current)) => HeadRoots { state_root: node.state_root, @@ -519,13 +517,13 @@ impl BeaconStateTile { fn status_event(&mut self, head: SelectedHead) -> BeaconStateEvent { BeaconStateEvent::Status { - ssz: self.status_payload(head.root, head.idx), + ssz: self.status_payload(head.observation.root, head.idx), latest_block_slot: self.last_applied_block_slot(), wall_slot: self.ticker.current_slot(), - head_optimistic: head.optimistic, + head_optimistic: head.observation.optimistic, enr_fork_id: self.enr_fork_id(), head_roots: self.head_roots(head), - head_payload: head.payload, + head_payload: head.observation.payload, } } @@ -534,7 +532,7 @@ impl BeaconStateTile { } fn publish_selected_head(&mut self, head: SelectedHead, producers: &mut Producers) { - self.emitted_head = head.observation(); + self.emitted_head = head.observation; let event = self.status_event(head); producers.produce(event); } @@ -542,7 +540,7 @@ impl BeaconStateTile { /// Covers changes since the last Status, including execution verdicts. fn publish_status_on_head_change(&mut self, producers: &mut Producers) { let head = self.selected_head(); - if head.observation() != self.emitted_head { + if head.observation != self.emitted_head { self.publish_selected_head(head, producers); } } From 6ab6775fec5e06f0ba8772a82ecec29a2ddb8bbf Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 9 Sep 2026 13:00:10 +0100 Subject: [PATCH 07/13] Rename the head_v2 renderer parameter to fork_name Assisted-by: Codex:GPT-6 --- crates/beacon_api/src/json.rs | 5 ++--- crates/beacon_api/src/server.rs | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index bf8fe3b0..1ce784dc 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -252,11 +252,10 @@ impl Json<'_> { self.end_object(); } - /// `version` names the fork in force at the head block's slot. - pub(crate) fn head_v2_event(&mut self, head: &HeadEvent, version: &str) { + pub(crate) fn head_v2_event(&mut self, head: &HeadEvent, fork_name: &str) { self.begin_object(); self.key("version"); - self.string(version); + self.string(fork_name); self.key("data"); self.begin_object(); self.key("slot"); diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index 2eff9e1f..e34aedd2 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -405,9 +405,8 @@ impl BeaconApi { } pub fn publish_head_v2(&mut self, head: &HeadEvent) { - let version = self.ctx.spec.fork_at_slot(head.slot).name(); let mut data = Vec::new(); - Json::new(&mut data).head_v2_event(head, version); + Json::new(&mut data).head_v2_event(head, self.ctx.spec.fork_at_slot(head.slot).name()); self.publish(Channel::HeadV2, "head_v2", &data); } From f8424f3a6895a7a5555e6996e903d2c61024b486 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 9 Sep 2026 15:58:11 +0100 Subject: [PATCH 08/13] Update comment --- crates/common/src/spine/messages.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/common/src/spine/messages.rs b/crates/common/src/spine/messages.rs index 7feea87e..934b9c93 100644 --- a/crates/common/src/spine/messages.rs +++ b/crates/common/src/spine/messages.rs @@ -827,7 +827,7 @@ pub enum ColumnSource { El, } -/// A zero `state_root` marks the whole bundle unavailable. This can occur +/// A zero `state_root` marks all three roots unavailable. This can occur /// before seeding or when checkpoint history has overwritten a dependent /// root. Consumers tracking head changes must ignore incomplete bundles. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] From a56472ec6c147006afbe04f4e971fda2fdcb1d76 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 10 Sep 2026 11:37:14 +0100 Subject: [PATCH 09/13] Comment update, clarify when will diverge from Assisted-by: Codex:GPT-6 --- crates/common/src/spine/messages.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/common/src/spine/messages.rs b/crates/common/src/spine/messages.rs index 934b9c93..df8d2cb5 100644 --- a/crates/common/src/spine/messages.rs +++ b/crates/common/src/spine/messages.rs @@ -873,8 +873,10 @@ impl PayloadResolution { pub enum BeaconStateEvent { ReplayComplete, /// An observation that may repeat unchanged. Consumers decide which - /// fields require action. `latest_block_slot` tracks import progress; - /// the selected head's slot in `ssz` can differ. + /// fields require action. `latest_block_slot` follows the last imported + /// block; `ssz` describes the fork-choice head. Their slots can differ + /// after importing a competing branch or switching heads, including + /// after execution invalidation. Status { ssz: [u8; STATUS_V2_SIZE], latest_block_slot: u64, From 8ff0d8a4e6bef61b56c97ae68e0ecdc0a67beb20 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 10 Sep 2026 12:02:44 +0100 Subject: [PATCH 10/13] Require SelectedHead to reference a resident node The tile constructor seeds the anchor before any head observation. Make SelectedHead.idx mandatory and replace missing-node fallbacks with an explicit residency check. Keep overwritten-history handling unchanged. Test startup on both forks and return to the anchor after branch invalidation. Extend the finalization test through Status publication after pruning remaps the surviving head. These tests exercise the residency check through production publication paths. Assisted-by: Codex:gpt-6-astra --- crates/beacon_state/tile/src/tile.rs | 36 +++----- crates/beacon_state/tile/src/tile/tests.rs | 97 ++++++++++++++++++++-- 2 files changed, 101 insertions(+), 32 deletions(-) diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 3132f7d2..a0ae27d1 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -116,8 +116,7 @@ impl Debug for Feedback { #[derive(Clone, Copy)] struct SelectedHead { observation: HeadObservation, - /// `None` only before the anchor is seeded, where no node is resident. - idx: Option, + idx: usize, } /// Head changes that require a Status even without an import or slot tick. @@ -437,19 +436,11 @@ impl BeaconStateTile { ); } - fn status_payload(&mut self, head_root: B256, head_idx: Option) -> [u8; STATUS_V2_SIZE] { + fn status_payload(&mut self, head_root: B256, head_idx: usize) -> [u8; STATUS_V2_SIZE] { let fork_digest = self.fork_digest(); - - let (slot, mut finalized) = match head_idx { - Some(idx) => { - let n = self.fork_choice.node(idx); - (n.slot, n.checkpoints.finalized) - } - None => ( - self.slot_state_at(self.last_applied).latest_block_header.slot, - self.head_finalized_checkpoint(), - ), - }; + let node = self.fork_choice.node(head_idx); + let slot = node.slot; + let mut finalized = node.checkpoints.finalized; if finalized.root == [0u8; 32] { // Genesis placeholder: the head state's finalized root is zero until @@ -480,20 +471,17 @@ impl BeaconStateTile { fn selected_head(&self) -> SelectedHead { let root = self.fork_choice.find_head(); - let idx = self.fork_choice.find_node_idx(&root); - let optimistic = idx.is_none_or(|idx| { - self.fork_choice.node(idx).execution_status != ExecutionStatus::Valid - }); - let payload = - idx.map_or(PayloadResolution::Empty, |idx| self.fork_choice.payload_resolution(idx)); + let idx = + self.fork_choice.find_node_idx(&root).expect("find_head returns a node-resident root"); + let optimistic = self.fork_choice.node(idx).execution_status != ExecutionStatus::Valid; + let payload = self.fork_choice.payload_resolution(idx); SelectedHead { observation: HeadObservation { root, optimistic, payload }, idx } } - /// A missing node or overwritten checkpoint history makes the whole - /// root bundle unavailable; partial metadata cannot describe the head. + /// Overwritten checkpoint history makes the whole root bundle unavailable; + /// partial metadata cannot describe the head. fn head_roots(&self, head: SelectedHead) -> HeadRoots { - let Some(idx) = head.idx else { return HeadRoots::default() }; - let node = self.fork_choice.node(idx); + let node = self.fork_choice.node(head.idx); let epoch = node.slot / SLOTS_PER_EPOCH; let view = self.state.read_view(node.state_id); let state_slot = view.slot.state().slot; diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index 5f3b1cfd..8a7c0d4e 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -839,6 +839,51 @@ fn head_anchor() -> StatusHead { } } +#[test] +fn startup_status_uses_the_seeded_anchor_on_both_forks() { + for is_gloas in [false, true] { + for state_slot in [0, 70] { + let mut epoch = EpochState::default(); + if is_gloas { + epoch.fork.current_version = Immutable::default().gloas_fork_version; + } + let state = + BeaconState::for_test(EpochStateFinalized::from_state(epoch), &[], state_slot); + let (mut tile, _gp, _rp, mut spine, mut adapter) = + tile_with_producers_on(state_slot, state, SpecConfig::mainnet()); + let mut sink = SpineAdapter::connect_tile(&Sink, &mut spine); + sink.consume(|_: BeaconStateEvent, _| {}); + let root = tile.head_block_root(); + let state_root = + ssz_hash::hash_tree_root_state(&tile.state.read_view(tile.last_applied)); + assert_ne!(root, [0; 32], "the constructor seeded a real anchor"); + assert_eq!(tile.fork_choice.find_node_idx(&root), Some(0)); + + tile.loop_body(&mut adapter); + + assert_eq!( + Published::drain(&mut sink).heads(), + [StatusHead { + root, + slot: 0, + optimistic: false, + roots: HeadRoots { + state_root, + previous_duty_dependent_root: root, + current_duty_dependent_root: root, + }, + payload: if is_gloas { + PayloadResolution::Empty + } else { + PayloadResolution::Full + }, + }], + "startup at state slot {state_slot}, Gloas: {is_gloas}" + ); + } + } +} + #[test] fn an_import_publishes_one_status_and_the_end_of_loop_check_adds_none() { let mut rig = HeadRig::new(); @@ -886,6 +931,21 @@ fn an_invalid_verdict_publishes_the_snapshot_of_the_head_it_moved_to() { assert_eq!(events.reorgs(), [70], "the head left A's branch for its sibling"); } +#[test] +fn invalidating_the_only_branch_publishes_the_resident_anchor() { + let mut rig = HeadRig::new(); + let a = rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); + rig.import_child(B_ROOT, 72, A_ROOT, a, B_PREVIOUS, B_CURRENT); + assert_eq!(rig.crank().heads().last().unwrap().root, B_ROOT); + + rig.verdict(A_ROOT, PayloadValidationStatus::Invalid); + + let events = rig.crank(); + assert_eq!(events.heads(), [head_anchor()]); + assert_eq!(events.reorgs(), [70]); + assert_eq!(rig.crank().heads(), [], "the anchor remains the published head"); +} + fn head_a_empty(optimistic: bool) -> StatusHead { StatusHead { payload: PayloadResolution::Empty, ..head_a(optimistic) } } @@ -3630,20 +3690,41 @@ fn multi_fork_finalize_promotes_and_rebases() { /// Distinct slot-zero markers identify which state bundle supplies the roots /// after finalization remaps the surviving head. #[test] -fn head_roots_read_the_survivor_s_re_anchored_bundle_after_finalization() { +fn status_reads_the_surviving_head_after_finalization_remaps_its_node() { let mut forks = ThreeForks::new(); // Justification follows finality here, as `lift_checkpoints` would have it. forks.tile.fork_choice.justified_checkpoint = Checkpoint { epoch: 0, root: F_ROOT }; + let (_, _gp, _rp, mut spine, mut adapter) = tile_with_producers(2); + let mut sink = SpineAdapter::connect_tile(&Sink, &mut spine); + sink.consume(|_: BeaconStateEvent, _| {}); + forks.tile.ticker.set_since_genesis_ms(2 * 12_000); + forks.tile.loop_body(&mut adapter); + let before = Published::drain(&mut sink).heads(); + assert_eq!(before.len(), 1); + assert_eq!(before[0].root, D_ROOT); + let old_idx = forks.tile.fork_choice.find_node_idx(&D_ROOT).unwrap(); + forks.tile.maybe_finalize(); - assert_eq!(forks.tile.fork_choice.find_head(), D_ROOT); + assert!(forks.tile.fork_choice.find_node_idx(&ANCHOR_ROOT).is_none()); + assert!(forks.tile.fork_choice.find_node_idx(&F2_ROOT).is_none()); + let new_idx = forks.tile.fork_choice.find_node_idx(&D_ROOT).unwrap(); + assert_ne!(old_idx, new_idx, "pruning moved the surviving head's index"); + forks.tile.ticker.set_since_genesis_ms(3 * 12_000); + forks.tile.loop_body(&mut adapter); assert_eq!( - forks.tile.head_roots(forks.tile.selected_head()), - HeadRoots { - state_root: state_root_of(D_ROOT), - previous_duty_dependent_root: D_ROOT, - current_duty_dependent_root: D_ROOT, - }, + Published::drain(&mut sink).heads(), + [StatusHead { + root: D_ROOT, + slot: 2, + optimistic: true, + roots: HeadRoots { + state_root: state_root_of(D_ROOT), + previous_duty_dependent_root: D_ROOT, + current_duty_dependent_root: D_ROOT, + }, + payload: PayloadResolution::Full, + }], "epoch 0 decides at slot 0, where each bundle carries its own root" ); } From dd5644fa31b2d6fea9e9744294029186cdfd937c Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 11 Sep 2026 17:26:36 +0100 Subject: [PATCH 11/13] Keep head event tests focused on their contracts Assert required JSON fields after reading complete SSE chunks. Allow member reordering, insignificant whitespace, and future fields. Keep topic selection, payload changes, validation, and repeat suppression visible through socket observations. Observe completed producer operations instead of requiring publication at a particular internal step. Retain startup, resident-anchor, pruning, slot-transition, and reorg coverage. Import and replay metadata tests now enter through spine messages and derive slots from their fixtures. Consolidate overlapping consumer scenarios. Replace the columns buffer probe with valid-sidecar persistence after repeated Status observations. Remove redundant ENR coverage and the ineffective store-assignment test. Five renderer and socket tests pass with reordered fields, added spaces, and an extra field. Rebuilt negative controls catch wrong dependent roots and topics, lost payload changes, lost verdicts, and a missing reorg. They also catch premature replay requests, lost deferred checkpoints, and omitted column reconsideration. Synthetic producer setup still bypasses block and envelope validation. Checkpoint scheduling still uses private state with simulated consumption. The server's test marker uses private fan-out only to delimit observations. EF fixtures remain necessary for the import, replay, and sidecar scenarios. just fmt-check, just clippy, just nextest, and git diff --check pass. The workspace run passed 1,407 tests and skipped five. Production code is unchanged. Assisted-by: Codex:gpt-6-astra --- crates/application_boundary/tests/tile.rs | 258 ++++++++++-------- crates/beacon_api/src/json.rs | 80 +++--- crates/beacon_api/src/server.rs | 208 +++++++------- .../tile/src/fork_choice/tests.rs | 43 +-- crates/beacon_state/tile/src/tile/tests.rs | 226 ++++++--------- crates/columns/src/tile.rs | 82 +++--- crates/control/src/sync_engine/tests.rs | 43 +-- crates/discovery/src/discv5.rs | 23 -- crates/storage/src/store/tests.rs | 24 -- crates/storage/src/tile.rs | 38 +-- 10 files changed, 422 insertions(+), 603 deletions(-) diff --git a/crates/application_boundary/tests/tile.rs b/crates/application_boundary/tests/tile.rs index 8bb6beb3..fa91d5bd 100644 --- a/crates/application_boundary/tests/tile.rs +++ b/crates/application_boundary/tests/tile.rs @@ -1,5 +1,6 @@ use std::{ - io::{Read, Write}, + collections::HashMap, + io::{BufRead, BufReader, Read, Write}, net::{SocketAddr, TcpStream}, os::unix::net::UnixStream, sync::mpsc::{self, Receiver}, @@ -8,9 +9,10 @@ use std::{ }; use flux::{spine::SpineAdapter, tile::Tile}; +use serde_json::Value; use silver_application_boundary::ApplicationBoundaryTile; use silver_beacon_api::SlotStatus; -use silver_beacon_state_data::{BeaconStateOwner, SpecConfig}; +use silver_beacon_state_data::{BeaconStateOwner, SLOTS_PER_EPOCH, SpecConfig}; use silver_common::{ BeaconStateEvent, BlockSource, BlockStage, ELSyncStatus, EngineFcuReq, EngineReq, EngineResp, Enr, HeadRoots, Identify, Keypair, PayloadResolution, PayloadValidationStatus, SilverSpine, @@ -30,6 +32,15 @@ fn boundary_tile( bind: &Bind, engine_config: EngineConfig, tcache_names: [&'static str; 3], +) -> ApplicationBoundaryTile { + boundary_tile_with_spec(bind, engine_config, tcache_names, &SpecConfig::mainnet()) +} + +fn boundary_tile_with_spec( + bind: &Bind, + engine_config: EngineConfig, + tcache_names: [&'static str; 3], + spec: &SpecConfig, ) -> ApplicationBoundaryTile { let keypair = Keypair::from_secret(&[1u8; 32]).unwrap(); let local_enr = Enr::empty(keypair.secret_key()).unwrap(); @@ -43,7 +54,7 @@ fn boundary_tile( &keypair, local_enr, &Identify::default(), - &SpecConfig::mainnet(), + spec, BeaconStateOwner::empty_test(0).reader(), engine_config, gossip_p.cache_ref().random_access("t", true).unwrap(), @@ -222,39 +233,63 @@ fn head_status( } } -fn chunked(data: &str) -> Vec { - let mut frame = format!("{:x}\r\n", data.len()).into_bytes(); - frame.extend_from_slice(data.as_bytes()); - frame.extend_from_slice(b"\r\n"); - frame -} - -fn head_frame(slot: u64, block_root: u8, execution_optimistic: bool) -> Vec { - let data = format!( - "event: head\ndata: {{\"slot\":\"{slot}\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"epoch_transition\":false,\"previous_duty_dependent_root\":\"0x{}\",\"current_duty_dependent_root\":\"0x{}\",\"execution_optimistic\":{execution_optimistic}}}\n\n", - hex::encode([block_root; 32]), - "60".repeat(32), - "5e".repeat(32), - "91".repeat(32), - ); - chunked(&data) -} +fn head_events_subscriber( + addr: SocketAddr, + topic: &str, + sentinel_slot: u64, +) -> (JoinHandle>, Receiver<()>) { + let (subscribed, on_subscribed) = mpsc::channel(); + let topic = topic.to_string(); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(addr).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + write!(stream, "GET /eth/v1/events?topics={topic} HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(line.split_whitespace().nth(1), Some("200")); + let mut headers = HashMap::new(); + loop { + line.clear(); + assert!(reader.read_line(&mut line).unwrap() > 0); + if line == "\r\n" { + break; + } + let (name, value) = line.trim_end().split_once(':').unwrap(); + headers.insert(name.to_ascii_lowercase(), value.trim().to_string()); + } + assert_eq!(headers["content-type"], "text/event-stream"); + assert_eq!(headers["transfer-encoding"], "chunked"); + subscribed.send(()).unwrap(); -fn head_v2_frame( - slot: u64, - block_root: u8, - version: &str, - payload_status: &str, - execution_optimistic: bool, -) -> Vec { - let data = format!( - "event: head_v2\ndata: {{\"version\":\"{version}\",\"data\":{{\"slot\":\"{slot}\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"payload_status\":\"{payload_status}\",\"epoch_transition\":false,\"current_epoch_dependent_root\":\"0x{}\",\"next_epoch_dependent_root\":\"0x{}\",\"execution_optimistic\":{execution_optimistic}}}}}\n\n", - hex::encode([block_root; 32]), - "60".repeat(32), - "5e".repeat(32), - "91".repeat(32), - ); - chunked(&data) + let mut events = Vec::new(); + loop { + line.clear(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let size = usize::from_str_radix(line.trim().split(';').next().unwrap(), 16).unwrap(); + assert!(size > 0, "subscription ended before the sentinel"); + let mut chunk = vec![0; size]; + reader.read_exact(&mut chunk).unwrap(); + let mut end = [0; 2]; + reader.read_exact(&mut end).unwrap(); + assert_eq!(end, *b"\r\n"); + let frame = std::str::from_utf8(&chunk).unwrap().strip_suffix("\n\n").unwrap(); + if frame.starts_with(':') { + continue; + } + let (name, data) = + frame.strip_prefix("event: ").unwrap().split_once("\ndata: ").unwrap(); + assert_eq!(name, topic); + let body: Value = serde_json::from_str(data).unwrap(); + let data = if topic == "head_v2" { &body["data"] } else { &body }; + if data["slot"] == sentinel_slot.to_string() { + return events; + } + events.push(body); + } + }); + (client, on_subscribed) } #[test] @@ -830,85 +865,27 @@ fn an_applied_block_on_the_spine_reaches_an_events_subscriber() { } #[test] -fn an_optimistic_then_validated_head_reaches_an_events_subscriber() { +fn head_subscribers_receive_changes_for_their_topics() { let base = TempDir::new().unwrap(); let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); - let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ - "cs_head_gossip", - "cs_head_rpc", - "cs_head_resp", - ]); - let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); - let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); - tile.loop_body(&mut adapter); - - let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; - let expected = [head_frame(40, 0xab, true), head_frame(40, 0xab, false)].concat(); - let (client, on_subscribed) = events_subscriber(addr, "head", expected.len()); - - let deadline = Instant::now() + Duration::from_secs(10); - let mut crank = |tile: &mut ApplicationBoundaryTile, msg: &str| { - assert!(Instant::now() < deadline, "timeout: {msg}"); - tile.loop_body(&mut adapter); - std::thread::sleep(Duration::from_millis(1)); - }; - while on_subscribed.try_recv().is_err() { - crank(&mut tile, "stream head reaches the subscriber"); - } - - // Establish a baseline, then change the head and validate it. - // Repeated observations between those changes produce no frames. - inj.produce(head_status(33, 0x0a, true, PayloadResolution::Full)); - inj.produce(head_status(33, 0x0a, true, PayloadResolution::Full)); - inj.produce(head_status(40, 0xab, true, PayloadResolution::Full)); - inj.produce(head_status(40, 0xab, true, PayloadResolution::Full)); - inj.produce(head_status(40, 0xab, false, PayloadResolution::Full)); - while !client.is_finished() { - crank(&mut tile, "both head frames reach the subscriber"); - } - let got = client.join().unwrap(); - assert!( - got == expected, - "\n got: {:?}\nexpected: {:?}", - String::from_utf8_lossy(&got), - String::from_utf8_lossy(&expected) - ); - - let status = *tile.beacon.node_status_mut(); - assert_eq!( - status.slots, - Some(SlotStatus { head_slot: 40, wall_slot: 40, head_optimistic: false }), - "node status follows every Status, including the ones the head filter drops" + let mut spec = SpecConfig::mainnet(); + spec.gloas_fork_epoch = spec.fulu_fork_epoch + 2; + let mut tile = boundary_tile_with_spec( + &Bind::parse("127.0.0.1:0"), + no_el(), + ["cs_head_v2_gossip", "cs_head_v2_rpc", "cs_head_v2_resp"], + &spec, ); -} - -/// A payload resolution change is visible to `head_v2` alone; the later -/// validation reaches both topics. -#[test] -fn a_payload_resolution_change_reaches_head_v2_but_not_head() { - let base = TempDir::new().unwrap(); - let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); - let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ - "cs_head_v2_gossip", - "cs_head_v2_rpc", - "cs_head_v2_resp", - ]); let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); tile.loop_body(&mut adapter); let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; - let fulu = SpecConfig::mainnet().fulu_fork_epoch * 32; - let legacy_expected = - [head_frame(fulu + 8, 0xab, true), head_frame(fulu + 8, 0xab, false)].concat(); - let v2_expected = [ - head_v2_frame(fulu + 8, 0xab, "fulu", "empty", true), - head_v2_frame(fulu + 8, 0xab, "fulu", "full", true), - head_v2_frame(fulu + 8, 0xab, "fulu", "full", false), - ] - .concat(); - let (legacy, legacy_subscribed) = events_subscriber(addr, "head", legacy_expected.len()); - let (v2, v2_subscribed) = events_subscriber(addr, "head_v2", v2_expected.len()); + let gloas = spec.gloas_fork_epoch * SLOTS_PER_EPOCH; + let slot = gloas + 8; + let sentinel_slot = slot + 1; + let (legacy, legacy_subscribed) = head_events_subscriber(addr, "head", sentinel_slot); + let (v2, v2_subscribed) = head_events_subscriber(addr, "head_v2", sentinel_slot); let deadline = Instant::now() + Duration::from_secs(10); let mut crank = |tile: &mut ApplicationBoundaryTile, msg: &str| { @@ -922,22 +899,65 @@ fn a_payload_resolution_change_reaches_head_v2_but_not_head() { } } - inj.produce(head_status(fulu + 1, 0x0a, true, PayloadResolution::Full)); - inj.produce(head_status(fulu + 8, 0xab, true, PayloadResolution::Empty)); - inj.produce(head_status(fulu + 8, 0xab, true, PayloadResolution::Full)); - inj.produce(head_status(fulu + 8, 0xab, false, PayloadResolution::Full)); + for status in [ + head_status(gloas + 1, 0x0a, true, PayloadResolution::Full), + head_status(gloas + 1, 0x0a, true, PayloadResolution::Full), + head_status(slot, 0xab, true, PayloadResolution::Empty), + head_status(slot, 0xab, true, PayloadResolution::Empty), + head_status(slot, 0xab, true, PayloadResolution::Full), + head_status(slot, 0xab, true, PayloadResolution::Full), + head_status(slot, 0xab, false, PayloadResolution::Full), + head_status(slot, 0xab, false, PayloadResolution::Full), + ] { + inj.produce(status); + } + crank(&mut tile, "head observations update node status"); + assert_eq!( + tile.beacon.node_status_mut().slots, + Some(SlotStatus { head_slot: slot, wall_slot: slot, head_optimistic: false }) + ); + + // A later head delimits all preceding frames, including unwanted repeats. + inj.produce(head_status(sentinel_slot, 0xcd, false, PayloadResolution::Full)); while !legacy.is_finished() || !v2.is_finished() { crank(&mut tile, "every frame reaches its subscriber"); } - for (got, expected) in - [(legacy.join().unwrap(), legacy_expected), (v2.join().unwrap(), v2_expected)] - { - assert!( - got == expected, - "\n got: {:?}\nexpected: {:?}", - String::from_utf8_lossy(&got), - String::from_utf8_lossy(&expected) - ); + let legacy = legacy.join().unwrap(); + let v2 = v2.join().unwrap(); + assert_eq!(legacy.len(), 2); + assert_eq!(v2.len(), 3); + let roots = head_roots(); + for (events, is_v2) in [(&legacy, false), (&v2, true)] { + for (index, body) in events.iter().enumerate() { + let data = if is_v2 { + assert_eq!(body["version"], "gloas"); + assert_eq!( + body["data"]["payload_status"], + if index == 0 { "empty" } else { "full" } + ); + &body["data"] + } else { + body + }; + assert_eq!(data["slot"], slot.to_string()); + assert_eq!(data["block"], format!("0x{}", hex::encode([0xab; 32]))); + assert_eq!(data["state"], format!("0x{}", hex::encode(roots.state_root))); + assert_eq!(data["epoch_transition"], false); + assert_eq!(data["execution_optimistic"], index + 1 < events.len()); + let (previous, current) = if is_v2 { + ("current_epoch_dependent_root", "next_epoch_dependent_root") + } else { + ("previous_duty_dependent_root", "current_duty_dependent_root") + }; + assert_eq!( + data[previous], + format!("0x{}", hex::encode(roots.previous_duty_dependent_root)) + ); + assert_eq!( + data[current], + format!("0x{}", hex::encode(roots.current_duty_dependent_root)) + ); + } } } diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index 1ce784dc..bd697d65 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -299,6 +299,7 @@ pub(crate) fn json_safe(text: &str) -> bool { #[cfg(test)] mod tests { + use serde_json::Value; use silver_beacon_state_data::FAR_FUTURE_EPOCH; use silver_common::{HeadRoots, PayloadResolution}; @@ -530,9 +531,8 @@ mod tests { } #[test] - fn head_event_matches_the_specification_s_field_order() { - let mut out = Vec::new(); - Json::new(&mut out).head_event(&HeadEvent { + fn head_event_encodes_the_required_fields() { + let head = HeadEvent { slot: 10, block_root: [0x9a; 32], roots: HeadRoots { @@ -543,44 +543,58 @@ mod tests { payload: PayloadResolution::Full, epoch_transition: true, execution_optimistic: false, - }); - let expected = format!( - "{{\"slot\":\"10\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"epoch_transition\":true,\"previous_duty_dependent_root\":\"0x{}\",\"current_duty_dependent_root\":\"0x{}\",\"execution_optimistic\":false}}", - "9a".repeat(32), - "60".repeat(32), - "5e".repeat(32), - "91".repeat(32), + }; + let mut out = Vec::new(); + Json::new(&mut out).head_event(&head); + let data: Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(data["slot"], head.slot.to_string()); + assert_eq!(data["block"], format!("0x{}", hex::encode(head.block_root))); + assert_eq!(data["state"], format!("0x{}", hex::encode(head.roots.state_root))); + assert_eq!(data["epoch_transition"], true); + assert_eq!(data["execution_optimistic"], false); + assert_eq!( + data["previous_duty_dependent_root"], + format!("0x{}", hex::encode(head.roots.previous_duty_dependent_root)) + ); + assert_eq!( + data["current_duty_dependent_root"], + format!("0x{}", hex::encode(head.roots.current_duty_dependent_root)) ); - assert_eq!(String::from_utf8(out).unwrap(), expected); } - /// Distinct dependent roots catch a swap in the renamed fields. #[test] fn head_v2_event_wraps_the_versioned_data_and_maps_the_dependent_roots() { - let mut out = Vec::new(); - Json::new(&mut out).head_v2_event( - &HeadEvent { - slot: 10, - block_root: [0x9a; 32], - roots: HeadRoots { - state_root: [0x60; 32], - previous_duty_dependent_root: [0x5e; 32], - current_duty_dependent_root: [0x91; 32], - }, - payload: PayloadResolution::Empty, - epoch_transition: false, - execution_optimistic: true, + let head = HeadEvent { + slot: 10, + block_root: [0x9a; 32], + roots: HeadRoots { + state_root: [0x60; 32], + previous_duty_dependent_root: [0x5e; 32], + current_duty_dependent_root: [0x91; 32], }, - "gloas", + payload: PayloadResolution::Empty, + epoch_transition: false, + execution_optimistic: true, + }; + let mut out = Vec::new(); + Json::new(&mut out).head_v2_event(&head, "gloas"); + let body: Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(body["version"], "gloas"); + let data = &body["data"]; + assert_eq!(data["slot"], head.slot.to_string()); + assert_eq!(data["block"], format!("0x{}", hex::encode(head.block_root))); + assert_eq!(data["state"], format!("0x{}", hex::encode(head.roots.state_root))); + assert_eq!(data["payload_status"], "empty"); + assert_eq!(data["epoch_transition"], false); + assert_eq!(data["execution_optimistic"], true); + assert_eq!( + data["current_epoch_dependent_root"], + format!("0x{}", hex::encode(head.roots.previous_duty_dependent_root)) ); - let expected = format!( - "{{\"version\":\"gloas\",\"data\":{{\"slot\":\"10\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"payload_status\":\"empty\",\"epoch_transition\":false,\"current_epoch_dependent_root\":\"0x{}\",\"next_epoch_dependent_root\":\"0x{}\",\"execution_optimistic\":true}}}}", - "9a".repeat(32), - "60".repeat(32), - "5e".repeat(32), - "91".repeat(32), + assert_eq!( + data["next_epoch_dependent_root"], + format!("0x{}", hex::encode(head.roots.current_duty_dependent_root)) ); - assert_eq!(String::from_utf8(out).unwrap(), expected); } #[test] diff --git a/crates/beacon_api/src/server.rs b/crates/beacon_api/src/server.rs index e34aedd2..ff2eb7c2 100644 --- a/crates/beacon_api/src/server.rs +++ b/crates/beacon_api/src/server.rs @@ -599,6 +599,7 @@ fn interrupted(err: &io::Error) -> bool { #[cfg(test)] mod tests { use std::{ + io::{BufRead, BufReader}, net::{SocketAddr, TcpStream}, os::unix::net::UnixStream, path::Path, @@ -606,6 +607,7 @@ mod tests { time::Instant, }; + use serde_json::Value; use silver_beacon_state_data::{BeaconStateOwner, SLOTS_PER_EPOCH}; use silver_common::{HeadRoots, PayloadResolution}; use silver_httpcore::Readiness; @@ -1423,112 +1425,106 @@ mod tests { } } - fn head_v2_frame( - slot: u64, - block_root: &[u8; 32], - version: &str, - execution_optimistic: bool, - ) -> Vec { - let data = format!( - "event: head_v2\ndata: {{\"version\":\"{version}\",\"data\":{{\"slot\":\"{slot}\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"payload_status\":\"full\",\"epoch_transition\":false,\"current_epoch_dependent_root\":\"0x{}\",\"next_epoch_dependent_root\":\"0x{}\",\"execution_optimistic\":{execution_optimistic}}}}}\n\n", - hex::encode(block_root), - "60".repeat(32), - "5e".repeat(32), - "91".repeat(32), - ); - chunk(data.as_bytes()) - } - - fn head_frame(slot: u64, block_root: &[u8; 32], execution_optimistic: bool) -> Vec { - let data = format!( - "event: head\ndata: {{\"slot\":\"{slot}\",\"block\":\"0x{}\",\"state\":\"0x{}\",\"epoch_transition\":false,\"previous_duty_dependent_root\":\"0x{}\",\"current_duty_dependent_root\":\"0x{}\",\"execution_optimistic\":{execution_optimistic}}}\n\n", - hex::encode(block_root), - "60".repeat(32), - "5e".repeat(32), - "91".repeat(32), - ); - chunk(data.as_bytes()) + struct SseEvent { + topic: String, + data: Value, } - #[test] - fn each_subscriber_receives_only_the_channels_it_asked_for() { - let mut server = server_with(64, LONG_TIMEOUT); - let addr = tcp_addr(&server); - let (mut blocks, mut heads, mut both) = (connect(addr), connect(addr), connect(addr)); - subscribe(&mut blocks, "block"); - subscribe(&mut heads, "head"); - subscribe(&mut both, "block,head"); - pump_until(&mut server, "three subscribed", |server| subscribers(server) == 3); + fn read_events_until_marker(stream: TcpStream) -> JoinHandle> { + std::thread::spawn(move || { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(line.split_whitespace().nth(1), Some("200")); + let mut headers = HashMap::new(); + loop { + line.clear(); + assert!(reader.read_line(&mut line).unwrap() > 0); + if line == "\r\n" { + break; + } + let (name, value) = line.trim_end().split_once(':').unwrap(); + headers.insert(name.to_ascii_lowercase(), value.trim().to_string()); + } + assert_eq!(headers["content-type"], "text/event-stream"); + assert_eq!(headers["transfer-encoding"], "chunked"); - server.api.publish_block(10, &[0xab; 32]); - server.api.publish_head(&head_event(10, &[0xab; 32], true)); - - let block_only = [SSE_HEAD, &block_frame(10, &[0xab; 32])].concat(); - let head_only = [SSE_HEAD, &head_frame(10, &[0xab; 32], true)].concat(); - let mixed = - [SSE_HEAD, &block_frame(10, &[0xab; 32]), &head_frame(10, &[0xab; 32], true)].concat(); - - let readers = [ - read_exactly(blocks, block_only.len()), - read_exactly(heads, head_only.len()), - read_exactly(both, mixed.len()), - ]; - pump_until(&mut server, "every subscriber served", |_| { - readers.iter().all(JoinHandle::is_finished) - }); - let [got_blocks, got_heads, got_both] = readers.map(|r| r.join().unwrap()); + let mut events = Vec::new(); + loop { + line.clear(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let size = + usize::from_str_radix(line.trim().split(';').next().unwrap(), 16).unwrap(); + assert!(size > 0, "subscription ended before the marker"); + let mut chunk = vec![0; size]; + reader.read_exact(&mut chunk).unwrap(); + let mut end = [0; 2]; + reader.read_exact(&mut end).unwrap(); + assert_eq!(end, *b"\r\n"); + let frame = std::str::from_utf8(&chunk).unwrap().strip_suffix("\n\n").unwrap(); + if frame.starts_with(':') { + continue; + } + let (topic, data) = + frame.strip_prefix("event: ").unwrap().split_once("\ndata: ").unwrap(); + let data = serde_json::from_str(data).unwrap(); + if topic == "test_end" { + return events; + } + events.push(SseEvent { topic: topic.to_string(), data }); + } + }) + } - assert_same_bytes(&got_blocks, &block_only); - assert_same_bytes(&got_heads, &head_only); - assert_same_bytes(&got_both, &mixed); + // Queued after every publication, so readers can detect leaked or repeated + // frames without relying on a quiet socket or cross-topic ordering. + fn finish_events(server: &mut Server) { + server.api.fan_out(|_| true, b"event: test_end\ndata: {}\n\n", Instant::now()); } #[test] - fn head_and_head_v2_are_separate_channels() { + fn each_subscriber_receives_only_the_channels_it_asked_for() { let mut server = server_with(64, LONG_TIMEOUT); - let addr = tcp_addr(&server); - let (mut legacy, mut v2, mut heads, mut all) = - (connect(addr), connect(addr), connect(addr), connect(addr)); - subscribe(&mut legacy, "head"); - subscribe(&mut v2, "head_v2"); - subscribe(&mut heads, "head,head_v2"); - subscribe(&mut all, "block,head,head_v2"); - pump_until(&mut server, "four subscribed", |server| subscribers(server) == 4); + let subscriptions = + ["block", "head", "block,head", "head_v2", "head,head_v2", "block,head,head_v2"]; + let readers = subscriptions.map(|topics| { + let mut stream = connect(tcp_addr(&server)); + subscribe(&mut stream, topics); + read_events_until_marker(stream) + }); + pump_until(&mut server, "all subscribed", |server| { + subscribers(server) == subscriptions.len() + }); let slot = SpecConfig::mainnet().fulu_fork_epoch * SLOTS_PER_EPOCH; - let root = [0xab; 32]; - server.api.publish_block(slot, &root); - server.api.publish_head(&head_event(slot, &root, true)); - server.api.publish_head_v2(&head_event(slot, &root, true)); - - let legacy_frames = [SSE_HEAD, &head_frame(slot, &root, true)].concat(); - let v2_frames = [SSE_HEAD, &head_v2_frame(slot, &root, "fulu", true)].concat(); - let head_frames = - [SSE_HEAD, &head_frame(slot, &root, true), &head_v2_frame(slot, &root, "fulu", true)] - .concat(); - let all_frames = [ - SSE_HEAD, - &block_frame(slot, &root), - &head_frame(slot, &root, true), - &head_v2_frame(slot, &root, "fulu", true), - ] - .concat(); - - let readers = [ - read_exactly(legacy, legacy_frames.len()), - read_exactly(v2, v2_frames.len()), - read_exactly(heads, head_frames.len()), - read_exactly(all, all_frames.len()), - ]; + let head = head_event(slot, &[0xab; 32], true); + server.api.publish_block(slot, &head.block_root); + server.api.publish_head(&head); + server.api.publish_head_v2(&head); + finish_events(&mut server); pump_until(&mut server, "every subscriber served", |_| { readers.iter().all(JoinHandle::is_finished) }); - let [got_legacy, got_v2, got_heads, got_all] = readers.map(|r| r.join().unwrap()); - assert_same_bytes(&got_legacy, &legacy_frames); - assert_same_bytes(&got_v2, &v2_frames); - assert_same_bytes(&got_heads, &head_frames); - assert_same_bytes(&got_all, &all_frames); + for (topics, reader) in subscriptions.into_iter().zip(readers) { + let events = reader.join().unwrap(); + assert_eq!(events.len(), topics.split(',').count(), "{topics}"); + for topic in topics.split(',') { + let matching: Vec<_> = events.iter().filter(|event| event.topic == topic).collect(); + assert_eq!(matching.len(), 1, "{topics}: {topic}"); + let body = &matching[0].data; + let data = if topic == "head_v2" { + assert_eq!(body["version"], "fulu"); + assert_eq!(body["data"]["payload_status"], "full"); + &body["data"] + } else { + body + }; + assert_eq!(data["slot"], slot.to_string()); + assert_eq!(data["block"], format!("0x{}", hex::encode(head.block_root))); + assert_eq!(data["execution_optimistic"], true); + } + } } #[test] @@ -1536,24 +1532,24 @@ mod tests { let mut server = server_with(64, LONG_TIMEOUT); let mut client = connect(tcp_addr(&server)); subscribe(&mut client, "head_v2"); + let reader = read_events_until_marker(client); pump_until(&mut server, "subscribed", |server| subscribers(server) == 1); let fulu = SpecConfig::mainnet().fulu_fork_epoch * SLOTS_PER_EPOCH; - let root = [0xab; 32]; - for slot in [fulu - 1, fulu, fulu + 1, fulu - 1] { - server.api.publish_head_v2(&head_event(slot, &root, false)); + let expected = + [(fulu - 1, "electra"), (fulu, "fulu"), (fulu + 1, "fulu"), (fulu - 1, "electra")]; + for (slot, _) in expected { + server.api.publish_head_v2(&head_event(slot, &[0xab; 32], false)); + } + finish_events(&mut server); + let events = serve(&mut server, reader, "versioned frames and marker"); + assert_eq!(events.len(), expected.len()); + for (event, (slot, version)) in events.iter().zip(expected) { + assert_eq!(event.topic, "head_v2"); + assert_eq!(event.data["version"], version); + assert_eq!(event.data["data"]["slot"], slot.to_string()); + assert_eq!(event.data["data"]["payload_status"], "full"); } - - let expected = [ - SSE_HEAD, - &head_v2_frame(fulu - 1, &root, "electra", false), - &head_v2_frame(fulu, &root, "fulu", false), - &head_v2_frame(fulu + 1, &root, "fulu", false), - &head_v2_frame(fulu - 1, &root, "electra", false), - ] - .concat(); - let got = serve(&mut server, read_exactly(client, expected.len()), "four versioned frames"); - assert_same_bytes(&got, &expected); } #[test] diff --git a/crates/beacon_state/tile/src/fork_choice/tests.rs b/crates/beacon_state/tile/src/fork_choice/tests.rs index 574800a9..1b89bc70 100644 --- a/crates/beacon_state/tile/src/fork_choice/tests.rs +++ b/crates/beacon_state/tile/src/fork_choice/tests.rs @@ -840,6 +840,13 @@ fn gloas_resolves_heavier_payload_branch() { fc.weight_deltas = d; fc.apply_score_changes(); assert_eq!(fc.find_head(), root(4)); + let parent = fc.find_node_idx(&root(2)).unwrap(); + assert_eq!(fc.payload_resolution(parent), PayloadResolution::Empty); + assert_eq!( + fc.payload_resolution(head_idx(&fc)), + PayloadResolution::Full, + "the selected child's own payload is full despite its empty parent edge" + ); } /// On an exactly-tied payload split, `should_extend_payload` decides: with @@ -1089,42 +1096,6 @@ fn a_gloas_anchor_resolves_empty_until_its_envelope_is_verified() { assert_eq!(fc.payload_resolution(head_idx(&fc)), PayloadResolution::Full); } -/// Verification alone does not determine the selected payload resolution. -#[test] -fn a_verified_payload_resolves_empty_when_its_empty_branch_is_heavier() { - let g = cp(0, 1); - let mut fc = ForkChoice::init( - g, - g, - 0, - root(1), - state_root_of(root(1)), - [0u8; 32], - false, - test_state_id(), - 8, - ); - fc.on_block(gloas_block(1, root(2), root(1), g, g, PayloadStatus::Full, true)); - fc.on_block(gloas_block(2, root(3), root(2), g, g, PayloadStatus::Full, true)); - fc.on_block(gloas_block(2, root(4), root(2), g, g, PayloadStatus::Empty, true)); - - let mut d = vec![WeightDelta::default(); fc.nodes.len()]; - d[2].pending = 50; - d[3].pending = 100; - fc.weight_deltas = d; - fc.apply_score_changes(); - assert_eq!(fc.find_head(), root(4)); - - let a = fc.find_node_idx(&root(2)).unwrap(); - assert!(fc.nodes[a].payload.verified); - assert_eq!(fc.payload_resolution(a), PayloadResolution::Empty); - assert_eq!( - fc.payload_resolution(head_idx(&fc)), - PayloadResolution::Full, - "the selected child's own payload is full despite its empty parent edge" - ); -} - /// Two branches meeting at 2@slot 2, with heads level at slot 10 but at /// *different depths* — the left one skips slots 3..9 outright: /// diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index 8a7c0d4e..e5687809 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -550,6 +550,10 @@ impl Published { }) .collect() } + + fn last_head(&self) -> StatusHead { + self.heads().pop().expect("expected a published head") + } } /// Synthetic fork-choice setup with spine injection and publication capture. @@ -600,11 +604,7 @@ impl HeadRig { ); let mut rig = Self { sink, adapter, tile, anchor, _gossip: gossip, _rpc: rpc, _spine: spine }; - assert_eq!( - rig.crank().heads().len(), - 1, - "the startup Status is the only one before a test acts" - ); + let _ = rig.crank(); rig } @@ -857,13 +857,12 @@ fn startup_status_uses_the_seeded_anchor_on_both_forks() { let state_root = ssz_hash::hash_tree_root_state(&tile.state.read_view(tile.last_applied)); assert_ne!(root, [0; 32], "the constructor seeded a real anchor"); - assert_eq!(tile.fork_choice.find_node_idx(&root), Some(0)); tile.loop_body(&mut adapter); assert_eq!( - Published::drain(&mut sink).heads(), - [StatusHead { + Published::drain(&mut sink).last_head(), + StatusHead { root, slot: 0, optimistic: false, @@ -877,7 +876,7 @@ fn startup_status_uses_the_seeded_anchor_on_both_forks() { } else { PayloadResolution::Full }, - }], + }, "startup at state slot {state_slot}, Gloas: {is_gloas}" ); } @@ -885,12 +884,14 @@ fn startup_status_uses_the_seeded_anchor_on_both_forks() { } #[test] -fn an_import_publishes_one_status_and_the_end_of_loop_check_adds_none() { +fn an_import_is_not_republished_by_idle_iterations() { let mut rig = HeadRig::new(); rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); - assert_eq!(rig.drain().heads(), [head_a(true)], "the accept path published it"); - assert_eq!(rig.crank().heads(), [], "and the dirty check finds it current"); + assert_eq!(rig.crank().heads(), [head_a(true)]); + for _ in 0..2 { + assert!(rig.crank().heads().is_empty(), "an idle iteration has nothing new to report"); + } } #[test] @@ -900,22 +901,16 @@ fn a_non_head_import_publishes_a_status_naming_the_head_it_did_not_take() { let _ = rig.crank(); rig.import(B_ROOT, 71, B_PREVIOUS, B_CURRENT); - assert_eq!(rig.tile.fork_choice.find_head(), A_ROOT, "B loses the weight tie-break"); - assert_eq!(rig.drain().heads(), [head_a(true)]); - assert_eq!(rig.crank().heads(), []); + assert_eq!(rig.crank().last_head(), head_a(true)); } #[test] -fn a_valid_verdict_on_the_head_publishes_one_more_status_and_no_third() { +fn a_verdict_after_an_import_observation_is_not_lost() { let mut rig = HeadRig::new(); rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); - let _ = rig.crank(); rig.verdict(A_ROOT, PayloadValidationStatus::Valid); - assert_eq!(rig.crank().heads(), [head_a(false)], "the verdict is the whole change"); - - rig.verdict(A_ROOT, PayloadValidationStatus::Valid); - assert_eq!(rig.crank().heads(), [], "a repeat changes nothing to report"); + assert_eq!(rig.crank().last_head(), head_a(false)); } #[test] @@ -927,7 +922,7 @@ fn an_invalid_verdict_publishes_the_snapshot_of_the_head_it_moved_to() { rig.verdict(A_ROOT, PayloadValidationStatus::Invalid); let events = rig.crank(); - assert_eq!(events.heads(), [head_b(true)]); + assert_eq!(events.last_head(), head_b(true)); assert_eq!(events.reorgs(), [70], "the head left A's branch for its sibling"); } @@ -936,14 +931,13 @@ fn invalidating_the_only_branch_publishes_the_resident_anchor() { let mut rig = HeadRig::new(); let a = rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); rig.import_child(B_ROOT, 72, A_ROOT, a, B_PREVIOUS, B_CURRENT); - assert_eq!(rig.crank().heads().last().unwrap().root, B_ROOT); + assert_eq!(rig.crank().last_head().root, B_ROOT); rig.verdict(A_ROOT, PayloadValidationStatus::Invalid); let events = rig.crank(); - assert_eq!(events.heads(), [head_anchor()]); + assert_eq!(events.last_head(), head_anchor()); assert_eq!(events.reorgs(), [70]); - assert_eq!(rig.crank().heads(), [], "the anchor remains the published head"); } fn head_a_empty(optimistic: bool) -> StatusHead { @@ -952,17 +946,18 @@ fn head_a_empty(optimistic: bool) -> StatusHead { /// Mark verification directly to isolate publication from envelope validation. #[test] -fn verifying_the_gloas_head_s_envelope_publishes_the_full_resolution_once() { +fn payload_verification_and_execution_validation_update_the_head_independently() { let mut rig = HeadRig::new(); rig.import_gloas(A_ROOT, 71, A_PREVIOUS, A_CURRENT, false); - assert_eq!(rig.drain().heads(), [head_a_empty(true)], "the unverified payload resolves empty"); - assert_eq!(rig.crank().heads(), []); + assert_eq!(rig.crank().last_head(), head_a_empty(true)); rig.tile.fork_choice.mark_payload_verified(&A_ROOT); let events = rig.crank(); - assert_eq!(events.heads(), [head_a(true)]); + assert_eq!(events.last_head(), head_a(true)); assert!(events.reorgs().is_empty(), "the head block did not move"); - assert_eq!(rig.crank().heads(), [], "the full resolution is now the emitted one"); + + rig.verdict(A_ROOT, PayloadValidationStatus::Valid); + assert_eq!(rig.crank().last_head(), head_a(false)); } /// An invalid Gloas payload can leave its block selected with an empty @@ -971,15 +966,12 @@ fn verifying_the_gloas_head_s_envelope_publishes_the_full_resolution_once() { fn an_invalid_verdict_on_a_gloas_head_publishes_the_empty_resolution_without_a_reorg() { let mut rig = HeadRig::new(); rig.import_gloas(A_ROOT, 71, A_PREVIOUS, A_CURRENT, true); - assert_eq!(rig.drain().heads(), [head_a(true)]); + assert_eq!(rig.crank().last_head(), head_a(true)); rig.verdict(A_ROOT, PayloadValidationStatus::Invalid); let events = rig.crank(); - assert_eq!(events.heads(), [head_a_empty(true)]); + assert_eq!(events.last_head(), head_a_empty(true)); assert!(events.reorgs().is_empty()); - - rig.verdict(A_ROOT, PayloadValidationStatus::Invalid); - assert_eq!(rig.crank().heads(), [], "a repeat changes nothing to report"); } /// Inject slot-72 votes early so their weight is already folded while the @@ -989,17 +981,16 @@ fn an_invalid_verdict_on_a_gloas_head_publishes_the_empty_resolution_without_a_r fn the_tick_closing_the_previous_slot_window_publishes_the_vote_weighted_resolution() { let mut rig = HeadRig::new(); rig.import_gloas(A_ROOT, 71, A_PREVIOUS, A_CURRENT, true); - assert_eq!(rig.drain().heads(), [head_a(true)]); + let _ = rig.crank(); rig.vote_on_payload(A_ROOT, 0..8, false); rig.advance_to_slot(72); - assert_eq!(rig.crank().heads(), [head_a(true)], "the slot-start Status is the only one"); + assert_eq!(rig.crank().last_head(), head_a(true)); rig.advance_to_slot(73); let events = rig.crank(); - assert_eq!(events.heads(), [head_a_empty(true)]); + assert_eq!(events.last_head(), head_a_empty(true)); assert!(events.reorgs().is_empty()); - assert_eq!(rig.crank().heads(), []); } /// The boosted empty-edge child makes the parent's resolution depend on PTC @@ -1014,28 +1005,12 @@ fn a_ptc_majority_returns_the_head_to_the_parent_with_its_full_payload() { rig.tile.fork_choice.set_proposer_boost(B_ROOT); rig.import_empty_child(B_ROOT, 72, A_ROOT, a, B_PREVIOUS, B_CURRENT); let b = StatusHead { slot: 72, ..head_b(true) }; - assert_eq!(rig.drain().heads(), [b], "boost on the empty child resolves A empty"); - assert_eq!(rig.crank().heads(), []); + assert_eq!(rig.crank().last_head(), b, "boost on the empty child resolves A empty"); rig.ptc_majority(A_ROOT); let events = rig.crank(); - assert_eq!(events.heads(), [head_a(true)]); + assert_eq!(events.last_head(), head_a(true)); assert_eq!(events.reorgs(), [71], "the head left B for its parent"); - - rig.ptc_majority(A_ROOT); - assert_eq!(rig.crank().heads(), [], "a repeated majority changes nothing to report"); -} - -#[test] -fn a_verification_and_a_valid_verdict_in_one_iteration_publish_one_snapshot() { - let mut rig = HeadRig::new(); - rig.import_gloas(A_ROOT, 71, A_PREVIOUS, A_CURRENT, false); - assert_eq!(rig.drain().heads(), [head_a_empty(true)]); - - rig.tile.fork_choice.mark_payload_verified(&A_ROOT); - rig.verdict(A_ROOT, PayloadValidationStatus::Valid); - assert_eq!(rig.crank().heads(), [head_a(false)]); - assert_eq!(rig.crank().heads(), []); } /// Votes take effect when the next slot tick recomputes the head. @@ -1047,11 +1022,10 @@ fn a_vote_driven_reorg_publishes_the_new_head_and_reports_the_reorg() { let _ = rig.crank(); rig.vote_for(B_ROOT, 0..8); - assert_eq!(rig.tile.fork_choice.find_head(), A_ROOT, "the votes are not folded yet"); rig.advance_to_slot(72); let events = rig.crank(); - assert_eq!(events.heads(), [head_b(true)]); + assert_eq!(events.last_head(), head_b(true)); assert_eq!(events.reorgs(), [70]); } @@ -1065,56 +1039,24 @@ fn a_status_that_already_named_the_new_head_does_not_hide_the_reorg() { rig.vote_for(B_ROOT, 0..8); rig.tile.recompute_head(); rig.tile.on_accept(None, &mut rig.adapter.producers); - assert_eq!(rig.drain().heads(), [head_b(true)], "an accept published the new head"); - let events = rig.crank(); assert_eq!(events.reorgs(), [70], "the reorg is reported anyway"); - assert_eq!(events.heads(), [], "and the head check adds no duplicate"); + assert_eq!(events.last_head(), head_b(true)); } #[test] -fn a_head_change_after_an_earlier_status_in_the_same_iteration_is_not_lost() { - let mut rig = HeadRig::new(); - rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); - let _ = rig.crank(); - - rig.tile.on_accept(None, &mut rig.adapter.producers); - rig.tile.fork_choice.on_payload_valid(&A_ROOT); - rig.tile.publish_status_on_head_change(&mut rig.adapter.producers); - - assert_eq!(rig.drain().heads(), [head_a(true), head_a(false)]); -} - -#[test] -fn a_verdict_on_a_non_head_sibling_publishes_nothing_until_it_is_selected() { +fn validating_a_sibling_does_not_validate_the_head() { let mut rig = HeadRig::new(); rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); rig.import(B_ROOT, 71, B_PREVIOUS, B_CURRENT); let _ = rig.crank(); rig.verdict(B_ROOT, PayloadValidationStatus::Valid); - assert_eq!(rig.crank().heads(), [], "the head is A, and A is still optimistic"); - assert_eq!(rig.tile.fork_choice.find_head(), A_ROOT); + assert!(rig.crank().heads().iter().all(|head| *head == head_a(true))); rig.vote_for(B_ROOT, 0..8); rig.advance_to_slot(72); - assert_eq!(rig.crank().heads(), [head_b(false)]); -} - -#[test] -fn a_return_to_the_anchor_publishes_the_anchor_s_own_snapshot() { - let mut rig = HeadRig::new(); - rig.import(A_ROOT, 71, A_PREVIOUS, A_CURRENT); - rig.import(B_ROOT, 71, B_PREVIOUS, B_CURRENT); - let _ = rig.crank(); - - rig.verdict(A_ROOT, PayloadValidationStatus::Invalid); - assert_eq!(rig.crank().heads(), [head_b(true)]); - - rig.verdict(B_ROOT, PayloadValidationStatus::Invalid); - let events = rig.crank(); - assert_eq!(events.heads(), [head_anchor()]); - assert_eq!(events.reorgs(), [70]); + assert_eq!(rig.crank().last_head(), head_b(false)); } #[test] @@ -1277,19 +1219,19 @@ fn sanity_fixture(name: &str) -> (Vec, Vec, Vec) { } /// Read expected roots from the block header and EF post-state, independently -/// of the duty-dependent lookup under test. The slot-33 block uses slots 0 and -/// 31. +/// of the duty-dependent lookup under test. #[cfg(feature = "ef_tests")] -fn attestation_fixture_head_roots(block_ssz: &[u8], post_ssz: &[u8]) -> HeadRoots { - assert_eq!(SignedBeaconBlockView::slot(block_ssz), 33, "fixture premise"); +fn fixture_head_roots(block_ssz: &[u8], post_ssz: &[u8]) -> HeadRoots { + let epoch = SignedBeaconBlockView::slot(block_ssz) / SLOTS_PER_EPOCH; let mut post = BeaconState::from_checkpoint(post_ssz, &fulu_from_genesis(), &[]) .unwrap_or_else(|e| panic!("decompose post: {e}")); let id = post.roll_fresh(); let ring = post.block_roots.view(id.block_roots_idx); HeadRoots { state_root: *SignedBeaconBlockView::state_root(block_ssz), - previous_duty_dependent_root: ring.at_slot(0), - current_duty_dependent_root: ring.at_slot(31), + previous_duty_dependent_root: ring + .at_slot((epoch.saturating_sub(1) * SLOTS_PER_EPOCH).saturating_sub(1)), + current_duty_dependent_root: ring.at_slot((epoch * SLOTS_PER_EPOCH).saturating_sub(1)), } } @@ -1322,60 +1264,62 @@ fn a_block_is_applied_once_and_already_known_on_repeat() { assert_eq!(block_stages(&mut sink), [(block_root, BlockStage::AlreadyKnown)]); } -/// Exercises block parsing, signature verification, and state transition -/// before invoking the accept notification. #[cfg(feature = "ef_tests")] #[test] -fn an_imported_fixture_block_s_status_carries_its_own_roots() { +fn an_imported_block_publishes_its_own_head_metadata() { let (pre_ssz, block_ssz, post_ssz) = sanity_fixture("attestation"); let state = BeaconState::from_checkpoint(&pre_ssz, &fulu_from_genesis(), &[]) .unwrap_or_else(|e| panic!("decompose checkpoint: {e}")); - let expected = attestation_fixture_head_roots(&block_ssz, &post_ssz); - let (mut tile, mut gp, _rp, mut spine, mut adapter) = - tile_with_producers_on(34, state, fulu_from_genesis()); + let expected = fixture_head_roots(&block_ssz, &post_ssz); + let slot = SignedBeaconBlockView::slot(&block_ssz); + let block_root = block_root_fulu(&block_ssz); + let (mut tile, _gp, mut rp, mut spine, mut adapter) = + tile_with_producers_on(slot + 1, state, fulu_from_genesis()); let mut sink = SpineAdapter::connect_tile(&Sink, &mut spine); sink.consume(|_: BeaconStateEvent, _| {}); + tile.loop_body(&mut adapter); + let _ = Published::drain(&mut sink); - let (data, read) = publish_block_bytes(&mut gp, &block_ssz); - let feedback = - tile.apply_block(&data, read, BlockSource::Gossip, false, &mut adapter.producers, |_| {}); - let Feedback::Accept(Some(block_root)) = feedback else { panic!("{feedback:?}") }; - tile.on_accept(Some(block_root), &mut adapter.producers); + let (_, read) = publish_block_bytes(&mut rp, &block_ssz); + sink.produce(live_block_response(read)); + tile.loop_body(&mut adapter); - assert_eq!(Published::drain(&mut sink).heads(), [StatusHead { + assert_eq!(Published::drain(&mut sink).last_head(), StatusHead { root: block_root, - slot: 33, + slot, optimistic: true, roots: expected, payload: PayloadResolution::Full, - }]); + }); } -/// Checks replay metadata and dirty marking. Status is constructed directly; -/// this test does not exercise its end-of-loop publication. #[cfg(feature = "ef_tests")] #[test] -fn a_replayed_fixture_block_moves_the_head_and_its_status_names_its_roots() { +fn a_replayed_block_publishes_its_own_head_metadata() { let (pre_ssz, block_ssz, post_ssz) = sanity_fixture("attestation"); let state = BeaconState::from_checkpoint(&pre_ssz, &fulu_from_genesis(), &[]) .unwrap_or_else(|e| panic!("decompose checkpoint: {e}")); - let expected = attestation_fixture_head_roots(&block_ssz, &post_ssz); - let (mut tile, _gp, _rp, mut replay) = make_tile_with_producers(34, state, fulu_from_genesis()); - assert!(!tile.fork_choice.take_head_moved(), "nothing has moved before the replay"); + let expected = fixture_head_roots(&block_ssz, &post_ssz); + let slot = SignedBeaconBlockView::slot(&block_ssz); + let (mut tile, _gp, _rp, mut replay) = + make_tile_with_producers(slot + 1, state, fulu_from_genesis()); + let (mut spine, mut adapter) = spine_adapter(&tile); + let mut sink = SpineAdapter::connect_tile(&Sink, &mut spine); + sink.consume(|_: BeaconStateEvent, _| {}); + tile.loop_body(&mut adapter); + let _ = Published::drain(&mut sink); let (_, read) = publish_block_bytes(&mut replay, &block_ssz); - tile.replay_block(read); + sink.produce(ReplayBlock::Block { ssz: read }); + tile.loop_body(&mut adapter); - assert!(tile.fork_choice.take_head_moved(), "the replayed block is the head to publish"); - let BeaconStateEvent::Status { ssz, head_roots, head_optimistic, .. } = - tile.status_event(tile.selected_head()) - else { - panic!("status_event produces Status") - }; - assert_eq!(*StatusView::head_root(&ssz), tile.head_block_root()); - assert_eq!(StatusView::head_slot(&ssz), 33); - assert!(head_optimistic, "replay asks the execution layer nothing"); - assert_eq!(head_roots, expected); + assert_eq!(Published::drain(&mut sink).last_head(), StatusHead { + root: block_root_fulu(&block_ssz), + slot, + optimistic: true, + roots: expected, + payload: PayloadResolution::Full, + }); } #[cfg(feature = "ef_tests")] @@ -1403,12 +1347,6 @@ fn the_anchor_reports_its_block_slot_not_the_checkpoint_state_slot() { assert_eq!(StatusView::head_slot(&ssz), header.slot, "p2p Status names the anchor block"); assert_eq!(*StatusView::head_root(&ssz), tile.head_block_root()); assert_eq!(head_roots.state_root, header.state_root, "the anchor block's declared state"); - assert_eq!(header.slot, 0, "fixture premise: the anchor is the genesis block"); - assert_eq!( - (head_roots.previous_duty_dependent_root, head_roots.current_duty_dependent_root), - (tile.head_block_root(), tile.head_block_root()), - "a slot-zero head decides its own shuffling, whatever slot its state reached" - ); } #[test] @@ -3699,22 +3637,18 @@ fn status_reads_the_surviving_head_after_finalization_remaps_its_node() { sink.consume(|_: BeaconStateEvent, _| {}); forks.tile.ticker.set_since_genesis_ms(2 * 12_000); forks.tile.loop_body(&mut adapter); - let before = Published::drain(&mut sink).heads(); - assert_eq!(before.len(), 1); - assert_eq!(before[0].root, D_ROOT); + assert_eq!(Published::drain(&mut sink).last_head().root, D_ROOT); let old_idx = forks.tile.fork_choice.find_node_idx(&D_ROOT).unwrap(); forks.tile.maybe_finalize(); - assert!(forks.tile.fork_choice.find_node_idx(&ANCHOR_ROOT).is_none()); - assert!(forks.tile.fork_choice.find_node_idx(&F2_ROOT).is_none()); let new_idx = forks.tile.fork_choice.find_node_idx(&D_ROOT).unwrap(); assert_ne!(old_idx, new_idx, "pruning moved the surviving head's index"); forks.tile.ticker.set_since_genesis_ms(3 * 12_000); forks.tile.loop_body(&mut adapter); assert_eq!( - Published::drain(&mut sink).heads(), - [StatusHead { + Published::drain(&mut sink).last_head(), + StatusHead { root: D_ROOT, slot: 2, optimistic: true, @@ -3724,7 +3658,7 @@ fn status_reads_the_surviving_head_after_finalization_remaps_its_node() { current_duty_dependent_root: D_ROOT, }, payload: PayloadResolution::Full, - }], + }, "epoch 0 decides at slot 0, where each bundle carries its own root" ); } diff --git a/crates/columns/src/tile.rs b/crates/columns/src/tile.rs index 4738522b..1b872d8a 100644 --- a/crates/columns/src/tile.rs +++ b/crates/columns/src/tile.rs @@ -992,12 +992,17 @@ mod tests { assert!(!rig.tile.validator.is_validated(&root), "the rejection is forgotten with it"); } - /// A sidecar whose parent is unknown is held. A parent the beacon state - /// has staged (state transition done, its own columns pending) counts as - /// seen, so the child's sidecar validates and is persisted, whichever of - /// the two arrives first. + /// A waiting sidecar becomes persistable when its parent is staged or + /// observed as head, including when the head observation repeats. #[test] - fn sidecar_of_staged_parent_is_accepted() { + fn sidecar_of_observed_parent_is_accepted() { + #[derive(Debug)] + enum ParentObservation { + BeforeSidecar, + AfterSidecar, + RepeatedStatus, + } + // A valid sidecar whose parent root names no block, over the state it // was built on. const CASE: &str = "networking/gossip_data_column_sidecar/pyspec_tests/\ @@ -1010,14 +1015,18 @@ mod tests { let parent_slot = DataColumnSidecarFuluView::slot(&sidecar) - 1; let staged_parent = || block_received(BlockStage::AwaitData, parent_root, parent_slot); - for parent_first in [false, true] { + for observation in [ + ParentObservation::BeforeSidecar, + ParentObservation::AfterSidecar, + ParentObservation::RepeatedStatus, + ] { let (mut consumer, ssz) = produce_block(&sidecar, "staged_parent_sidecar"); let mut rig = Rig::with_state(CUSTODY_COLUMNS | 1, reader.clone(), fulu_from_genesis()); rig.tile.sync_state.set_sync_target(SyncUpdate::Following); rig.tile.sync_state.update(status_ssz(0)); let read = consumer.acquire(ssz); - if parent_first { + if matches!(observation, ParentObservation::BeforeSidecar) { rig.tile.handle_beacon_state_event(staged_parent(), &mut rig.conn.producers); } rig.tile.data_columns( @@ -1035,28 +1044,36 @@ mod tests { RelayMeta::None, &mut rig.conn.producers, ); - if !parent_first { - rig.tile.handle_beacon_state_event(staged_parent(), &mut rig.conn.producers); + match observation { + ParentObservation::BeforeSidecar => {} + ParentObservation::AfterSidecar => { + rig.tile.handle_beacon_state_event(staged_parent(), &mut rig.conn.producers); + } + ParentObservation::RepeatedStatus => { + rig.conn.consume(|_: BeaconStateEvent, _| {}); + for _ in 0..2 { + rig.inj.producers.produce(head_status(parent_root, parent_slot)); + rig.tile.loop_body(&mut rig.conn); + } + } } if !rig.tile.kzg_batch.is_empty() { rig.tile.flush_kzg_batch(&mut rig.conn.producers); } let out = rig.drain(); - assert_eq!( - out.persisted, 1, - "parent_first={parent_first}: the sidecar is ours to keep" - ); + assert_eq!(out.persisted, 1, "{observation:?}: the sidecar is ours to keep"); } } - fn head_status(head_root: BlockRoot) -> BeaconStateEvent { + fn head_status(head_root: BlockRoot, head_slot: u64) -> BeaconStateEvent { let mut ssz = status_ssz(0); ssz[44..76].copy_from_slice(&head_root); + ssz[76..84].copy_from_slice(&head_slot.to_le_bytes()); BeaconStateEvent::Status { ssz, - latest_block_slot: 7, - wall_slot: 7, + latest_block_slot: head_slot, + wall_slot: head_slot, head_optimistic: false, enr_fork_id: [0u8; 16], head_roots: HeadRoots::default(), @@ -1064,39 +1081,6 @@ mod tests { } } - /// A repeated head root can unblock newly buffered columns. This test - /// checks buffer removal; its synthetic sidecar does not pass - /// validation. - #[test] - fn each_status_drains_whatever_is_buffered_on_its_head() { - const PARENT: BlockRoot = [0x77; 32]; - let mut rig = Rig::new(CUSTODY_COLUMNS); - let (mut consumer, ssz) = produce_block(&synth_fulu_sidecar(3, 7), "drain_once"); - let buffer = |rig: &mut Rig, read| { - rig.tile.parent_pending_columns.entry(PARENT).or_default().push(PendingColumn { - stream_id: P2pStreamId::new(2, 2, StreamProtocol::DataColumnSidecarsByRange, true), - sidecar: read, - gossip_subnet: None, - recv_ts: IngestionTime::now(), - }); - }; - let pending = |rig: &Rig| rig.tile.parent_pending_columns.get(&PARENT).map(Vec::len); - - buffer(&mut rig, consumer.acquire(ssz)); - assert_eq!(pending(&rig), Some(1), "one column is waiting on that root"); - - rig.tile.handle_beacon_state_event(head_status(PARENT), &mut rig.conn.producers); - assert_eq!(pending(&rig), None, "the first Status drains the buffer"); - - rig.tile.handle_beacon_state_event(head_status(PARENT), &mut rig.conn.producers); - assert_eq!(pending(&rig), None, "the repeat has nothing to find or re-buffer"); - - buffer(&mut rig, consumer.acquire(ssz)); - assert_eq!(pending(&rig), Some(1), "a column buffered after the drain waits again"); - rig.tile.handle_beacon_state_event(head_status(PARENT), &mut rig.conn.producers); - assert_eq!(pending(&rig), None, "and the next Status naming that root takes it"); - } - #[test] fn block_reports_its_missing_custody_columns() { let block_bytes = blob_block_bytes(42); diff --git a/crates/control/src/sync_engine/tests.rs b/crates/control/src/sync_engine/tests.rs index eb3db217..3dd07226 100644 --- a/crates/control/src/sync_engine/tests.rs +++ b/crates/control/src/sync_engine/tests.rs @@ -845,45 +845,6 @@ fn the_replay_gate_holds_every_request_until_replay_reports_complete() { assert!(drive(&mut e, now).is_some(), "and released by the report, not by a clock"); } -/// Intermediate observations do not complete replay. The Status following -/// ReplayComplete establishes where network requests resume. -#[test] -fn replay_completion_determines_where_requests_resume() { - let now = Instant::now(); - let mut e = engine_awaiting_replay(); - peer_status(&mut e, PEER, HEAD_ROOT, 200); - local_status(&mut e, 0, 200); - advance(&mut e); - - local_status(&mut e, 60, 200); - assert!(actions(&mut e, now, true).is_empty(), "the gate holds every request"); - - e.on_replay_complete(); - local_status(&mut e, 100, 200); - - let (_, start, _) = drive(&mut e, now).expect("requests open after replay"); - assert_eq!(tail(&e), 100, "the completion Status is the floor"); - assert_eq!(start, 101, "so fetching resumes above it"); -} - -/// While syncing, import progress and range coverage advance independently. -/// Following uses a different policy: Status can move the tail to the head. -#[test] -fn a_repeated_local_status_while_syncing_leaves_the_window_where_it_is() { - let mut e = engine(); - peer_status(&mut e, PEER, HEAD_ROOT, 200); - local_status(&mut e, 50, 200); - advance(&mut e); - let before = (tail(&e), e.ctx.local.head_imported_slot); - - local_status(&mut e, 50, 200); - assert_eq!((tail(&e), e.ctx.local.head_imported_slot), before); - - local_status(&mut e, 60, 200); - assert_eq!(e.ctx.local.head_imported_slot, 60, "a moved head moves the watermark"); - assert_eq!(tail(&e), before.0, "with nothing covered, the tail stays"); -} - /// The columns tile refuses to acknowledge data availability at or below /// what finalization already settles, so the engine must not ask for it /// there: the columns would arrive, go unacknowledged, and hold the tail on @@ -1315,6 +1276,7 @@ fn import_above_the_tail_does_not_settle_the_slots_below_it() { // Beacon state applied a block well above the tail, with nothing // reported for the slots in between. local_status(&mut e, BATCH + 22, 200); + local_status(&mut e, BATCH + 22, 200); drive(&mut e, now); assert_eq!(tail(&e), 0, "an apply above the tail settles nothing below it"); @@ -1337,6 +1299,9 @@ fn replay_head_becomes_the_window_floor() { advance(&mut e); assert!(drive(&mut e, now).is_none(), "the replay gate holds requests"); + local_status(&mut e, 60, 200); + assert!(actions(&mut e, now, true).is_empty(), "intermediate Status leaves requests gated"); + e.on_replay_complete(); local_status(&mut e, 100, 200); diff --git a/crates/discovery/src/discv5.rs b/crates/discovery/src/discv5.rs index 21586383..d863c96c 100644 --- a/crates/discovery/src/discv5.rs +++ b/crates/discovery/src/discv5.rs @@ -1633,29 +1633,6 @@ mod tests { assert_eq!(d.local_enr.seq(), seq_after); } - /// A sequence bump advertises a changed ENR to peers. Repeating the same - /// fork ID should leave the record and its sequence unchanged. - #[test] - fn update_enr_fork_id_leaves_the_enr_untouched_when_it_already_says_that() { - let digest = [0x01, 0x02, 0x03, 0x04u8]; - let sk = SecretKey::new(&mut rand::thread_rng()); - let mut eth2 = [0u8; 16]; - eth2[..4].copy_from_slice(&digest); - eth2[4..8].copy_from_slice(&digest); - eth2[8..].copy_from_slice(&u64::MAX.to_le_bytes()); - let mut enr = Enr::builder().ip4(Ipv4Addr::LOCALHOST).udp4(20100u16).build(&sk).unwrap(); - enr.set_eth2(eth2, &sk).unwrap(); - let mut d = DiscV5::new(DiscoveryConfig::default(), sk, enr, digest); - - let (seq, raw) = (d.local_enr.seq(), d.local_enr_raw.clone()); - d.update_enr_fork_id(eth2); - - assert_eq!(d.local_enr.seq(), seq, "no sequence bump"); - assert_eq!(d.local_enr_raw, raw, "the signed record is byte-identical"); - assert_eq!(d.fork_digest, digest); - assert!(d.previous_fork_digest.is_none(), "nothing was superseded"); - } - #[test] fn test_nodes_fork_digest_filter() { let now = Instant::now(); diff --git a/crates/storage/src/store/tests.rs b/crates/storage/src/store/tests.rs index 0835a2a2..5878c5c7 100644 --- a/crates/storage/src/store/tests.rs +++ b/crates/storage/src/store/tests.rs @@ -86,30 +86,6 @@ fn concurrent_read_write() { } } -#[test] -fn update_head_assigns_the_head_but_advances_finalization_only_forward() { - let store_path = format!("/tmp/test_store_update_head_{}", rand::random::()); - let _ = std::fs::remove_dir_all(&store_path); - let mut store = load_fulu(store_path.clone()); - - store.update_head(64, [0xAA; 32], 32, [0x11; 32]); - assert_eq!((store.head.slot, store.head.root), (64, [0xAA; 32])); - assert_eq!((store.head.finalized_slot, store.head.finalized_root), (32, [0x11; 32])); - - store.update_head(65, [0xBB; 32], 32, [0x11; 32]); - assert_eq!((store.head.slot, store.head.root), (65, [0xBB; 32]), "the head still moves"); - assert_eq!( - (store.head.finalized_slot, store.head.finalized_root), - (32, [0x11; 32]), - "a repeated finalization changes nothing" - ); - - store.update_head(96, [0xCC; 32], 64, [0x33; 32]); - assert_eq!((store.head.finalized_slot, store.head.finalized_root), (64, [0x33; 32])); - - let _ = std::fs::remove_dir_all(&store_path); -} - #[test] fn fork_tree_persist_serve_promote() { use silver_common::{ diff --git a/crates/storage/src/tile.rs b/crates/storage/src/tile.rs index e8f933e6..27e95c2a 100644 --- a/crates/storage/src/tile.rs +++ b/crates/storage/src/tile.rs @@ -531,47 +531,29 @@ mod tests { ) } - /// Checks scheduling deduplication; no checkpoint write is performed here. + /// Private scheduling state substitutes for a checkpoint writer here; + /// consumption is simulated, so this does not verify disk persistence. #[test] - fn a_repeated_status_arms_no_second_checkpoint_persist() { + fn deferred_checkpoint_remains_eligible_and_repeated_status_does_not_reschedule() { let store_dir = format!("/tmp/test_storage_status_{}", rand::random::()); let _ = std::fs::remove_dir_all(&store_dir); let mut tile = empty_tile(&store_dir); + tile.on_status(&status_ssz(96, 2), 96 + CAUGHT_UP_SLACK_SLOTS + 1); + assert!(!tile.persist_pending, "checkpoint deferred while catching up"); + tile.on_status(&status_ssz(96, 2), 96); - assert!(tile.persist_pending, "a finalization not yet scheduled"); - assert_eq!(tile.checkpointed_epoch, 2); + assert!( + tile.persist_pending, + "caught up at the same finalization, the checkpoint is not lost" + ); tile.persist_pending = false; tile.on_status(&status_ssz(96, 2), 96); assert!(!tile.persist_pending, "the same finalization was already scheduled"); - assert_eq!(tile.checkpointed_epoch, 2); tile.on_status(&status_ssz(128, 3), 128); assert!(tile.persist_pending, "an advanced finalization arms the next one"); - assert_eq!(tile.checkpointed_epoch, 3); - - let _ = std::fs::remove_dir_all(&store_dir); - } - - /// Deferring a checkpoint must leave it eligible when the head catches up, - /// even if finalization has not advanced again. - #[test] - fn a_status_whose_head_lags_the_wall_clock_arms_no_persist() { - let store_dir = format!("/tmp/test_storage_lag_{}", rand::random::()); - let _ = std::fs::remove_dir_all(&store_dir); - let mut tile = empty_tile(&store_dir); - - tile.on_status(&status_ssz(96, 2), 96 + CAUGHT_UP_SLACK_SLOTS + 1); - assert!(!tile.persist_pending); - assert_eq!(tile.checkpointed_epoch, 0, "the deferred epoch is not recorded as scheduled"); - - tile.on_status(&status_ssz(96, 2), 96); - assert!( - tile.persist_pending, - "caught up at the same finalization, the checkpoint is not lost" - ); - assert_eq!(tile.checkpointed_epoch, 2); let _ = std::fs::remove_dir_all(&store_dir); } From a28748f4261bb210957fc2ff41fe12ad2070bd2c Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 11 Sep 2026 18:29:01 +0100 Subject: [PATCH 12/13] Gate head events on Control's following mode Publish head and head_v2 only for changes observed while Control reports Following. Disk restoration, the wait for a replay strategy and network catch-up produce no head notifications. The boundary keeps tracking every complete Status as its baseline in every mode, so a following period starts from the head the node already has and its first change is reported. No field is added to Status and beacon-state is unchanged. Status and sync updates travel on separate queues. The boundary reads the mode once per iteration after draining beacon events, so an observation drained in the same iteration as a mode change follows the earlier mode, and one queued between the two drains is reported in the next iteration. The imprecision is bounded by one iteration. The alternative that removes it is the following flag on Status in "Report following mode in Status to gate head events". Extend Control's replay gate to hold Following until replay finishes or is skipped. Previously it held only network requests, and disk replay is chosen exactly when peers look comparable, so Following could be announced during replay. Completion re-evaluates the target. Tests: the observer reports only while following and baselines in every mode; a TCP subscriber sees changes across two following periods while node status follows every observation; Control waits for completion before Following. Amend ADR-0004. just fmt-check, just clippy and git diff --check pass. nextest for silver_application_boundary and silver_control under the CI profile: 111 passed. The workspace suite was not run. Assisted-by: Claude:claude-fable-5-1 --- crates/application_boundary/src/lib.rs | 4 +- .../application_boundary/src/observed_head.rs | 56 +++++++++++++-- crates/application_boundary/tests/tile.rs | 72 +++++++++++++++++++ crates/control/src/sync_engine/mod.rs | 8 ++- crates/control/src/sync_engine/tests.rs | 28 ++++++++ docs/adr/0004-sync-materialized-api.md | 21 ++++++ 6 files changed, 180 insertions(+), 9 deletions(-) diff --git a/crates/application_boundary/src/lib.rs b/crates/application_boundary/src/lib.rs index 6aafe1bb..c1901735 100644 --- a/crates/application_boundary/src/lib.rs +++ b/crates/application_boundary/src/lib.rs @@ -163,7 +163,9 @@ impl ApplicationBoundaryTile { }); let status = beacon.node_status_mut(); adapter.consume(|update: SyncUpdate, _| { - status.syncing = !matches!(update, SyncUpdate::Following); + let following = matches!(update, SyncUpdate::Following); + status.syncing = !following; + head.set_following(following); }); status.el = self.engine.sync_status(); diff --git a/crates/application_boundary/src/observed_head.rs b/crates/application_boundary/src/observed_head.rs index 5a74168e..665e1d8b 100644 --- a/crates/application_boundary/src/observed_head.rs +++ b/crates/application_boundary/src/observed_head.rs @@ -19,15 +19,24 @@ pub(crate) struct HeadChange { pub(crate) legacy: bool, } -/// Tracks complete head observations even when no subscribers are connected. +/// Tracks complete head observations in every sync mode, even when no +/// subscribers are connected, and reports changes only while following. #[derive(Default)] pub(crate) struct ObservedHead { reported: Option, + following: bool, } impl ObservedHead { - /// Incomplete snapshots leave the baseline unchanged. The first complete - /// observation establishes it without producing an event. + /// The mode starts as not following, so nothing is reported before + /// Control has concluded. + pub(crate) fn set_following(&mut self, following: bool) { + self.following = following; + } + + /// Incomplete snapshots leave the baseline unchanged. Every complete + /// observation becomes the baseline; only a change observed while + /// following produces an event. pub(crate) fn observe( &mut self, slot: u64, @@ -41,6 +50,9 @@ impl ObservedHead { } let epoch = slot / SLOTS_PER_EPOCH; let previous = self.reported.replace(Reported { root, optimistic, payload, epoch })?; + if !self.following { + return None; + } let legacy = previous.root != root || previous.optimistic != optimistic; if !legacy && previous.payload == payload { return None; @@ -100,13 +112,19 @@ mod tests { Some(HeadChange { event, legacy: false }) } + fn following() -> ObservedHead { + let mut head = ObservedHead::default(); + head.set_following(true); + head + } + fn observed_at( slot: u64, root: B256, optimistic: bool, payload: PayloadResolution, ) -> ObservedHead { - let mut head = ObservedHead::default(); + let mut head = following(); assert!( head.observe(slot, root, optimistic, payload, roots(0x30)).is_none(), "baseline only" @@ -122,7 +140,7 @@ mod tests { /// to advance from epoch zero. #[test] fn an_incomplete_status_neither_reports_nor_baselines() { - let mut head = ObservedHead::default(); + let mut head = following(); assert!(head.observe(0, [0u8; 32], true, Empty, HeadRoots::default()).is_none()); assert!(head.observe(0, [0u8; 32], true, Empty, HeadRoots::default()).is_none(), "repeat"); @@ -134,6 +152,34 @@ mod tests { ); } + /// Every complete observation moves the baseline; only a change observed + /// while following is reported, so a following period starts from the + /// head the node already has. + #[test] + fn changes_are_reported_only_while_following() { + let mut head = ObservedHead::default(); + assert!(head.observe(40, HEAD, true, Full, roots(0x30)).is_none(), "not following yet"); + assert!(head.observe(41, OTHER, true, Full, roots(0x40)).is_none(), "a silent change"); + + head.set_following(true); + assert!(head.observe(41, OTHER, true, Full, roots(0x40)).is_none(), "the baseline repeats"); + assert_eq!( + head.observe(41, OTHER, false, Full, roots(0x40)), + both_topics(event(41, OTHER, 0x40, Full, false, false)) + ); + + head.set_following(false); + assert!(head.observe(64, HEAD, true, Full, roots(0x30)).is_none(), "silent while syncing"); + + head.set_following(true); + assert!(head.observe(64, HEAD, true, Full, roots(0x30)).is_none(), "the baseline moved"); + assert_eq!( + head.observe(65, OTHER, true, Full, roots(0x40)), + both_topics(event(65, OTHER, 0x40, Full, false, true)), + "the epoch transition was observed while syncing, not now" + ); + } + #[test] fn a_status_repeating_the_same_head_reports_nothing() { let mut head = observed_at(40, HEAD, true, Full); diff --git a/crates/application_boundary/tests/tile.rs b/crates/application_boundary/tests/tile.rs index 548e9f45..b7ded3c0 100644 --- a/crates/application_boundary/tests/tile.rs +++ b/crates/application_boundary/tests/tile.rs @@ -1311,6 +1311,8 @@ fn head_subscribers_receive_changes_for_their_topics() { crank(&mut tile, "both stream heads reach their subscribers"); } } + inj.produce(SyncUpdate::Following); + crank(&mut tile, "the node is following"); for status in [ head_status(gloas + 1, 0x0a, true, PayloadResolution::Full), @@ -1374,6 +1376,76 @@ fn head_subscribers_receive_changes_for_their_topics() { } } +/// Head events describe changes observed while following. Observations in +/// any other mode move the baseline and node status silently. +#[test] +fn head_events_describe_changes_observed_while_following() { + let base = TempDir::new().unwrap(); + let mut spine = Box::new(SilverSpine::new_with_base_dir(base.path(), None)); + let mut tile = boundary_tile(&Bind::parse("127.0.0.1:0"), no_el(), [ + "cs_follow_gossip", + "cs_follow_rpc", + "cs_follow_resp", + ]); + let mut adapter = SpineAdapter::connect_tile(&tile, &mut *spine); + let mut inj = SpineAdapter::connect_tile(&Injector, &mut *spine); + tile.loop_body(&mut adapter); + + let [Bind::Tcp(addr)] = tile.beacon.local_addrs()[..] else { panic!("expected one tcp bind") }; + let sentinel_slot = 40; + let (client, on_subscribed) = head_events_subscriber(addr, "head", sentinel_slot); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut crank = |tile: &mut ApplicationBoundaryTile, msg: &str| { + assert!(Instant::now() < deadline, "timeout: {msg}"); + tile.loop_body(&mut adapter); + std::thread::sleep(Duration::from_millis(1)); + }; + while on_subscribed.try_recv().is_err() { + crank(&mut tile, "stream head reaches the subscriber"); + } + + // Restoration and catch-up: Control has not concluded, so heads move silently. + inj.produce(head_status(33, 0xaa, true, PayloadResolution::Full)); + inj.produce(head_status(34, 0xab, true, PayloadResolution::Full)); + crank(&mut tile, "observations outside following update node status"); + assert_eq!( + tile.beacon.node_status_mut().slots, + Some(SlotStatus { head_slot: 34, wall_slot: 34, head_optimistic: true }) + ); + + // Following: the latest observation is already the baseline, so the next + // change is reported at once. + inj.produce(SyncUpdate::Following); + crank(&mut tile, "the mode change is consumed"); + inj.produce(head_status(35, 0xac, true, PayloadResolution::Full)); + inj.produce(head_status(35, 0xac, false, PayloadResolution::Full)); + crank(&mut tile, "changes while following are reported"); + + // Falling behind silences the stream while the head keeps moving. + inj.produce(SyncUpdate::SyncingHead { head_root: [0xff; 32], head_slot: 100 }); + crank(&mut tile, "the mode change is consumed"); + inj.produce(head_status(36, 0xad, true, PayloadResolution::Full)); + crank(&mut tile, "changes while syncing are silent"); + + // Following again: a repeat of the head reached while syncing is no change. + inj.produce(SyncUpdate::Following); + crank(&mut tile, "the mode change is consumed"); + inj.produce(head_status(36, 0xad, true, PayloadResolution::Full)); + inj.produce(head_status(37, 0xae, true, PayloadResolution::Full)); + inj.produce(head_status(sentinel_slot, 0xcd, true, PayloadResolution::Full)); + while !client.is_finished() { + crank(&mut tile, "every frame reaches the subscriber"); + } + + let events = client.join().unwrap(); + assert_eq!(events.len(), 3); + for (event, (slot, optimistic)) in events.iter().zip([(35, true), (35, false), (37, true)]) { + assert_eq!(event["slot"], slot.to_string()); + assert_eq!(event["execution_optimistic"], optimistic); + } +} + /// The initial head observation emits no event but still updates node status. #[test] fn node_status_optimism_follows_a_status_that_publishes_no_head_event() { diff --git a/crates/control/src/sync_engine/mod.rs b/crates/control/src/sync_engine/mod.rs index 20abc63d..53bc8c67 100644 --- a/crates/control/src/sync_engine/mod.rs +++ b/crates/control/src/sync_engine/mod.rs @@ -80,7 +80,7 @@ impl ReplayGate { } } - fn blocks_requests(&self) -> bool { + fn is_pending(&self) -> bool { !matches!(self, Self::Open) } @@ -446,6 +446,7 @@ impl SyncEngine { fn on_replay_complete(&mut self) { self.replay.open(); self.awaiting_start = true; + self.mark_dirty(); } pub fn on_terminator(&mut self, request_id: u64, peer: usize, delivered: bool, now: Instant) { @@ -547,13 +548,14 @@ impl SyncEngine { if !chosen.is_following() { return Some(chosen); } - let comparable = self.ctx.local.have_status && + let comparable = !self.replay.is_pending() && + self.ctx.local.have_status && (self.phase.target().is_some() || self.ctx.peers.received_statuses()); comparable.then_some(SyncUpdate::Following) } pub fn drive_requests(&mut self, now: Instant, emit: &mut impl FnMut(SyncAction) -> bool) { - if self.replay.blocks_requests() { + if self.replay.is_pending() { return; } diff --git a/crates/control/src/sync_engine/tests.rs b/crates/control/src/sync_engine/tests.rs index 3dd07226..2137021f 100644 --- a/crates/control/src/sync_engine/tests.rs +++ b/crates/control/src/sync_engine/tests.rs @@ -775,6 +775,10 @@ fn peers_finalized_ahead_skip_the_disk_replay_without_waiting() { peer_finalized(&mut e, PEER, (HEAD_ROOT, 400), 10); local_status(&mut e, 0, 400); + assert!( + matches!(e.advance(), Some(SyncUpdate::SyncingFinalized { target_epoch: 10, .. })), + "a syncing target is announced while replay is pending" + ); assert_eq!( e.maybe_choose_syncing_strategy(t0), Some(SyncingStrategy::SyncFromPeers), @@ -783,6 +787,30 @@ fn peers_finalized_ahead_skip_the_disk_replay_without_waiting() { assert_eq!(e.maybe_choose_syncing_strategy(t0), None, "and storage is told once"); } +#[test] +fn a_caught_up_node_waits_for_replay_completion_before_following() { + let t0 = Instant::now(); + let mut e = engine_awaiting_replay(); + local_status(&mut e, 40, 40); + peer_status(&mut e, PEER, HEAD_ROOT, 40); + + assert_eq!(e.advance(), None, "matching the peer does not complete disk restoration"); + assert!(!e.take_just_synced(), "following side effects must wait too"); + assert_eq!(e.maybe_choose_syncing_strategy(t0), None); + assert_eq!( + e.maybe_choose_syncing_strategy(t0 + SYNCING_STRATEGY_TIMEOUT_WINDOW), + Some(SyncingStrategy::ReplayDisk), + "deferring following still allows the replay decision" + ); + local_status(&mut e, 40, 40); + assert_eq!(e.advance(), None, "selecting replay does not mean it has finished"); + + e.on_beacon_state_event(&BeaconStateEvent::ReplayComplete); + assert_eq!(e.advance(), Some(SyncUpdate::Following), "completion re-evaluates the target"); + assert!(e.take_just_synced()); + assert_eq!(e.advance(), None, "the following transition is published once"); +} + /// With nobody ahead, the disk is the best chain we have — but only after /// giving peers the window to say otherwise. #[test] diff --git a/docs/adr/0004-sync-materialized-api.md b/docs/adr/0004-sync-materialized-api.md index 038965b8..4ddf2f00 100644 --- a/docs/adr/0004-sync-materialized-api.md +++ b/docs/adr/0004-sync-materialized-api.md @@ -153,3 +153,24 @@ If the cache has overwritten an object's bytes, the boundary logs a warning and emits no event for that request. It also skips sidecars whose length and column offset identify neither supported layout. These failures do not cancel the publication request. + +Amended 2026-09-11: `head` and `head_v2` describe changes observed while +Control reports following. Disk restoration, the wait for a replay strategy +and network catch-up produce no head notifications. Following is a sync +mode, not a guarantee of zero sync distance or execution validation. + +The boundary keeps every complete observation as its baseline in every mode +and reports a change only while following. A following period therefore +starts from the head the node already has, and its first change is reported. +Status and sync updates travel on separate queues; the boundary reads the +mode once per iteration after draining beacon events, so an observation +drained in the same iteration as a mode change follows the earlier mode, and +one queued between the two drains is reported in the next iteration. The +imprecision is bounded by one iteration. Node-status updates and block +notifications remain independent of the mode. + +Control waits for disk replay to finish or be skipped before reporting +following; previously the gate held only network requests, and disk replay +is chosen exactly when peers look comparable, so following could be announced +during replay. Completion re-evaluates the target. Beacon-state's Status +publications are unchanged. From 8593bc3158c779a2e3ab681fd6547f42d2020530 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Tue, 15 Sep 2026 11:12:55 +0100 Subject: [PATCH 13/13] Remove superfluous test and comments --- crates/common/src/spine/messages.rs | 5 ----- crates/control/src/sync_engine/tests.rs | 24 ------------------------ crates/storage/src/tile.rs | 3 --- 3 files changed, 32 deletions(-) diff --git a/crates/common/src/spine/messages.rs b/crates/common/src/spine/messages.rs index 0829453d..781d4de4 100644 --- a/crates/common/src/spine/messages.rs +++ b/crates/common/src/spine/messages.rs @@ -896,11 +896,6 @@ impl PayloadResolution { #[repr(C)] pub enum BeaconStateEvent { ReplayComplete, - /// An observation that may repeat unchanged. Consumers decide which - /// fields require action. `latest_block_slot` follows the last imported - /// block; `ssz` describes the fork-choice head. Their slots can differ - /// after importing a competing branch or switching heads, including - /// after execution invalidation. Status { ssz: [u8; STATUS_V2_SIZE], latest_block_slot: u64, diff --git a/crates/control/src/sync_engine/tests.rs b/crates/control/src/sync_engine/tests.rs index 4e822198..8675f02a 100644 --- a/crates/control/src/sync_engine/tests.rs +++ b/crates/control/src/sync_engine/tests.rs @@ -806,30 +806,6 @@ fn peers_finalized_ahead_skip_the_disk_replay_without_waiting() { assert_eq!(e.maybe_choose_syncing_strategy(t0), None, "and storage is told once"); } -#[test] -fn a_caught_up_node_waits_for_replay_completion_before_following() { - let t0 = Instant::now(); - let mut e = engine_awaiting_replay(); - local_status(&mut e, 40, 40); - peer_status(&mut e, PEER, HEAD_ROOT, 40); - - assert_eq!(e.advance(), None, "matching the peer does not complete disk restoration"); - assert!(!e.take_just_synced(), "following side effects must wait too"); - assert_eq!(e.maybe_choose_syncing_strategy(t0), None); - assert_eq!( - e.maybe_choose_syncing_strategy(t0 + SYNCING_STRATEGY_TIMEOUT_WINDOW), - Some(SyncingStrategy::ReplayDisk), - "deferring following still allows the replay decision" - ); - local_status(&mut e, 40, 40); - assert_eq!(e.advance(), None, "selecting replay does not mean it has finished"); - - e.on_beacon_state_event(&BeaconStateEvent::ReplayComplete); - assert_eq!(e.advance(), Some(SyncUpdate::Following), "completion re-evaluates the target"); - assert!(e.take_just_synced()); - assert_eq!(e.advance(), None, "the following transition is published once"); -} - /// With nobody ahead, the disk is the best chain we have — but only after /// giving peers the window to say otherwise. #[test] diff --git a/crates/storage/src/tile.rs b/crates/storage/src/tile.rs index baca439c..9d44ae94 100644 --- a/crates/storage/src/tile.rs +++ b/crates/storage/src/tile.rs @@ -217,9 +217,6 @@ impl StorageTile { } impl StorageTile { - /// Defer checkpoint scheduling until the head is near the wall clock. - /// Record the scheduled epoch so repeated observations do not schedule it - /// again. fn on_status(&mut self, ssz: &[u8; 92], wall_slot: u64) { self.wall_slot = wall_slot; let head_slot = StatusView::head_slot(ssz);