diff --git a/crates/blockchain/Cargo.toml b/crates/blockchain/Cargo.toml index f25234cd..3a5725d6 100644 --- a/crates/blockchain/Cargo.toml +++ b/crates/blockchain/Cargo.toml @@ -18,6 +18,7 @@ ethlambda-fork-choice.workspace = true ethlambda-crypto.workspace = true ethlambda-metrics.workspace = true ethlambda-types.workspace = true +ethlambda-test-fixtures.workspace = true libssz.workspace = true @@ -33,7 +34,6 @@ tracing.workspace = true hex.workspace = true [dev-dependencies] -ethlambda-test-fixtures.workspace = true serde = { workspace = true } serde_json = { workspace = true } hex = { workspace = true } diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 05fa8b03..cdb4fb8c 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -37,6 +37,7 @@ pub(crate) mod fork_choice_tree; pub mod key_manager; pub mod metrics; pub mod reaggregate; +pub mod spec_test_runner; pub mod store; mod sync_status; diff --git a/crates/blockchain/src/spec_test_runner.rs b/crates/blockchain/src/spec_test_runner.rs new file mode 100644 index 00000000..eb8ee305 --- /dev/null +++ b/crates/blockchain/src/spec_test_runner.rs @@ -0,0 +1,115 @@ +//! Shared execution primitives for leanSpec fixtures. +//! +//! Both the offline spec-test binaries and Hive's HTTP test driver use these +//! functions so fixture replay cannot drift between the two entry points. + +use ethlambda_storage::Store; +use ethlambda_test_fixtures::fork_choice::ForkChoiceStep; +use ethlambda_types::{ + attestation::{ + AggregationBits, HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, + }, + block::{ByteList512KiB, SingleMessageAggregate}, +}; + +use crate::{MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, store}; + +/// Prefix emitted by leanSpec's mocked aggregation prover. +const MOCK_PROOF_PREFIX: &[u8] = b"\x00MOCKED-AGGREGATION-PROOF\x00"; + +/// Apply one fork-choice fixture step. +/// +/// `proofs_are_mocked` is supplied by complete offline vectors through their +/// `proofSetting`. Hive sends individual steps, so `None` detects the mocked +/// prover's sentinel directly from the proof bytes. +pub fn apply_fork_choice_step( + store: &mut Store, + step: &ForkChoiceStep, + proofs_are_mocked: Option, +) -> Result<(), String> { + match step.step_type.as_str() { + "tick" => { + let genesis_time = store.config().expect("config exists").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 + .as_ref() + .ok_or_else(|| "block step missing block data".to_string())?; + let signed_block = block_data.to_blank_signed_block(); + if step.tick_to_slot { + let block_time_ms = store.config().expect("config exists").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())?; + + let block = block_data.to_block(); + let entries = block.body.attestations.iter().map(|att| { + ( + HashedAttestationData::new(att.data.clone()), + SingleMessageAggregate::empty(att.aggregation_bits.clone()), + ) + }); + store.insert_known_aggregated_payloads_batch(entries.collect()); + store::update_head(store, false); + Ok(()) + } + "attestation" => { + let att = step + .attestation + .as_ref() + .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.clone().into(), + signature: att + .signature + .clone() + .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 + .as_ref() + .ok_or_else(|| "gossipAggregatedAttestation step missing data".to_string())?; + let proof = att + .proof + .as_ref() + .ok_or_else(|| "gossipAggregatedAttestation step missing proof".to_string())?; + let participants: AggregationBits = proof.participants.clone().into(); + let proof_bytes: Vec = proof.proof.clone().into(); + let is_mocked = + proofs_are_mocked.unwrap_or_else(|| proof_bytes.starts_with(MOCK_PROOF_PREFIX)); + let proof_data = ByteList512KiB::try_from(proof_bytes) + .map_err(|err| format!("aggregated proof data too large: {err:?}"))?; + let aggregated = SignedAggregatedAttestation { + proof: SingleMessageAggregate::new(participants, proof_data), + data: att.data.clone().into(), + }; + if is_mocked { + store::on_gossip_aggregated_attestation_without_verification(store, aggregated) + .map_err(|e| e.to_string()) + } else { + store::on_gossip_aggregated_attestation(store, aggregated) + .map_err(|e| e.to_string()) + } + } + "checks" => Ok(()), + other => Err(format!("unknown step type: {other}")), + } +} diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index 8e60fdef..e3c5cd67 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -4,15 +4,12 @@ use std::{ sync::Arc, }; -use ethlambda_blockchain::{MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, store}; +use ethlambda_blockchain::{spec_test_runner::apply_fork_choice_step, store}; use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ - attestation::{ - AttestationData, HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, - validator_indices, - }, - block::{Block, SingleMessageAggregate}, - primitives::{ByteList, H256, HashTreeRoot as _}, + attestation::{AttestationData, validator_indices}, + block::Block, + primitives::{H256, HashTreeRoot as _}, state::{State, anchor_pair_is_consistent}, }; @@ -57,8 +54,6 @@ fn run(path: &Path) -> datatest_stable::Result<()> { // `get_forkchoice_store`'s assert! panic out of the test harness. let mut anchor_state: State = test.anchor_state.into(); let anchor_block: Block = test.anchor_block.into(); - let genesis_time = anchor_state.config.genesis_time; - let pair_ok = anchor_pair_is_consistent(&mut anchor_state, &anchor_block); if test.steps.is_empty() { if pair_ok { @@ -107,132 +102,16 @@ fn run(path: &Path) -> datatest_stable::Result<()> { let old_head = store.head()?; // Block built/imported this step, for the block-body checks. Mirrors // leanSpec's per-step `filled_block` (only a block step sets it). - let mut filled_block: Option = None; - match step.step_type.as_str() { - "block" => { - let block_data = step.block.expect("block step missing block data"); - - // Register block label if present - if let Some(ref label) = block_data.block_root_label { - let block: Block = block_data.to_block(); - let root = block.hash_tree_root(); - block_registry.insert(label.clone(), root); - } - - // The block this step delivers is the leanSpec `filled_block`. - filled_block = Some(block_data.to_block()); - - let signed_block = block_data.to_blank_signed_block(); - - // Advance time to the block's slot unless the test delivers - // the block ahead of the store clock. - // NOTE: the has_proposal argument is set to true, following the spec - if step.tick_to_slot { - let block_time_ms = - genesis_time * 1000 + signed_block.message.slot * MILLISECONDS_PER_SLOT; - store::on_tick(&mut store, block_time_ms, true); - } - let result = store::on_block_without_verification(&mut store, signed_block); - let import_ok = result.is_ok(); - assert_step_outcome(step_idx, step.valid, result)?; - - // Deconstruct the imported block into per-attestation - // single-message aggregates, mirroring the node's post-import - // reaggregation. The real node SNARK-splits the block's merged - // multi-message aggregate proof and folds the recovered - // single-message aggregates into the pool so block-borne votes carry - // fork-choice weight; leanSpec's fork-choice harness gets the - // same effect by simulating the proposer build. Fixture blocks - // are blank (no real proof to split), so reconstruct structurally - // from the body's aggregation_bits — fork choice reads only the - // participant set, not the proof bytes. The recovered entries go - // straight into the known pool to match the proposer-view store - // the fixtures encode. - if import_ok { - let block = block_data.to_block(); - let entries: Vec<(HashedAttestationData, SingleMessageAggregate)> = block - .body - .attestations - .iter() - .map(|att| { - ( - HashedAttestationData::new(att.data.clone()), - SingleMessageAggregate::empty(att.aggregation_bits.clone()), - ) - }) - .collect(); - store.insert_known_aggregated_payloads_batch(entries); - // on_block already ran the head update before these votes - // existed; recompute so the head reflects the block's own - // attestations, matching the proposer-view store. - store::update_head(&mut store, false); - } - } - "tick" => { - // Fixtures use either `time` (UNIX seconds) or `interval` - // (absolute interval count since genesis). Interval fixtures - // encode `genesis_time_ms + interval * MILLISECONDS_PER_INTERVAL`. - 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) => panic!("tick step missing both time and interval"), - }; - let has_proposal = step.has_proposal.unwrap_or(false); - store::on_tick(&mut store, timestamp_ms, has_proposal); - } - "attestation" => { - let att_data = step - .attestation - .expect("attestation step missing attestation data"); - let signed_attestation = SignedAttestation { - validator_id: att_data - .validator_id - .expect("attestation step missing validator_id"), - data: att_data.data.into(), - signature: att_data - .signature - .expect("attestation step missing signature"), - }; - let is_aggregator = step.is_aggregator.unwrap_or(false); - - let result = store::on_gossip_attestation( - &mut store, - &signed_attestation, - is_aggregator, - ); - assert_step_outcome(step_idx, step.valid, result)?; - } - "gossipAggregatedAttestation" => { - let att_data = step - .attestation - .expect("gossipAggregatedAttestation step missing attestation data"); - let proof_fixture = att_data - .proof - .expect("gossipAggregatedAttestation step missing proof"); - let proof_bytes: Vec = proof_fixture.proof.into(); - let proof_data = ByteList::try_from(proof_bytes) - .expect("aggregated proof data fits in ByteList512KiB"); - let data: AttestationData = att_data.data.into(); - let proof = - SingleMessageAggregate::new(proof_fixture.participants.into(), proof_data); - let aggregated = SignedAggregatedAttestation { data, proof }; - - let result = if proofs_are_mocked { - store::on_gossip_aggregated_attestation_without_verification( - &mut store, aggregated, - ) - } else { - store::on_gossip_aggregated_attestation(&mut store, aggregated) - }; - assert_step_outcome(step_idx, step.valid, result)?; - } - other => { - return Err(format!("Unsupported step type '{other}'").into()); - } + let filled_block = step.block.as_ref().map(|block_data| block_data.to_block()); + if let Some(block_data) = step.block.as_ref() + && let Some(label) = block_data.block_root_label.as_ref() + { + block_registry.insert(label.clone(), block_data.to_block().hash_tree_root()); } + let result = apply_fork_choice_step(&mut store, &step, Some(proofs_are_mocked)); + assert_step_outcome(step_idx, step.valid, result)?; + // Fold this step's blocks into the cumulative tree before checks so // ancestry walks see blocks finalization may have just pruned from // the live-chain index (see `all_blocks` above). diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index 913370b4..bd79d6a1 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -30,8 +30,7 @@ use axum::{ routing::{get, post}, }; use ethlambda_blockchain::{ - MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, - store::{self, verify_block_signatures}, + spec_test_runner::apply_fork_choice_step, store::verify_block_signatures, }; use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_test_fixtures::{ @@ -39,11 +38,7 @@ use ethlambda_test_fixtures::{ state_transition::StateTransitionRunRequest, verify_signatures::TestSignedBlock, }; use ethlambda_types::{ - attestation::{ - AggregationBits as EthAggregationBits, HashedAttestationData, SignedAggregatedAttestation, - SignedAttestation, - }, - block::{Block, ByteList512KiB, SingleMessageAggregate}, + block::Block, checkpoint::Checkpoint, primitives::H256, state::{State, anchor_pair_is_consistent}, @@ -58,13 +53,6 @@ use tracing::debug; /// of `"1"`, `"true"`, or `"yes"` (case-insensitive) enables the driver. pub const TEST_DRIVER_ENV: &str = "HIVE_LEAN_TEST_DRIVER"; -/// Sentinel prefixing every placeholder proof leanSpec's mocked prover emits -/// (`proofSetting: 0` fixtures). Matches `MOCK_PROOF_PREFIX` in leanSpec's -/// `packages/testing/src/consensus_testing/crypto_mode.py`. Proofs carrying it -/// are accepted without cryptographic verification, mirroring leanSpec's mocked -/// verifier; genuine (`proofSetting: 1`) proofs still run the real verifier. -const MOCK_PROOF_PREFIX: &[u8] = b"\x00MOCKED-AGGREGATION-PROOF\x00"; - /// Whether the supplied env-var value should activate the driver. fn parse_truthy_env_value(value: &str) -> bool { matches!( @@ -238,7 +226,7 @@ async fn step_fork_choice( // 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 outcome = apply_fork_choice_step(&mut guard, &step, None); let (accepted, error) = match outcome { Ok(()) => (true, None), Err(err) => { @@ -343,110 +331,6 @@ async fn run_verify_signatures( // Helpers // ============================================================================ -/// 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().expect("config exists").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, unless the step delivers the block ahead of - // the store clock. - if step.tick_to_slot { - let block_time_ms = store.config().expect("config exists").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())?; - - // Fold the block's attestations into the fork-choice known pool so - // block-borne votes carry weight. The production node SNARK-splits - // the block's merged proof and folds the recovered single-message - // aggregates; fixture blocks are blank, so reconstruct structurally - // from aggregation_bits (fork choice reads only the participant set, - // not the proof bytes). Mirrors the same fold in - // crates/blockchain/tests/forkchoice_spectests.rs. - let block = block_data.to_block(); - let entries: Vec<_> = block - .body - .attestations - .iter() - .map(|att| { - let hashed_attestation = HashedAttestationData::new(att.data.clone()); - let aggregate = SingleMessageAggregate::empty(att.aggregation_bits.clone()); - (hashed_attestation, aggregate) - }) - .collect(); - store.insert_known_aggregated_payloads_batch(entries); - store::update_head(store, false); - Ok(()) - } - "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.into(); - // leanSpec's mocked prover (proofSetting=0) emits placeholder proofs - // prefixed with MOCK_PROOF_PREFIX and expects verifiers to accept them - // unchecked. Route those through the non-verifying path; genuine proofs - // still run the real verifier. - let is_mocked = proof_bytes.starts_with(MOCK_PROOF_PREFIX); - let proof_data = ByteList512KiB::try_from(proof_bytes) - .map_err(|err| format!("aggregated proof data too large: {err:?}"))?; - let data: ethlambda_types::attestation::AttestationData = att.data.into(); - let aggregated = SignedAggregatedAttestation { - proof: SingleMessageAggregate::new(participants, proof_data), - data, - }; - if is_mocked { - store::on_gossip_aggregated_attestation_without_verification(store, aggregated) - .map_err(|e| e.to_string()) - } else { - 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 {