diff --git a/Cargo.lock b/Cargo.lock index 26718db0..d410f613 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2156,9 +2156,12 @@ 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", "http-body-util", "jemalloc_pprof", @@ -2208,6 +2211,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..89bb4974 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, @@ -120,6 +118,22 @@ 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 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")] @@ -275,6 +289,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..dcdcdcf6 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -7,20 +7,16 @@ 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, }; -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] = &[]; @@ -66,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; @@ -161,29 +157,6 @@ fn assert_step_outcome( } } -fn build_signed_block(block_data: types::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, @@ -374,7 +347,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/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs deleted file mode 100644 index 5f955aaf..00000000 --- a/crates/blockchain/tests/signature_types.rs +++ /dev/null @@ -1,112 +0,0 @@ -use super::common::{AggregationBits, Block, Container, TestInfo, TestState, deser_xmss_hex}; -use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; -use ethlambda_types::block::{ - AggregatedSignatureProof, AttestationSignatures, BlockSignatures, SignedBlock, -}; -use serde::Deserialize; -use std::collections::HashMap; -use std::path::Path; - -/// Root struct for verify signatures test vectors -#[derive(Debug, Clone, Deserialize)] -pub struct VerifySignaturesTestVector { - #[serde(flatten)] - pub tests: HashMap, -} - -impl VerifySignaturesTestVector { - /// Load a verify signatures test vector from a JSON file - pub fn from_file>(path: P) -> Result> { - let content = std::fs::read_to_string(path)?; - let test_vector = serde_json::from_str(&content)?; - Ok(test_vector) - } -} - -/// A single verify signatures test case -#[derive(Debug, Clone, Deserialize)] -pub struct VerifySignaturesTest { - #[allow(dead_code)] - pub network: String, - #[serde(rename = "leanEnv")] - #[allow(dead_code)] - pub lean_env: String, - #[serde(rename = "anchorState")] - pub anchor_state: TestState, - #[serde(rename = "signedBlock")] - pub signed_block: TestSignedBlock, - #[serde(rename = "expectException")] - pub expect_exception: Option, - #[serde(rename = "_info")] - #[allow(dead_code)] - pub info: TestInfo, -} - -// ============================================================================ -// Signed Block Types -// ============================================================================ - -/// Signed block with signature bundle (devnet4: no proposer attestation wrapper) -#[derive(Debug, Clone, Deserialize)] -pub struct TestSignedBlock { - #[serde(alias = "message")] - pub block: Block, - pub signature: TestSignatureBundle, -} - -impl From for SignedBlock { - fn from(value: TestSignedBlock) -> Self { - let block = value.block.into(); - let proposer_signature = value.signature.proposer_signature; - - let attestation_signatures: AttestationSignatures = value - .signature - .attestation_signatures - .data - .into_iter() - .map(|att_sig| { - let participants: EthAggregationBits = att_sig.participants.into(); - AggregatedSignatureProof::empty(participants) - }) - .collect::>() - .try_into() - .expect("too many attestation signatures"); - - SignedBlock { - message: block, - signature: BlockSignatures { - attestation_signatures, - proposer_signature, - }, - } - } -} - -// ============================================================================ -// Signature Types -// ============================================================================ - -/// Bundle of signatures for block and attestations -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct TestSignatureBundle { - #[serde(rename = "proposerSignature", deserialize_with = "deser_xmss_hex")] - pub proposer_signature: XmssSignature, - #[serde(rename = "attestationSignatures")] - pub attestation_signatures: Container, -} - -/// Attestation signature from a validator -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct AttestationSignature { - pub participants: AggregationBits, - #[serde(rename = "proofData")] - pub proof_data: ProofData, -} - -/// Placeholder for future SNARK proof data -#[derive(Debug, Clone, Deserialize)] -pub struct ProofData { - pub data: String, -} 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 71% rename from crates/blockchain/tests/types.rs rename to crates/common/test-fixtures/src/fork_choice.rs index 97b56dd1..99fedc20 100644 --- a/crates/blockchain/tests/types.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -1,6 +1,18 @@ -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::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; @@ -48,6 +60,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 +81,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 +98,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 +135,48 @@ 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(), + } + } + + /// 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"), + }, } - .into() } } @@ -160,6 +208,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 +219,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/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs new file mode 100644 index 00000000..59c5febc --- /dev/null +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -0,0 +1,201 @@ +//! 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, ByteListMiB, SignedBlock, +}; +use serde::Deserialize; +use std::collections::HashMap; +use std::fmt; +use std::path::Path; + +/// Root struct for verify signatures test vectors +#[derive(Debug, Clone, Deserialize)] +pub struct VerifySignaturesTestVector { + #[serde(flatten)] + pub tests: HashMap, +} + +impl VerifySignaturesTestVector { + /// Load a verify signatures test vector from a JSON file + pub fn from_file>(path: P) -> Result> { + let content = std::fs::read_to_string(path)?; + let test_vector = serde_json::from_str(&content)?; + Ok(test_vector) + } +} + +/// A single verify signatures test case +#[derive(Debug, Clone, Deserialize)] +pub struct VerifySignaturesTest { + #[allow(dead_code)] + pub network: String, + #[serde(rename = "leanEnv")] + #[allow(dead_code)] + pub lean_env: String, + #[serde(rename = "anchorState")] + pub anchor_state: TestState, + #[serde(rename = "signedBlock")] + pub signed_block: TestSignedBlock, + #[serde(rename = "expectException")] + pub expect_exception: Option, + #[serde(rename = "_info")] + #[allow(dead_code)] + pub info: TestInfo, +} + +// ============================================================================ +// Signed Block Types +// ============================================================================ + +/// Signed block with signature bundle (devnet4: no proposer attestation wrapper) +#[derive(Debug, Clone, Deserialize)] +pub struct TestSignedBlock { + #[serde(alias = "message")] + pub block: Block, + 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(); + let proposer_signature = value.signature.proposer_signature; + + let attestation_signatures: AttestationSignatures = value + .signature + .attestation_signatures + .data + .into_iter() + .map(|att_sig| { + let participants: EthAggregationBits = att_sig.participants.into(); + AggregatedSignatureProof::empty(participants) + }) + .collect::>() + .try_into() + .expect("too many attestation signatures"); + + SignedBlock { + message: block, + signature: BlockSignatures { + attestation_signatures, + proposer_signature, + }, + } + } +} + +/// 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 +// ============================================================================ + +/// Bundle of signatures for block and attestations +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct TestSignatureBundle { + #[serde(rename = "proposerSignature", deserialize_with = "deser_xmss_hex")] + pub proposer_signature: XmssSignature, + #[serde(rename = "attestationSignatures")] + pub attestation_signatures: Container, +} + +/// Attestation signature from a validator +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct AttestationSignature { + pub participants: AggregationBits, + #[serde(rename = "proofData")] + pub proof_data: ProofData, +} + +/// Placeholder for future SNARK proof data +#[derive(Debug, Clone, Deserialize)] +pub struct ProofData { + pub data: String, +} diff --git a/crates/net/rpc/Cargo.toml b/crates/net/rpc/Cargo.toml index df7e23c0..8c7524a0 100644 --- a/crates/net/rpc/Cargo.toml +++ b/crates/net/rpc/Cargo.toml @@ -13,14 +13,17 @@ 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 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..2700a707 --- /dev/null +++ b/crates/net/rpc/src/test_driver.rs @@ -0,0 +1,491 @@ +//! 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::ForkChoiceStep, + state_transition::StateTransitionRunRequest, verify_signatures::TestSignedBlock, +}; +use ethlambda_types::{ + attestation::{ + AggregationBits as EthAggregationBits, SignedAggregatedAttestation, SignedAttestation, + }, + block::{AggregatedSignatureProof, Block, ByteListMiB}, + checkpoint::Checkpoint, + primitives::{H256, HashTreeRoot as _}, + 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"; + +/// Whether the supplied env-var value should activate the driver. +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 { + 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. +/// +/// `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 DriverSnapshot { + head_slot: u64, + head_root: H256, + /// Store time in 800 ms intervals since genesis (matches [`Store::time`]). + time: u64, + /// `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, +} + +#[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(&mut 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 { + // 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) => { + debug!(%err, "fork-choice step rejected"); + (false, Some(err)) + } + }; + let snapshot = snapshot_store(&guard); + drop(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 response = match apply_state_transition(&mut state, &blocks, request.expect_exception) { + Ok(()) => StateTransitionResponse { + succeeded: true, + error: None, + post: Some(post_summary(&state)), + }, + Err(err) => StateTransitionResponse { + succeeded: false, + error: Some(err), + post: None, + }, + }; + 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 +/// 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 = 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 { + succeeded: true, + error: None, + }, + Err(err) => VerifySignaturesResponse { + succeeded: false, + error: Some(err.to_string()), + }, + }; + Json(response) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// 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. +/// +/// 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; + block_header.state_root = H256::ZERO; + if state_header != block_header { + return false; + } + + 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; + + if saved != H256::ZERO && saved != computed { + return false; + } + + block.state_root == 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 = 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 + + 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}")), + } +} + +/// 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 { + DriverSnapshot { + head_slot: store.head_slot(), + head_root: store.head(), + time: store.time(), + justified_checkpoint: store.latest_justified(), + finalized_checkpoint: store.latest_finalized(), + safe_target: store.safe_target(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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"); + } + } + + #[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..687ccf31 --- /dev/null +++ b/crates/net/rpc/tests/test_driver_e2e.rs @@ -0,0 +1,276 @@ +//! 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, state::State}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tokio::sync::RwLock; +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 { + 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). +/// +/// `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": genesis_state_root_hex(genesis_time), + "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(1234), + }); + + 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(0); + 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 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(); + let router = build_router(driver); + + let init = json!({ + "anchorState": genesis_anchor_state_json(0), + "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); + + // 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(0), + }); + 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()); +}