diff --git a/Cargo.lock b/Cargo.lock index dee117167..68be17ab2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3660,6 +3660,7 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "anyhow", + "ethereum_ssz", "ream-consensus-lean", "ream-consensus-misc", "ream-fork-choice-lean", diff --git a/crates/common/consensus/lean/src/attestation.rs b/crates/common/consensus/lean/src/attestation.rs index abb96d60d..1bab8e7f4 100644 --- a/crates/common/consensus/lean/src/attestation.rs +++ b/crates/common/consensus/lean/src/attestation.rs @@ -102,7 +102,7 @@ impl AggregatedAttestation { } /// Validator attestation bundled with its signature. -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Encode, Decode)] pub struct SignedAttestation { pub validator_id: u64, pub message: AttestationData, diff --git a/crates/common/fork_choice/lean/src/store.rs b/crates/common/fork_choice/lean/src/store.rs index 288efec9e..2b4128847 100644 --- a/crates/common/fork_choice/lean/src/store.rs +++ b/crates/common/fork_choice/lean/src/store.rs @@ -50,6 +50,8 @@ use ream_storage::{ }, }; use ream_sync::rwlock::{Reader, Writer}; +#[cfg(feature = "devnet3")] +use ssz::{Decode, Encode}; use ssz_types::{BitList, VariableList, typenum::U4096}; use tokio::sync::Mutex; use tree_hash::TreeHash; @@ -1275,7 +1277,10 @@ impl Store { proposer_attestation.validator_id, &proposer_attestation.data, ), - signed_block_with_attestation.signature.proposer_signature, + signed_block_with_attestation + .signature + .proposer_signature + .clone(), )?; #[cfg(feature = "devnet3")] @@ -1293,7 +1298,10 @@ impl Store { proposer_attestation.validator_id, &proposer_attestation.data, ), - signed_block_with_attestation.signature.proposer_signature, + signed_block_with_attestation + .signature + .proposer_signature + .clone(), )?; } } @@ -1304,7 +1312,10 @@ impl Store { SignedAttestation { validator_id: proposer_attestation.validator_id, message: proposer_attestation.data.clone(), - signature: signed_block_with_attestation.signature.proposer_signature, + signature: signed_block_with_attestation + .signature + .proposer_signature + .clone(), }, false, ) @@ -1323,8 +1334,7 @@ impl Store { let signature_bytes = signed_block_with_attestation .signature .proposer_signature - .inner - .to_vec(); + .as_ssz_bytes(); let proof_data = signature_bytes.try_into().map_err(|err| { anyhow!("Failed to convert proposer signature to VariableList {err:?}") @@ -1505,7 +1515,8 @@ impl Store { }) .collect::>>()?; - let sig = Signature::from(proof.proof_data.as_ref()); + let sig = Signature::from_ssz_bytes(proof.proof_data.as_ref()) + .map_err(|err| anyhow!("Failed to decode signature: {err:?}"))?; for pubkey in &public_keys { let is_valid = sig.verify(pubkey, attestation_slot as u32, &data_root.0)?; @@ -1624,7 +1635,7 @@ impl Store { ) -> anyhow::Result<()> { let validator_id = signed_attestation.validator_id; let attestation_data = &signed_attestation.message; - let signature = signed_attestation.signature; + let signature = signed_attestation.signature.clone(); self.validate_attestation(&signed_attestation).await?; diff --git a/crates/crypto/post_quantum/src/lean_multisig/aggregate.rs b/crates/crypto/post_quantum/src/lean_multisig/aggregate.rs index 61b36c243..356f000e7 100644 --- a/crates/crypto/post_quantum/src/lean_multisig/aggregate.rs +++ b/crates/crypto/post_quantum/src/lean_multisig/aggregate.rs @@ -40,9 +40,8 @@ pub fn aggregate_signatures( .map_err(|err| anyhow!("Failed to convert public keys: {err}"))?, &signatures .iter() - .map(|signature| signature.as_lean_sig()) - .collect::, _>>() - .map_err(|err| anyhow!("Failed to convert signatures: {err}"))?, + .map(|signature| signature.as_lean_sig().clone()) + .collect::>(), message, epoch, ) diff --git a/crates/crypto/post_quantum/src/leansig/private_key.rs b/crates/crypto/post_quantum/src/leansig/private_key.rs index 5444b3734..eefa31c83 100644 --- a/crates/crypto/post_quantum/src/leansig/private_key.rs +++ b/crates/crypto/post_quantum/src/leansig/private_key.rs @@ -76,7 +76,7 @@ impl PrivateKey { let signature = ::sign(&self.inner, epoch, message) .map_err(LeanSigError::SigningFailed)?; - Signature::from_lean_sig(signature) + Ok(Signature::from_lean_sig(signature)) } pub fn from_bytes(bytes: &[u8]) -> Result { diff --git a/crates/crypto/post_quantum/src/leansig/signature.rs b/crates/crypto/post_quantum/src/leansig/signature.rs index daa3857a2..71f76a519 100644 --- a/crates/crypto/post_quantum/src/leansig/signature.rs +++ b/crates/crypto/post_quantum/src/leansig/signature.rs @@ -1,40 +1,59 @@ -use alloy_primitives::FixedBytes; -use anyhow::anyhow; -use leansig::{MESSAGE_LENGTH, serialization::Serializable, signature::SignatureScheme}; +use leansig::{signature::SignatureScheme, MESSAGE_LENGTH}; use serde::{Deserialize, Serialize}; +use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; -use tree_hash_derive::TreeHash; -use crate::leansig::{LeanSigScheme, errors::LeanSigError, public_key::PublicKey}; +use crate::leansig::{public_key::PublicKey, LeanSigScheme}; -const SIGNATURE_SIZE: usize = 3112; +/// The inner leansig signature type with built-in SSZ support. +pub type LeanSigSignature = ::Signature; -type LeanSigSignature = ::Signature; +const BLANK_SIGNATURE_SSZ_BYTES: [u8; 40] = [ + 36, 0, 0, 0, // offset_path = 36 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // rho (28 zeros) + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // + 40, 0, 0, 0, // offset_hashes = 40 + 4, 0, 0, 0, // path: empty HashTreeOpening +]; -/// Wrapper around a fixed-size serialized hash-based signature. -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, Copy)] +/// Wrapper around leansig's signature type. +/// Uses leansig's built-in SSZ encoding for interoperability with other clients. +#[derive(Clone, Serialize, Deserialize, Encode, Decode)] +#[ssz(struct_behaviour = "transparent")] pub struct Signature { - pub inner: FixedBytes, + pub inner: LeanSigSignature, } -impl From<&[u8]> for Signature { - fn from(value: &[u8]) -> Self { - Self { - inner: FixedBytes::from_slice(value), - } +impl std::fmt::Debug for Signature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Signature") + .field("inner", &"") + .finish() } } -impl Signature { - pub fn new(inner: FixedBytes) -> Self { - Self { inner } +impl PartialEq for Signature { + fn eq(&self, other: &Self) -> bool { + // Compare by SSZ encoding since LeanSigSignature doesn't implement PartialEq + self.inner.as_ssz_bytes() == other.inner.as_ssz_bytes() } +} + +impl Eq for Signature {} +impl Signature { + /// Create a blank/placeholder signature. + /// + /// This decodes from minimal valid SSZ bytes, avoiding expensive key generation. + /// Only use in contexts where the signature won't be validated. pub fn blank() -> Self { - Self::new(Default::default()) + Self::from_ssz_bytes(&BLANK_SIGNATURE_SSZ_BYTES).expect("blank signature bytes are valid") } /// Create a mock signature for testing purposes. + /// + /// Note: This generates a real signature which is expensive. Prefer `blank()` when + /// you just need a placeholder signature. pub fn mock() -> Self { use rand::rng; @@ -48,15 +67,12 @@ impl Signature { .expect("Mock signature generation failed") } - pub fn from_lean_sig(signature: LeanSigSignature) -> Result { - Ok(Self { - inner: FixedBytes::try_from(signature.to_bytes().as_slice())?, - }) + pub fn from_lean_sig(signature: LeanSigSignature) -> Self { + Self { inner: signature } } - pub fn as_lean_sig(&self) -> anyhow::Result { - LeanSigSignature::from_bytes(self.inner.as_slice()) - .map_err(|err| anyhow!("Failed to decode LeanSigSignature from SSZ: {err:?}")) + pub fn as_lean_sig(&self) -> &LeanSigSignature { + &self.inner } pub fn verify( @@ -69,17 +85,27 @@ impl Signature { &public_key.as_lean_sig()?, epoch, message, - &self.as_lean_sig()?, + &self.inner, )) } } #[cfg(test)] mod tests { + use alloy_primitives::FixedBytes; + use leansig::serialization::Serializable; use rand::rng; + use ssz::{Decode, Encode}; use crate::leansig::{private_key::PrivateKey, signature::Signature}; + const LEGACY_SIGNATURE_SIZE: usize = 3112; + + #[derive(ssz_derive::Encode)] + struct LegacySignature { + inner: FixedBytes, + } + #[test] fn test_serialization_roundtrip() { let mut rng = rng(); @@ -100,13 +126,32 @@ mod tests { assert!(result.is_ok(), "Signing should succeed"); let signature = result.unwrap(); - // convert to leansig signature - let hash_sig_signature = signature.as_lean_sig().unwrap(); - - // convert back to signature - let signature_returned = Signature::from_lean_sig(hash_sig_signature).unwrap(); + // SSZ roundtrip test + let ssz_bytes = signature.as_ssz_bytes(); + let signature_decoded = Signature::from_ssz_bytes(&ssz_bytes).unwrap(); // verify roundtrip - assert_eq!(signature, signature_returned); + assert_eq!(signature, signature_decoded); + } + + #[test] + fn test_ssz_bytes_match_legacy_signature_wrapper() { + let mut rng = rng(); + let activation_epoch = 0; + let num_active_epochs = 10; + + let (_, private_key) = + PrivateKey::generate_key_pair(&mut rng, activation_epoch, num_active_epochs); + + let epoch = 5; + let message = [0u8; 32]; + let signature = private_key.sign(&message, epoch).unwrap(); + + let legacy_signature = LegacySignature { + inner: FixedBytes::try_from(signature.as_lean_sig().to_bytes().as_slice()) + .expect("legacy signature bytes should match fixed size"), + }; + + assert_eq!(legacy_signature.as_ssz_bytes(), signature.as_ssz_bytes()); } } diff --git a/testing/lean-spec-tests/Cargo.toml b/testing/lean-spec-tests/Cargo.toml index c4af0b3f8..fd155f9b6 100644 --- a/testing/lean-spec-tests/Cargo.toml +++ b/testing/lean-spec-tests/Cargo.toml @@ -20,6 +20,7 @@ alloy-primitives.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true +ssz = { package = "ethereum_ssz", version = "0.10" } ssz_types.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/testing/lean-spec-tests/src/lib.rs b/testing/lean-spec-tests/src/lib.rs index e69c81c43..90520f21b 100644 --- a/testing/lean-spec-tests/src/lib.rs +++ b/testing/lean-spec-tests/src/lib.rs @@ -1,3 +1,4 @@ pub mod fork_choice; +pub mod ssz_test; pub mod state_transition; pub mod types; diff --git a/testing/lean-spec-tests/src/ssz_test.rs b/testing/lean-spec-tests/src/ssz_test.rs new file mode 100644 index 000000000..43c7e5aed --- /dev/null +++ b/testing/lean-spec-tests/src/ssz_test.rs @@ -0,0 +1,123 @@ +use std::path::Path; + +use alloy_primitives::hex; +use anyhow::{anyhow, bail}; +use ream_consensus_lean::{ + attestation::{ + AggregatedAttestation, AggregatedAttestations, AttestationData, SignedAttestation, + }, + block::{ + Block, BlockBody, BlockHeader, BlockSignatures, BlockWithAttestation, + SignedBlockWithAttestation, + }, + checkpoint::Checkpoint, + config::Config, + state::LeanState, + validator::Validator, +}; +use ssz::Encode; +use tracing::{debug, info, warn}; + +use crate::types::{ + TestFixture, + ssz_test::{ + AggregatedAttestationJSON, AttestationDataJSON, AttestationJSON, BlockBodyJSON, + BlockHeaderJSON, BlockJSON, BlockSignaturesJSON, BlockWithAttestationJSON, CheckpointJSON, + ConfigJSON, SSZTest, SignedAttestationJSON, SignedBlockWithAttestationJSON, StateJSON, + ValidatorJSON, + }, +}; + +/// Load an SSZ test fixture from a JSON file +pub fn load_ssz_test(path: impl AsRef) -> anyhow::Result> { + let content = std::fs::read_to_string(path.as_ref()).map_err(|err| { + anyhow!( + "Failed to read test file {:?}: {err}", + path.as_ref().display() + ) + })?; + + let fixture: TestFixture = serde_json::from_str(&content).map_err(|err| { + anyhow!( + "Failed to parse test file {:?}: {err}", + path.as_ref().display() + ) + })?; + + Ok(fixture) +} + +/// Run a single SSZ test case +pub fn run_ssz_test(test_name: &str, test: &SSZTest) -> anyhow::Result<()> { + info!("Running SSZ test: {test_name}"); + debug!(" Network: {}", test.network); + debug!(" Type: {}", test.type_name); + + let expected_ssz = parse_hex_bytes(&test.serialized)?; + + match test.type_name.as_str() { + "Checkpoint" => run_test::(&test.value, &expected_ssz), + "AttestationData" => { + run_test::(&test.value, &expected_ssz) + } + "AggregatedAttestation" => { + run_test::(&test.value, &expected_ssz) + } + "Attestation" => { + run_test::(&test.value, &expected_ssz) + } + "BlockBody" => run_test::(&test.value, &expected_ssz), + "BlockHeader" => run_test::(&test.value, &expected_ssz), + "Block" => run_test::(&test.value, &expected_ssz), + "Config" => run_test::(&test.value, &expected_ssz), + "Validator" => run_test::(&test.value, &expected_ssz), + "State" => run_test::(&test.value, &expected_ssz), + "SignedAttestation" => { + run_test::(&test.value, &expected_ssz) + } + "BlockSignatures" => { + run_test::(&test.value, &expected_ssz) + } + "BlockWithAttestation" => { + run_test::(&test.value, &expected_ssz) + } + "SignedBlockWithAttestation" => run_test::< + SignedBlockWithAttestationJSON, + SignedBlockWithAttestation, + >(&test.value, &expected_ssz), + + _ => { + warn!("Unknown type: {}, skipping", test.type_name); + Ok(()) + } + } +} + +/// Run SSZ test. J is the JSON type, T is the target type. +fn run_test(value: &serde_json::Value, expected_ssz: &[u8]) -> anyhow::Result<()> +where + J: serde::de::DeserializeOwned, + T: for<'a> TryFrom<&'a J, Error = anyhow::Error> + Encode, +{ + let json_value: J = serde_json::from_value(value.clone()) + .map_err(|err| anyhow!("Failed to deserialize JSON: {err}"))?; + let typed_value: T = (&json_value).try_into()?; + verify_ssz(&typed_value, expected_ssz) +} + +fn verify_ssz(value: &T, expected: &[u8]) -> anyhow::Result<()> { + let actual = value.as_ssz_bytes(); + if actual != expected { + bail!( + "SSZ mismatch:\n expected: 0x{}\n got: 0x{}", + hex::encode(expected), + hex::encode(&actual) + ); + } + Ok(()) +} + +fn parse_hex_bytes(hex_str: &str) -> anyhow::Result> { + let hex_str = hex_str.strip_prefix("0x").unwrap_or(hex_str); + hex::decode(hex_str).map_err(|err| anyhow!("Failed to parse hex: {err}")) +} diff --git a/testing/lean-spec-tests/src/types/mod.rs b/testing/lean-spec-tests/src/types/mod.rs index 5303bb64e..e764ce594 100644 --- a/testing/lean-spec-tests/src/types/mod.rs +++ b/testing/lean-spec-tests/src/types/mod.rs @@ -1,4 +1,5 @@ pub mod fork_choice; +pub mod ssz_test; pub mod state_transition; use std::collections::HashMap; diff --git a/testing/lean-spec-tests/src/types/ssz_test.rs b/testing/lean-spec-tests/src/types/ssz_test.rs new file mode 100644 index 000000000..14bf14d3b --- /dev/null +++ b/testing/lean-spec-tests/src/types/ssz_test.rs @@ -0,0 +1,437 @@ +//! Intermediate JSON types for leanSpec SSZ test fixtures. +//! +//! These types handle camelCase JSON and convert to ream-consensus-lean types. +//! +//! These intermediate conversions are needed because the test vectors define the +//! expected deserialized keys & values as JSON and in camelCase while Rust and our +//! codebase uses snake_case. + +use alloy_primitives::B256; +use anyhow::anyhow; +use ream_consensus_lean::{ + attestation::{ + AggregatedAttestation as ReamAggregatedAttestation, + AggregatedAttestations as ReamAggregatedAttestations, + AggregatedSignatureProof as ReamAggregatedSignatureProof, + AttestationData as ReamAttestationData, SignedAttestation as ReamSignedAttestation, + }, + block::{ + Block as ReamBlock, BlockBody as ReamBlockBody, BlockHeader as ReamBlockHeader, + BlockSignatures as ReamBlockSignatures, BlockWithAttestation as ReamBlockWithAttestation, + SignedBlockWithAttestation as ReamSignedBlockWithAttestation, + }, + checkpoint::Checkpoint as ReamCheckpoint, + config::Config as ReamConfig, + state::LeanState as ReamState, + validator::Validator as ReamValidator, +}; +use ream_post_quantum_crypto::leansig::{public_key::PublicKey, signature::Signature}; +use serde::Deserialize; +use ssz::Decode; +use ssz_types::{ + typenum::{U1048576, U1073741824, U262144}, + BitList, VariableList, +}; + +// ============================================================================ +// Helpers +// ============================================================================ + +fn decode_hex(hex: &str) -> anyhow::Result> { + alloy_primitives::hex::decode(hex.trim_start_matches("0x")) + .map_err(|err| anyhow!("hex decode failed: {err}")) +} + +fn decode_signature(hex: &str) -> anyhow::Result { + Signature::from_ssz_bytes(&decode_hex(hex)?) + .map_err(|err| anyhow!("signature decode failed: {err:?}")) +} + +fn bools_to_bitlist(bools: &[bool]) -> anyhow::Result> { + let mut bits = BitList::::with_capacity(bools.len()) + .map_err(|err| anyhow!("BitList creation failed: {err:?}"))?; + for (index, &bit) in bools.iter().enumerate() { + bits.set(index, bit) + .map_err(|err| anyhow!("BitList set failed: {err:?}"))?; + } + Ok(bits) +} + +// ============================================================================ +// Test case structure +// ============================================================================ + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SSZTest { + pub network: String, + pub lean_env: String, + pub type_name: String, + pub value: serde_json::Value, + pub serialized: String, +} + +// ============================================================================ +// Common JSON wrapper types +// ============================================================================ + +#[derive(Debug, Deserialize, Clone)] +pub struct DataListJSON { + pub data: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct AggregationBitsJSON { + pub data: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(transparent)] +pub struct CheckpointJSON(pub ReamCheckpoint); + +impl TryFrom<&CheckpointJSON> for ReamCheckpoint { + type Error = anyhow::Error; + + fn try_from(value: &CheckpointJSON) -> anyhow::Result { + Ok(value.0.clone()) + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(transparent)] +pub struct AttestationDataJSON(pub ReamAttestationData); + +impl TryFrom<&AttestationDataJSON> for ReamAttestationData { + type Error = anyhow::Error; + + fn try_from(value: &AttestationDataJSON) -> anyhow::Result { + Ok(value.0.clone()) + } +} + +// ============================================================================ +// Config +// ============================================================================ + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ConfigJSON { + pub genesis_time: u64, +} + +impl TryFrom<&ConfigJSON> for ReamConfig { + type Error = anyhow::Error; + + fn try_from(value: &ConfigJSON) -> anyhow::Result { + Ok(Self { + genesis_time: value.genesis_time, + }) + } +} + +// ============================================================================ +// BlockHeader +// ============================================================================ + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BlockHeaderJSON { + pub slot: u64, + pub proposer_index: u64, + pub parent_root: B256, + pub state_root: B256, + pub body_root: B256, +} + +impl TryFrom<&BlockHeaderJSON> for ReamBlockHeader { + type Error = anyhow::Error; + + fn try_from(value: &BlockHeaderJSON) -> anyhow::Result { + Ok(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, Deserialize, Clone)] +pub struct ValidatorJSON { + pub pubkey: String, + pub index: u64, +} + +impl TryFrom<&ValidatorJSON> for ReamValidator { + type Error = anyhow::Error; + + fn try_from(value: &ValidatorJSON) -> anyhow::Result { + let bytes = decode_hex(&value.pubkey)?; + if bytes.len() != 52 { + return Err(anyhow!("Expected 52-byte pubkey, got {}", bytes.len())); + } + + Ok(Self { + public_key: PublicKey::from(&bytes[..]), + index: value.index, + }) + } +} + +// ============================================================================ +// Attestations +// ============================================================================ + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AggregatedAttestationJSON { + pub aggregation_bits: AggregationBitsJSON, + pub data: ReamAttestationData, +} + +impl TryFrom<&AggregatedAttestationJSON> for ReamAggregatedAttestation { + type Error = anyhow::Error; + + fn try_from(value: &AggregatedAttestationJSON) -> anyhow::Result { + Ok(Self { + aggregation_bits: bools_to_bitlist(&value.aggregation_bits.data)?, + message: value.data.clone(), + }) + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AttestationJSON { + pub validator_id: u64, + pub data: ReamAttestationData, +} + +impl TryFrom<&AttestationJSON> for ReamAggregatedAttestations { + type Error = anyhow::Error; + + fn try_from(value: &AttestationJSON) -> anyhow::Result { + Ok(Self { + validator_id: value.validator_id, + data: value.data.clone(), + }) + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SignedAttestationJSON { + pub validator_id: u64, + pub message: ReamAttestationData, + pub signature: String, +} + +impl TryFrom<&SignedAttestationJSON> for ReamSignedAttestation { + type Error = anyhow::Error; + + fn try_from(value: &SignedAttestationJSON) -> anyhow::Result { + Ok(Self { + validator_id: value.validator_id, + message: value.message.clone(), + signature: decode_signature(&value.signature)?, + }) + } +} + +// ============================================================================ +// Block +// ============================================================================ + +#[derive(Debug, Deserialize, Clone)] +pub struct BlockBodyJSON { + pub attestations: DataListJSON, +} + +impl TryFrom<&BlockBodyJSON> for ReamBlockBody { + type Error = anyhow::Error; + + fn try_from(value: &BlockBodyJSON) -> anyhow::Result { + Ok(Self { + attestations: VariableList::try_from( + value + .attestations + .data + .iter() + .map(TryInto::try_into) + .collect::, _>>()?, + ) + .map_err(|err| anyhow!("{err}"))?, + }) + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BlockJSON { + pub slot: u64, + pub proposer_index: u64, + pub parent_root: B256, + pub state_root: B256, + pub body: BlockBodyJSON, +} + +impl TryFrom<&BlockJSON> for ReamBlock { + type Error = anyhow::Error; + + fn try_from(value: &BlockJSON) -> anyhow::Result { + Ok(Self { + slot: value.slot, + proposer_index: value.proposer_index, + parent_root: value.parent_root, + state_root: value.state_root, + body: (&value.body).try_into()?, + }) + } +} + +// ============================================================================ +// State +// ============================================================================ + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StateJSON { + pub config: ConfigJSON, + pub slot: u64, + pub latest_block_header: BlockHeaderJSON, + pub latest_justified: ReamCheckpoint, + pub latest_finalized: ReamCheckpoint, + pub historical_block_hashes: DataListJSON, + pub justified_slots: DataListJSON, + pub validators: DataListJSON, + pub justifications_roots: DataListJSON, + pub justifications_validators: DataListJSON, +} + +impl TryFrom<&StateJSON> for ReamState { + type Error = anyhow::Error; + + fn try_from(value: &StateJSON) -> anyhow::Result { + Ok(Self { + config: (&value.config).try_into()?, + slot: value.slot, + latest_block_header: (&value.latest_block_header).try_into()?, + latest_justified: value.latest_justified, + latest_finalized: value.latest_finalized, + historical_block_hashes: VariableList::try_from( + value.historical_block_hashes.data.clone(), + ) + .map_err(|err| anyhow!("{err}"))?, + justified_slots: bools_to_bitlist::(&value.justified_slots.data)?, + validators: VariableList::try_from( + value + .validators + .data + .iter() + .map(TryInto::try_into) + .collect::, _>>()?, + ) + .map_err(|err| anyhow!("{err}"))?, + justifications_roots: VariableList::try_from(value.justifications_roots.data.clone()) + .map_err(|err| anyhow!("{err}"))?, + justifications_validators: bools_to_bitlist::( + &value.justifications_validators.data, + )?, + }) + } +} + +// ============================================================================ +// Signature-related types +// ============================================================================ + +#[derive(Debug, Deserialize, Clone)] +pub struct ProofDataJSON { + pub data: String, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AggregatedSignatureProofJSON { + pub participants: AggregationBitsJSON, + pub proof_data: ProofDataJSON, +} + +impl TryFrom<&AggregatedSignatureProofJSON> for ReamAggregatedSignatureProof { + type Error = anyhow::Error; + + fn try_from(value: &AggregatedSignatureProofJSON) -> anyhow::Result { + Ok(Self { + participants: bools_to_bitlist(&value.participants.data)?, + proof_data: VariableList::::try_from(decode_hex(&value.proof_data.data)?) + .map_err(|err| anyhow!("{err}"))?, + }) + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BlockSignaturesJSON { + pub attestation_signatures: DataListJSON, + pub proposer_signature: String, +} + +impl TryFrom<&BlockSignaturesJSON> for ReamBlockSignatures { + type Error = anyhow::Error; + + fn try_from(value: &BlockSignaturesJSON) -> anyhow::Result { + Ok(Self { + attestation_signatures: VariableList::try_from( + value + .attestation_signatures + .data + .iter() + .map(TryInto::try_into) + .collect::, _>>()?, + ) + .map_err(|err| anyhow!("{err}"))?, + proposer_signature: decode_signature(&value.proposer_signature)?, + }) + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BlockWithAttestationJSON { + pub block: BlockJSON, + pub proposer_attestation: AttestationJSON, +} + +impl TryFrom<&BlockWithAttestationJSON> for ReamBlockWithAttestation { + type Error = anyhow::Error; + + fn try_from(value: &BlockWithAttestationJSON) -> anyhow::Result { + Ok(Self { + block: (&value.block).try_into()?, + proposer_attestation: (&value.proposer_attestation).try_into()?, + }) + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SignedBlockWithAttestationJSON { + pub message: BlockWithAttestationJSON, + pub signature: BlockSignaturesJSON, +} + +impl TryFrom<&SignedBlockWithAttestationJSON> for ReamSignedBlockWithAttestation { + type Error = anyhow::Error; + + fn try_from(value: &SignedBlockWithAttestationJSON) -> anyhow::Result { + Ok(Self { + message: (&value.message).try_into()?, + signature: (&value.signature).try_into()?, + }) + } +} diff --git a/testing/lean-spec-tests/tests/tests.rs b/testing/lean-spec-tests/tests/tests.rs index 0172a501d..843ce98ec 100644 --- a/testing/lean-spec-tests/tests/tests.rs +++ b/testing/lean-spec-tests/tests/tests.rs @@ -2,7 +2,10 @@ use std::{env, fs, path::PathBuf}; #[cfg(feature = "devnet2")] use lean_spec_tests::fork_choice::{load_fork_choice_test, run_fork_choice_test}; -use lean_spec_tests::state_transition::{load_state_transition_test, run_state_transition_test}; +use lean_spec_tests::{ + ssz_test::{load_ssz_test, run_ssz_test}, + state_transition::{load_state_transition_test, run_state_transition_test}, +}; use tracing::{debug, error, info, warn}; use tracing_subscriber::EnvFilter; @@ -156,3 +159,67 @@ fn test_all_state_transition_fixtures() { assert_eq!(failed, 0, "Some state transition tests failed"); } + +#[test] +fn test_all_ssz_fixtures() { + // Initialize tracing subscriber for test output + let env_filter = match env::var(EnvFilter::DEFAULT_ENV) { + Ok(filter) => EnvFilter::builder().parse_lossy(filter), + Err(_) => EnvFilter::new("info"), + }; + let _ = tracing_subscriber::fmt() + .with_env_filter(env_filter) + .try_init(); + + #[cfg(feature = "devnet2")] + let fixtures = find_json_files("fixtures/devnet2/ssz/devnet"); + #[cfg(feature = "devnet3")] + let fixtures = find_json_files("fixtures/devnet3/ssz/devnet"); + + if fixtures.is_empty() { + info!( + "No SSZ fixtures found. Skipping tests. Run 'make test' in lean-spec-tests to download fixtures." + ); + return; + } + + info!("Found {} SSZ test fixtures", fixtures.len()); + + let mut total_tests = 0; + let mut passed = 0; + let mut failed = 0; + + for fixture_path in fixtures { + debug!("\n=== Loading fixture: {:?} ===", fixture_path.file_name()); + + match load_ssz_test(&fixture_path) { + Ok(fixture) => { + for (test_name, test) in &fixture { + total_tests += 1; + info!("Starting test: {}", test_name); + match run_ssz_test(test_name, test) { + Ok(_) => { + passed += 1; + info!("PASSED: {}", test_name); + } + Err(err) => { + failed += 1; + error!("FAILED: {test_name} - {err:?}"); + } + } + } + } + Err(err) => { + error!("Failed to load fixture {fixture_path:?}: {err:?}"); + failed += 1; + } + } + } + + info!("\n=== SSZ Test Summary ==="); + info!("Total tests: {total_tests}"); + info!("Passed: {passed}"); + info!("Failed: {failed}"); + + assert_eq!(failed, 0, "Some SSZ tests failed"); +}