diff --git a/Cargo.lock b/Cargo.lock index 929b871..ce0e3e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2210,6 +2210,8 @@ dependencies = [ name = "ethlambda-types" version = "0.1.0" dependencies = [ + "datatest-stable 0.3.3", + "ethlambda-test-fixtures", "hex", "leansig", "libssz", diff --git a/Makefile b/Makefile index 36377ed..5bad411 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,8 @@ docker-build: ## 🐳 Build the Docker image -t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG) . @echo -LEAN_SPEC_COMMIT_HASH:=45e87fd8a56ac3849ae25906e96960cc116f8d81 +# 2026-04-14 +LEAN_SPEC_COMMIT_HASH:=76d4792ecd9d5bbcab60bfb022b72b590946b511 leanSpec: git clone https://github.com/leanEthereum/leanSpec.git --single-branch diff --git a/crates/blockchain/state_transition/tests/stf_spectests.rs b/crates/blockchain/state_transition/tests/stf_spectests.rs index 59bbbbe..5c25c8a 100644 --- a/crates/blockchain/state_transition/tests/stf_spectests.rs +++ b/crates/blockchain/state_transition/tests/stf_spectests.rs @@ -1,7 +1,12 @@ +use std::collections::HashMap; use std::path::Path; use ethlambda_state_transition::state_transition; -use ethlambda_types::{block::Block, state::State}; +use ethlambda_types::{ + block::Block, + primitives::{H256, HashTreeRoot as _}, + state::State, +}; use crate::types::PostState; @@ -24,8 +29,13 @@ fn run(path: &Path) -> datatest_stable::Result<()> { let mut pre_state: State = test.pre.into(); let mut result = Ok(()); - for block in test.blocks { + // Build a block registry mapping "block_N" labels to hash tree roots. + // Labels are 1-indexed: "block_1" is the first block in the array. + let mut block_registry: HashMap = HashMap::new(); + for (i, block) in test.blocks.into_iter().enumerate() { let block: Block = block.into(); + let label = format!("block_{}", i + 1); + block_registry.insert(label, block.hash_tree_root()); result = state_transition(&mut pre_state, &block); if result.is_err() { break; @@ -34,7 +44,7 @@ fn run(path: &Path) -> datatest_stable::Result<()> { let post_state = pre_state; match (result, test.post) { (Ok(_), Some(expected_post)) => { - compare_post_states(&post_state, &expected_post)?; + compare_post_states(&post_state, &expected_post, &block_registry)?; } (Ok(_), None) => { return Err( @@ -55,9 +65,24 @@ fn run(path: &Path) -> datatest_stable::Result<()> { Ok(()) } +fn resolve_label( + label: &str, + block_registry: &HashMap, +) -> datatest_stable::Result { + block_registry.get(label).copied().ok_or_else(|| { + format!( + "label '{}' not found in block registry. Available: {:?}", + label, + block_registry.keys().collect::>() + ) + .into() + }) +} + fn compare_post_states( post_state: &State, expected_post: &PostState, + block_registry: &HashMap, ) -> datatest_stable::Result<()> { let PostState { config_genesis_time, @@ -77,6 +102,11 @@ fn compare_post_states( justifications_roots, justifications_validators, validator_count, + latest_justified_root_label, + latest_finalized_root_label, + justifications_roots_labels, + justifications_roots_count, + justifications_validators_count, } = expected_post; if let Some(config_genesis_time) = config_genesis_time && post_state.config.genesis_time != *config_genesis_time @@ -237,6 +267,57 @@ fn compare_post_states( .into()); } } + if let Some(label) = latest_justified_root_label { + let expected = resolve_label(label, block_registry)?; + if post_state.latest_justified.root != expected { + return Err(format!( + "latest_justified.root mismatch (via label '{label}'): expected {expected:?}, got {:?}", + post_state.latest_justified.root + ) + .into()); + } + } + if let Some(label) = latest_finalized_root_label { + let expected = resolve_label(label, block_registry)?; + if post_state.latest_finalized.root != expected { + return Err(format!( + "latest_finalized.root mismatch (via label '{label}'): expected {expected:?}, got {:?}", + post_state.latest_finalized.root + ) + .into()); + } + } + if let Some(labels) = justifications_roots_labels { + let expected_roots: Vec = labels + .iter() + .map(|label| resolve_label(label, block_registry)) + .collect::>>()?; + let post_roots: Vec<_> = post_state.justifications_roots.iter().copied().collect(); + if post_roots != expected_roots { + return Err(format!( + "justifications_roots mismatch (via labels {labels:?}): expected {expected_roots:?}, got {post_roots:?}", + ) + .into()); + } + } + if let Some(expected_count) = justifications_roots_count { + let count = post_state.justifications_roots.len() as u64; + if count != *expected_count { + return Err(format!( + "justifications_roots count mismatch: expected {expected_count}, got {count}", + ) + .into()); + } + } + if let Some(expected_count) = justifications_validators_count { + let count = post_state.justifications_validators.len() as u64; + if count != *expected_count { + return Err(format!( + "justifications_validators count mismatch: expected {expected_count}, got {count}", + ) + .into()); + } + } Ok(()) } diff --git a/crates/blockchain/state_transition/tests/types.rs b/crates/blockchain/state_transition/tests/types.rs index b54896c..dab07f6 100644 --- a/crates/blockchain/state_transition/tests/types.rs +++ b/crates/blockchain/state_transition/tests/types.rs @@ -81,4 +81,18 @@ pub struct PostState { #[serde(rename = "validatorCount")] pub validator_count: Option, + + // Label-based root checks: "block_N" labels resolved to hash_tree_root of the Nth block. + #[serde(rename = "latestJustifiedRootLabel")] + pub latest_justified_root_label: Option, + #[serde(rename = "latestFinalizedRootLabel")] + pub latest_finalized_root_label: Option, + #[serde(rename = "justificationsRootsLabels")] + pub justifications_roots_labels: Option>, + + // Count checks for variable-length collections. + #[serde(rename = "justificationsRootsCount")] + pub justifications_roots_count: Option, + #[serde(rename = "justificationsValidatorsCount")] + pub justifications_validators_count: Option, } diff --git a/crates/common/test-fixtures/src/lib.rs b/crates/common/test-fixtures/src/lib.rs index 47c9bcb..a05ea13 100644 --- a/crates/common/test-fixtures/src/lib.rs +++ b/crates/common/test-fixtures/src/lib.rs @@ -131,7 +131,7 @@ pub struct TestState { #[serde(rename = "historicalBlockHashes")] pub historical_block_hashes: Container, #[serde(rename = "justifiedSlots")] - pub justified_slots: Container, + pub justified_slots: Container, pub validators: Container, #[serde(rename = "justificationsRoots")] pub justifications_roots: Container, @@ -154,6 +154,16 @@ impl From for State { .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, @@ -161,10 +171,10 @@ impl From for State { latest_justified: value.latest_justified.into(), latest_finalized: value.latest_finalized.into(), historical_block_hashes, - justified_slots: JustifiedSlots::new(), + justified_slots, validators, justifications_roots, - justifications_validators: JustificationValidators::new(), + justifications_validators, } } } diff --git a/crates/common/types/Cargo.toml b/crates/common/types/Cargo.toml index ccf6815..1de09a6 100644 --- a/crates/common/types/Cargo.toml +++ b/crates/common/types/Cargo.toml @@ -25,3 +25,11 @@ libssz-types.workspace = true serde_json.workspace = true serde_yaml_ng.workspace = true rand.workspace = true +ethlambda-test-fixtures.workspace = true + +datatest-stable = "0.3.3" + +[[test]] +name = "ssz_spectests" +path = "tests/ssz_spectests.rs" +harness = false diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 9018f6d..55f3ecf 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -35,7 +35,7 @@ pub struct AttestationData { } /// Validator attestation bundled with its signature. -#[derive(Debug, Clone, SszEncode, SszDecode)] +#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct SignedAttestation { /// The index of the validator making the attestation. pub validator_id: u64, @@ -79,7 +79,7 @@ pub fn validator_indices(bits: &AggregationBits) -> impl Iterator + } /// Aggregated attestation with its signature proof, used for gossip on the aggregation topic. -#[derive(Debug, Clone, SszEncode, SszDecode)] +#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct SignedAggregatedAttestation { pub data: AttestationData, pub proof: AggregatedSignatureProof, diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 826f7fc..13c1457 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -12,7 +12,7 @@ use crate::{ use primitives::HashTreeRoot as _; /// Envelope carrying a block and its aggregated signatures. -#[derive(Clone, SszEncode, SszDecode)] +#[derive(Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct SignedBlock { /// The block being signed. pub message: Block, @@ -35,7 +35,7 @@ impl core::fmt::Debug for SignedBlock { } /// Signature payload for the block. -#[derive(Clone, SszEncode, SszDecode)] +#[derive(Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct BlockSignatures { /// Attestation signatures for the aggregated attestations in the block body. /// @@ -69,7 +69,7 @@ pub type AttestationSignatures = SszList; /// The proof can verify that all participants signed the same message in the /// same epoch, using a single verification operation instead of checking /// each signature individually. -#[derive(Debug, Clone, SszEncode, SszDecode)] +#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct AggregatedSignatureProof { /// Bitfield indicating which validators' signatures are included. pub participants: AggregationBits, diff --git a/crates/common/types/tests/ssz_spectests.rs b/crates/common/types/tests/ssz_spectests.rs new file mode 100644 index 0000000..955c11a --- /dev/null +++ b/crates/common/types/tests/ssz_spectests.rs @@ -0,0 +1,151 @@ +use std::path::Path; + +use ethlambda_types::primitives::HashTreeRoot; + +mod ssz_types; +use ssz_types::{SszTestCase, SszTestVector, decode_hex, decode_hex_h256}; + +const SUPPORTED_FIXTURE_FORMAT: &str = "ssz"; + +fn run(path: &Path) -> datatest_stable::Result<()> { + let tests = SszTestVector::from_file(path)?; + + for (name, test) in tests.tests { + if test.info.fixture_format != SUPPORTED_FIXTURE_FORMAT { + return Err(format!( + "Unsupported fixture format: {} (expected {})", + test.info.fixture_format, SUPPORTED_FIXTURE_FORMAT + ) + .into()); + } + + println!("Running SSZ test: {name}"); + run_ssz_test(&test)?; + } + Ok(()) +} + +fn run_ssz_test(test: &SszTestCase) -> datatest_stable::Result<()> { + match test.type_name.as_str() { + // Consensus containers + "Config" => run_typed_test::(test), + "Checkpoint" => { + run_typed_test::(test) + } + "BlockHeader" => { + run_typed_test::(test) + } + "Validator" => { + run_typed_test::(test) + } + "AttestationData" => run_typed_test::< + ssz_types::AttestationData, + ethlambda_types::attestation::AttestationData, + >(test), + "Attestation" => run_typed_test::< + ssz_types::Attestation, + ethlambda_types::attestation::Attestation, + >(test), + "AggregatedAttestation" => run_typed_test::< + ssz_types::AggregatedAttestation, + ethlambda_types::attestation::AggregatedAttestation, + >(test), + "BlockBody" => { + run_typed_test::(test) + } + "Block" => run_typed_test::(test), + "State" => run_typed_test::(test), + "SignedAttestation" => run_typed_test::< + ssz_types::SignedAttestation, + ethlambda_types::attestation::SignedAttestation, + >(test), + "SignedBlock" => { + run_typed_test::(test) + } + "BlockSignatures" => run_typed_test::< + ssz_types::BlockSignatures, + ethlambda_types::block::BlockSignatures, + >(test), + "AggregatedSignatureProof" => run_typed_test::< + ssz_types::AggregatedSignatureProof, + ethlambda_types::block::AggregatedSignatureProof, + >(test), + "SignedAggregatedAttestation" => run_typed_test::< + ssz_types::SignedAggregatedAttestation, + ethlambda_types::attestation::SignedAggregatedAttestation, + >(test), + + // Unsupported types: skip with a message + other => { + println!(" Skipping unsupported type: {other}"); + Ok(()) + } + } +} + +/// Run an SSZ test for a given fixture type `F` that converts into domain type `D`. +/// +/// Tests: +/// 1. JSON value deserializes into fixture type and converts to domain type +/// 2. SSZ encoding matches expected serialized bytes +/// 3. SSZ decoding from expected bytes re-encodes identically (round-trip) +/// 4. Hash tree root matches expected root +fn run_typed_test(test: &SszTestCase) -> datatest_stable::Result<()> +where + F: serde::de::DeserializeOwned + Into, + D: libssz::SszEncode + libssz::SszDecode + HashTreeRoot, +{ + let expected_bytes = decode_hex(&test.serialized) + .map_err(|e| format!("Failed to decode serialized hex: {e}"))?; + let expected_root = + decode_hex_h256(&test.root).map_err(|e| format!("Failed to decode root hex: {e}"))?; + + // Step 1: Deserialize JSON value into fixture type, then convert to domain type + let fixture_value: F = serde_json::from_value(test.value.clone()) + .map_err(|e| format!("Failed to deserialize value: {e}"))?; + let domain_value: D = fixture_value.into(); + + // Step 2: SSZ encode and compare with expected serialized bytes + let encoded = ::to_ssz(&domain_value); + if encoded != expected_bytes { + return Err(format!( + "SSZ encoding mismatch for {}:\n expected: 0x{}\n got: 0x{}", + test.type_name, + hex::encode(&expected_bytes), + hex::encode(&encoded), + ) + .into()); + } + + // Step 3: SSZ decode from expected bytes and re-encode (round-trip) + let decoded = D::from_ssz_bytes(&expected_bytes) + .map_err(|e| format!("SSZ decode failed for {}: {e:?}", test.type_name))?; + let re_encoded = ::to_ssz(&decoded); + if re_encoded != expected_bytes { + return Err(format!( + "SSZ round-trip mismatch for {}:\n expected: 0x{}\n got: 0x{}", + test.type_name, + hex::encode(&expected_bytes), + hex::encode(&re_encoded), + ) + .into()); + } + + // Step 4: Verify hash tree root + let computed_root = HashTreeRoot::hash_tree_root(&domain_value); + if computed_root != expected_root { + return Err(format!( + "Hash tree root mismatch for {}:\n expected: {expected_root}\n got: {computed_root}", + test.type_name, + ) + .into()); + } + + Ok(()) +} + +datatest_stable::harness!({ + test = run, + root = "../../../leanSpec/fixtures/consensus/ssz", + pattern = r".*\.json" +}); diff --git a/crates/common/types/tests/ssz_types.rs b/crates/common/types/tests/ssz_types.rs new file mode 100644 index 0000000..27bd2bd --- /dev/null +++ b/crates/common/types/tests/ssz_types.rs @@ -0,0 +1,215 @@ +use std::collections::HashMap; +use std::path::Path; + +pub use ethlambda_test_fixtures::{ + AggregatedAttestation, AggregationBits, AttestationData, Block, BlockBody, BlockHeader, + Checkpoint, Config, Container, TestInfo, TestState, Validator, +}; +use ethlambda_types::{ + attestation::{ + Attestation as DomainAttestation, + SignedAggregatedAttestation as DomainSignedAggregatedAttestation, + SignedAttestation as DomainSignedAttestation, XmssSignature, + }, + block::{ + AggregatedSignatureProof as DomainAggregatedSignatureProof, AttestationSignatures, + BlockSignatures as DomainBlockSignatures, ByteListMiB, SignedBlock as DomainSignedBlock, + }, + primitives::H256, +}; +use libssz_types::SszVector; +use serde::Deserialize; + +// ============================================================================ +// Root Structure +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct SszTestVector { + #[serde(flatten)] + pub tests: HashMap, +} + +impl SszTestVector { + 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) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SszTestCase { + #[allow(dead_code)] + pub network: String, + #[serde(rename = "leanEnv")] + #[allow(dead_code)] + pub lean_env: String, + #[serde(rename = "typeName")] + pub type_name: String, + pub value: serde_json::Value, + pub serialized: String, + pub root: String, + #[serde(rename = "_info")] + pub info: TestInfo, +} + +// ============================================================================ +// Hex Helpers +// ============================================================================ + +pub fn decode_hex(hex_str: &str) -> Result, Box> { + let stripped = hex_str.strip_prefix("0x").unwrap_or(hex_str); + Ok(hex::decode(stripped)?) +} + +pub fn decode_hex_h256(hex_str: &str) -> Result> { + let bytes = decode_hex(hex_str)?; + if bytes.len() != 32 { + return Err(format!("expected 32 bytes for H256, got {}", bytes.len()).into()); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Ok(H256(arr)) +} + +// ============================================================================ +// Attestation (not in test-fixtures: unsigned non-aggregated attestation) +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Attestation { + #[serde(rename = "validatorId")] + pub validator_id: u64, + pub data: AttestationData, +} + +impl From for DomainAttestation { + fn from(value: Attestation) -> Self { + Self { + validator_id: value.validator_id, + data: value.data.into(), + } + } +} + +// ============================================================================ +// Signed Types (SSZ-specific fixtures) +// ============================================================================ + +fn deser_signature_hex<'de, D>(d: D) -> Result +where + D: serde::Deserializer<'de>, +{ + 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("Signature value is not valid hex"))?; + SszVector::try_from(bytes) + .map_err(|e| D::Error::custom(format!("Invalid signature length: {e:?}"))) +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SignedAttestation { + #[serde(rename = "validatorId")] + pub validator_id: u64, + pub data: AttestationData, + #[serde(deserialize_with = "deser_signature_hex")] + pub signature: XmssSignature, +} + +impl From for DomainSignedAttestation { + fn from(value: SignedAttestation) -> Self { + Self { + validator_id: value.validator_id, + data: value.data.into(), + signature: value.signature, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SignedBlock { + pub block: Block, + pub signature: BlockSignatures, +} + +impl From for DomainSignedBlock { + fn from(value: SignedBlock) -> Self { + Self { + message: value.block.into(), + signature: value.signature.into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BlockSignatures { + #[serde(rename = "attestationSignatures")] + pub attestation_signatures: Container, + #[serde(rename = "proposerSignature")] + #[serde(deserialize_with = "deser_signature_hex")] + pub proposer_signature: XmssSignature, +} + +impl From for DomainBlockSignatures { + fn from(value: BlockSignatures) -> Self { + let att_sigs: Vec = value + .attestation_signatures + .data + .into_iter() + .map(Into::into) + .collect(); + Self { + attestation_signatures: AttestationSignatures::try_from(att_sigs) + .expect("too many attestation signatures"), + proposer_signature: value.proposer_signature, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AggregatedSignatureProof { + pub participants: AggregationBits, + #[serde(rename = "proofData")] + pub proof_data: HexByteList, +} + +impl From for DomainAggregatedSignatureProof { + fn from(value: AggregatedSignatureProof) -> Self { + let proof_bytes: Vec = value.proof_data.into(); + Self { + participants: value.participants.into(), + proof_data: ByteListMiB::try_from(proof_bytes).expect("proof data too large"), + } + } +} + +/// Hex-encoded byte list in the fixture format: `{ "data": "0xdeadbeef" }` +#[derive(Debug, Clone, Deserialize)] +pub struct HexByteList { + data: String, +} + +impl From for Vec { + fn from(value: HexByteList) -> Self { + let stripped = value.data.strip_prefix("0x").unwrap_or(&value.data); + hex::decode(stripped).expect("invalid hex in proof data") + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SignedAggregatedAttestation { + pub data: AttestationData, + pub proof: AggregatedSignatureProof, +} + +impl From for DomainSignedAggregatedAttestation { + fn from(value: SignedAggregatedAttestation) -> Self { + Self { + data: value.data.into(), + proof: value.proof.into(), + } + } +}