From 4b0df4a9433dd1869a09fe548c726de364cd6daf Mon Sep 17 00:00:00 2001 From: bomanaps Date: Wed, 13 May 2026 07:07:38 +0100 Subject: [PATCH 1/5] add test_driver endpoints and fixture crate and metrics --- lean_client/Cargo.lock | 15 + lean_client/Cargo.toml | 2 + lean_client/Dockerfile | 35 +- lean_client/fork_choice/src/handlers.rs | 32 +- .../tests/fork_choice_test_vectors.rs | 2 +- lean_client/http_api/Cargo.toml | 3 + lean_client/http_api/src/lib.rs | 4 +- lean_client/http_api/src/server.rs | 27 +- lean_client/http_api/src/test_driver.rs | 493 ++++++++++++++++++ lean_client/metrics/src/metrics.rs | 22 + lean_client/networking/src/network/service.rs | 20 + lean_client/spec_test_fixtures/Cargo.toml | 17 + lean_client/spec_test_fixtures/src/common.rs | 468 +++++++++++++++++ .../spec_test_fixtures/src/fork_choice.rs | 167 ++++++ lean_client/spec_test_fixtures/src/lib.rs | 31 ++ .../src/state_transition.rs | 87 ++++ .../src/verify_signatures.rs | 40 ++ lean_client/src/main.rs | 45 +- 18 files changed, 1482 insertions(+), 28 deletions(-) create mode 100644 lean_client/http_api/src/test_driver.rs create mode 100644 lean_client/spec_test_fixtures/Cargo.toml create mode 100644 lean_client/spec_test_fixtures/src/common.rs create mode 100644 lean_client/spec_test_fixtures/src/fork_choice.rs create mode 100644 lean_client/spec_test_fixtures/src/lib.rs create mode 100644 lean_client/spec_test_fixtures/src/state_transition.rs create mode 100644 lean_client/spec_test_fixtures/src/verify_signatures.rs diff --git a/lean_client/Cargo.lock b/lean_client/Cargo.lock index de73c807..ae474765 100644 --- a/lean_client/Cargo.lock +++ b/lean_client/Cargo.lock @@ -2415,6 +2415,7 @@ dependencies = [ "anyhow", "axum", "clap", + "containers", "fork_choice", "futures", "hex", @@ -2423,6 +2424,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", + "spec_test_fixtures", "ssz", "test-generator", "tokio", @@ -2430,6 +2432,7 @@ dependencies = [ "tower-http", "tracing", "validator", + "xmss", ] [[package]] @@ -5792,6 +5795,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spec_test_fixtures" +version = "0.1.0" +dependencies = [ + "containers", + "hex", + "serde", + "serde_json", + "ssz", + "xmss", +] + [[package]] name = "spin" version = "0.9.8" diff --git a/lean_client/Cargo.toml b/lean_client/Cargo.toml index 00607668..a0f0a3f9 100644 --- a/lean_client/Cargo.toml +++ b/lean_client/Cargo.toml @@ -7,6 +7,7 @@ members = [ "http_api", "metrics", "networking", + "spec_test_fixtures", "validator", "xmss", ] @@ -227,6 +228,7 @@ fork_choice = { path = "./fork_choice" } http_api = { path = "./http_api" } metrics = { path = "./metrics" } networking = { path = "./networking" } +spec_test_fixtures = { path = "./spec_test_fixtures" } validator = { path = "./validator" } xmss = { path = "./xmss" } diff --git a/lean_client/Dockerfile b/lean_client/Dockerfile index 9cdbc6f4..700806fc 100644 --- a/lean_client/Dockerfile +++ b/lean_client/Dockerfile @@ -1,20 +1,27 @@ -FROM ubuntu:22.04 +FROM rustlang/rust:nightly-bookworm AS builder + +RUN apt-get update && apt-get install -y \ + build-essential \ + pkg-config \ + libssl-dev \ + libclang-dev \ + cmake \ + git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build -ARG COMMIT_SHA -ARG BUILD_DATE -ARG GIT_BRANCH +COPY . . -LABEL org.opencontainers.image.title="grandine" -LABEL org.opencontainers.image.description="High performance Ethereum lean client" -LABEL org.opencontainers.image.authors="Grandine " -LABEL org.opencontainers.image.source=https://github.com/grandinetech/lean -LABEL org.opencontainers.image.licenses=MIT -LABEL org.opencontainers.image.revision=$COMMIT_SHA -LABEL org.opencontainers.image.ref.name=$GIT_BRANCH -LABEL org.opencontainers.image.created=$BUILD_DATE +RUN cargo build --release + +FROM ubuntu:22.04 -ARG TARGETARCH +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* -COPY ./bin/$TARGETARCH/lean_client /usr/local/bin/lean_client +COPY --from=builder /build/target/release/lean_client /usr/local/bin/lean_client ENTRYPOINT ["lean_client"] \ No newline at end of file diff --git a/lean_client/fork_choice/src/handlers.rs b/lean_client/fork_choice/src/handlers.rs index 3111eb86..579c18ee 100644 --- a/lean_client/fork_choice/src/handlers.rs +++ b/lean_client/fork_choice/src/handlers.rs @@ -493,6 +493,7 @@ pub fn on_block( store: &mut Store, cache: &mut BlockCache, signed_block: SignedBlock, + verify_signatures: bool, ) -> Result { let block_root = signed_block.block.hash_tree_root(); @@ -509,8 +510,8 @@ pub fn on_block( ); } - process_block_internal(store, signed_block, block_root)?; - process_pending_blocks(store, cache, vec![block_root]); + process_block_internal(store, signed_block, block_root, verify_signatures)?; + process_pending_blocks(store, cache, vec![block_root], verify_signatures); Ok(BlockOutcome::Applied) } @@ -518,14 +519,25 @@ pub fn on_block( /// CPU-bound portion of block processing: verify XMSS signatures against the parent state /// and run the state transition. Safe to run on a `DedicatedExecutor` thread because it /// touches no `Store` state. -pub fn verify_and_transition(parent_state: State, signed_block: SignedBlock) -> Result { +/// +/// Pass `verify_signatures = false` to skip the cryptographic signature check — only +/// safe when signatures have already been validated upstream or when the caller is +/// driving the function with synthetic signature placeholders (e.g. spec-test fixtures +/// that ship unsigned blocks). +pub fn verify_and_transition( + parent_state: State, + signed_block: SignedBlock, + verify_signatures: bool, +) -> Result { let _timer = METRICS.get().map(|metrics| { metrics .lean_fork_choice_block_processing_time_seconds .start_timer() }); - signed_block.verify_signatures(parent_state.clone())?; + if verify_signatures { + signed_block.verify_signatures(parent_state.clone())?; + } parent_state.state_transition(signed_block, true) } @@ -822,6 +834,7 @@ fn process_block_internal( store: &mut Store, signed_block: SignedBlock, block_root: H256, + verify_signatures: bool, ) -> Result<()> { let block = signed_block.block.clone(); let attestations_count = block.body.attestations.len_u64(); @@ -841,11 +854,16 @@ fn process_block_internal( "Processing block - parent state info" ); - let new_state = verify_and_transition(parent_state, signed_block.clone())?; + let new_state = verify_and_transition(parent_state, signed_block.clone(), verify_signatures)?; apply_verified_block(store, signed_block, new_state, block_root) } -pub fn process_pending_blocks(store: &mut Store, cache: &mut BlockCache, mut roots: Vec) { +pub fn process_pending_blocks( + store: &mut Store, + cache: &mut BlockCache, + mut roots: Vec, + verify_signatures: bool, +) { while let Some(parent_root) = roots.pop() { let children: Vec<(H256, SignedBlock)> = cache .get_children(&parent_root) @@ -855,7 +873,7 @@ pub fn process_pending_blocks(store: &mut Store, cache: &mut BlockCache, mut roo for (child_root, child_block) in children { cache.remove(&child_root); - if process_block_internal(store, child_block, child_root).is_ok() { + if process_block_internal(store, child_block, child_root, verify_signatures).is_ok() { roots.push(child_root); } } diff --git a/lean_client/fork_choice/tests/fork_choice_test_vectors.rs b/lean_client/fork_choice/tests/fork_choice_test_vectors.rs index 17f4aed1..0d16b03f 100644 --- a/lean_client/fork_choice/tests/fork_choice_test_vectors.rs +++ b/lean_client/fork_choice/tests/fork_choice_test_vectors.rs @@ -563,7 +563,7 @@ fn forkchoice(spec_file: &str) { (store.config.genesis_time + (signed_block.block.slot.0 * 4)) * 1000; on_tick(&mut store, block_time_millis, false); - on_block(&mut store, &mut cache, signed_block).unwrap(); + on_block(&mut store, &mut cache, signed_block, true).unwrap(); Ok(block_root) })); diff --git a/lean_client/http_api/Cargo.toml b/lean_client/http_api/Cargo.toml index fceaf2d0..42627d89 100644 --- a/lean_client/http_api/Cargo.toml +++ b/lean_client/http_api/Cargo.toml @@ -7,6 +7,7 @@ edition = { workspace = true } anyhow = { workspace = true } axum = { workspace = true } clap = { workspace = true } +containers = { workspace = true } fork_choice = { workspace = true } validator = { workspace = true } futures = { workspace = true } @@ -15,10 +16,12 @@ metrics = { workspace = true } parking_lot = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +spec_test_fixtures = { workspace = true } ssz = { workspace = true } tokio = { workspace = true } tower-http = { workspace = true } tracing = { workspace = true } +xmss = { workspace = true } [dev-dependencies] fork_choice = { workspace = true } diff --git a/lean_client/http_api/src/lib.rs b/lean_client/http_api/src/lib.rs index 1d22c599..ceeb5377 100644 --- a/lean_client/http_api/src/lib.rs +++ b/lean_client/http_api/src/lib.rs @@ -4,9 +4,11 @@ mod config; mod handlers; mod routing; mod server; +mod test_driver; pub use aggregator_controller::AggregatorController; pub use config::HttpServerConfig; pub use handlers::SharedStore; pub use routing::normal_routes; -pub use server::run_server; +pub use server::{run_server, run_test_driver_server}; +pub use test_driver::{TestDriverState, test_driver_routes}; diff --git a/lean_client/http_api/src/server.rs b/lean_client/http_api/src/server.rs index d0d80da4..b8ad826f 100644 --- a/lean_client/http_api/src/server.rs +++ b/lean_client/http_api/src/server.rs @@ -5,8 +5,11 @@ use futures::{TryFutureExt as _, future::FutureExt as _}; use tracing::info; use crate::{ - aggregator_controller::SharedController, config::HttpServerConfig, handlers::SharedStore, + aggregator_controller::SharedController, + config::HttpServerConfig, + handlers::SharedStore, routing::normal_routes, + test_driver::{TestDriverState, test_driver_routes}, }; pub async fn run_server( @@ -15,7 +18,29 @@ pub async fn run_server( aggregator_controller: SharedController, ) -> Result<()> { let router = normal_routes(&config, store, aggregator_controller); + serve(config, router).await +} + +/// Variant of [`run_server`] that additionally mounts the +/// `/lean/v0/test_driver/*` endpoints. +/// +/// The test-driver routes are needed for the hive `spec-assets-*` test +/// suites; they are gated behind a separate startup path so they cannot +/// accidentally be served in production. The caller is responsible for +/// deciding (e.g. via the `HIVE_LEAN_TEST_DRIVER` environment variable) +/// whether to invoke this variant or [`run_server`]. +pub async fn run_test_driver_server( + config: HttpServerConfig, + store: SharedStore, + aggregator_controller: SharedController, +) -> Result<()> { + let driver_state = TestDriverState::new(store.clone()); + let router = normal_routes(&config, store, aggregator_controller) + .merge(test_driver_routes(driver_state)); + serve(config, router).await +} +async fn serve(config: HttpServerConfig, router: axum::Router) -> Result<()> { let listener = config .listener() .await diff --git a/lean_client/http_api/src/test_driver.rs b/lean_client/http_api/src/test_driver.rs new file mode 100644 index 00000000..7e18b28d --- /dev/null +++ b/lean_client/http_api/src/test_driver.rs @@ -0,0 +1,493 @@ +//! Implementation of the `/lean/v0/test_driver/*` HTTP endpoints used by the +//! hive `spec-assets-*` test suites. +//! +//! These endpoints are only mounted when the lean client is launched in +//! test-driver mode (`HIVE_LEAN_TEST_DRIVER=1`). They expose the existing +//! consensus primitives (`get_forkchoice_store`, `on_tick`, `on_block`, +//! `on_gossip_attestation`, `on_aggregated_attestation`, `State::state_transition`, +//! `SignedBlock::verify_signatures`) over JSON so the simulator can drive them +//! directly with vendored leanSpec test vectors. +//! +//! The wire shapes here are dictated by the hive simulator at +//! `simulators/lean/src/scenarios/spec_assets.rs`. + +use std::sync::Arc; + +use axum::{ + Json, Router, body::Bytes, extract::State as AxumState, http::StatusCode, routing::post, +}; +use containers::{ + AggregatedSignatureProof, BlockHeader, BlockSignatures, Config, SignedAggregatedAttestation, + SignedAttestation, SignedBlock, State, +}; +use fork_choice::{ + block_cache::BlockCache, + handlers::{on_aggregated_attestation, on_block, on_gossip_attestation, on_tick}, + store::{MILLIS_PER_INTERVAL, SECONDS_PER_SLOT, Store, get_forkchoice_store}, +}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use spec_test_fixtures::{ + ForkChoiceStep, GossipAggregatedAttestationStep, TestAnchorBlock, TestAnchorState, TestCase, + VerifySignaturesTestCase, +}; +use ssz::SszHash; +use xmss::{AggregatedSignature, Signature}; + +/// Shared state for test-driver routes. Carries a writable handle to the +/// fork-choice store plus the `BlockCache` that `on_block` requires. +/// +/// Created exclusively by [`crate::server::run_test_driver_server`]; the +/// production HTTP server does not construct this state and does not mount +/// the routes that depend on it. +#[derive(Clone)] +pub struct TestDriverState { + pub store: Arc>, + pub cache: Arc>, +} + +impl TestDriverState { + pub fn new(store: Arc>) -> Self { + Self { + store, + cache: Arc::new(RwLock::new(BlockCache::new())), + } + } +} + +/// Mount the test-driver routes on a new router. Returns the router so the +/// caller can layer it with other routers (e.g. the production routes). +#[must_use] +pub fn test_driver_routes(state: TestDriverState) -> Router { + Router::new() + .route( + "/lean/v0/test_driver/fork_choice/init", + post(init_fork_choice), + ) + .route( + "/lean/v0/test_driver/fork_choice/step", + post(step_fork_choice), + ) + .route( + "/lean/v0/test_driver/state_transition/run", + post(run_state_transition), + ) + .route( + "/lean/v0/test_driver/verify_signatures/run", + post(run_verify_signatures), + ) + .with_state(state) +} + +// === Wire types ============================================================= + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ForkChoiceInitRequest { + anchor_state: TestAnchorState, + anchor_block: TestAnchorBlock, + #[serde(default)] + genesis_time: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DriverSnapshot { + head_slot: u64, + head_root: String, + time: u64, + justified_checkpoint: DriverCheckpoint, + finalized_checkpoint: DriverCheckpoint, + safe_target: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DriverCheckpoint { + slot: u64, + root: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DriverStepResponse { + accepted: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + snapshot: DriverSnapshot, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StateTransitionResponse { + succeeded: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + post: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StateTransitionPost { + slot: u64, + latest_block_header_slot: u64, + latest_block_header_state_root: String, + historical_block_hashes_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct VerifySignaturesResponse { + succeeded: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +// === Handlers =============================================================== + +/// `POST /lean/v0/test_driver/fork_choice/init` +/// +/// Replaces the live fork-choice store with a fresh one anchored at the +/// supplied state and block. Responds with `204 No Content` on success and +/// any non-2xx status when the anchor cannot be initialised — matching the +/// simulator's expectation in `spec_assets.rs`. +async fn init_fork_choice( + AxumState(state): AxumState, + Json(request): Json, +) -> StatusCode { + let mut anchor_state: State = request.anchor_state.into(); + let anchor_block: SignedBlock = request.anchor_block.into(); + + // Apply the optional genesis time override before computing any roots. + if let Some(genesis_time) = request.genesis_time { + anchor_state.config.genesis_time = genesis_time; + } + + // Anchor consistency precondition: the block's claimed state_root + // must equal the hash of the supplied anchor state. Fixtures tagged + // `anchor_valid=False` deliberately violate this; reject them here so + // the simulator records the expected non-2xx response. + // + // Performed BEFORE the body_root patch below so the check is on the + // exact state the fixture supplied, not on a derived one. + if anchor_block.block.state_root != anchor_state.hash_tree_root() { + return StatusCode::BAD_REQUEST; + } + + let config = Config { + genesis_time: anchor_state.config.genesis_time, + }; + + // Patch the latest_block_header.body_root to match the actual anchor block + // body. Fixtures emit a placeholder zero root in `latestBlockHeader` and + // expect the harness to fill it in from the anchor block (the existing + // local fork-choice test does the same). + let body_root = anchor_block.block.body.hash_tree_root(); + anchor_state.latest_block_header = BlockHeader { + slot: anchor_block.block.slot, + proposer_index: anchor_block.block.proposer_index, + parent_root: anchor_block.block.parent_root, + state_root: anchor_block.block.state_root, + body_root, + }; + + let new_store = get_forkchoice_store(anchor_state, anchor_block, config, false); + + *state.store.write() = new_store; + *state.cache.write() = BlockCache::new(); + + StatusCode::NO_CONTENT +} + +/// `POST /lean/v0/test_driver/fork_choice/step` +/// +/// Applies a single fork-choice step to the store and returns the resulting +/// snapshot. Always responds with `200 OK`; the test-level success/failure is +/// reported in the JSON body's `accepted` field. +async fn step_fork_choice( + AxumState(state): AxumState, + Json(step): Json, +) -> Json { + let mut store = state.store.write(); + let mut cache = state.cache.write(); + + let outcome = apply_step(&mut store, &mut cache, step); + let snapshot = build_snapshot(&store); + + Json(match outcome { + Ok(()) => DriverStepResponse { + accepted: true, + error: None, + snapshot, + }, + Err(err) => DriverStepResponse { + accepted: false, + error: Some(err), + snapshot, + }, + }) +} + +/// `POST /lean/v0/test_driver/state_transition/run` +/// +/// Executes `State::state_transition` over `pre + blocks` and returns the +/// resulting post-state summary. Errors are surfaced in the JSON `error` +/// field; the HTTP status is always `200 OK`. +/// +/// The fixtures supply blocks with placeholder signatures; we set +/// `valid_signatures = true` so the transition focuses on state/block-root +/// validation. Signature correctness is exercised separately by the +/// `verify_signatures` suite. +/// +/// Reads the body as `Bytes` and runs `serde_json::from_slice` manually so +/// that fixture-shape mismatches surface as `succeeded: false` with the +/// underlying serde error instead of an opaque axum 422. +async fn run_state_transition(body: Bytes) -> Json { + let case: TestCase = match serde_json::from_slice(&body) { + Ok(case) => case, + Err(err) => { + return Json(StateTransitionResponse { + succeeded: false, + error: Some(format!( + "failed to deserialize state_transition request: {err}" + )), + post: None, + }); + } + }; + let mut state: State = case.pre.into(); + let blocks: Vec = case + .blocks + .unwrap_or_default() + .into_iter() + .map(Into::into) + .collect(); + let blocks_was_empty = blocks.is_empty(); + + let mut last_err: Option = None; + for block in blocks { + let signed = SignedBlock { + block, + signature: BlockSignatures::default(), + }; + match state.state_transition(signed, true) { + Ok(next) => state = next, + Err(err) => { + last_err = Some(err.to_string()); + break; + } + } + } + + // Some fixtures supply no blocks but still expect the transition to + // raise — for example, the slot-monotonicity case where target == state + // slot must be rejected. When that's the shape, exercise + // `process_slots(state.slot)` so the invariant fires and the resulting + // error surfaces as `succeeded: false`. + if last_err.is_none() && blocks_was_empty && case.expect_exception.is_some() { + let target_slot = state.slot; + if let Err(err) = state.clone().process_slots(target_slot) { + last_err = Some(format!("process_slots({target_slot:?}) failed: {err}")); + } + } + + let response = match last_err { + Some(err) => StateTransitionResponse { + succeeded: false, + error: Some(err), + post: None, + }, + None => StateTransitionResponse { + succeeded: true, + error: None, + post: Some(post_summary(&state)), + }, + }; + + Json(response) +} + +/// `POST /lean/v0/test_driver/verify_signatures/run` +/// +/// Verifies the supplied signed block against the supplied anchor state. +/// Always `200 OK`; success is reported in the JSON body. +async fn run_verify_signatures( + Json(case): Json, +) -> Json { + let anchor_state: State = case.anchor_state.into(); + let signed_block = match SignedBlock::try_from(case.signed_block) { + Ok(block) => block, + Err(err) => { + return Json(VerifySignaturesResponse { + succeeded: false, + error: Some(format!("failed to construct signed block: {err}")), + }); + } + }; + + Json(match signed_block.verify_signatures(anchor_state) { + Ok(()) => VerifySignaturesResponse { + succeeded: true, + error: None, + }, + Err(err) => VerifySignaturesResponse { + succeeded: false, + error: Some(err.to_string()), + }, + }) +} + +// === Step dispatcher ======================================================== + +/// Apply one fork-choice step. Returns a stringified error on failure so the +/// caller can surface it via the JSON `error` field. +#[allow(clippy::needless_pass_by_value)] // ForkChoiceStep is moved by design. +fn apply_step( + store: &mut Store, + cache: &mut BlockCache, + step: ForkChoiceStep, +) -> Result<(), String> { + match step { + ForkChoiceStep::Tick { + time, + interval, + has_proposal, + .. + } + | ForkChoiceStep::Time { + time, + interval, + has_proposal, + .. + } => { + // Fixtures supply exactly one of: + // - `time` — absolute wall-clock seconds (multiply by 1000 + // to feed `on_tick`, which expects milliseconds); + // - `interval` — target store-interval to advance to (relative). + // Translate it into the equivalent absolute time + // in milliseconds: `genesis_ms + interval * MILLIS_PER_INTERVAL`. + // `on_tick` advances the store one interval at a time until it + // reaches the requested target. + let target_time_millis = match (time, interval) { + (Some(seconds), _) => seconds * 1000, + (None, Some(target_interval)) => { + store.config.genesis_time * 1000 + target_interval * MILLIS_PER_INTERVAL + } + (None, None) => { + return Err("tick step missing 'time'/'interval' field".to_string()); + } + }; + on_tick(store, target_time_millis, has_proposal.unwrap_or(false)); + Ok(()) + } + ForkChoiceStep::Block { block, .. } => { + let block: containers::Block = block.into(); + let signed = SignedBlock { + block, + signature: BlockSignatures::default(), + }; + + // Advance store time to the block's slot before applying it. + // Mirrors the local fork-choice test: attestations embedded in + // the block reference the slot, so the store needs to be at or + // past that interval. + let slot_time_millis = + (store.config.genesis_time + signed.block.slot.0 * SECONDS_PER_SLOT) * 1000; + on_tick(store, slot_time_millis, false); + + // Skip XMSS signature verification — fork_choice fixtures ship + // unsigned step blocks, so we apply them with a placeholder + // signature and let `state_transition` validate the rest. + on_block(store, cache, signed, false) + .map(|_| ()) + .map_err(|err| err.to_string()) + } + ForkChoiceStep::Attestation { attestation, .. } => { + let attestation: containers::Attestation = attestation.into(); + let signed = SignedAttestation { + validator_id: attestation.validator_id, + message: attestation.data, + signature: Signature::default(), + }; + on_gossip_attestation(store, signed).map_err(|err| err.to_string()) + } + ForkChoiceStep::GossipAggregatedAttestation { attestation, .. } => { + let Some(step) = attestation else { + // No payload supplied; treat as a no-op so subsequent + // `Checks` steps still see a snapshot. + return Ok(()); + }; + let signed = build_signed_aggregated_attestation(step)?; + on_aggregated_attestation(store, signed).map_err(|err| err.to_string()) + } + ForkChoiceStep::Checks { .. } => { + // Pure-assertion step. The simulator validates against the + // returned snapshot — no store mutation required here. + Ok(()) + } + } +} + +// === Snapshot extraction ==================================================== + +fn build_snapshot(store: &Store) -> DriverSnapshot { + let head_slot = store + .blocks + .get(&store.head) + .map(|block| block.slot.0) + .unwrap_or(0); + + DriverSnapshot { + head_slot, + head_root: hex_root(&store.head), + time: store.time, + justified_checkpoint: DriverCheckpoint { + slot: store.latest_justified.slot.0, + root: hex_root(&store.latest_justified.root), + }, + finalized_checkpoint: DriverCheckpoint { + slot: store.latest_finalized.slot.0, + root: hex_root(&store.latest_finalized.root), + }, + safe_target: hex_root(&store.safe_target), + } +} + +fn post_summary(state: &State) -> StateTransitionPost { + StateTransitionPost { + slot: state.slot.0, + latest_block_header_slot: state.latest_block_header.slot.0, + latest_block_header_state_root: hex_root(&state.latest_block_header.state_root), + historical_block_hashes_count: state.historical_block_hashes.len_u64() as usize, + } +} + +fn hex_root(root: &ssz::H256) -> String { + format!("0x{}", hex::encode(root.as_bytes())) +} + +/// Build a `SignedAggregatedAttestation` from the fixture-supplied +/// `gossipAggregatedAttestation` step payload. +/// +/// The fixture carries the pre-computed aggregated XMSS proof as a hex +/// string (the harness cannot re-aggregate without the signers' private +/// keys), so we decode it directly into an [`AggregatedSignature`] and +/// wrap with the participants bitfield from the same payload. +fn build_signed_aggregated_attestation( + step: GossipAggregatedAttestationStep, +) -> Result { + let proof_hex = step.proof.proof_data.data.trim_start_matches("0x"); + let proof_bytes = hex::decode(proof_hex) + .map_err(|err| format!("invalid hex in aggregate proof_data: {err}"))?; + let proof_data = AggregatedSignature::new(&proof_bytes) + .map_err(|err| format!("failed to construct aggregated signature: {err}"))?; + + Ok(SignedAggregatedAttestation { + data: step.data.into(), + proof: AggregatedSignatureProof { + participants: step.proof.participants.into(), + proof_data, + }, + }) +} diff --git a/lean_client/metrics/src/metrics.rs b/lean_client/metrics/src/metrics.rs index 5b72f34c..cbd8cc44 100644 --- a/lean_client/metrics/src/metrics.rs +++ b/lean_client/metrics/src/metrics.rs @@ -81,6 +81,9 @@ pub struct Metrics { /// Depth of fork choice reorgs (in blocks) pub lean_fork_choice_reorg_depth: Histogram, + /// Elapsed time between consecutive chain-task tick intervals + pub lean_tick_interval_duration_seconds: Histogram, + // State Transition Metrics /// Latest justified slot pub lean_latest_justified_slot: IntGauge, @@ -117,6 +120,9 @@ pub struct Metrics { /// Number of connected peers pub lean_connected_peers: IntGaugeVec, + /// Number of peers in the gossipsub mesh + pub lean_gossip_mesh_peers: IntGaugeVec, + /// Total number of peer connection events lean_peer_connection_events_total: IntCounterVec, @@ -344,6 +350,13 @@ impl Metrics { "Depth of fork choice reorgs (in blocks)", vec![1.0, 2.0, 3.0, 5.0, 7.0, 10.0, 20.0, 30.0, 50.0, 100.0] ))?, + lean_tick_interval_duration_seconds: Histogram::with_opts(histogram_opts!( + "lean_tick_interval_duration_seconds", + "Elapsed time between clock ticks in seconds", + vec![ + 0.4, 0.6, 0.75, 0.8, 0.805, 0.81, 0.815, 0.82, 0.825, 0.85, 0.9, 1.0, 1.2, 1.6, + ] + ))?, // State Transition Metrics lean_latest_justified_slot: IntGauge::new( @@ -407,6 +420,13 @@ impl Metrics { opts!("lean_connected_peers", "Number of connected peers",), &["client"], )?, + lean_gossip_mesh_peers: IntGaugeVec::new( + opts!( + "lean_gossip_mesh_peers", + "Number of peers in the gossipsub mesh", + ), + &["client"], + )?, lean_peer_connection_events_total: IntCounterVec::new( opts!( "lean_peer_connection_events_total", @@ -694,6 +714,7 @@ impl Metrics { ))?; default_registry.register(Box::new(self.lean_fork_choice_reorgs_total.clone()))?; default_registry.register(Box::new(self.lean_fork_choice_reorg_depth.clone()))?; + default_registry.register(Box::new(self.lean_tick_interval_duration_seconds.clone()))?; default_registry.register(Box::new(self.lean_latest_justified_slot.clone()))?; default_registry.register(Box::new(self.lean_latest_finalized_slot.clone()))?; default_registry.register(Box::new(self.lean_finalizations_total.clone()))?; @@ -719,6 +740,7 @@ impl Metrics { ))?; default_registry.register(Box::new(self.lean_validators_count.clone()))?; default_registry.register(Box::new(self.lean_connected_peers.clone()))?; + default_registry.register(Box::new(self.lean_gossip_mesh_peers.clone()))?; default_registry.register(Box::new(self.lean_peer_connection_events_total.clone()))?; default_registry.register(Box::new(self.lean_peer_disconnection_events_total.clone()))?; diff --git a/lean_client/networking/src/network/service.rs b/lean_client/networking/src/network/service.rs index a0240376..d9caf98d 100644 --- a/lean_client/networking/src/network/service.rs +++ b/lean_client/networking/src/network/service.rs @@ -350,6 +350,12 @@ where let mut timeout_interval = interval(BLOCKS_BY_ROOT_REQUEST_TIMEOUT); timeout_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + // Periodic gossipsub mesh-peer count refresh. Reads the current set of + // unique peers across all subscribed mesh topics and publishes it as a + // gauge so churn between subscribe/unsubscribe events is captured. + let mut mesh_metric_interval = interval(Duration::from_secs(10)); + mesh_metric_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { select! { _ = reconnect_interval.tick() => { @@ -361,6 +367,20 @@ where _ = timeout_interval.tick() => { self.sweep_timed_out_requests(); } + _ = mesh_metric_interval.tick() => { + let mesh_peer_count = self + .swarm + .behaviour() + .gossipsub + .all_mesh_peers() + .count() as i64; + METRICS.get().map(|metrics| { + metrics + .lean_gossip_mesh_peers + .with_label_values(&["unknown"]) + .set(mesh_peer_count) + }); + } _ = discovery_interval.tick() => { // Trigger active peer discovery if let Some(ref discovery) = self.discovery { diff --git a/lean_client/spec_test_fixtures/Cargo.toml b/lean_client/spec_test_fixtures/Cargo.toml new file mode 100644 index 00000000..466b61a7 --- /dev/null +++ b/lean_client/spec_test_fixtures/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "spec_test_fixtures" +version = { workspace = true } +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } + +[dependencies] +containers = { workspace = true } +hex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +ssz = { workspace = true } +xmss = { workspace = true } + +[lints] +workspace = true diff --git a/lean_client/spec_test_fixtures/src/common.rs b/lean_client/spec_test_fixtures/src/common.rs new file mode 100644 index 00000000..ef4aa332 --- /dev/null +++ b/lean_client/spec_test_fixtures/src/common.rs @@ -0,0 +1,468 @@ +//! Shared serde types for all leanSpec spec-test JSON fixtures. +//! +//! One module (this one) holds every fixture-shape consensus type (state, +//! block, attestation, signed block); the per-family modules +//! (`fork_choice.rs`, `state_transition.rs`, `verify_signatures.rs`) hold +//! only the top-level fixture wrappers. + +use containers::{ + AggregatedAttestation, AggregatedSignatureProof, AggregationBits, Attestation, AttestationData, + AttestationSignatures, Block, BlockBody, BlockHeader, BlockSignatures, Checkpoint, Config, + HistoricalBlockHashes, JustificationRoots, JustificationValidators, JustifiedSlots, + SignedBlock, Slot, State, Validator, Validators, +}; +use serde::Deserialize; +use ssz::{BitList, H256, PersistentList}; +use xmss::{AggregatedSignature, PublicKey, Signature}; + +// === Primitive wrappers ==================================================== + +/// Wrapper that matches the `{"data": [...]}` envelope used by leanSpec +/// fixtures for variable-length lists (e.g. `historicalBlockHashes`, +/// `validators`, `justifiedSlots`). +#[derive(Debug, Default, Deserialize)] +pub struct TestDataWrapper { + pub data: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestConfig { + pub genesis_time: u64, +} + +impl From for Config { + fn from(value: TestConfig) -> Self { + Self { + genesis_time: value.genesis_time, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct TestCheckpoint { + pub root: String, + pub slot: u64, +} + +impl From for Checkpoint { + fn from(value: TestCheckpoint) -> Self { + Self { + root: parse_root(&value.root), + slot: Slot(value.slot), + } + } +} + +/// Validator entry as it appears in fork-choice fixtures. Both pubkey fields +/// are loaded as strings; the conversion to `containers::Validator` parses +/// them via `xmss::PublicKey::FromStr`. +#[derive(Debug, Deserialize)] +pub struct TestValidator { + /// Some fixtures emit `pubkey` instead of `attestationPubkey`; both map to + /// the same field. + #[serde(alias = "pubkey", alias = "attestationPubkey")] + pub attestation_pubkey: String, + #[serde(default, alias = "proposalPubkey")] + pub proposal_pubkey: Option, + #[serde(default)] + pub index: u64, +} + +/// Parse a 32-byte root encoded as either `0x...` hex or a short all-zero +/// placeholder used by some fixtures. Panics on malformed input — these +/// fixtures are vendored and validated, so a malformed root indicates a real +/// bug rather than user input. +#[must_use] +pub fn parse_root(hex_str: &str) -> H256 { + let hex = hex_str.trim_start_matches("0x"); + let mut bytes = [0u8; 32]; + + if hex.len() == 64 { + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16) + .unwrap_or_else(|_| panic!("Invalid hex at position {i}: {hex}")); + } + } else if !hex.chars().all(|c| c == '0') { + panic!("Invalid root length: {} (expected 64 hex chars)", hex.len()); + } + + H256::from(bytes) +} + +// === Attestation types ===================================================== + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestAttestation { + pub validator_id: u64, + pub data: TestAttestationData, +} + +impl From for Attestation { + fn from(value: TestAttestation) -> Self { + Self { + validator_id: value.validator_id, + data: value.data.into(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct TestAttestationData { + pub slot: u64, + pub head: TestCheckpoint, + pub target: TestCheckpoint, + pub source: TestCheckpoint, +} + +impl From for AttestationData { + fn from(value: TestAttestationData) -> Self { + Self { + slot: Slot(value.slot), + head: value.head.into(), + target: value.target.into(), + source: value.source.into(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestAggregatedAttestation { + pub aggregation_bits: TestAggregationBits, + pub data: TestAttestationData, +} + +impl From for AggregatedAttestation { + fn from(value: TestAggregatedAttestation) -> Self { + Self { + aggregation_bits: value.aggregation_bits.into(), + data: value.data.into(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct TestAggregationBits { + pub data: Vec, +} + +impl From for AggregationBits { + fn from(value: TestAggregationBits) -> Self { + let mut bitlist = BitList::with_length(value.data.len()); + for (i, &bit) in value.data.iter().enumerate() { + bitlist.set(i, bit); + } + Self(bitlist) + } +} + +// === State + block-header types ============================================ + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestAnchorState { + pub config: TestConfig, + pub slot: u64, + pub latest_block_header: TestBlockHeader, + pub latest_justified: TestCheckpoint, + pub latest_finalized: TestCheckpoint, + #[serde(default)] + pub historical_block_hashes: TestDataWrapper, + #[serde(default)] + pub justified_slots: TestDataWrapper, + pub validators: TestDataWrapper, + #[serde(default)] + pub justifications_roots: TestDataWrapper, + #[serde(default)] + pub justifications_validators: TestDataWrapper, +} + +impl From for State { + fn from(value: TestAnchorState) -> Self { + let config = value.config.into(); + let latest_block_header = value.latest_block_header.into(); + + let mut historical_block_hashes = HistoricalBlockHashes::default(); + for hash_str in &value.historical_block_hashes.data { + historical_block_hashes + .push(parse_root(hash_str)) + .expect("historical_block_hashes within capacity"); + } + + let mut justified_slots = + JustifiedSlots(BitList::new(false, value.justified_slots.data.len())); + for (i, &bit) in value.justified_slots.data.iter().enumerate() { + if bit { + justified_slots.0.set(i, true); + } + } + + let mut justifications_roots = JustificationRoots::default(); + for root_str in &value.justifications_roots.data { + justifications_roots + .push(parse_root(root_str)) + .expect("justifications_roots within capacity"); + } + + let mut justifications_validators = + JustificationValidators::new(false, value.justifications_validators.data.len()); + for (i, &bit) in value.justifications_validators.data.iter().enumerate() { + if bit { + justifications_validators.set(i, true); + } + } + + let mut validators = Validators::default(); + for test_validator in &value.validators.data { + let attestation_pubkey: PublicKey = test_validator + .attestation_pubkey + .parse() + .expect("Failed to parse validator attestation_pubkey"); + let proposal_pubkey: PublicKey = test_validator + .proposal_pubkey + .as_deref() + .map(|s| { + s.parse() + .expect("Failed to parse validator proposal_pubkey") + }) + .unwrap_or_default(); + validators + .push(Validator { + attestation_pubkey, + proposal_pubkey, + index: test_validator.index, + }) + .expect("validators within capacity"); + } + + Self { + config, + slot: Slot(value.slot), + latest_block_header, + latest_justified: value.latest_justified.into(), + latest_finalized: value.latest_finalized.into(), + historical_block_hashes, + justified_slots, + validators, + justifications_roots, + justifications_validators, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestBlockHeader { + pub slot: u64, + pub proposer_index: u64, + pub parent_root: String, + pub state_root: String, + pub body_root: String, +} + +impl From for BlockHeader { + fn from(value: TestBlockHeader) -> Self { + Self { + slot: Slot(value.slot), + proposer_index: value.proposer_index, + parent_root: parse_root(&value.parent_root), + state_root: parse_root(&value.state_root), + body_root: parse_root(&value.body_root), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestBlock { + pub slot: u64, + pub proposer_index: u64, + pub parent_root: String, + pub state_root: String, + pub body: TestBlockBody, +} + +impl From for Block { + fn from(value: TestBlock) -> Self { + Self { + slot: Slot(value.slot), + proposer_index: value.proposer_index, + parent_root: parse_root(&value.parent_root), + state_root: parse_root(&value.state_root), + body: value.body.into(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct TestBlockBody { + pub attestations: TestDataWrapper, +} + +impl From for BlockBody { + fn from(value: TestBlockBody) -> Self { + let mut attestations = PersistentList::default(); + for attestation in value.attestations.data { + attestations + .push(attestation.into()) + .expect("block body attestations within capacity"); + } + Self { attestations } + } +} + +/// Variant of `TestBlock` used inside fork-choice steps. Carries an optional +/// `blockRootLabel` that the fixture uses to refer to a block produced by +/// this step in subsequent `headRootLabel` checks. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestBlockWithAttestation { + #[serde(flatten)] + pub block: TestBlock, + /// Ignored in devnet4 — proposer attestation removed from block format. + #[serde(default)] + pub proposer_attestation: Option, + #[serde(default)] + pub block_root_label: Option, +} + +impl From for Block { + fn from(value: TestBlockWithAttestation) -> Self { + value.block.into() + } +} + +// === Anchor block ========================================================== + +/// Anchor block fixture type — leanSpec's anchor block JSON does not carry a +/// signature, so the conversion wraps the inner block in a `SignedBlock` with +/// the default (zero) `BlockSignatures`. Distinct from `TestSignedBlock` +/// (which the simulator uses for the `verify_signatures` family) because +/// anchor JSON has no `signature` envelope. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestAnchorBlock { + pub slot: u64, + pub proposer_index: u64, + pub parent_root: String, + pub state_root: String, + pub body: TestAnchorBlockBody, +} + +#[derive(Debug, Deserialize)] +pub struct TestAnchorBlockBody { + pub attestations: TestDataWrapper, +} + +impl From for SignedBlock { + fn from(value: TestAnchorBlock) -> Self { + let mut attestations = PersistentList::default(); + for attestation in value.body.attestations.data { + attestations + .push(attestation.into()) + .expect("anchor block attestations within capacity"); + } + + let block = Block { + slot: Slot(value.slot), + proposer_index: value.proposer_index, + parent_root: parse_root(&value.parent_root), + state_root: parse_root(&value.state_root), + body: BlockBody { attestations }, + }; + + Self { + block, + signature: BlockSignatures::default(), + } + } +} + +// === Signed block (verify_signatures family) =============================== + +/// Wrapper around hex-encoded byte payloads serialized as `{"data": "0x..."}`. +/// Reused by `TestAggregatedSignatureProofFixture::proof_data` and by +/// fork-choice's `gossipAggregatedAttestation` step proof bundle. +#[derive(Debug, Deserialize)] +pub struct HexBytesJSON { + pub data: String, +} + +/// Fixture-shape signed block. Mirrors the JSON the simulator POSTs. +/// +/// `containers::SignedBlock` deserializes its `signature` field via +/// `xmss::Signature`, whose JSON form is the structured XMSS object +/// (`{path, rho, hashes}`). leanSpec fixtures, however, ship signatures as +/// plain hex strings (`"0x24…"`) and aggregated proofs as +/// `{participants:{data:[bool]}, proofData:{data:"0x…"}}`. This type matches +/// the wire format and provides a `TryFrom` conversion to the consensus type. +#[derive(Debug, Deserialize)] +pub struct TestSignedBlock { + pub block: TestBlock, + pub signature: TestBlockSignaturesFixture, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestBlockSignaturesFixture { + pub attestation_signatures: TestDataWrapper, + pub proposer_signature: String, +} + +/// One entry inside `signature.attestationSignatures.data`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestAggregatedSignatureProofFixture { + pub participants: TestAggregationBits, + pub proof_data: HexBytesJSON, +} + +impl TryFrom for SignedBlock { + type Error = String; + + fn try_from(value: TestSignedBlock) -> Result { + let block = value.block.into(); + + let proposer_signature: Signature = value + .signature + .proposer_signature + .parse() + .map_err(|err| format!("invalid hex in proposer_signature: {err}"))?; + + let mut attestation_signatures = AttestationSignatures::default(); + for entry in value.signature.attestation_signatures.data { + let proof: AggregatedSignatureProof = entry.try_into()?; + PersistentList::push(&mut attestation_signatures, proof) + .map_err(|err| format!("attestation_signatures push: {err:?}"))?; + } + + Ok(Self { + block, + signature: BlockSignatures { + attestation_signatures, + proposer_signature, + }, + }) + } +} + +impl TryFrom for AggregatedSignatureProof { + type Error = String; + + fn try_from(value: TestAggregatedSignatureProofFixture) -> Result { + let bytes = decode_hex(&value.proof_data.data)?; + let proof_data = AggregatedSignature::new(&bytes) + .map_err(|err| format!("aggregated signature decode: {err}"))?; + Ok(Self { + participants: value.participants.into(), + proof_data, + }) + } +} + +fn decode_hex(s: &str) -> Result, String> { + let trimmed = s.trim_start_matches("0x"); + hex::decode(trimmed).map_err(|err| format!("hex decode failed: {err}")) +} diff --git a/lean_client/spec_test_fixtures/src/fork_choice.rs b/lean_client/spec_test_fixtures/src/fork_choice.rs new file mode 100644 index 00000000..9e0b27a7 --- /dev/null +++ b/lean_client/spec_test_fixtures/src/fork_choice.rs @@ -0,0 +1,167 @@ +//! Fork-choice fixture types matching the JSON emitted by leanSpec. +//! +//! The `steps` array is a discriminated union keyed on `stepType`. We model +//! it as a Rust enum tagged with `#[serde(tag = "stepType")]` so the +//! deserializer rejects unknown variants instead of silently dropping fields. + +use serde::Deserialize; + +use crate::common::{ + HexBytesJSON, TestAggregationBits, TestAnchorBlock, TestAnchorState, TestAttestation, + TestAttestationData, TestBlockWithAttestation, +}; + +/// Top-level fork-choice fixture case (one entry inside the JSON file's +/// `{test_name -> case}` map). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ForkChoiceTest { + pub network: String, + pub anchor_state: TestAnchorState, + pub anchor_block: TestAnchorBlock, + pub steps: Vec, +} + +/// One step in a fork-choice fixture's `steps` array. +/// +/// The `stepType` discriminator selects the variant. Unknown discriminators +/// will fail to deserialize, which is the desired behaviour: a fixture using +/// a step type the harness does not understand should fail loudly so we know +/// to add support for it. +#[derive(Debug, Deserialize)] +#[serde(tag = "stepType", rename_all = "camelCase")] +pub enum ForkChoiceStep { + /// Advance store time. Fixtures supply exactly one of: + /// - `time` — absolute wall-clock seconds since the unix epoch. + /// - `interval` — target store-interval to advance to (relative). + /// `hasProposal` defaults to false. + #[serde(rename = "tick")] + Tick { + #[serde(default)] + valid: Option, + #[serde(default)] + time: Option, + #[serde(default)] + interval: Option, + #[serde(default)] + has_proposal: Option, + #[serde(default)] + checks: Option, + }, + /// Older alias used by some fixtures that emit `stepType: "time"`. + /// Same payload semantics as `Tick`. + #[serde(rename = "time")] + Time { + #[serde(default)] + valid: Option, + #[serde(default)] + time: Option, + #[serde(default)] + interval: Option, + #[serde(default)] + has_proposal: Option, + #[serde(default)] + checks: Option, + }, + /// Apply a block to the store. + #[serde(rename = "block")] + Block { + valid: bool, + #[serde(default)] + checks: Option, + block: TestBlockWithAttestation, + }, + /// Apply a single-validator gossip attestation to the store. + #[serde(rename = "attestation")] + Attestation { + valid: bool, + #[serde(default)] + checks: Option, + attestation: TestAttestation, + }, + /// Apply an aggregated attestation received via gossip. + /// + /// Unlike `Attestation`, the fixture for this step ships the + /// pre-computed aggregated XMSS proof (the harness can never + /// re-aggregate it without the producer's private keys), so the inner + /// payload is the richer [`GossipAggregatedAttestationStep`] rather + /// than a plain `TestAggregatedAttestation`. + #[serde(rename = "gossipAggregatedAttestation")] + GossipAggregatedAttestation { + #[serde(default)] + valid: Option, + #[serde(default)] + checks: Option, + #[serde(default)] + attestation: Option, + }, + /// A pure assertion step — no state mutation, only checks. + #[serde(rename = "checks")] + Checks { checks: StoreChecks }, +} + +/// Subset of fork-choice store fields a fixture step can assert against. +/// All fields are optional; only the ones the fixture supplies are checked. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoreChecks { + #[serde(default)] + pub head_slot: Option, + #[serde(default)] + pub head_root: Option, + #[serde(default)] + pub head_root_label: Option, + #[serde(default)] + pub time: Option, + #[serde(default)] + pub justified_checkpoint: Option, + #[serde(default)] + pub finalized_checkpoint: Option, + #[serde(default)] + pub safe_target: Option, + #[serde(default)] + pub attestation_checks: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointCheck { + pub slot: u64, + pub root: String, +} + +/// Per-validator attestation check inside a fork-choice step's `attestationChecks` array. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttestationCheck { + pub validator: u64, + #[serde(default)] + pub attestation_slot: Option, + #[serde(default)] + pub source_slot: Option, + #[serde(default)] + pub target_slot: Option, + pub location: String, +} + +/// Payload for the `gossipAggregatedAttestation` step variant. +/// +/// Mirrors the shape leanSpec emits: an attestation `data` body plus a +/// pre-computed aggregated XMSS proof. The harness consumes the proof bytes +/// directly because reconstructing them would require the producer's +/// private keys. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GossipAggregatedAttestationStep { + pub data: TestAttestationData, + pub proof: GossipProofJSON, +} + +/// The pre-computed aggregated proof bundle attached to a gossip aggregated +/// attestation step. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GossipProofJSON { + pub participants: TestAggregationBits, + pub proof_data: HexBytesJSON, +} diff --git a/lean_client/spec_test_fixtures/src/lib.rs b/lean_client/spec_test_fixtures/src/lib.rs new file mode 100644 index 00000000..8df25d91 --- /dev/null +++ b/lean_client/spec_test_fixtures/src/lib.rs @@ -0,0 +1,31 @@ +//! Shared serde types for lean spec-test JSON fixtures. +//! +//! These types deserialize the JSON test vectors that drive the spec-asset +//! suites and convert them into the consensus types defined in the +//! `containers` and related crates. +//! +//! The same fixture format is consumed by: +//! - the `containers` and `fork_choice` integration tests, and +//! - the `http_api` test-driver endpoints (`/lean/v0/test_driver/*`). +//! +//! Layout: all inner consensus shapes live in `common`, while the per-family +//! modules host only the top-level wrappers. + +pub mod common; +pub mod fork_choice; +pub mod state_transition; +pub mod verify_signatures; + +pub use common::{ + HexBytesJSON, TestAggregatedAttestation, TestAggregatedSignatureProofFixture, + TestAggregationBits, TestAnchorBlock, TestAnchorState, TestAttestation, TestAttestationData, + TestBlock, TestBlockBody, TestBlockHeader, TestBlockSignaturesFixture, + TestBlockWithAttestation, TestCheckpoint, TestConfig, TestDataWrapper, TestSignedBlock, + TestValidator, parse_root, +}; +pub use fork_choice::{ + AttestationCheck, ForkChoiceStep, ForkChoiceTest, GossipAggregatedAttestationStep, + GossipProofJSON, StoreChecks, +}; +pub use state_transition::{Info, PostState, TestCase, TestVectorFile}; +pub use verify_signatures::{VerifySignaturesTestCase, VerifySignaturesTestVectorFile}; diff --git a/lean_client/spec_test_fixtures/src/state_transition.rs b/lean_client/spec_test_fixtures/src/state_transition.rs new file mode 100644 index 00000000..d7af50ba --- /dev/null +++ b/lean_client/spec_test_fixtures/src/state_transition.rs @@ -0,0 +1,87 @@ +//! State-transition fixture types. +//! +//! Uses fixture-shape wrappers (`TestAnchorState`, `TestBlock`) rather than +//! the raw `containers::State` / `containers::Block` because +//! `containers::Validator` is derived without `#[serde(rename_all = +//! "camelCase")]` and so cannot deserialize the leanSpec fixture's +//! `attestationPubkey` / `proposalPubkey` keys. + +use std::collections::HashMap; + +use containers::Slot; +use serde::{Deserialize, Deserializer}; +use serde_json::Value; + +use crate::common::{TestAnchorState, TestBlock}; + +/// Top-level wrapper for a state-transition fixture file. Each file holds a +/// `{test_name -> case}` map. +#[derive(Debug, Deserialize)] +pub struct TestVectorFile { + #[serde(flatten)] + pub tests: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestCase { + #[serde(default)] + pub network: Option, + pub pre: TestAnchorState, + /// Some fixtures wrap the block list in `{"data": [...]}`; others use a + /// plain array. The flexible deserializer handles both shapes. + #[serde(deserialize_with = "deserialize_flexible", default)] + pub blocks: Option>, + #[serde(default)] + pub post: Option, + #[serde(default)] + pub expect_exception: Option, + /// `_info` is metadata for traceability; we don't read any sub-field, so + /// we keep it fully optional to tolerate fixtures that omit it. + #[serde(default, rename = "_info")] + pub info: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PostState { + pub slot: Slot, + #[serde(default)] + pub validator_count: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Info { + pub hash: String, + pub comment: String, + pub test_id: String, + pub description: String, + pub fixture_format: String, +} + +/// Deserialize a value that may appear either as a plain JSON value or as +/// `{"data": }`. Used because some leanSpec fixtures wrap collections +/// in a `data` envelope while others do not. +fn deserialize_flexible<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: serde::de::DeserializeOwned, +{ + use serde::de::Error; + + let value = Value::deserialize(deserializer)?; + + if let Value::Object(ref map) = value { + if map.len() == 1 { + if let Some(data_value) = map.get("data") { + return serde_json::from_value(data_value.clone()).map_err(|e| { + D::Error::custom(format!("Failed to deserialize from data wrapper: {e}")) + }); + } + } + } + + serde_json::from_value(value) + .map_err(|e| D::Error::custom(format!("Failed to deserialize plain value: {e}"))) +} diff --git a/lean_client/spec_test_fixtures/src/verify_signatures.rs b/lean_client/spec_test_fixtures/src/verify_signatures.rs new file mode 100644 index 00000000..dd6c362d --- /dev/null +++ b/lean_client/spec_test_fixtures/src/verify_signatures.rs @@ -0,0 +1,40 @@ +//! Verify-signatures fixture types. +//! +//! Uses fixture-shape wrapper types (`TestAnchorState`, `TestSignedBlock`) +//! rather than the raw `containers::State` / `containers::SignedBlock` +//! because the fixtures encode validators, signatures and aggregated proofs +//! as JSON wrappers / hex strings that the consensus types' own Deserialize +//! impls do not currently accept. + +use std::collections::HashMap; + +use serde::Deserialize; + +use crate::{ + common::{TestAnchorState, TestSignedBlock}, + state_transition::Info, +}; + +/// Top-level wrapper for a verify-signatures fixture file. Each file holds a +/// `{test_name -> case}` map. +#[derive(Debug, Deserialize)] +pub struct VerifySignaturesTestVectorFile { + #[serde(flatten)] + pub tests: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifySignaturesTestCase { + #[serde(default)] + pub network: Option, + pub anchor_state: TestAnchorState, + pub signed_block: TestSignedBlock, + #[serde(default)] + pub expect_exception: Option, + /// `_info` is a metadata blob the test harness emits for traceability. + /// Not all fixtures populate every sub-field (and we do not consume any + /// of them), so it is fully optional. + #[serde(default, rename = "_info")] + pub info: Option, +} diff --git a/lean_client/src/main.rs b/lean_client/src/main.rs index 536593f4..60e5f7d5 100644 --- a/lean_client/src/main.rs +++ b/lean_client/src/main.rs @@ -979,10 +979,31 @@ async fn main() -> Result<()> { let http_store = store.clone(); let aggregator_controller = Arc::new(AggregatorController::new(store.clone(), vs_for_controller)); + // The hive `spec-assets-*` test suites drive the client through the + // `/lean/v0/test_driver/*` endpoints. They are only mounted when the + // simulator launches the client with `HIVE_LEAN_TEST_DRIVER=1`, so a + // production-mode binary will continue to expose the normal HTTP API + // and return 404 for any test-driver paths that get accidentally hit. + let test_driver_enabled = matches!( + std::env::var("HIVE_LEAN_TEST_DRIVER") + .ok() + .as_deref() + .map(str::trim), + Some("1") | Some("true") | Some("TRUE") | Some("yes") + ); task::spawn(async move { - if let Err(err) = + let result = if test_driver_enabled { + info!("HTTP server starting in test-driver mode (HIVE_LEAN_TEST_DRIVER=1)"); + http_api::run_test_driver_server( + args.http_config, + http_store, + Some(aggregator_controller), + ) + .await + } else { http_api::run_server(args.http_config, http_store, Some(aggregator_controller)).await - { + }; + if let Err(err) = result { error!("HTTP Server failed with error: {err:?}"); } }); @@ -1041,6 +1062,9 @@ async fn main() -> Result<()> { // guard never fires twice for the same slot. let mut last_agg_slot: u64 = 0; let mut last_status_slot: Option = None; + // Tracks the previous tick instant so the duration between consecutive + // chain-task ticks can be observed at the top of each tick arm. + let mut last_tick_instant: Option = None; let mut block_cache = BlockCache::new(); let mut sync_state = if has_aggregator { SyncState::Syncing @@ -1057,6 +1081,18 @@ async fn main() -> Result<()> { tokio::select! { biased; _ = tick_interval.tick() => { + // Observe the duration since the previous tick before any + // processing. The first tick after startup has nothing to + // measure against and is skipped. + let tick_instant = Instant::now(); + if let Some(prev) = last_tick_instant { + let elapsed = tick_instant.duration_since(prev).as_secs_f64(); + METRICS.get().map(|m| { + m.lean_tick_interval_duration_seconds.observe(elapsed) + }); + } + last_tick_instant = Some(tick_instant); + let now_millis = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -1117,7 +1153,7 @@ async fn main() -> Result<()> { // 1. Normal: on_tick lands exactly at interval 2 → trigger fires. // 2. Catch-up: on_tick skips past interval 2 (e.g. lands at 3 or 4 // after a long block-processing burst) → trigger still fires for - // the current slot, mirroring zeam's explicit catch-up loop. + // the current slot via an explicit catch-up. // last_agg_slot prevents double-firing within the same slot. // Only trigger if this node is the aggregator for this slot. // Previously gated on `has_aggregator` (any-validator-service), @@ -1277,6 +1313,7 @@ async fn main() -> Result<()> { verify_and_transition( parent_state_for_child, child_for_verify, + true, ) }); let outcome = job.await.unwrap_or_else(|e| { @@ -1460,7 +1497,7 @@ async fn main() -> Result<()> { tokio::spawn(async move { let signed_block_for_send = signed_block_for_verify.clone(); let job = exec.spawn(async move { - verify_and_transition(parent_state, signed_block_for_verify) + verify_and_transition(parent_state, signed_block_for_verify, true) }); let outcome = job .await From 17fa2b7a26ec403658a1c157c3d7606cb68f7078 Mon Sep 17 00:00:00 2001 From: bomanaps Date: Wed, 13 May 2026 10:29:44 +0100 Subject: [PATCH 2/5] revert: drop release.yaml submodules fix and Dockerfile change (movedto separate PR) --- .github/workflows/release.yaml | 1 - lean_client/Dockerfile | 35 ++++++++++++++-------------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fcf9eb05..7b6c003f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -18,7 +18,6 @@ jobs: - uses: actions/checkout@v5 with: ref: ${{ inputs.ref || github.ref }} - submodules: recursive - name: Login to Docker Hub uses: docker/login-action@v3 diff --git a/lean_client/Dockerfile b/lean_client/Dockerfile index 700806fc..9cdbc6f4 100644 --- a/lean_client/Dockerfile +++ b/lean_client/Dockerfile @@ -1,27 +1,20 @@ -FROM rustlang/rust:nightly-bookworm AS builder - -RUN apt-get update && apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libclang-dev \ - cmake \ - git \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /build - -COPY . . +FROM ubuntu:22.04 -RUN cargo build --release +ARG COMMIT_SHA +ARG BUILD_DATE +ARG GIT_BRANCH -FROM ubuntu:22.04 +LABEL org.opencontainers.image.title="grandine" +LABEL org.opencontainers.image.description="High performance Ethereum lean client" +LABEL org.opencontainers.image.authors="Grandine " +LABEL org.opencontainers.image.source=https://github.com/grandinetech/lean +LABEL org.opencontainers.image.licenses=MIT +LABEL org.opencontainers.image.revision=$COMMIT_SHA +LABEL org.opencontainers.image.ref.name=$GIT_BRANCH +LABEL org.opencontainers.image.created=$BUILD_DATE -RUN apt-get update && apt-get install -y \ - ca-certificates \ - libssl3 \ - && rm -rf /var/lib/apt/lists/* +ARG TARGETARCH -COPY --from=builder /build/target/release/lean_client /usr/local/bin/lean_client +COPY ./bin/$TARGETARCH/lean_client /usr/local/bin/lean_client ENTRYPOINT ["lean_client"] \ No newline at end of file From 927854f1355fcb5901ee7559739a7304f52363f6 Mon Sep 17 00:00:00 2001 From: bomanaps Date: Wed, 13 May 2026 07:07:38 +0100 Subject: [PATCH 3/5] add test_driver endpoints and fixture crate and metrics --- lean_client/Dockerfile | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/lean_client/Dockerfile b/lean_client/Dockerfile index 9cdbc6f4..700806fc 100644 --- a/lean_client/Dockerfile +++ b/lean_client/Dockerfile @@ -1,20 +1,27 @@ -FROM ubuntu:22.04 +FROM rustlang/rust:nightly-bookworm AS builder + +RUN apt-get update && apt-get install -y \ + build-essential \ + pkg-config \ + libssl-dev \ + libclang-dev \ + cmake \ + git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build -ARG COMMIT_SHA -ARG BUILD_DATE -ARG GIT_BRANCH +COPY . . -LABEL org.opencontainers.image.title="grandine" -LABEL org.opencontainers.image.description="High performance Ethereum lean client" -LABEL org.opencontainers.image.authors="Grandine " -LABEL org.opencontainers.image.source=https://github.com/grandinetech/lean -LABEL org.opencontainers.image.licenses=MIT -LABEL org.opencontainers.image.revision=$COMMIT_SHA -LABEL org.opencontainers.image.ref.name=$GIT_BRANCH -LABEL org.opencontainers.image.created=$BUILD_DATE +RUN cargo build --release + +FROM ubuntu:22.04 -ARG TARGETARCH +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* -COPY ./bin/$TARGETARCH/lean_client /usr/local/bin/lean_client +COPY --from=builder /build/target/release/lean_client /usr/local/bin/lean_client ENTRYPOINT ["lean_client"] \ No newline at end of file From 6ef2cf8820d43c1122de1867429a4fb30dd9c982 Mon Sep 17 00:00:00 2001 From: bomanaps Date: Thu, 14 May 2026 15:08:39 +0100 Subject: [PATCH 4/5] fix more test --- lean_client/Cargo.lock | 1 + lean_client/Cargo.toml | 1 + lean_client/Dockerfile | 35 ++++++++++--------------- lean_client/containers/src/state.rs | 21 +++++++++++++++ lean_client/fork_choice/Cargo.toml | 1 + lean_client/fork_choice/src/store.rs | 20 ++++++++------ lean_client/http_api/src/test_driver.rs | 15 +---------- 7 files changed, 51 insertions(+), 43 deletions(-) diff --git a/lean_client/Cargo.lock b/lean_client/Cargo.lock index ae474765..c0de67c1 100644 --- a/lean_client/Cargo.lock +++ b/lean_client/Cargo.lock @@ -1905,6 +1905,7 @@ dependencies = [ "bls", "containers", "env-config", + "indexmap 2.14.0", "metrics", "rand 0.10.1", "rand_chacha 0.10.0", diff --git a/lean_client/Cargo.toml b/lean_client/Cargo.toml index a0f0a3f9..5f05c80e 100644 --- a/lean_client/Cargo.toml +++ b/lean_client/Cargo.toml @@ -247,6 +247,7 @@ futures = "0.3" features = { git = "https://github.com/grandinetech/grandine", rev = "64afdee3c6be79fceffb66933dcb69a943f3f1ae" } git-version = "0.3" hex = "0.4.3" +indexmap = "2" http-body-util = "0.1" http_api_utils = { git = "https://github.com/grandinetech/grandine", rev = "64afdee3c6be79fceffb66933dcb69a943f3f1ae" } k256 = "0.13" diff --git a/lean_client/Dockerfile b/lean_client/Dockerfile index 700806fc..9cdbc6f4 100644 --- a/lean_client/Dockerfile +++ b/lean_client/Dockerfile @@ -1,27 +1,20 @@ -FROM rustlang/rust:nightly-bookworm AS builder - -RUN apt-get update && apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libclang-dev \ - cmake \ - git \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /build - -COPY . . +FROM ubuntu:22.04 -RUN cargo build --release +ARG COMMIT_SHA +ARG BUILD_DATE +ARG GIT_BRANCH -FROM ubuntu:22.04 +LABEL org.opencontainers.image.title="grandine" +LABEL org.opencontainers.image.description="High performance Ethereum lean client" +LABEL org.opencontainers.image.authors="Grandine " +LABEL org.opencontainers.image.source=https://github.com/grandinetech/lean +LABEL org.opencontainers.image.licenses=MIT +LABEL org.opencontainers.image.revision=$COMMIT_SHA +LABEL org.opencontainers.image.ref.name=$GIT_BRANCH +LABEL org.opencontainers.image.created=$BUILD_DATE -RUN apt-get update && apt-get install -y \ - ca-certificates \ - libssl3 \ - && rm -rf /var/lib/apt/lists/* +ARG TARGETARCH -COPY --from=builder /build/target/release/lean_client /usr/local/bin/lean_client +COPY ./bin/$TARGETARCH/lean_client /usr/local/bin/lean_client ENTRYPOINT ["lean_client"] \ No newline at end of file diff --git a/lean_client/containers/src/state.rs b/lean_client/containers/src/state.rs index c7c670ee..b345fd78 100644 --- a/lean_client/containers/src/state.rs +++ b/lean_client/containers/src/state.rs @@ -419,6 +419,27 @@ impl State { .start_timer() }); + // Each unique AttestationData must appear at most once per block. + // Mirrors leanSpec spec.py:1247-1252. Our own builder collapses + // duplicates upstream via `aggregate_by_data`, so this guards + // externally-built blocks. + ensure!( + !AggregatedAttestation::has_duplicate_data(attestations), + "Block contains duplicate AttestationData entries; \ + each AttestationData must appear at most once", + ); + + // Cap distinct AttestationData entries per block. Mirrors leanSpec + // spec.py:1253-1256. With the duplicate check above, len == + // distinct-count, so this is equivalent to the spec's + // `len(att_data_set) <= MAX_ATTESTATIONS_DATA`. + ensure!( + (attestations.len_u64() as usize) <= MAX_ATTESTATIONS_DATA, + "Block contains {} distinct AttestationData entries; maximum is {}", + attestations.len_u64(), + MAX_ATTESTATIONS_DATA, + ); + ensure!( self.justifications_roots .into_iter() diff --git a/lean_client/fork_choice/Cargo.toml b/lean_client/fork_choice/Cargo.toml index a5aeb353..525b1f6d 100644 --- a/lean_client/fork_choice/Cargo.toml +++ b/lean_client/fork_choice/Cargo.toml @@ -7,6 +7,7 @@ anyhow = { workspace = true } bls = { workspace = true } containers = { workspace = true } env-config = { workspace = true } +indexmap = { workspace = true } metrics = { workspace = true } ssz = { workspace = true } tracing = { workspace = true } diff --git a/lean_client/fork_choice/src/store.rs b/lean_client/fork_choice/src/store.rs index fd4b87f1..20c60f5e 100644 --- a/lean_client/fork_choice/src/store.rs +++ b/lean_client/fork_choice/src/store.rs @@ -5,6 +5,7 @@ use containers::{ AggregatedSignatureProof, AttestationData, Block, BlockHeader, Checkpoint, Config, SignatureKey, SignedAggregatedAttestation, SignedAttestation, SignedBlock, Slot, State, }; +use indexmap::IndexMap; use metrics::{METRICS, set_gauge_u64}; use ssz::{H256, SszHash}; use tracing::{info, warn}; @@ -74,14 +75,17 @@ pub struct Store { /// Aggregated signature proofs from block bodies (on-chain). /// These are attestations that have been included in blocks and are part of /// the "known" pool for safe target computation. - /// Keyed by attestation data root (H256). - pub latest_known_aggregated_payloads: HashMap>, + /// Keyed by attestation data root (H256). `IndexMap` preserves insertion + /// order so same-slot equivocation tie-breaks are deterministic and match + /// leanSpec's first-vote-wins semantics (Python dict insertion order). + pub latest_known_aggregated_payloads: IndexMap>, /// Aggregated signature proofs from gossip aggregation topic. /// These are newly received aggregations that haven't been migrated to "known" yet. /// At interval 3, we merge this with latest_known_aggregated_payloads for safe target. - /// Keyed by attestation data root (H256). - pub latest_new_aggregated_payloads: HashMap>, + /// Keyed by attestation data root (H256). See note on the `known` pool above + /// for why this is `IndexMap`. + pub latest_new_aggregated_payloads: IndexMap>, /// Attestation data indexed by hash (data_root). /// Used to look up the exact attestation data that was signed when @@ -264,8 +268,8 @@ pub fn get_forkchoice_store( latest_known_attestations: HashMap::new(), latest_new_attestations: HashMap::new(), gossip_signatures: HashMap::new(), - latest_known_aggregated_payloads: HashMap::new(), - latest_new_aggregated_payloads: HashMap::new(), + latest_known_aggregated_payloads: IndexMap::new(), + latest_new_aggregated_payloads: IndexMap::new(), attestation_data_by_root: HashMap::new(), pending_attestations: HashMap::new(), pending_aggregated_attestations: HashMap::new(), @@ -438,7 +442,7 @@ pub fn update_head(store: &mut Store) { /// Walks through all aggregated proofs and extracts the latest attestation /// data for each validator based on their participation bits. fn extract_attestations_from_aggregated_payloads( - payloads: &HashMap>, + payloads: &IndexMap>, attestation_data_by_root: &HashMap, ) -> HashMap { let mut attestations: HashMap = HashMap::new(); @@ -525,7 +529,7 @@ pub fn accept_new_attestations(store: &mut Store) { .extend(store.latest_new_attestations.drain()); // Promote gossip-received aggregated proofs to the known pool so they // are available for block production at the next interval 0. - for (data_root, proofs) in store.latest_new_aggregated_payloads.drain() { + for (data_root, proofs) in store.latest_new_aggregated_payloads.drain(..) { store .latest_known_aggregated_payloads .entry(data_root) diff --git a/lean_client/http_api/src/test_driver.rs b/lean_client/http_api/src/test_driver.rs index 7e18b28d..485cd08a 100644 --- a/lean_client/http_api/src/test_driver.rs +++ b/lean_client/http_api/src/test_driver.rs @@ -17,7 +17,7 @@ use axum::{ Json, Router, body::Bytes, extract::State as AxumState, http::StatusCode, routing::post, }; use containers::{ - AggregatedSignatureProof, BlockHeader, BlockSignatures, Config, SignedAggregatedAttestation, + AggregatedSignatureProof, BlockSignatures, Config, SignedAggregatedAttestation, SignedAttestation, SignedBlock, State, }; use fork_choice::{ @@ -179,19 +179,6 @@ async fn init_fork_choice( genesis_time: anchor_state.config.genesis_time, }; - // Patch the latest_block_header.body_root to match the actual anchor block - // body. Fixtures emit a placeholder zero root in `latestBlockHeader` and - // expect the harness to fill it in from the anchor block (the existing - // local fork-choice test does the same). - let body_root = anchor_block.block.body.hash_tree_root(); - anchor_state.latest_block_header = BlockHeader { - slot: anchor_block.block.slot, - proposer_index: anchor_block.block.proposer_index, - parent_root: anchor_block.block.parent_root, - state_root: anchor_block.block.state_root, - body_root, - }; - let new_store = get_forkchoice_store(anchor_state, anchor_block, config, false); *state.store.write() = new_store; From fe575897550f1f41af3d8ad44f5a826e346935c2 Mon Sep 17 00:00:00 2001 From: bomanaps Date: Wed, 20 May 2026 11:01:46 +0100 Subject: [PATCH 5/5] restore submodules recursive in release.yaml --- .github/workflows/release.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7b6c003f..fcf9eb05 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -18,6 +18,7 @@ jobs: - uses: actions/checkout@v5 with: ref: ${{ inputs.ref || github.ref }} + submodules: recursive - name: Login to Docker Hub uses: docker/login-action@v3