From 41b86fd1334a3648f4121cfdaae14585063fc7f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 11 May 2026 14:22:45 -0300 Subject: [PATCH 1/6] feat(rpc): add Hive lean spec-assets test-driver endpoints The ethereum/hive lean simulator gained three spec-asset suites in #1480 (lean-spec-tests-{fork-choice,state-transition,verify-signatures}) that drive leanSpec fixtures into the client over four HTTP endpoints under /lean/v0/test_driver/. ethlambda currently fails them all because the endpoints don't exist. Adds an opt-in driver router gated by HIVE_LEAN_TEST_DRIVER. In driver mode the binary skips the BlockChain actor and P2P swarm and only serves: POST /lean/v0/test_driver/fork_choice/init -> 204 / 400 POST /lean/v0/test_driver/fork_choice/step -> StepResponse POST /lean/v0/test_driver/state_transition/run -> StateTransitionResponse POST /lean/v0/test_driver/verify_signatures/run -> VerifySignaturesResponse The store is held behind Arc> and replaced on every fork_choice/init so a single container can replay many fixtures back-to-back. Step dispatch reuses the existing on_tick / on_block_without_verification / on_gossip_* code paths; the STF and verify_signatures endpoints reuse the production state_transition and verify_block_signatures helpers, so the driver exercises the same code the consensus stack does. Also lifts the fork-choice and verify-signatures fixture deserialization types out of crates/blockchain/tests into the shared ethlambda-test-fixtures crate so the RPC handler and the offline spec-test runner consume the same JSON schemas. Covered by seven oneshot integration tests in crates/net/rpc/tests/test_driver_e2e.rs. --- Cargo.lock | 5 + bin/ethlambda/src/main.rs | 45 ++ crates/blockchain/src/store.rs | 14 +- crates/blockchain/tests/common.rs | 3 - .../blockchain/tests/forkchoice_spectests.rs | 11 +- .../blockchain/tests/signature_spectests.rs | 4 +- crates/common/test-fixtures/Cargo.toml | 1 + crates/common/test-fixtures/src/common.rs | 331 +++++++++++ .../test-fixtures/src/fork_choice.rs} | 38 +- crates/common/test-fixtures/src/lib.rs | 343 +---------- .../test-fixtures/src/state_transition.rs | 24 + .../test-fixtures/src/verify_signatures.rs} | 8 +- crates/net/rpc/Cargo.toml | 6 +- crates/net/rpc/src/lib.rs | 23 + crates/net/rpc/src/test_driver.rs | 554 ++++++++++++++++++ crates/net/rpc/tests/test_driver_e2e.rs | 244 ++++++++ 16 files changed, 1299 insertions(+), 355 deletions(-) delete mode 100644 crates/blockchain/tests/common.rs create mode 100644 crates/common/test-fixtures/src/common.rs rename crates/{blockchain/tests/types.rs => common/test-fixtures/src/fork_choice.rs} (84%) create mode 100644 crates/common/test-fixtures/src/state_transition.rs rename crates/{blockchain/tests/signature_types.rs => common/test-fixtures/src/verify_signatures.rs} (91%) create mode 100644 crates/net/rpc/src/test_driver.rs create mode 100644 crates/net/rpc/tests/test_driver_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index 26718db0..4f9fb143 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2156,10 +2156,14 @@ name = "ethlambda-rpc" version = "0.1.0" dependencies = [ "axum", + "ethlambda-blockchain", "ethlambda-fork-choice", "ethlambda-metrics", + "ethlambda-state-transition", "ethlambda-storage", + "ethlambda-test-fixtures", "ethlambda-types", + "hex", "http-body-util", "jemalloc_pprof", "libssz", @@ -2208,6 +2212,7 @@ dependencies = [ "hex", "libssz-types", "serde", + "serde_json", ] [[package]] diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 5c4ce16f..8b2520b7 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -120,6 +120,17 @@ async fn main() -> eyre::Result<()> { println!("{ASCII_ART}"); info!(version = version::CLIENT_VERSION, "Starting ethlambda"); + + // Hive lean spec-asset suites boot the client with + // HIVE_LEAN_TEST_DRIVER=1 so it skips the consensus/p2p stack and + // exposes only the `/lean/v0/test_driver/...` endpoints driven by the + // simulator. Detected here before any config / key / genesis loading + // so the driver run doesn't need any of those files. + if ethlambda_rpc::test_driver::test_driver_enabled() { + info!("HIVE_LEAN_TEST_DRIVER detected — booting in test-driver mode"); + return run_test_driver(rpc_config).await; + } + #[cfg(not(target_env = "msvc"))] info!("Using jemalloc allocator with heap profiling enabled"); #[cfg(target_env = "msvc")] @@ -275,6 +286,40 @@ async fn main() -> eyre::Result<()> { Ok(()) } +/// Boot the binary in Hive test-driver mode. +/// +/// Skips every consensus/p2p subsystem and just exposes the +/// `/lean/v0/test_driver/...` HTTP endpoints over the configured API port. +/// The driver-mode store is seeded with an empty in-memory state and is +/// replaced on every `fork_choice/init` request from the simulator. +async fn run_test_driver(rpc_config: RpcConfig) -> eyre::Result<()> { + use tokio::sync::RwLock; + + let driver: ethlambda_rpc::test_driver::DriverState = + Arc::new(RwLock::new(ethlambda_rpc::test_driver::empty_driver_store())); + + let shutdown_token = CancellationToken::new(); + let rpc_shutdown = shutdown_token.clone(); + + let rpc_handle = tokio::spawn(async move { + if let Err(err) = + ethlambda_rpc::start_test_driver_rpc_server(rpc_config, driver, rpc_shutdown).await + { + error!(%err, "Test-driver RPC server failed"); + } + }); + + info!("Test-driver RPC ready"); + + tokio::signal::ctrl_c().await.ok(); + info!("Shutdown signal received, stopping test-driver RPC..."); + shutdown_token.cancel(); + let _ = rpc_handle.await; + info!("Shutdown complete"); + + Ok(()) +} + /// Subset of `validator-config.yaml` consumed by ethlambda. /// /// The `config` block is a network-wide settings bag shared across clients; diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 66baf2c9..7c7e7f86 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -477,7 +477,7 @@ fn on_block_core( let sig_verification_start = std::time::Instant::now(); if verify { // Validate cryptographic signatures - verify_signatures(&parent_state, &signed_block)?; + verify_block_signatures(&parent_state, &signed_block)?; } let sig_verification = sig_verification_start.elapsed(); @@ -1157,7 +1157,15 @@ fn build_block( /// Verify all signatures in a signed block. /// /// Each attestation has a corresponding proof in the signature list. -fn verify_signatures(state: &State, signed_block: &SignedBlock) -> Result<(), StoreError> { +/// +/// Exposed publicly so RPC handlers (notably the Hive test-driver +/// `verify_signatures/run` endpoint) can run the exact same verification path +/// the import pipeline uses; the production import path also calls this from +/// [`on_block_core`]. +pub fn verify_block_signatures( + state: &State, + signed_block: &SignedBlock, +) -> Result<(), StoreError> { use ethlambda_crypto::verify_aggregated_signature; use ethlambda_types::signature::ValidatorSignature; @@ -1372,7 +1380,7 @@ mod tests { }, }; - let result = verify_signatures(&state, &signed_block); + let result = verify_block_signatures(&state, &signed_block); assert!( matches!(result, Err(StoreError::ParticipantsMismatch)), "Expected ParticipantsMismatch, got: {result:?}" diff --git a/crates/blockchain/tests/common.rs b/crates/blockchain/tests/common.rs deleted file mode 100644 index 36e104d0..00000000 --- a/crates/blockchain/tests/common.rs +++ /dev/null @@ -1,3 +0,0 @@ -#![allow(dead_code)] - -pub use ethlambda_test_fixtures::*; diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index 23f4503d..45834a86 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -14,13 +14,10 @@ use ethlambda_types::{ state::State, }; -use crate::types::{ForkChoiceTestVector, StoreChecks}; +use ethlambda_test_fixtures::fork_choice::{AttestationCheck, ForkChoiceTestVector, StoreChecks}; const SUPPORTED_FIXTURE_FORMAT: &str = "fork_choice_test"; -mod common; -mod types; - /// List of skipped tests. const SKIP_TESTS: &[&str] = &[]; @@ -161,7 +158,9 @@ fn assert_step_outcome( } } -fn build_signed_block(block_data: types::BlockStepData) -> SignedBlock { +fn build_signed_block( + block_data: ethlambda_test_fixtures::fork_choice::BlockStepData, +) -> SignedBlock { let block: Block = block_data.to_block(); // Build one empty proof per attestation, matching the aggregation_bits from @@ -374,7 +373,7 @@ fn validate_checks( fn validate_attestation_check( st: &Store, - check: &types::AttestationCheck, + check: &AttestationCheck, step_idx: usize, ) -> datatest_stable::Result<()> { let validator_id = check.validator; diff --git a/crates/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs index e7c1a888..fdba2e56 100644 --- a/crates/blockchain/tests/signature_spectests.rs +++ b/crates/blockchain/tests/signature_spectests.rs @@ -9,9 +9,7 @@ use ethlambda_types::{ state::State, }; -mod common; -mod signature_types; -use signature_types::VerifySignaturesTestVector; +use ethlambda_test_fixtures::verify_signatures::VerifySignaturesTestVector; const SUPPORTED_FIXTURE_FORMAT: &str = "verify_signatures_test"; diff --git a/crates/common/test-fixtures/Cargo.toml b/crates/common/test-fixtures/Cargo.toml index 86765ed7..ac2b117d 100644 --- a/crates/common/test-fixtures/Cargo.toml +++ b/crates/common/test-fixtures/Cargo.toml @@ -14,4 +14,5 @@ ethlambda-types.workspace = true libssz-types.workspace = true serde.workspace = true +serde_json.workspace = true hex.workspace = true diff --git a/crates/common/test-fixtures/src/common.rs b/crates/common/test-fixtures/src/common.rs new file mode 100644 index 00000000..b3ce4b7e --- /dev/null +++ b/crates/common/test-fixtures/src/common.rs @@ -0,0 +1,331 @@ +use ethlambda_types::{ + attestation::{ + AggregatedAttestation as DomainAggregatedAttestation, + AggregationBits as DomainAggregationBits, AttestationData as DomainAttestationData, + XmssSignature, + }, + block::{Block as DomainBlock, BlockBody as DomainBlockBody}, + checkpoint::Checkpoint as DomainCheckpoint, + primitives::H256, + signature::SIGNATURE_SIZE, + state::{ + ChainConfig, JustificationValidators, JustifiedSlots, State, Validator as DomainValidator, + ValidatorPubkeyBytes, + }, +}; +use libssz_types::SszList; +use serde::Deserialize; + +// ============================================================================ +// Generic Container +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Container { + pub data: Vec, +} + +// ============================================================================ +// Config +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(rename = "genesisTime")] + pub genesis_time: u64, +} + +impl From for ChainConfig { + fn from(value: Config) -> Self { + ChainConfig { + genesis_time: value.genesis_time, + } + } +} + +// ============================================================================ +// Checkpoint +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Checkpoint { + pub root: H256, + pub slot: u64, +} + +impl From for DomainCheckpoint { + fn from(value: Checkpoint) -> Self { + Self { + root: value.root, + slot: value.slot, + } + } +} + +// ============================================================================ +// BlockHeader +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct BlockHeader { + pub slot: u64, + #[serde(rename = "proposerIndex")] + pub proposer_index: u64, + #[serde(rename = "parentRoot")] + pub parent_root: H256, + #[serde(rename = "stateRoot")] + pub state_root: H256, + #[serde(rename = "bodyRoot")] + pub body_root: H256, +} + +impl From for ethlambda_types::block::BlockHeader { + fn from(value: BlockHeader) -> Self { + Self { + slot: value.slot, + proposer_index: value.proposer_index, + parent_root: value.parent_root, + state_root: value.state_root, + body_root: value.body_root, + } + } +} + +// ============================================================================ +// Validator +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Validator { + index: u64, + #[serde(rename = "attestationPubkey")] + #[serde(deserialize_with = "deser_pubkey_hex")] + attestation_pubkey: ValidatorPubkeyBytes, + #[serde(rename = "proposalPubkey")] + #[serde(deserialize_with = "deser_pubkey_hex")] + proposal_pubkey: ValidatorPubkeyBytes, +} + +impl From for DomainValidator { + fn from(value: Validator) -> Self { + Self { + index: value.index, + attestation_pubkey: value.attestation_pubkey, + proposal_pubkey: value.proposal_pubkey, + } + } +} + +// ============================================================================ +// State +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct TestState { + pub config: Config, + pub slot: u64, + #[serde(rename = "latestBlockHeader")] + pub latest_block_header: BlockHeader, + #[serde(rename = "latestJustified")] + pub latest_justified: Checkpoint, + #[serde(rename = "latestFinalized")] + pub latest_finalized: Checkpoint, + #[serde(rename = "historicalBlockHashes")] + pub historical_block_hashes: Container, + #[serde(rename = "justifiedSlots")] + pub justified_slots: Container, + pub validators: Container, + #[serde(rename = "justificationsRoots")] + pub justifications_roots: Container, + #[serde(rename = "justificationsValidators")] + pub justifications_validators: Container, +} + +impl From for State { + fn from(value: TestState) -> Self { + let historical_block_hashes = + SszList::try_from(value.historical_block_hashes.data).unwrap(); + let validators = SszList::try_from( + value + .validators + .data + .into_iter() + .map(Into::into) + .collect::>(), + ) + .unwrap(); + let justifications_roots = SszList::try_from(value.justifications_roots.data).unwrap(); + + let mut justified_slots = JustifiedSlots::new(); + for &b in &value.justified_slots.data { + justified_slots.push(b).unwrap(); + } + + let mut justifications_validators = JustificationValidators::new(); + for &b in &value.justifications_validators.data { + justifications_validators.push(b).unwrap(); + } + + State { + config: value.config.into(), + slot: value.slot, + latest_block_header: value.latest_block_header.into(), + latest_justified: value.latest_justified.into(), + latest_finalized: value.latest_finalized.into(), + historical_block_hashes, + justified_slots, + validators, + justifications_roots, + justifications_validators, + } + } +} + +// ============================================================================ +// Block Types +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Block { + pub slot: u64, + #[serde(rename = "proposerIndex")] + pub proposer_index: u64, + #[serde(rename = "parentRoot")] + pub parent_root: H256, + #[serde(rename = "stateRoot")] + pub state_root: H256, + pub body: BlockBody, +} + +impl From for DomainBlock { + fn from(value: Block) -> Self { + Self { + slot: value.slot, + proposer_index: value.proposer_index, + parent_root: value.parent_root, + state_root: value.state_root, + body: value.body.into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BlockBody { + pub attestations: Container, +} + +impl From for DomainBlockBody { + fn from(value: BlockBody) -> Self { + let attestations = value + .attestations + .data + .into_iter() + .map(Into::into) + .collect::>(); + Self { + attestations: SszList::try_from(attestations).expect("too many attestations"), + } + } +} + +// ============================================================================ +// Attestation Types +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct AggregatedAttestation { + #[serde(rename = "aggregationBits")] + pub aggregation_bits: AggregationBits, + pub data: AttestationData, +} + +impl From for DomainAggregatedAttestation { + fn from(value: AggregatedAttestation) -> Self { + Self { + aggregation_bits: value.aggregation_bits.into(), + data: value.data.into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AggregationBits { + pub data: Vec, +} + +impl From for DomainAggregationBits { + fn from(value: AggregationBits) -> Self { + let mut bits = DomainAggregationBits::new(); + for &b in value.data.iter() { + bits.push(b).unwrap(); + } + bits + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AttestationData { + pub slot: u64, + pub head: Checkpoint, + pub target: Checkpoint, + pub source: Checkpoint, +} + +impl From for DomainAttestationData { + fn from(value: AttestationData) -> Self { + Self { + slot: value.slot, + head: value.head.into(), + target: value.target.into(), + source: value.source.into(), + } + } +} + +// ============================================================================ +// Metadata +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct TestInfo { + pub hash: String, + pub comment: String, + #[serde(rename = "testId")] + pub test_id: String, + pub description: String, + #[serde(rename = "fixtureFormat")] + pub fixture_format: String, +} + +// ============================================================================ +// Helpers +// ============================================================================ + +pub fn deser_pubkey_hex<'de, D>(d: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + use serde::de::Error; + + let value = String::deserialize(d)?; + let pubkey: ValidatorPubkeyBytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .map_err(|_| D::Error::custom("ValidatorPubkey value is not valid hex"))? + .try_into() + .map_err(|_| D::Error::custom("ValidatorPubkey length != 52"))?; + Ok(pubkey) +} + +pub fn deser_xmss_hex<'de, D>(d: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + use serde::de::Error; + + let value = String::deserialize(d)?; + let bytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .map_err(|_| D::Error::custom("XmssSignature value is not valid hex"))?; + XmssSignature::try_from(bytes) + .map_err(|_| D::Error::custom(format!("XmssSignature length != {SIGNATURE_SIZE}"))) +} diff --git a/crates/blockchain/tests/types.rs b/crates/common/test-fixtures/src/fork_choice.rs similarity index 84% rename from crates/blockchain/tests/types.rs rename to crates/common/test-fixtures/src/fork_choice.rs index 97b56dd1..eceacd4a 100644 --- a/crates/blockchain/tests/types.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -1,4 +1,12 @@ -use super::common::{self, Block, TestInfo, TestState, deser_xmss_hex}; +//! Fork-choice test fixture types. +//! +//! Used both by the offline spec-test runner and the Hive `/lean/v0/test_driver/fork_choice/*` +//! endpoints, which receive the same JSON shapes from the lean spec-assets simulator. + +use crate::{ + AggregationBits, AttestationData, Block, BlockBody, Checkpoint, TestInfo, TestState, + deser_xmss_hex, +}; use ethlambda_types::attestation::XmssSignature; use ethlambda_types::primitives::H256; use serde::{Deserialize, Deserializer}; @@ -48,6 +56,11 @@ pub struct ForkChoiceTest { #[derive(Debug, Clone, Deserialize)] pub struct ForkChoiceStep { + /// Whether this step is expected to be accepted by the store. + /// + /// Defaults to `true` because the simulator omits the field when it expects + /// success (`checks`-only steps don't carry a `valid` flag at all). + #[serde(default = "default_true")] pub valid: bool, pub checks: Option, #[serde(rename = "stepType")] @@ -64,11 +77,15 @@ pub struct ForkChoiceStep { pub is_aggregator: Option, } +fn default_true() -> bool { + true +} + #[derive(Debug, Clone, Deserialize)] pub struct AttestationStepData { #[serde(rename = "validatorId")] pub validator_id: Option, - pub data: common::AttestationData, + pub data: AttestationData, #[serde(default, deserialize_with = "deser_opt_xmss_hex")] pub signature: Option, /// Present on `gossipAggregatedAttestation` steps. @@ -77,7 +94,7 @@ pub struct AttestationStepData { #[derive(Debug, Clone, Deserialize)] pub struct ProofStepData { - pub participants: common::AggregationBits, + pub participants: AggregationBits, #[serde(rename = "proofData")] pub proof_data: HexByteList, } @@ -114,21 +131,20 @@ pub struct BlockStepData { pub parent_root: H256, #[serde(rename = "stateRoot")] pub state_root: H256, - pub body: common::BlockBody, + pub body: BlockBody, #[serde(rename = "blockRootLabel")] pub block_root_label: Option, } impl BlockStepData { pub fn to_block(&self) -> ethlambda_types::block::Block { - Block { + ethlambda_types::block::Block { slot: self.slot, proposer_index: self.proposer_index, parent_root: self.parent_root, state_root: self.state_root, - body: self.body.clone(), + body: self.body.clone().into(), } - .into() } } @@ -160,6 +176,10 @@ pub struct StoreChecks { #[serde(rename = "latestJustifiedRootLabel")] pub latest_justified_root_label: Option, + /// camelCase alias used by Hive's spec-assets fixtures (`justifiedCheckpoint`). + #[serde(rename = "justifiedCheckpoint")] + pub justified_checkpoint: Option, + #[serde(rename = "latestFinalizedSlot")] pub latest_finalized_slot: Option, #[serde(rename = "latestFinalizedRoot")] @@ -167,6 +187,10 @@ pub struct StoreChecks { #[serde(rename = "latestFinalizedRootLabel")] pub latest_finalized_root_label: Option, + /// camelCase alias used by Hive's spec-assets fixtures (`finalizedCheckpoint`). + #[serde(rename = "finalizedCheckpoint")] + pub finalized_checkpoint: Option, + /// Legacy single-field schema; expected safe target block root. #[serde(rename = "safeTarget")] pub safe_target: Option, diff --git a/crates/common/test-fixtures/src/lib.rs b/crates/common/test-fixtures/src/lib.rs index b3ce4b7e..8b52e0a6 100644 --- a/crates/common/test-fixtures/src/lib.rs +++ b/crates/common/test-fixtures/src/lib.rs @@ -1,331 +1,12 @@ -use ethlambda_types::{ - attestation::{ - AggregatedAttestation as DomainAggregatedAttestation, - AggregationBits as DomainAggregationBits, AttestationData as DomainAttestationData, - XmssSignature, - }, - block::{Block as DomainBlock, BlockBody as DomainBlockBody}, - checkpoint::Checkpoint as DomainCheckpoint, - primitives::H256, - signature::SIGNATURE_SIZE, - state::{ - ChainConfig, JustificationValidators, JustifiedSlots, State, Validator as DomainValidator, - ValidatorPubkeyBytes, - }, -}; -use libssz_types::SszList; -use serde::Deserialize; - -// ============================================================================ -// Generic Container -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct Container { - pub data: Vec, -} - -// ============================================================================ -// Config -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct Config { - #[serde(rename = "genesisTime")] - pub genesis_time: u64, -} - -impl From for ChainConfig { - fn from(value: Config) -> Self { - ChainConfig { - genesis_time: value.genesis_time, - } - } -} - -// ============================================================================ -// Checkpoint -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct Checkpoint { - pub root: H256, - pub slot: u64, -} - -impl From for DomainCheckpoint { - fn from(value: Checkpoint) -> Self { - Self { - root: value.root, - slot: value.slot, - } - } -} - -// ============================================================================ -// BlockHeader -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct BlockHeader { - pub slot: u64, - #[serde(rename = "proposerIndex")] - pub proposer_index: u64, - #[serde(rename = "parentRoot")] - pub parent_root: H256, - #[serde(rename = "stateRoot")] - pub state_root: H256, - #[serde(rename = "bodyRoot")] - pub body_root: H256, -} - -impl From for ethlambda_types::block::BlockHeader { - fn from(value: BlockHeader) -> Self { - Self { - slot: value.slot, - proposer_index: value.proposer_index, - parent_root: value.parent_root, - state_root: value.state_root, - body_root: value.body_root, - } - } -} - -// ============================================================================ -// Validator -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct Validator { - index: u64, - #[serde(rename = "attestationPubkey")] - #[serde(deserialize_with = "deser_pubkey_hex")] - attestation_pubkey: ValidatorPubkeyBytes, - #[serde(rename = "proposalPubkey")] - #[serde(deserialize_with = "deser_pubkey_hex")] - proposal_pubkey: ValidatorPubkeyBytes, -} - -impl From for DomainValidator { - fn from(value: Validator) -> Self { - Self { - index: value.index, - attestation_pubkey: value.attestation_pubkey, - proposal_pubkey: value.proposal_pubkey, - } - } -} - -// ============================================================================ -// State -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct TestState { - pub config: Config, - pub slot: u64, - #[serde(rename = "latestBlockHeader")] - pub latest_block_header: BlockHeader, - #[serde(rename = "latestJustified")] - pub latest_justified: Checkpoint, - #[serde(rename = "latestFinalized")] - pub latest_finalized: Checkpoint, - #[serde(rename = "historicalBlockHashes")] - pub historical_block_hashes: Container, - #[serde(rename = "justifiedSlots")] - pub justified_slots: Container, - pub validators: Container, - #[serde(rename = "justificationsRoots")] - pub justifications_roots: Container, - #[serde(rename = "justificationsValidators")] - pub justifications_validators: Container, -} - -impl From for State { - fn from(value: TestState) -> Self { - let historical_block_hashes = - SszList::try_from(value.historical_block_hashes.data).unwrap(); - let validators = SszList::try_from( - value - .validators - .data - .into_iter() - .map(Into::into) - .collect::>(), - ) - .unwrap(); - let justifications_roots = SszList::try_from(value.justifications_roots.data).unwrap(); - - let mut justified_slots = JustifiedSlots::new(); - for &b in &value.justified_slots.data { - justified_slots.push(b).unwrap(); - } - - let mut justifications_validators = JustificationValidators::new(); - for &b in &value.justifications_validators.data { - justifications_validators.push(b).unwrap(); - } - - State { - config: value.config.into(), - slot: value.slot, - latest_block_header: value.latest_block_header.into(), - latest_justified: value.latest_justified.into(), - latest_finalized: value.latest_finalized.into(), - historical_block_hashes, - justified_slots, - validators, - justifications_roots, - justifications_validators, - } - } -} - -// ============================================================================ -// Block Types -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct Block { - pub slot: u64, - #[serde(rename = "proposerIndex")] - pub proposer_index: u64, - #[serde(rename = "parentRoot")] - pub parent_root: H256, - #[serde(rename = "stateRoot")] - pub state_root: H256, - pub body: BlockBody, -} - -impl From for DomainBlock { - fn from(value: Block) -> Self { - Self { - slot: value.slot, - proposer_index: value.proposer_index, - parent_root: value.parent_root, - state_root: value.state_root, - body: value.body.into(), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct BlockBody { - pub attestations: Container, -} - -impl From for DomainBlockBody { - fn from(value: BlockBody) -> Self { - let attestations = value - .attestations - .data - .into_iter() - .map(Into::into) - .collect::>(); - Self { - attestations: SszList::try_from(attestations).expect("too many attestations"), - } - } -} - -// ============================================================================ -// Attestation Types -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct AggregatedAttestation { - #[serde(rename = "aggregationBits")] - pub aggregation_bits: AggregationBits, - pub data: AttestationData, -} - -impl From for DomainAggregatedAttestation { - fn from(value: AggregatedAttestation) -> Self { - Self { - aggregation_bits: value.aggregation_bits.into(), - data: value.data.into(), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AggregationBits { - pub data: Vec, -} - -impl From for DomainAggregationBits { - fn from(value: AggregationBits) -> Self { - let mut bits = DomainAggregationBits::new(); - for &b in value.data.iter() { - bits.push(b).unwrap(); - } - bits - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AttestationData { - pub slot: u64, - pub head: Checkpoint, - pub target: Checkpoint, - pub source: Checkpoint, -} - -impl From for DomainAttestationData { - fn from(value: AttestationData) -> Self { - Self { - slot: value.slot, - head: value.head.into(), - target: value.target.into(), - source: value.source.into(), - } - } -} - -// ============================================================================ -// Metadata -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct TestInfo { - pub hash: String, - pub comment: String, - #[serde(rename = "testId")] - pub test_id: String, - pub description: String, - #[serde(rename = "fixtureFormat")] - pub fixture_format: String, -} - -// ============================================================================ -// Helpers -// ============================================================================ - -pub fn deser_pubkey_hex<'de, D>(d: D) -> Result -where - D: serde::Deserializer<'de>, -{ - use serde::Deserialize; - use serde::de::Error; - - let value = String::deserialize(d)?; - let pubkey: ValidatorPubkeyBytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) - .map_err(|_| D::Error::custom("ValidatorPubkey value is not valid hex"))? - .try_into() - .map_err(|_| D::Error::custom("ValidatorPubkey length != 52"))?; - Ok(pubkey) -} - -pub fn deser_xmss_hex<'de, D>(d: D) -> Result -where - D: serde::Deserializer<'de>, -{ - use serde::Deserialize; - use serde::de::Error; - - let value = String::deserialize(d)?; - let bytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) - .map_err(|_| D::Error::custom("XmssSignature value is not valid hex"))?; - XmssSignature::try_from(bytes) - .map_err(|_| D::Error::custom(format!("XmssSignature length != {SIGNATURE_SIZE}"))) -} +//! Shared deserialization types for leanSpec test fixtures. +//! +//! Used by the blockchain crate's spec-test runners and by the RPC crate's +//! Hive test-driver handlers (which receive the same fixture JSON over HTTP +//! from the lean spec-assets simulator). + +mod common; +pub mod fork_choice; +pub mod state_transition; +pub mod verify_signatures; + +pub use common::*; diff --git a/crates/common/test-fixtures/src/state_transition.rs b/crates/common/test-fixtures/src/state_transition.rs new file mode 100644 index 00000000..5bf159d2 --- /dev/null +++ b/crates/common/test-fixtures/src/state_transition.rs @@ -0,0 +1,24 @@ +//! State-transition test fixture types. +//! +//! Used by the Hive `/lean/v0/test_driver/state_transition/run` endpoint, +//! which receives the entire fixture case as the JSON body from the lean +//! spec-assets simulator. Extra fields (such as `_info` or `post`) are +//! ignored by serde, so we only deserialize the parts the driver needs. + +use crate::{Block, TestState}; +use serde::Deserialize; + +/// Request body for `POST /lean/v0/test_driver/state_transition/run`. +/// +/// The simulator sends the full fixture case verbatim; we only need `pre` and +/// `blocks` to drive the STF. `expect_exception` is captured because Ream's +/// driver uses its presence to force a deterministic error when `blocks` is +/// empty (otherwise the suite would expect a failure with no STF call to +/// produce one). +#[derive(Debug, Clone, Deserialize)] +pub struct StateTransitionRunRequest { + pub pre: TestState, + pub blocks: Vec, + #[serde(default, rename = "expectException")] + pub expect_exception: Option, +} diff --git a/crates/blockchain/tests/signature_types.rs b/crates/common/test-fixtures/src/verify_signatures.rs similarity index 91% rename from crates/blockchain/tests/signature_types.rs rename to crates/common/test-fixtures/src/verify_signatures.rs index 5f955aaf..f2044c28 100644 --- a/crates/blockchain/tests/signature_types.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -1,4 +1,10 @@ -use super::common::{AggregationBits, Block, Container, TestInfo, TestState, deser_xmss_hex}; +//! Signature-verification test fixture types. +//! +//! Used both by the offline spec-test runner and the Hive +//! `/lean/v0/test_driver/verify_signatures/run` endpoint, which receives the +//! same JSON shapes from the lean spec-assets simulator. + +use crate::{AggregationBits, Block, Container, TestInfo, TestState, deser_xmss_hex}; use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; use ethlambda_types::block::{ AggregatedSignatureProof, AttestationSignatures, BlockSignatures, SignedBlock, diff --git a/crates/net/rpc/Cargo.toml b/crates/net/rpc/Cargo.toml index df7e23c0..7e978890 100644 --- a/crates/net/rpc/Cargo.toml +++ b/crates/net/rpc/Cargo.toml @@ -13,14 +13,18 @@ version.workspace = true axum = "0.8.1" tokio.workspace = true tokio-util.workspace = true +ethlambda-blockchain.workspace = true ethlambda-fork-choice.workspace = true ethlambda-metrics.workspace = true -tracing.workspace = true +ethlambda-state-transition.workspace = true ethlambda-storage.workspace = true +ethlambda-test-fixtures.workspace = true ethlambda-types.workspace = true libssz.workspace = true +hex.workspace = true serde.workspace = true serde_json.workspace = true +tracing.workspace = true jemalloc_pprof.workspace = true [dev-dependencies] diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index a0f7e0e0..a8e18319 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -19,6 +19,7 @@ mod admin; mod fork_choice; mod heap_profiling; pub mod metrics; +pub mod test_driver; #[derive(Debug, Clone)] pub struct RpcConfig { @@ -27,6 +28,28 @@ pub struct RpcConfig { pub metrics_port: u16, } +/// Start the RPC server in Hive test-driver mode. +/// +/// Exposes only the `/lean/v0/test_driver/...` endpoints plus a `/lean/v0/health` +/// stub. The driver swaps its own `Store` on every `fork_choice/init`, so we +/// don't share state with the regular consensus path (which isn't running in +/// driver mode anyway — see `bin/ethlambda/src/main.rs`). +pub async fn start_test_driver_rpc_server( + config: RpcConfig, + driver: test_driver::DriverState, + shutdown: CancellationToken, +) -> Result<(), std::io::Error> { + let app = test_driver::build_router(driver); + let addr = SocketAddr::new(config.http_address, config.api_port); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app) + .with_graceful_shutdown(async move { + shutdown.cancelled().await; + }) + .await?; + Ok(()) +} + pub async fn start_rpc_server( config: RpcConfig, store: Store, diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs new file mode 100644 index 00000000..ff1ff2a5 --- /dev/null +++ b/crates/net/rpc/src/test_driver.rs @@ -0,0 +1,554 @@ +//! Hive lean spec-asset test-driver endpoints. +//! +//! Exposes four POST endpoints under `/lean/v0/test_driver/...` that the +//! [`ethereum/hive`](https://github.com/ethereum/hive) lean simulator drives +//! against the client to replay leanSpec fixtures over HTTP: +//! +//! ```text +//! POST /lean/v0/test_driver/fork_choice/init -> 204 / 400 +//! POST /lean/v0/test_driver/fork_choice/step -> StepResponse +//! POST /lean/v0/test_driver/state_transition/run -> StateTransitionResponse +//! POST /lean/v0/test_driver/verify_signatures/run -> VerifySignaturesResponse +//! ``` +//! +//! The driver replaces the in-process [`Store`] on every `fork_choice/init` so +//! a single client container can replay many independent fixtures back-to-back +//! without restart. State is held behind an `Arc>`; all +//! store-mutating operations themselves are synchronous, so the write lock is +//! never held across `.await`. +//! +//! Activated by setting `HIVE_LEAN_TEST_DRIVER=1` in the container env; see +//! [`test_driver_enabled`] and the boot path in `bin/ethlambda/src/main.rs`. + +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::State as AxumState, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use ethlambda_blockchain::{ + MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, + store::{self, verify_block_signatures}, +}; +use ethlambda_storage::{Store, backend::InMemoryBackend}; +use ethlambda_test_fixtures::{ + Block as FixtureBlock, TestState, + fork_choice::{BlockStepData, ForkChoiceStep}, + state_transition::StateTransitionRunRequest, + verify_signatures::TestSignedBlock, +}; +use ethlambda_types::{ + attestation::{ + AggregationBits as EthAggregationBits, SignedAggregatedAttestation, SignedAttestation, + XmssSignature, + }, + block::{ + AggregatedSignatureProof, AttestationSignatures, Block, BlockSignatures, ByteListMiB, + SignedBlock, + }, + checkpoint::Checkpoint, + primitives::{H256, HashTreeRoot as _}, + signature::SIGNATURE_SIZE, + state::State, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tracing::debug; + +/// Environment variable that activates the test driver at boot time. +/// +/// The hive simulator sets this to `"1"` for each spec-asset fixture run; any +/// of `"1"`, `"true"`, or `"yes"` (case-insensitive) enables the driver. +pub const TEST_DRIVER_ENV: &str = "HIVE_LEAN_TEST_DRIVER"; + +/// Returns true when the binary should boot into test-driver mode. +pub fn test_driver_enabled() -> bool { + match std::env::var(TEST_DRIVER_ENV) { + Ok(value) => matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ), + Err(_) => false, + } +} + +/// Shared, runtime-replaceable Store backing every test-driver handler. +/// +/// `fork_choice/init` swaps the contents wholesale; all other handlers either +/// take a write lock (to mutate fork choice) or a read lock (to snapshot). +pub type DriverState = Arc>; + +/// Build an empty in-memory Store with no validators. +/// +/// Used as the placeholder seed before the first `fork_choice/init` call. +pub fn empty_driver_store() -> Store { + let backend = Arc::new(InMemoryBackend::new()); + Store::from_anchor_state(backend, State::from_genesis(0, vec![])) +} + +/// Build the test-driver router, including a `/lean/v0/health` endpoint so the +/// hive port liveness check has something to talk to. +pub fn build_router(state: DriverState) -> Router { + Router::new() + .route("/lean/v0/health", get(crate::metrics::get_health)) + .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) +} + +// ============================================================================ +// Request / response types +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct InitForkChoiceRequest { + #[serde(rename = "anchorState")] + anchor_state: TestState, + #[serde(rename = "anchorBlock")] + anchor_block: FixtureBlock, + #[serde(default, rename = "genesisTime")] + genesis_time: Option, +} + +#[derive(Debug, Deserialize)] +struct VerifySignaturesRequest { + #[serde(rename = "anchorState")] + anchor_state: TestState, + #[serde(rename = "signedBlock")] + signed_block: TestSignedBlock, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DriverCheckpoint { + slot: u64, + root: H256, +} + +impl From for DriverCheckpoint { + fn from(value: Checkpoint) -> Self { + Self { + slot: value.slot, + root: value.root, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DriverSnapshot { + head_slot: u64, + head_root: H256, + /// Store time in 800 ms intervals since genesis (matches [`Store::time`]). + time: u64, + justified_checkpoint: DriverCheckpoint, + finalized_checkpoint: DriverCheckpoint, + safe_target: H256, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StepResponse { + accepted: bool, + error: Option, + snapshot: DriverSnapshot, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StateTransitionPost { + slot: u64, + latest_block_header_slot: u64, + latest_block_header_state_root: H256, + historical_block_hashes_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StateTransitionResponse { + succeeded: bool, + error: Option, + post: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct VerifySignaturesResponse { + succeeded: bool, + error: Option, +} + +// ============================================================================ +// Handlers +// ============================================================================ + +/// `POST /lean/v0/test_driver/fork_choice/init` +/// +/// Validates the supplied (anchor_state, anchor_block) pair, then replaces the +/// shared Store with a freshly initialized one. Returns 204 on success, 400 +/// when the anchor pair is inconsistent (the simulator's +/// `anchor_valid=False` fixtures rely on the 4xx). +async fn init_fork_choice( + AxumState(driver): AxumState, + Json(request): Json, +) -> Response { + let mut state: State = request.anchor_state.into(); + if let Some(genesis_time) = request.genesis_time { + state.config.genesis_time = genesis_time; + } + let block: Block = request.anchor_block.into(); + + // Mirror Store::get_forkchoice_store's invariants explicitly so we can + // surface a clean 400 instead of panicking the handler task. + if !anchor_pair_is_consistent(&state, &block) { + return ( + StatusCode::BAD_REQUEST, + "anchor block does not match anchor state", + ) + .into_response(); + } + + let backend = Arc::new(InMemoryBackend::new()); + let new_store = Store::from_anchor_state(backend, state); + + *driver.write().await = new_store; + + StatusCode::NO_CONTENT.into_response() +} + +/// `POST /lean/v0/test_driver/fork_choice/step` +/// +/// Applies a single fork-choice step against the current Store and always +/// returns 200 with `{accepted, error?, snapshot}`. The simulator compares +/// `accepted` to the step's `valid` flag and `snapshot` to the step's `checks`. +async fn step_fork_choice( + AxumState(driver): AxumState, + Json(step): Json, +) -> Json { + let outcome = { + let mut guard = driver.write().await; + apply_step(&mut guard, step) + }; + let (accepted, error) = match outcome { + Ok(()) => (true, None), + Err(err) => { + debug!(%err, "fork-choice step rejected"); + (false, Some(err)) + } + }; + let snapshot = { + let guard = driver.read().await; + snapshot_store(&guard) + }; + Json(StepResponse { + accepted, + error, + snapshot, + }) +} + +/// `POST /lean/v0/test_driver/state_transition/run` +/// +/// Runs `state_transition(pre, block)` for each block in sequence. The +/// `succeeded` flag reflects whether the full STF chain executed without +/// error; the simulator compares it to the fixture's `expectException` field. +async fn run_state_transition( + Json(request): Json, +) -> Json { + let mut state: State = request.pre.into(); + let blocks: Vec = request.blocks.into_iter().map(Into::into).collect(); + let blocks_empty = blocks.is_empty(); + + let result = (|| -> Result<(), String> { + for block in &blocks { + ethlambda_state_transition::state_transition(&mut state, block) + .map_err(|err| err.to_string())?; + } + + // Match Ream's behavior: fixtures may carry `expectException` with an + // empty `blocks` list to exercise pre-state-only invariants. The STF + // entry point only runs when there's a block, so force a deterministic + // failure here to keep the simulator's `expectException` assertion + // consistent. + if blocks_empty && request.expect_exception.is_some() { + let target_slot = state.slot; + ethlambda_state_transition::process_slots(&mut state, target_slot) + .map_err(|err| err.to_string())?; + } + + Ok(()) + })(); + + let response = match result { + Ok(()) => StateTransitionResponse { + succeeded: true, + error: None, + post: Some(post_summary(&state)), + }, + Err(err) => StateTransitionResponse { + succeeded: false, + error: Some(err), + post: None, + }, + }; + Json(response) +} + +/// `POST /lean/v0/test_driver/verify_signatures/run` +/// +/// Runs the exact same `verify_block_signatures` path the production block +/// import pipeline uses, against the fixture-supplied (anchor_state, +/// signed_block) pair. +async fn run_verify_signatures( + Json(request): Json, +) -> Json { + let state: State = request.anchor_state.into(); + let signed_block = signed_block_from_fixture(request.signed_block); + + let response = match verify_block_signatures(&state, &signed_block) { + Ok(()) => VerifySignaturesResponse { + succeeded: true, + error: None, + }, + Err(err) => VerifySignaturesResponse { + succeeded: false, + error: Some(err.to_string()), + }, + }; + Json(response) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Replicate the invariant `Store::get_forkchoice_store` asserts (without the +/// panic): anchor_block and state.latest_block_header must agree on every +/// field once `state_root` is zeroed, AND the original `state_root` must be +/// either zero or the computed tree-hash root of the state. +fn anchor_pair_is_consistent(state: &State, block: &Block) -> bool { + let mut state_header = state.latest_block_header.clone(); + let mut block_header = block.header(); + state_header.state_root = H256::ZERO; + block_header.state_root = H256::ZERO; + if state_header != block_header { + return false; + } + + let original = state.latest_block_header.state_root; + if original == H256::ZERO { + return true; + } + let mut zeroed = state.clone(); + zeroed.latest_block_header.state_root = H256::ZERO; + let computed = zeroed.hash_tree_root(); + original == computed +} + +/// Dispatch a fork-choice step against the held Store. +fn apply_step(store: &mut Store, step: ForkChoiceStep) -> Result<(), String> { + match step.step_type.as_str() { + "tick" => { + let genesis_time = store.config().genesis_time; + let timestamp_ms = match (step.time, step.interval) { + (Some(time_s), _) => time_s * 1000, + (None, Some(interval)) => { + genesis_time * 1000 + interval * MILLISECONDS_PER_INTERVAL + } + (None, None) => return Err("tick step missing time and interval".to_string()), + }; + store::on_tick(store, timestamp_ms, step.has_proposal.unwrap_or(false)); + Ok(()) + } + "block" => { + let block_data = step + .block + .ok_or_else(|| "block step missing block data".to_string())?; + let signed_block = blank_signed_block(block_data); + // Match the spec-test runner: advance time to the block's slot + // before importing so the future-slot guard doesn't reject it. + let block_time_ms = store.config().genesis_time * 1000 + + signed_block.message.slot * MILLISECONDS_PER_SLOT; + store::on_tick(store, block_time_ms, true); + store::on_block_without_verification(store, signed_block).map_err(|e| e.to_string()) + } + "attestation" => { + let att = step + .attestation + .ok_or_else(|| "attestation step missing data".to_string())?; + let signed = SignedAttestation { + validator_id: att + .validator_id + .ok_or_else(|| "attestation step missing validatorId".to_string())?, + data: att.data.into(), + signature: att + .signature + .ok_or_else(|| "attestation step missing signature".to_string())?, + }; + store::on_gossip_attestation(store, &signed, step.is_aggregator.unwrap_or(false)) + .map_err(|e| e.to_string()) + } + "gossipAggregatedAttestation" => { + let att = step + .attestation + .ok_or_else(|| "gossipAggregatedAttestation step missing data".to_string())?; + let proof = att + .proof + .ok_or_else(|| "gossipAggregatedAttestation step missing proof".to_string())?; + let participants: EthAggregationBits = proof.participants.into(); + let proof_bytes: Vec = proof.proof_data.into(); + let proof_data = ByteListMiB::try_from(proof_bytes) + .map_err(|err| format!("aggregated proof data too large: {err:?}"))?; + let aggregated = SignedAggregatedAttestation { + data: att.data.into(), + proof: AggregatedSignatureProof::new(participants, proof_data), + }; + store::on_gossip_aggregated_attestation(store, aggregated).map_err(|e| e.to_string()) + } + // `checks`-only steps are no-ops here — the simulator validates them + // against the snapshot returned alongside this response. + "checks" => Ok(()), + other => Err(format!("unknown step type: {other}")), + } +} + +/// Build a SignedBlock for fork-choice import without real signatures. +/// +/// Matches the offline spec-test runner's `build_signed_block`: one empty +/// proof per attestation (the participant bits get checked against the +/// attestation's `aggregation_bits` during import) and a zeroed proposer +/// signature. Fork-choice steps use `on_block_without_verification`, so +/// these placeholders never reach the crypto layer. +fn blank_signed_block(block_data: BlockStepData) -> SignedBlock { + let block: Block = block_data.to_block(); + let proofs: Vec = block + .body + .attestations + .iter() + .map(|att| AggregatedSignatureProof::empty(att.aggregation_bits.clone())) + .collect(); + + SignedBlock { + message: block, + signature: BlockSignatures { + proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]) + .expect("zero-filled signature has the correct length"), + attestation_signatures: AttestationSignatures::try_from(proofs) + .expect("attestation proofs within limit"), + }, + } +} + +/// Materialize a SignedBlock that preserves the fixture-supplied per-validator +/// proof bytes, so `verify_block_signatures` actually exercises the leanVM +/// aggregate path (vs. the `From` shortcut that drops it). +fn signed_block_from_fixture(value: TestSignedBlock) -> SignedBlock { + let block: Block = value.block.into(); + let proposer_signature = value.signature.proposer_signature; + let proofs: Vec = value + .signature + .attestation_signatures + .data + .into_iter() + .map(|att_sig| { + let participants: EthAggregationBits = att_sig.participants.into(); + let stripped = att_sig + .proof_data + .data + .strip_prefix("0x") + .unwrap_or(&att_sig.proof_data.data); + let proof_bytes = hex::decode(stripped).unwrap_or_default(); + let proof_data = + ByteListMiB::try_from(proof_bytes).unwrap_or_else(|_| ByteListMiB::default()); + AggregatedSignatureProof::new(participants, proof_data) + }) + .collect(); + + SignedBlock { + message: block, + signature: BlockSignatures { + attestation_signatures: AttestationSignatures::try_from(proofs) + .expect("attestation proofs within limit"), + proposer_signature, + }, + } +} + +/// Read the post-state summary expected by the hive `state_transition/run` +/// schema. +fn post_summary(state: &State) -> StateTransitionPost { + StateTransitionPost { + slot: state.slot, + latest_block_header_slot: state.latest_block_header.slot, + latest_block_header_state_root: state.latest_block_header.state_root, + historical_block_hashes_count: state.historical_block_hashes.len(), + } +} + +/// Snapshot the store fields exposed by the fork-choice `step` response. +fn snapshot_store(store: &Store) -> DriverSnapshot { + let head_root = store.head(); + let head_slot = store + .get_block_header(&head_root) + .map(|header| header.slot) + .unwrap_or(0); + + DriverSnapshot { + head_slot, + head_root, + time: store.time(), + justified_checkpoint: store.latest_justified().into(), + finalized_checkpoint: store.latest_finalized().into(), + safe_target: store.safe_target(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn driver_recognizes_truthy_env_values() { + for value in ["1", "true", "TRUE", " Yes "] { + // SAFETY: tests run single-threaded for shared env vars in this + // file; the std::env contract here is just to flip the toggle. + unsafe { std::env::set_var(TEST_DRIVER_ENV, value) }; + assert!(test_driver_enabled(), "{value:?} should enable the driver"); + } + unsafe { std::env::set_var(TEST_DRIVER_ENV, "0") }; + assert!(!test_driver_enabled(), "0 should disable the driver"); + unsafe { std::env::remove_var(TEST_DRIVER_ENV) }; + assert!( + !test_driver_enabled(), + "unset env should disable the driver" + ); + } + + #[test] + fn empty_driver_store_is_usable_as_seed() { + let store = empty_driver_store(); + // Head, time, checkpoints all read without panicking; that's the + // contract `init_fork_choice` relies on before the first reset. + let _ = store.head(); + assert_eq!(store.time(), 0); + assert_eq!(store.latest_justified().slot, 0); + assert_eq!(store.latest_finalized().slot, 0); + } +} diff --git a/crates/net/rpc/tests/test_driver_e2e.rs b/crates/net/rpc/tests/test_driver_e2e.rs new file mode 100644 index 00000000..5907aad6 --- /dev/null +++ b/crates/net/rpc/tests/test_driver_e2e.rs @@ -0,0 +1,244 @@ +//! End-to-end tests for the Hive lean test-driver router. +//! +//! These tests exercise the four `/lean/v0/test_driver/...` endpoints exactly +//! as the hive simulator does — same JSON bodies, same HTTP method, same +//! response shape — using `tower::ServiceExt::oneshot` so no real socket is +//! involved. They're the closest thing to running the suite under hive +//! without spinning up docker. + +use std::sync::Arc; + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use ethlambda_rpc::test_driver::{DriverState, build_router, empty_driver_store}; +use ethlambda_types::{block::BlockBody, primitives::HashTreeRoot}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tokio::sync::RwLock; +use tower::ServiceExt; + +const ZERO_ROOT: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; + +/// Build a genesis-shaped `anchorState` JSON object that the test driver's +/// `init_fork_choice` handler will accept. +fn genesis_anchor_state_json(genesis_time: u64) -> Value { + let body_root = format!("{}", BlockBody::default().hash_tree_root()); + json!({ + "config": {"genesisTime": genesis_time}, + "slot": 0, + "latestBlockHeader": { + "slot": 0, + "proposerIndex": 0, + "parentRoot": ZERO_ROOT, + "stateRoot": ZERO_ROOT, + "bodyRoot": body_root, + }, + "latestJustified": {"root": ZERO_ROOT, "slot": 0}, + "latestFinalized": {"root": ZERO_ROOT, "slot": 0}, + "historicalBlockHashes": {"data": []}, + "justifiedSlots": {"data": []}, + "validators": {"data": []}, + "justificationsRoots": {"data": []}, + "justificationsValidators": {"data": []}, + }) +} + +/// Build the matching genesis `anchorBlock` JSON (slot 0, empty body). +fn genesis_anchor_block_json() -> Value { + json!({ + "slot": 0, + "proposerIndex": 0, + "parentRoot": ZERO_ROOT, + "stateRoot": ZERO_ROOT, + "body": {"attestations": {"data": []}}, + }) +} + +fn fresh_driver() -> DriverState { + Arc::new(RwLock::new(empty_driver_store())) +} + +async fn post(router: &axum::Router, path: &str, body: &Value) -> (StatusCode, Value) { + let response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let value: Value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(Value::Null) + }; + (status, value) +} + +#[tokio::test] +async fn init_with_genesis_anchor_returns_204_and_resets_store() { + let driver = fresh_driver(); + let router = build_router(driver.clone()); + + let body = json!({ + "anchorState": genesis_anchor_state_json(1234), + "anchorBlock": genesis_anchor_block_json(), + }); + + let (status, _) = post(&router, "/lean/v0/test_driver/fork_choice/init", &body).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // The driver's store should now reflect the supplied genesis time. + let guard = driver.read().await; + assert_eq!(guard.config().genesis_time, 1234); +} + +#[tokio::test] +async fn init_with_mismatched_anchor_returns_400() { + let driver = fresh_driver(); + let router = build_router(driver); + + // Genesis state but the anchor block claims a different slot — the + // header comparison must reject this pair. + let mut anchor_block = genesis_anchor_block_json(); + anchor_block["slot"] = json!(42); + + let body = json!({ + "anchorState": genesis_anchor_state_json(0), + "anchorBlock": anchor_block, + }); + + let (status, _) = post(&router, "/lean/v0/test_driver/fork_choice/init", &body).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn step_tick_advances_store_time_and_returns_snapshot() { + let driver = fresh_driver(); + let router = build_router(driver); + + let init = json!({ + "anchorState": genesis_anchor_state_json(0), + "anchorBlock": genesis_anchor_block_json(), + }); + let (status, _) = post(&router, "/lean/v0/test_driver/fork_choice/init", &init).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // tick: advance store time to genesis + 1 second (just before interval 2). + let tick = json!({ + "stepType": "tick", + "time": 1u64, + "hasProposal": false, + }); + let (status, body) = post(&router, "/lean/v0/test_driver/fork_choice/step", &tick).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["accepted"], json!(true)); + // 1000ms / 800ms interval = 1 interval. + assert_eq!(body["snapshot"]["time"], json!(1)); + assert_eq!(body["snapshot"]["headSlot"], json!(0)); + assert_eq!(body["snapshot"]["headRoot"].as_str().unwrap().len(), 66); +} + +#[tokio::test] +async fn checks_step_is_noop_but_returns_current_snapshot() { + let driver = fresh_driver(); + let router = build_router(driver); + + let init = json!({ + "anchorState": genesis_anchor_state_json(0), + "anchorBlock": genesis_anchor_block_json(), + }); + let (_, _) = post(&router, "/lean/v0/test_driver/fork_choice/init", &init).await; + + let checks = json!({ + "stepType": "checks", + "checks": {"headSlot": 0}, + }); + let (status, body) = post(&router, "/lean/v0/test_driver/fork_choice/step", &checks).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["accepted"], json!(true)); + assert_eq!(body["snapshot"]["headSlot"], json!(0)); +} + +#[tokio::test] +async fn state_transition_with_no_blocks_and_expect_exception_reports_failure() { + let driver = fresh_driver(); + let router = build_router(driver); + + let body = json!({ + "pre": genesis_anchor_state_json(0), + "blocks": [], + "expectException": "any failure", + }); + + let (status, response) = + post(&router, "/lean/v0/test_driver/state_transition/run", &body).await; + assert_eq!(status, StatusCode::OK); + // No blocks + expectException present → driver forces an STF error so the + // simulator's `succeeded == expectException.is_none()` check holds. + assert_eq!(response["succeeded"], json!(false)); + assert!(response["post"].is_null()); + assert!(response["error"].as_str().is_some()); +} + +#[tokio::test] +async fn state_transition_with_no_blocks_succeeds_when_no_exception_expected() { + let driver = fresh_driver(); + let router = build_router(driver); + + let body = json!({ + "pre": genesis_anchor_state_json(0), + "blocks": [], + }); + + let (status, response) = + post(&router, "/lean/v0/test_driver/state_transition/run", &body).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["succeeded"], json!(true)); + assert_eq!(response["post"]["slot"], json!(0)); + assert_eq!(response["post"]["historicalBlockHashesCount"], json!(0)); +} + +#[tokio::test] +async fn verify_signatures_with_empty_validator_set_fails_cleanly() { + let driver = fresh_driver(); + let router = build_router(driver); + + // Build a signed block referencing the genesis state but with an invalid + // proposer (no validators in the set). The driver should return + // succeeded:false with a descriptive error — matching the simulator's + // expectException path. + let signed_block = json!({ + "message": { + "slot": 1, + "proposerIndex": 0, + "parentRoot": ZERO_ROOT, + "stateRoot": ZERO_ROOT, + "body": {"attestations": {"data": []}}, + }, + "signature": { + "proposerSignature": "0x".to_string() + &"00".repeat(ethlambda_types::signature::SIGNATURE_SIZE), + "attestationSignatures": {"data": []}, + }, + }); + + let body = json!({ + "anchorState": genesis_anchor_state_json(0), + "signedBlock": signed_block, + }); + + let (status, response) = + post(&router, "/lean/v0/test_driver/verify_signatures/run", &body).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["succeeded"], json!(false)); + assert!(response["error"].as_str().is_some()); +} From b1f2ba559a25f671c25c1ef2e406ba93755b5a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 11 May 2026 14:38:18 -0300 Subject: [PATCH 2/6] fix(bin): check HIVE_LEAN_TEST_DRIVER before reading --node-key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver mode short-circuit ran *after* read_hex_file_bytes(&node_key), so the binary panicked when the hive shim invoked us with a non-existent node-key path (which it does in spec-asset suites — no validator setup). Move the env-var check and rpc_config construction ahead of the node-key read; rpc_config only needs CLI-parsed values, never touches the FS. --- bin/ethlambda/src/main.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 8b2520b7..f9298e9f 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -109,8 +109,6 @@ async fn main() -> eyre::Result<()> { ethlambda_blockchain::metrics::set_node_info("ethlambda", version::CLIENT_VERSION); ethlambda_blockchain::metrics::set_node_start_time(); - let node_p2p_key = read_hex_file_bytes(&options.node_key); - let p2p_socket = SocketAddr::new(IpAddr::from([0, 0, 0, 0]), options.gossipsub_port); let rpc_config = RpcConfig { http_address: options.http_address, api_port: options.api_port, @@ -125,12 +123,17 @@ async fn main() -> eyre::Result<()> { // HIVE_LEAN_TEST_DRIVER=1 so it skips the consensus/p2p stack and // exposes only the `/lean/v0/test_driver/...` endpoints driven by the // simulator. Detected here before any config / key / genesis loading - // so the driver run doesn't need any of those files. + // so the driver run doesn't touch --node-key, --custom-network-config-dir, + // or any other consensus prerequisite the hive shim doesn't bother to + // provision. if ethlambda_rpc::test_driver::test_driver_enabled() { info!("HIVE_LEAN_TEST_DRIVER detected — booting in test-driver mode"); return run_test_driver(rpc_config).await; } + let node_p2p_key = read_hex_file_bytes(&options.node_key); + let p2p_socket = SocketAddr::new(IpAddr::from([0, 0, 0, 0]), options.gossipsub_port); + #[cfg(not(target_env = "msvc"))] info!("Using jemalloc allocator with heap profiling enabled"); #[cfg(target_env = "msvc")] From 1aa9f8f29d41af15fb50a1b17dc3d5ab216927df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 11 May 2026 15:09:29 -0300 Subject: [PATCH 3/6] fix(rpc): reject anchors whose block.state_root disagrees with the state The init handler validated state.latest_block_header.state_root (allowing the standard zero sentinel or the computed root), but never compared anchor_block.state_root against the same computed value. The hive spec-asset fixture test_checkpoint_sync::test_store_from_anchor_rejects_mismatched_state_root exercises exactly that mismatch (block.state_root = 0xffff... while the state's header keeps the zero sentinel) and expects init to return a non-2xx; we were returning 204. Add the third invariant to anchor_pair_is_consistent: block.state_root must equal state.hash_tree_root() with the header's state_root zeroed. This mirrors the same check Ream's driver enforces. Add an e2e regression test that mirrors the spec fixture and update the genesis test helper so the block carries the correct (non-zero) state root the consistency check now requires. --- crates/net/rpc/src/test_driver.rs | 29 +++++++++++----- crates/net/rpc/tests/test_driver_e2e.rs | 46 +++++++++++++++++++++---- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index ff1ff2a5..328631e8 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -339,10 +339,19 @@ async fn run_verify_signatures( // Helpers // ============================================================================ -/// Replicate the invariant `Store::get_forkchoice_store` asserts (without the -/// panic): anchor_block and state.latest_block_header must agree on every -/// field once `state_root` is zeroed, AND the original `state_root` must be -/// either zero or the computed tree-hash root of the state. +/// Replicate the invariants `Store::get_forkchoice_store` asserts (without +/// the panic): +/// +/// 1. `anchor_block` and `state.latest_block_header` must agree on every field +/// once `state_root` is zeroed. +/// 2. The state's own `latest_block_header.state_root` must be either zero +/// (raw / pre-fill form) or match the tree-hash root of the state computed +/// with that field zeroed. +/// 3. `anchor_block.state_root` must equal the tree-hash root of the state +/// (with the header's `state_root` zeroed). This is the invariant the +/// `test_store_from_anchor_rejects_mismatched_state_root` spec fixture +/// targets: a block whose `state_root` disagrees with the supplied +/// anchor state is structurally inconsistent and must be refused at init. fn anchor_pair_is_consistent(state: &State, block: &Block) -> bool { let mut state_header = state.latest_block_header.clone(); let mut block_header = block.header(); @@ -352,14 +361,16 @@ fn anchor_pair_is_consistent(state: &State, block: &Block) -> bool { return false; } - let original = state.latest_block_header.state_root; - if original == H256::ZERO { - return true; - } let mut zeroed = state.clone(); zeroed.latest_block_header.state_root = H256::ZERO; let computed = zeroed.hash_tree_root(); - original == computed + + let header_state_root = state.latest_block_header.state_root; + if header_state_root != H256::ZERO && header_state_root != computed { + return false; + } + + block.state_root == computed } /// Dispatch a fork-choice step against the held Store. diff --git a/crates/net/rpc/tests/test_driver_e2e.rs b/crates/net/rpc/tests/test_driver_e2e.rs index 5907aad6..3f6eee7d 100644 --- a/crates/net/rpc/tests/test_driver_e2e.rs +++ b/crates/net/rpc/tests/test_driver_e2e.rs @@ -13,7 +13,7 @@ use axum::{ http::{Request, StatusCode}, }; use ethlambda_rpc::test_driver::{DriverState, build_router, empty_driver_store}; -use ethlambda_types::{block::BlockBody, primitives::HashTreeRoot}; +use ethlambda_types::{block::BlockBody, primitives::HashTreeRoot, state::State}; use http_body_util::BodyExt; use serde_json::{Value, json}; use tokio::sync::RwLock; @@ -21,6 +21,14 @@ use tower::ServiceExt; const ZERO_ROOT: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; +/// Compute the state_root the genesis `anchorBlock` must carry for an init +/// request to pass the consistency check: the tree-hash root of the empty +/// genesis state with `latest_block_header.state_root` zeroed. +fn genesis_state_root_hex(genesis_time: u64) -> String { + let state = State::from_genesis(genesis_time, vec![]); + format!("{}", state.hash_tree_root()) +} + /// Build a genesis-shaped `anchorState` JSON object that the test driver's /// `init_fork_choice` handler will accept. fn genesis_anchor_state_json(genesis_time: u64) -> Value { @@ -46,12 +54,15 @@ fn genesis_anchor_state_json(genesis_time: u64) -> Value { } /// Build the matching genesis `anchorBlock` JSON (slot 0, empty body). -fn genesis_anchor_block_json() -> Value { +/// +/// `stateRoot` must equal `genesis_state_root_hex(genesis_time)` for the +/// driver's anchor consistency check to pass. +fn genesis_anchor_block_json(genesis_time: u64) -> Value { json!({ "slot": 0, "proposerIndex": 0, "parentRoot": ZERO_ROOT, - "stateRoot": ZERO_ROOT, + "stateRoot": genesis_state_root_hex(genesis_time), "body": {"attestations": {"data": []}}, }) } @@ -91,7 +102,7 @@ async fn init_with_genesis_anchor_returns_204_and_resets_store() { let body = json!({ "anchorState": genesis_anchor_state_json(1234), - "anchorBlock": genesis_anchor_block_json(), + "anchorBlock": genesis_anchor_block_json(1234), }); let (status, _) = post(&router, "/lean/v0/test_driver/fork_choice/init", &body).await; @@ -109,7 +120,7 @@ async fn init_with_mismatched_anchor_returns_400() { // Genesis state but the anchor block claims a different slot — the // header comparison must reject this pair. - let mut anchor_block = genesis_anchor_block_json(); + let mut anchor_block = genesis_anchor_block_json(0); anchor_block["slot"] = json!(42); let body = json!({ @@ -121,6 +132,27 @@ async fn init_with_mismatched_anchor_returns_400() { assert_eq!(status, StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn init_with_garbage_block_state_root_returns_400() { + // Regression for the `test_store_from_anchor_rejects_mismatched_state_root` + // spec fixture: the anchor block's `stateRoot` field is the only thing + // wrong, and the driver must catch it instead of accepting the pair. + let driver = fresh_driver(); + let router = build_router(driver); + + let mut anchor_block = genesis_anchor_block_json(0); + anchor_block["stateRoot"] = + json!("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + + let body = json!({ + "anchorState": genesis_anchor_state_json(0), + "anchorBlock": anchor_block, + }); + + let (status, _) = post(&router, "/lean/v0/test_driver/fork_choice/init", &body).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn step_tick_advances_store_time_and_returns_snapshot() { let driver = fresh_driver(); @@ -128,7 +160,7 @@ async fn step_tick_advances_store_time_and_returns_snapshot() { let init = json!({ "anchorState": genesis_anchor_state_json(0), - "anchorBlock": genesis_anchor_block_json(), + "anchorBlock": genesis_anchor_block_json(0), }); let (status, _) = post(&router, "/lean/v0/test_driver/fork_choice/init", &init).await; assert_eq!(status, StatusCode::NO_CONTENT); @@ -155,7 +187,7 @@ async fn checks_step_is_noop_but_returns_current_snapshot() { let init = json!({ "anchorState": genesis_anchor_state_json(0), - "anchorBlock": genesis_anchor_block_json(), + "anchorBlock": genesis_anchor_block_json(0), }); let (_, _) = post(&router, "/lean/v0/test_driver/fork_choice/init", &init).await; From 6b9e1f2727da9f9a1a5bb3ce86f224f358f15633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 11 May 2026 17:36:33 -0300 Subject: [PATCH 4/6] refactor(rpc): apply test-driver review feedback Consolidates fixes from automated review and the /simplify pass: - Drop `DriverCheckpoint`; `ethlambda_types::checkpoint::Checkpoint` already serializes to the same `{root, slot}` shape hive expects. - Use `Store::head_slot()` in `snapshot_store` instead of re-deriving it from `get_block_header(head)`. - Hold the `step_fork_choice` write lock through the snapshot read so the returned snapshot reflects the step that was just applied (no cross-request interleave possible). - Replace the full `State::clone` in `anchor_pair_is_consistent` with an in-place save/restore of `latest_block_header.state_root` around the hash computation. Saves a per-init allocation that scales with validator set + historical-roots size. - Lift the placeholder-signature `SignedBlock` builder out of `crates/blockchain/tests/forkchoice_spectests.rs` (`build_signed_block`) and `crates/net/rpc/src/test_driver.rs` (`blank_signed_block`) into a shared `BlockStepData::to_blank_signed_block` method on the fixture type; both call sites now use it. - Add `TestSignedBlock::try_into_signed_block_with_proofs` that preserves the fixture's per-attestation proof bytes and returns a typed `SignedBlockConvertError` on malformed hex / oversized proofs. Deletes the in-handler `signed_block_from_fixture` and its `.unwrap_or_default()` swallows that masked malformed-fixture bugs as unrelated crypto failures. - Split `test_driver_enabled` into a pure `parse_truthy_env_value(&str)` + a thin env reader. The test no longer mutates process-global env via `unsafe { set_var }`, so it stays sound under cargo's parallel test runner. - Replace em-dashes in newly-added comments / log messages with colons/semicolons per the repo's style guide. - Drop the now-unused `hex` dependency from `ethlambda-rpc`. All 26 ethlambda-rpc tests (18 lib + 8 e2e) still pass; cargo fmt and clippy --all-targets -D warnings clean. --- Cargo.lock | 1 - bin/ethlambda/src/main.rs | 2 +- .../blockchain/tests/forkchoice_spectests.rs | 32 +-- .../common/test-fixtures/src/fork_choice.rs | 32 +++ .../test-fixtures/src/verify_signatures.rs | 85 +++++++- crates/net/rpc/Cargo.toml | 1 - crates/net/rpc/src/test_driver.rs | 202 ++++++------------ crates/net/rpc/tests/test_driver_e2e.rs | 8 +- 8 files changed, 188 insertions(+), 175 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f9fb143..d410f613 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2163,7 +2163,6 @@ dependencies = [ "ethlambda-storage", "ethlambda-test-fixtures", "ethlambda-types", - "hex", "http-body-util", "jemalloc_pprof", "libssz", diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index f9298e9f..89bb4974 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -127,7 +127,7 @@ async fn main() -> eyre::Result<()> { // or any other consensus prerequisite the hive shim doesn't bother to // provision. if ethlambda_rpc::test_driver::test_driver_enabled() { - info!("HIVE_LEAN_TEST_DRIVER detected — booting in test-driver mode"); + info!("HIVE_LEAN_TEST_DRIVER detected; booting in test-driver mode"); return run_test_driver(rpc_config).await; } diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index 45834a86..dcdcdcf6 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -7,10 +7,9 @@ use std::{ use ethlambda_blockchain::{MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, store}; use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ - attestation::{AttestationData, SignedAggregatedAttestation, SignedAttestation, XmssSignature}, - block::{AggregatedSignatureProof, Block, BlockSignatures, SignedBlock}, + attestation::{AttestationData, SignedAggregatedAttestation, SignedAttestation}, + block::{AggregatedSignatureProof, Block}, primitives::{ByteList, H256, HashTreeRoot as _}, - signature::SIGNATURE_SIZE, state::State, }; @@ -63,7 +62,7 @@ fn run(path: &Path) -> datatest_stable::Result<()> { block_registry.insert(label.clone(), root); } - let signed_block = build_signed_block(block_data); + let signed_block = block_data.to_blank_signed_block(); let block_time_ms = genesis_time * 1000 + signed_block.message.slot * MILLISECONDS_PER_SLOT; @@ -158,31 +157,6 @@ fn assert_step_outcome( } } -fn build_signed_block( - block_data: ethlambda_test_fixtures::fork_choice::BlockStepData, -) -> SignedBlock { - let block: Block = block_data.to_block(); - - // Build one empty proof per attestation, matching the aggregation_bits from - // each attestation in the block body. Block processing zips attestations with - // signatures, so they must be the same length for attestations to reach - // fork choice. - let proofs: Vec<_> = block - .body - .attestations - .iter() - .map(|att| AggregatedSignatureProof::empty(att.aggregation_bits.clone())) - .collect(); - - SignedBlock { - message: block, - signature: BlockSignatures { - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), - attestation_signatures: proofs.try_into().expect("attestation proofs within limit"), - }, - } -} - fn validate_checks( st: &Store, checks: &StoreChecks, diff --git a/crates/common/test-fixtures/src/fork_choice.rs b/crates/common/test-fixtures/src/fork_choice.rs index eceacd4a..99fedc20 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -8,7 +8,11 @@ use crate::{ deser_xmss_hex, }; use ethlambda_types::attestation::XmssSignature; +use ethlambda_types::block::{ + AggregatedSignatureProof, AttestationSignatures, BlockSignatures, SignedBlock, +}; use ethlambda_types::primitives::H256; +use ethlambda_types::signature::SIGNATURE_SIZE; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; use std::path::Path; @@ -146,6 +150,34 @@ impl BlockStepData { body: self.body.clone().into(), } } + + /// Build a SignedBlock with placeholder signatures: one empty aggregated + /// proof per attestation (participant bits copied from the block body) and + /// a zeroed proposer signature. + /// + /// Used by callers that import the block via `on_block_without_verification` + /// (fork-choice spec-test runner and Hive test-driver), where the crypto + /// layer is never invoked but the SignedBlock shape must still satisfy the + /// length checks `on_block_core` performs before dispatching. + pub fn to_blank_signed_block(&self) -> SignedBlock { + let block = self.to_block(); + let proofs: Vec = block + .body + .attestations + .iter() + .map(|att| AggregatedSignatureProof::empty(att.aggregation_bits.clone())) + .collect(); + + SignedBlock { + message: block, + signature: BlockSignatures { + proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]) + .expect("zero-filled signature has the correct length"), + attestation_signatures: AttestationSignatures::try_from(proofs) + .expect("attestation proofs within limit"), + }, + } + } } // ============================================================================ diff --git a/crates/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs index f2044c28..59c5febc 100644 --- a/crates/common/test-fixtures/src/verify_signatures.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -7,10 +7,11 @@ use crate::{AggregationBits, Block, Container, TestInfo, TestState, deser_xmss_hex}; use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; use ethlambda_types::block::{ - AggregatedSignatureProof, AttestationSignatures, BlockSignatures, SignedBlock, + AggregatedSignatureProof, AttestationSignatures, BlockSignatures, ByteListMiB, SignedBlock, }; use serde::Deserialize; use std::collections::HashMap; +use std::fmt; use std::path::Path; /// Root struct for verify signatures test vectors @@ -60,6 +61,11 @@ pub struct TestSignedBlock { pub signature: TestSignatureBundle, } +/// Lossy fixture-to-SignedBlock conversion: per-attestation proof bytes from +/// the fixture are dropped, leaving empty payloads. Adequate for callers that +/// don't reach the leanVM aggregate verifier (e.g. signature spec tests whose +/// fixtures all set `expectException`). For real signature verification use +/// [`TestSignedBlock::try_into_signed_block_with_proofs`]. impl From for SignedBlock { fn from(value: TestSignedBlock) -> Self { let block = value.block.into(); @@ -88,6 +94,83 @@ impl From for SignedBlock { } } +/// Error returned by [`TestSignedBlock::try_into_signed_block_with_proofs`]. +#[derive(Debug)] +pub enum SignedBlockConvertError { + InvalidProofHex { index: usize, reason: String }, + ProofTooLarge { index: usize, len: usize }, + TooManyAttestationSignatures, +} + +impl fmt::Display for SignedBlockConvertError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidProofHex { index, reason } => { + write!( + f, + "attestation_signatures[{index}].proofData: invalid hex: {reason}" + ) + } + Self::ProofTooLarge { index, len } => { + write!( + f, + "attestation_signatures[{index}].proofData: {len} bytes exceeds ByteListMiB limit" + ) + } + Self::TooManyAttestationSignatures => { + f.write_str("attestation_signatures list exceeds AttestationSignatures limit") + } + } + } +} + +impl std::error::Error for SignedBlockConvertError {} + +impl TestSignedBlock { + /// Materialize a `SignedBlock` that preserves the fixture-supplied + /// per-attestation proof bytes verbatim. Required for verifying signatures + /// against the leanVM aggregate path; the lossy [`From`] impl above drops + /// these bytes. + pub fn try_into_signed_block_with_proofs(self) -> Result { + let block = self.block.into(); + let proposer_signature = self.signature.proposer_signature; + + let proofs: Vec = self + .signature + .attestation_signatures + .data + .into_iter() + .enumerate() + .map(|(index, att_sig)| { + let participants: EthAggregationBits = att_sig.participants.into(); + let raw = &att_sig.proof_data.data; + let stripped = raw.strip_prefix("0x").unwrap_or(raw); + let bytes = hex::decode(stripped).map_err(|err| { + SignedBlockConvertError::InvalidProofHex { + index, + reason: err.to_string(), + } + })?; + let len = bytes.len(); + let proof_data = ByteListMiB::try_from(bytes) + .map_err(|_| SignedBlockConvertError::ProofTooLarge { index, len })?; + Ok(AggregatedSignatureProof::new(participants, proof_data)) + }) + .collect::>()?; + + let attestation_signatures: AttestationSignatures = AttestationSignatures::try_from(proofs) + .map_err(|_| SignedBlockConvertError::TooManyAttestationSignatures)?; + + Ok(SignedBlock { + message: block, + signature: BlockSignatures { + attestation_signatures, + proposer_signature, + }, + }) + } +} + // ============================================================================ // Signature Types // ============================================================================ diff --git a/crates/net/rpc/Cargo.toml b/crates/net/rpc/Cargo.toml index 7e978890..8c7524a0 100644 --- a/crates/net/rpc/Cargo.toml +++ b/crates/net/rpc/Cargo.toml @@ -21,7 +21,6 @@ ethlambda-storage.workspace = true ethlambda-test-fixtures.workspace = true ethlambda-types.workspace = true libssz.workspace = true -hex.workspace = true serde.workspace = true serde_json.workspace = true tracing.workspace = true diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index 328631e8..571bfd91 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -35,23 +35,16 @@ use ethlambda_blockchain::{ }; use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_test_fixtures::{ - Block as FixtureBlock, TestState, - fork_choice::{BlockStepData, ForkChoiceStep}, - state_transition::StateTransitionRunRequest, - verify_signatures::TestSignedBlock, + Block as FixtureBlock, TestState, fork_choice::ForkChoiceStep, + state_transition::StateTransitionRunRequest, verify_signatures::TestSignedBlock, }; use ethlambda_types::{ attestation::{ AggregationBits as EthAggregationBits, SignedAggregatedAttestation, SignedAttestation, - XmssSignature, - }, - block::{ - AggregatedSignatureProof, AttestationSignatures, Block, BlockSignatures, ByteListMiB, - SignedBlock, }, + block::{AggregatedSignatureProof, Block, ByteListMiB}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, - signature::SIGNATURE_SIZE, state::State, }; use serde::{Deserialize, Serialize}; @@ -64,15 +57,24 @@ use tracing::debug; /// of `"1"`, `"true"`, or `"yes"` (case-insensitive) enables the driver. pub const TEST_DRIVER_ENV: &str = "HIVE_LEAN_TEST_DRIVER"; +/// Whether the supplied env-var value should activate the driver. +/// +/// Pure helper so [`test_driver_enabled`] stays a thin wrapper over +/// `std::env::var` and the parsing rules are unit-testable without mutating +/// process-global env state (which would be `unsafe` and racy under cargo's +/// parallel test runner). +fn parse_truthy_env_value(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) +} + /// Returns true when the binary should boot into test-driver mode. pub fn test_driver_enabled() -> bool { - match std::env::var(TEST_DRIVER_ENV) { - Ok(value) => matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" - ), - Err(_) => false, - } + std::env::var(TEST_DRIVER_ENV) + .map(|value| parse_truthy_env_value(&value)) + .unwrap_or(false) } /// Shared, runtime-replaceable Store backing every test-driver handler. @@ -135,22 +137,6 @@ struct VerifySignaturesRequest { signed_block: TestSignedBlock, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct DriverCheckpoint { - slot: u64, - root: H256, -} - -impl From for DriverCheckpoint { - fn from(value: Checkpoint) -> Self { - Self { - slot: value.slot, - root: value.root, - } - } -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct DriverSnapshot { @@ -158,8 +144,10 @@ struct DriverSnapshot { head_root: H256, /// Store time in 800 ms intervals since genesis (matches [`Store::time`]). time: u64, - justified_checkpoint: DriverCheckpoint, - finalized_checkpoint: DriverCheckpoint, + /// `Checkpoint` already serializes as `{root, slot}`, which is the shape + /// hive's `DriverCheckpoint` expects; no wrapper type needed. + justified_checkpoint: Checkpoint, + finalized_checkpoint: Checkpoint, safe_target: H256, } @@ -217,7 +205,7 @@ async fn init_fork_choice( // Mirror Store::get_forkchoice_store's invariants explicitly so we can // surface a clean 400 instead of panicking the handler task. - if !anchor_pair_is_consistent(&state, &block) { + if !anchor_pair_is_consistent(&mut state, &block) { return ( StatusCode::BAD_REQUEST, "anchor block does not match anchor state", @@ -242,10 +230,12 @@ async fn step_fork_choice( AxumState(driver): AxumState, Json(step): Json, ) -> Json { - let outcome = { - let mut guard = driver.write().await; - apply_step(&mut guard, step) - }; + // Hold the write guard across both the step and the snapshot read so the + // returned snapshot reflects this step (and no interleaved request can + // mutate the store in between, even though the hive simulator drives + // steps serially per fixture). + let mut guard = driver.write().await; + let outcome = apply_step(&mut guard, step); let (accepted, error) = match outcome { Ok(()) => (true, None), Err(err) => { @@ -253,10 +243,8 @@ async fn step_fork_choice( (false, Some(err)) } }; - let snapshot = { - let guard = driver.read().await; - snapshot_store(&guard) - }; + let snapshot = snapshot_store(&guard); + drop(guard); Json(StepResponse { accepted, error, @@ -320,7 +308,15 @@ async fn run_verify_signatures( Json(request): Json, ) -> Json { let state: State = request.anchor_state.into(); - let signed_block = signed_block_from_fixture(request.signed_block); + let signed_block = match request.signed_block.try_into_signed_block_with_proofs() { + Ok(block) => block, + Err(err) => { + return Json(VerifySignaturesResponse { + succeeded: false, + error: Some(format!("malformed signedBlock fixture: {err}")), + }); + } + }; let response = match verify_block_signatures(&state, &signed_block) { Ok(()) => VerifySignaturesResponse { @@ -352,7 +348,12 @@ async fn run_verify_signatures( /// `test_store_from_anchor_rejects_mismatched_state_root` spec fixture /// targets: a block whose `state_root` disagrees with the supplied /// anchor state is structurally inconsistent and must be refused at init. -fn anchor_pair_is_consistent(state: &State, block: &Block) -> bool { +/// +/// Takes `&mut State` so we can zero the header field in-place around the +/// hash computation rather than cloning the whole state (validator set + +/// historical roots can be hundreds of KB). The original `state_root` is +/// restored before the function returns. +fn anchor_pair_is_consistent(state: &mut State, block: &Block) -> bool { let mut state_header = state.latest_block_header.clone(); let mut block_header = block.header(); state_header.state_root = H256::ZERO; @@ -361,12 +362,12 @@ fn anchor_pair_is_consistent(state: &State, block: &Block) -> bool { return false; } - let mut zeroed = state.clone(); - zeroed.latest_block_header.state_root = H256::ZERO; - let computed = zeroed.hash_tree_root(); + let saved = state.latest_block_header.state_root; + state.latest_block_header.state_root = H256::ZERO; + let computed = state.hash_tree_root(); + state.latest_block_header.state_root = saved; - let header_state_root = state.latest_block_header.state_root; - if header_state_root != H256::ZERO && header_state_root != computed { + if saved != H256::ZERO && saved != computed { return false; } @@ -392,7 +393,7 @@ fn apply_step(store: &mut Store, step: ForkChoiceStep) -> Result<(), String> { let block_data = step .block .ok_or_else(|| "block step missing block data".to_string())?; - let signed_block = blank_signed_block(block_data); + let signed_block = block_data.to_blank_signed_block(); // Match the spec-test runner: advance time to the block's slot // before importing so the future-slot guard doesn't reject it. let block_time_ms = store.config().genesis_time * 1000 @@ -433,75 +434,13 @@ fn apply_step(store: &mut Store, step: ForkChoiceStep) -> Result<(), String> { }; store::on_gossip_aggregated_attestation(store, aggregated).map_err(|e| e.to_string()) } - // `checks`-only steps are no-ops here — the simulator validates them + // `checks`-only steps are no-ops here: the simulator validates them // against the snapshot returned alongside this response. "checks" => Ok(()), other => Err(format!("unknown step type: {other}")), } } -/// Build a SignedBlock for fork-choice import without real signatures. -/// -/// Matches the offline spec-test runner's `build_signed_block`: one empty -/// proof per attestation (the participant bits get checked against the -/// attestation's `aggregation_bits` during import) and a zeroed proposer -/// signature. Fork-choice steps use `on_block_without_verification`, so -/// these placeholders never reach the crypto layer. -fn blank_signed_block(block_data: BlockStepData) -> SignedBlock { - let block: Block = block_data.to_block(); - let proofs: Vec = block - .body - .attestations - .iter() - .map(|att| AggregatedSignatureProof::empty(att.aggregation_bits.clone())) - .collect(); - - SignedBlock { - message: block, - signature: BlockSignatures { - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]) - .expect("zero-filled signature has the correct length"), - attestation_signatures: AttestationSignatures::try_from(proofs) - .expect("attestation proofs within limit"), - }, - } -} - -/// Materialize a SignedBlock that preserves the fixture-supplied per-validator -/// proof bytes, so `verify_block_signatures` actually exercises the leanVM -/// aggregate path (vs. the `From` shortcut that drops it). -fn signed_block_from_fixture(value: TestSignedBlock) -> SignedBlock { - let block: Block = value.block.into(); - let proposer_signature = value.signature.proposer_signature; - let proofs: Vec = value - .signature - .attestation_signatures - .data - .into_iter() - .map(|att_sig| { - let participants: EthAggregationBits = att_sig.participants.into(); - let stripped = att_sig - .proof_data - .data - .strip_prefix("0x") - .unwrap_or(&att_sig.proof_data.data); - let proof_bytes = hex::decode(stripped).unwrap_or_default(); - let proof_data = - ByteListMiB::try_from(proof_bytes).unwrap_or_else(|_| ByteListMiB::default()); - AggregatedSignatureProof::new(participants, proof_data) - }) - .collect(); - - SignedBlock { - message: block, - signature: BlockSignatures { - attestation_signatures: AttestationSignatures::try_from(proofs) - .expect("attestation proofs within limit"), - proposer_signature, - }, - } -} - /// Read the post-state summary expected by the hive `state_transition/run` /// schema. fn post_summary(state: &State) -> StateTransitionPost { @@ -515,18 +454,12 @@ fn post_summary(state: &State) -> StateTransitionPost { /// Snapshot the store fields exposed by the fork-choice `step` response. fn snapshot_store(store: &Store) -> DriverSnapshot { - let head_root = store.head(); - let head_slot = store - .get_block_header(&head_root) - .map(|header| header.slot) - .unwrap_or(0); - DriverSnapshot { - head_slot, - head_root, + head_slot: store.head_slot(), + head_root: store.head(), time: store.time(), - justified_checkpoint: store.latest_justified().into(), - finalized_checkpoint: store.latest_finalized().into(), + justified_checkpoint: store.latest_justified(), + finalized_checkpoint: store.latest_finalized(), safe_target: store.safe_target(), } } @@ -536,20 +469,13 @@ mod tests { use super::*; #[test] - fn driver_recognizes_truthy_env_values() { - for value in ["1", "true", "TRUE", " Yes "] { - // SAFETY: tests run single-threaded for shared env vars in this - // file; the std::env contract here is just to flip the toggle. - unsafe { std::env::set_var(TEST_DRIVER_ENV, value) }; - assert!(test_driver_enabled(), "{value:?} should enable the driver"); + fn parse_truthy_env_value_accepts_canonical_truthy_strings() { + for value in ["1", "true", "TRUE", " Yes ", "yes\n"] { + assert!(parse_truthy_env_value(value), "{value:?} should be truthy"); + } + for value in ["0", "false", "no", "", " ", "1.0"] { + assert!(!parse_truthy_env_value(value), "{value:?} should be falsy"); } - unsafe { std::env::set_var(TEST_DRIVER_ENV, "0") }; - assert!(!test_driver_enabled(), "0 should disable the driver"); - unsafe { std::env::remove_var(TEST_DRIVER_ENV) }; - assert!( - !test_driver_enabled(), - "unset env should disable the driver" - ); } #[test] diff --git a/crates/net/rpc/tests/test_driver_e2e.rs b/crates/net/rpc/tests/test_driver_e2e.rs index 3f6eee7d..687ccf31 100644 --- a/crates/net/rpc/tests/test_driver_e2e.rs +++ b/crates/net/rpc/tests/test_driver_e2e.rs @@ -1,8 +1,8 @@ //! End-to-end tests for the Hive lean test-driver router. //! //! These tests exercise the four `/lean/v0/test_driver/...` endpoints exactly -//! as the hive simulator does — same JSON bodies, same HTTP method, same -//! response shape — using `tower::ServiceExt::oneshot` so no real socket is +//! as the hive simulator does: same JSON bodies, same HTTP method, same +//! response shape, using `tower::ServiceExt::oneshot` so no real socket is //! involved. They're the closest thing to running the suite under hive //! without spinning up docker. @@ -118,7 +118,7 @@ async fn init_with_mismatched_anchor_returns_400() { let driver = fresh_driver(); let router = build_router(driver); - // Genesis state but the anchor block claims a different slot — the + // Genesis state but the anchor block claims a different slot: the // header comparison must reject this pair. let mut anchor_block = genesis_anchor_block_json(0); anchor_block["slot"] = json!(42); @@ -247,7 +247,7 @@ async fn verify_signatures_with_empty_validator_set_fails_cleanly() { // Build a signed block referencing the genesis state but with an invalid // proposer (no validators in the set). The driver should return - // succeeded:false with a descriptive error — matching the simulator's + // succeeded:false with a descriptive error, matching the simulator's // expectException path. let signed_block = json!({ "message": { From dd838dfd4e628209ed0c5096ed4e6d5d1b73d5a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 11 May 2026 18:02:59 -0300 Subject: [PATCH 5/6] chore: remove comment --- crates/net/rpc/src/test_driver.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index 571bfd91..b93231a8 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -58,11 +58,6 @@ use tracing::debug; pub const TEST_DRIVER_ENV: &str = "HIVE_LEAN_TEST_DRIVER"; /// Whether the supplied env-var value should activate the driver. -/// -/// Pure helper so [`test_driver_enabled`] stays a thin wrapper over -/// `std::env::var` and the parsing rules are unit-testable without mutating -/// process-global env state (which would be `unsafe` and racy under cargo's -/// parallel test runner). fn parse_truthy_env_value(value: &str) -> bool { matches!( value.trim().to_ascii_lowercase().as_str(), From 64c2a3327e610914f35394d5c83dd0bb0c2d60d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 11 May 2026 18:03:18 -0300 Subject: [PATCH 6/6] refactor(rpc): extract STF loop from run_state_transition Replaces the inline `(|| -> Result<_, _> { ... })()` IIFE in the state_transition/run handler with a named helper `apply_state_transition` that takes the state, the block slice, and the optional `expect_exception`. Makes the handler body two lines of glue around `match` and pushes the fork-choice-vs-STF mechanics into a function that's directly callable from tests if we ever want to exercise the empty-blocks branch in isolation. Pure refactor; 8 e2e tests still pass. --- crates/net/rpc/src/test_driver.rs | 49 +++++++++++++++++-------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index b93231a8..2700a707 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -257,29 +257,8 @@ async fn run_state_transition( ) -> Json { let mut state: State = request.pre.into(); let blocks: Vec = request.blocks.into_iter().map(Into::into).collect(); - let blocks_empty = blocks.is_empty(); - let result = (|| -> Result<(), String> { - for block in &blocks { - ethlambda_state_transition::state_transition(&mut state, block) - .map_err(|err| err.to_string())?; - } - - // Match Ream's behavior: fixtures may carry `expectException` with an - // empty `blocks` list to exercise pre-state-only invariants. The STF - // entry point only runs when there's a block, so force a deterministic - // failure here to keep the simulator's `expectException` assertion - // consistent. - if blocks_empty && request.expect_exception.is_some() { - let target_slot = state.slot; - ethlambda_state_transition::process_slots(&mut state, target_slot) - .map_err(|err| err.to_string())?; - } - - Ok(()) - })(); - - let response = match result { + let response = match apply_state_transition(&mut state, &blocks, request.expect_exception) { Ok(()) => StateTransitionResponse { succeeded: true, error: None, @@ -294,6 +273,32 @@ async fn run_state_transition( Json(response) } +/// Run the STF for each block in `blocks` and return the first error (if any). +/// +/// When `blocks` is empty and `expect_exception` is set the spec fixture wants +/// failure but the STF entry point never runs, so call `process_slots(slot)` +/// against the current slot. That call returns `Err(StateSlotIsNewer)` because +/// the STF rejects `target_slot <= current_slot`, giving the simulator a +/// deterministic non-2xx outcome that matches the fixture's `expectException`. +fn apply_state_transition( + state: &mut State, + blocks: &[Block], + expect_exception: Option, +) -> Result<(), String> { + for block in blocks { + ethlambda_state_transition::state_transition(state, block) + .map_err(|err| err.to_string())?; + } + + if blocks.is_empty() && expect_exception.is_some() { + let target_slot = state.slot; + ethlambda_state_transition::process_slots(state, target_slot) + .map_err(|err| err.to_string())?; + } + + Ok(()) +} + /// `POST /lean/v0/test_driver/verify_signatures/run` /// /// Runs the exact same `verify_block_signatures` path the production block