diff --git a/anchor/spec_tests/src/lib.rs b/anchor/spec_tests/src/lib.rs index 7cb431af3..e3c352df5 100644 --- a/anchor/spec_tests/src/lib.rs +++ b/anchor/spec_tests/src/lib.rs @@ -1,12 +1,10 @@ #![cfg(test)] +mod runner; mod types; mod utils; -use std::{ - fs, - path::{Path, PathBuf}, -}; +use std::path::Path; use serde::de::DeserializeOwned; @@ -23,76 +21,12 @@ fn run_test(path: &Path, contents: &str) -> Result<(), String> { test.run() } -/// Run all type spec tests from the fixture directory. -/// Dispatches each JSON file to the correct test type based on exact prefix match. -fn run_types_tests() { - let dir: PathBuf = - Path::new(env!("CARGO_MANIFEST_DIR")).join("ssv-spec/types/spectest/generate/tests"); - assert!( - dir.exists(), - "Fixture directory not found: {}", - dir.display() - ); - - let mut failures = Vec::new(); - let mut count = 0; - - for entry in fs::read_dir(&dir).expect("Failed to read fixture directory") { - let entry = entry.expect("Failed to read directory entry"); - let path = entry.path(); - - if path.extension() != Some("json".as_ref()) { - continue; - } - - let filename = path.file_name().unwrap().to_string_lossy().to_string(); - let contents = fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display())); - - // Extract exact type prefix - let prefix = filename.split('_').next().unwrap_or(""); - - let result = match prefix { - // Encoding tests - "beaconvote.EncodingTest" => { - run_test::(&path, &contents) - } - - // TODO(spec-tests): Add more test types here as they are implemented. - // This arm will be replaced with panic!() once all test types are added. - _ => { - eprintln!("SKIP (not yet implemented): {prefix}"); - continue; - } - }; - - count += 1; - if let Err(e) = result { - failures.push(format!(" {filename}: {e}")); - } - } - - assert!( - count > 0, - "No type spec test fixtures found in {}", - dir.display() - ); - - if !failures.is_empty() { - panic!( - "\n{} of {count} type spec tests failed:\n{}", - failures.len(), - failures.join("\n") - ); - } -} - #[cfg(test)] mod spec_tests { use super::*; #[test] fn types_spec_tests() { - run_types_tests(); + runner::run_all_type_fixtures(); } } diff --git a/anchor/spec_tests/src/runner/mod.rs b/anchor/spec_tests/src/runner/mod.rs new file mode 100644 index 000000000..ab40aab6b --- /dev/null +++ b/anchor/spec_tests/src/runner/mod.rs @@ -0,0 +1,3 @@ +mod types_dispatcher; + +pub use types_dispatcher::run_all_type_fixtures; diff --git a/anchor/spec_tests/src/runner/types_dispatcher.rs b/anchor/spec_tests/src/runner/types_dispatcher.rs new file mode 100644 index 000000000..b6dc7025b --- /dev/null +++ b/anchor/spec_tests/src/runner/types_dispatcher.rs @@ -0,0 +1,122 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::{run_test, types}; + +/// Dispatch a single fixture file to its test type based on the exact prefix before the first `_`. +/// +/// Returns `Some(result)` if the prefix matched an implemented test type, +/// or `None` if the fixture was skipped (known-inapplicable or not-yet-implemented). +fn dispatch_fixture_by_prefix( + prefix: &str, + path: &Path, + contents: &str, + skipped_known_count: &mut usize, + skipped_unknown_count: &mut usize, +) -> Option> { + match prefix { + // Encoding tests + "aggregatorcommitteeconsensusdata.EncodingTest" => Some(run_test::< + types::AggregatorCommitteeConsensusDataEncodingTest, + >(path, contents)), + "beaconvote.EncodingTest" => { + Some(run_test::(path, contents)) + } + "partialsigmessage.EncodingTest" => Some(run_test::( + path, contents, + )), + "signedssvmsg.EncodingTest" => Some(run_test::( + path, contents, + )), + "ssvmsg.EncodingTest" => Some(run_test::(path, contents)), + "proposerconsensusdata.EncodingTest" => Some(run_test::< + types::ProposerConsensusDataEncodingTest, + >(path, contents)), + + // Anchor's `Share` is architecturally different from Go spec's `Share` + // (different fields, decomposed across multiple types). Not applicable. + "share.EncodingTest" => { + eprintln!("SKIP (known-inapplicable): {prefix}"); + *skipped_known_count += 1; + None + } + + // TODO(spec-tests): Add more test types here as they are implemented. + // This arm will be replaced with panic!() once all test types are added. + _ => { + eprintln!("SKIP (not yet implemented): {prefix}"); + *skipped_unknown_count += 1; + None + } + } +} + +/// Run all type spec tests from the fixture directory. +/// +/// Iterates over every `.json` fixture, dispatches each to the appropriate test type, +/// and reports failures at the end. +pub fn run_all_type_fixtures() { + let dir: PathBuf = + Path::new(env!("CARGO_MANIFEST_DIR")).join("ssv-spec/types/spectest/generate/tests"); + assert!( + dir.exists(), + "Fixture directory not found: {}", + dir.display() + ); + + let mut failures = Vec::new(); + let mut executed_count = 0; + let mut skipped_known_count = 0; + let mut skipped_unknown_count = 0; + + for entry in fs::read_dir(&dir).expect("Failed to read fixture directory") { + let entry = entry.expect("Failed to read directory entry"); + let path = entry.path(); + + if path.extension() != Some("json".as_ref()) { + continue; + } + + let filename = path.file_name().unwrap().to_string_lossy().to_string(); + let contents = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display())); + + // Extract exact type prefix + let prefix = filename.split('_').next().unwrap_or(""); + + let Some(result) = dispatch_fixture_by_prefix( + prefix, + &path, + &contents, + &mut skipped_known_count, + &mut skipped_unknown_count, + ) else { + continue; + }; + + executed_count += 1; + if let Err(e) = result { + failures.push(format!(" {filename}: {e}")); + } + } + + assert!( + executed_count > 0, + "No type spec test fixtures found in {}", + dir.display() + ); + + eprintln!( + "{executed_count} executed, {skipped_known_count} skipped (known-inapplicable), {skipped_unknown_count} skipped (not yet implemented)" + ); + + if !failures.is_empty() { + panic!( + "\n{} of {executed_count} type spec tests failed:\n{}", + failures.len(), + failures.join("\n") + ); + } +} diff --git a/anchor/spec_tests/src/types/aggregator_committee_consensus_data_encoding.rs b/anchor/spec_tests/src/types/aggregator_committee_consensus_data_encoding.rs new file mode 100644 index 000000000..fddd67698 --- /dev/null +++ b/anchor/spec_tests/src/types/aggregator_committee_consensus_data_encoding.rs @@ -0,0 +1,30 @@ +use serde::Deserialize; +use types::Hash256; + +use crate::{ + SpecTest, + utils::{ + check_roundtrip_with_root, + deserializers::{deserialize_base64, deserialize_bytes_to_hash256}, + }, +}; + +/// Mirrors Go's `EncodingTest` for `AggregatorCommitteeConsensusData`. +/// +/// Validates SSZ encode/decode roundtrip and hash tree root. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct AggregatorCommitteeConsensusDataEncodingTest { + #[serde(deserialize_with = "deserialize_base64")] + data: Vec, + #[serde(deserialize_with = "deserialize_bytes_to_hash256")] + expected_root: Hash256, +} + +impl SpecTest for AggregatorCommitteeConsensusDataEncodingTest { + fn run(&self) -> Result<(), String> { + check_roundtrip_with_root::< + ssv_types::consensus::AggregatorCommitteeConsensusData, + >(&self.data, self.expected_root) + } +} diff --git a/anchor/spec_tests/src/types/beacon_vote_encoding.rs b/anchor/spec_tests/src/types/beacon_vote_encoding.rs index 6c93faa5c..c9458ccbe 100644 --- a/anchor/spec_tests/src/types/beacon_vote_encoding.rs +++ b/anchor/spec_tests/src/types/beacon_vote_encoding.rs @@ -1,11 +1,12 @@ use serde::Deserialize; -use ssz::{Decode, Encode}; -use tree_hash::TreeHash; use types::Hash256; use crate::{ SpecTest, - utils::deserializers::{deserialize_base64, deserialize_bytes_to_hash256}, + utils::{ + check_roundtrip_with_root, + deserializers::{deserialize_base64, deserialize_bytes_to_hash256}, + }, }; /// Mirrors Go's `EncodingTest` for `BeaconVote`. @@ -22,29 +23,9 @@ pub struct BeaconVoteEncodingTest { impl SpecTest for BeaconVoteEncodingTest { fn run(&self) -> Result<(), String> { - // Decode BeaconVote from SSZ bytes - let decoded = ssv_types::consensus::BeaconVote::from_ssz_bytes(&self.data) - .map_err(|e| format!("SSZ decode failed: {e:?}"))?; - - // Encode back and verify roundtrip - let encoded = decoded.as_ssz_bytes(); - if encoded != self.data { - return Err(format!( - "SSZ roundtrip mismatch: encoded {} bytes, expected {} bytes", - encoded.len(), - self.data.len() - )); - } - - // Verify hash tree root - let root = decoded.tree_hash_root(); - if root != self.expected_root { - return Err(format!( - "Hash tree root mismatch: got {root:?}, expected {:?}", - self.expected_root - )); - } - - Ok(()) + check_roundtrip_with_root::( + &self.data, + self.expected_root, + ) } } diff --git a/anchor/spec_tests/src/types/mod.rs b/anchor/spec_tests/src/types/mod.rs index ea828d034..f78643cb0 100644 --- a/anchor/spec_tests/src/types/mod.rs +++ b/anchor/spec_tests/src/types/mod.rs @@ -1,2 +1,13 @@ +mod aggregator_committee_consensus_data_encoding; mod beacon_vote_encoding; +mod partial_sig_message_encoding; +mod proposer_consensus_data_encoding; +mod signed_ssv_msg_encoding; +mod ssv_message_encoding; + +pub use aggregator_committee_consensus_data_encoding::*; pub use beacon_vote_encoding::*; +pub use partial_sig_message_encoding::*; +pub use proposer_consensus_data_encoding::*; +pub use signed_ssv_msg_encoding::*; +pub use ssv_message_encoding::*; diff --git a/anchor/spec_tests/src/types/partial_sig_message_encoding.rs b/anchor/spec_tests/src/types/partial_sig_message_encoding.rs new file mode 100644 index 000000000..5157a3aca --- /dev/null +++ b/anchor/spec_tests/src/types/partial_sig_message_encoding.rs @@ -0,0 +1,31 @@ +use serde::Deserialize; +use types::Hash256; + +use crate::{ + SpecTest, + utils::{ + check_roundtrip_with_root, + deserializers::{deserialize_base64, deserialize_bytes_to_hash256}, + }, +}; + +/// Mirrors Go's `EncodingTest` for `PartialSignatureMessages`. +/// +/// Validates SSZ encode/decode roundtrip and hash tree root. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct PartialSigMessageEncodingTest { + #[serde(deserialize_with = "deserialize_base64")] + data: Vec, + #[serde(deserialize_with = "deserialize_bytes_to_hash256")] + expected_root: Hash256, +} + +impl SpecTest for PartialSigMessageEncodingTest { + fn run(&self) -> Result<(), String> { + check_roundtrip_with_root::( + &self.data, + self.expected_root, + ) + } +} diff --git a/anchor/spec_tests/src/types/proposer_consensus_data_encoding.rs b/anchor/spec_tests/src/types/proposer_consensus_data_encoding.rs new file mode 100644 index 000000000..580792416 --- /dev/null +++ b/anchor/spec_tests/src/types/proposer_consensus_data_encoding.rs @@ -0,0 +1,31 @@ +use serde::Deserialize; +use types::Hash256; + +use crate::{ + SpecTest, + utils::{ + check_roundtrip_with_root, + deserializers::{deserialize_base64, deserialize_bytes_to_hash256}, + }, +}; + +/// Mirrors Go's `EncodingTest` for `ProposerConsensusData`. +/// +/// Validates SSZ encode/decode roundtrip and hash tree root. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ProposerConsensusDataEncodingTest { + #[serde(deserialize_with = "deserialize_base64")] + data: Vec, + #[serde(deserialize_with = "deserialize_bytes_to_hash256")] + expected_root: Hash256, +} + +impl SpecTest for ProposerConsensusDataEncodingTest { + fn run(&self) -> Result<(), String> { + check_roundtrip_with_root::( + &self.data, + self.expected_root, + ) + } +} diff --git a/anchor/spec_tests/src/types/signed_ssv_msg_encoding.rs b/anchor/spec_tests/src/types/signed_ssv_msg_encoding.rs new file mode 100644 index 000000000..5f998e2c5 --- /dev/null +++ b/anchor/spec_tests/src/types/signed_ssv_msg_encoding.rs @@ -0,0 +1,22 @@ +use serde::Deserialize; + +use crate::{ + SpecTest, + utils::{check_roundtrip, deserializers::deserialize_base64}, +}; + +/// Mirrors Go's `EncodingTest` for `SignedSSVMessage`. +/// +/// Validates SSZ encode/decode roundtrip (no tree hash root check). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SignedSSVMessageEncodingTest { + #[serde(deserialize_with = "deserialize_base64")] + data: Vec, +} + +impl SpecTest for SignedSSVMessageEncodingTest { + fn run(&self) -> Result<(), String> { + check_roundtrip::(&self.data) + } +} diff --git a/anchor/spec_tests/src/types/ssv_message_encoding.rs b/anchor/spec_tests/src/types/ssv_message_encoding.rs new file mode 100644 index 000000000..73382ce83 --- /dev/null +++ b/anchor/spec_tests/src/types/ssv_message_encoding.rs @@ -0,0 +1,28 @@ +use serde::Deserialize; +use types::Hash256; + +use crate::{ + SpecTest, + utils::{ + check_roundtrip_with_root, + deserializers::{deserialize_base64, deserialize_bytes_to_hash256}, + }, +}; + +/// Mirrors Go's `EncodingTest` for `SSVMessage`. +/// +/// Validates SSZ encode/decode roundtrip and hash tree root. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SSVMessageEncodingTest { + #[serde(deserialize_with = "deserialize_base64")] + data: Vec, + #[serde(deserialize_with = "deserialize_bytes_to_hash256")] + expected_root: Hash256, +} + +impl SpecTest for SSVMessageEncodingTest { + fn run(&self) -> Result<(), String> { + check_roundtrip_with_root::(&self.data, self.expected_root) + } +} diff --git a/anchor/spec_tests/src/utils/encoding_helpers.rs b/anchor/spec_tests/src/utils/encoding_helpers.rs new file mode 100644 index 000000000..83abb2d69 --- /dev/null +++ b/anchor/spec_tests/src/utils/encoding_helpers.rs @@ -0,0 +1,40 @@ +use ssz::{Decode, Encode}; +use tree_hash::TreeHash; +use types::Hash256; + +/// SSZ roundtrip: decode → encode → compare bytes. +pub fn check_roundtrip(data: &[u8]) -> Result<(), String> { + let decoded = T::from_ssz_bytes(data).map_err(|e| format!("SSZ decode failed: {e:?}"))?; + let encoded = decoded.as_ssz_bytes(); + if encoded != data { + return Err(format!( + "SSZ roundtrip mismatch: encoded {} bytes, expected {} bytes", + encoded.len(), + data.len() + )); + } + Ok(()) +} + +/// SSZ roundtrip + hash tree root verification. +pub fn check_roundtrip_with_root( + data: &[u8], + expected_root: Hash256, +) -> Result<(), String> { + let decoded = T::from_ssz_bytes(data).map_err(|e| format!("SSZ decode failed: {e:?}"))?; + let encoded = decoded.as_ssz_bytes(); + if encoded != data { + return Err(format!( + "SSZ roundtrip mismatch: encoded {} bytes, expected {} bytes", + encoded.len(), + data.len() + )); + } + let root = decoded.tree_hash_root(); + if root != expected_root { + return Err(format!( + "Hash tree root mismatch: got {root:?}, expected {expected_root:?}", + )); + } + Ok(()) +} diff --git a/anchor/spec_tests/src/utils/mod.rs b/anchor/spec_tests/src/utils/mod.rs index 0b2b81a4f..81d7fc19d 100644 --- a/anchor/spec_tests/src/utils/mod.rs +++ b/anchor/spec_tests/src/utils/mod.rs @@ -1 +1,4 @@ pub mod deserializers; +pub mod encoding_helpers; + +pub use encoding_helpers::{check_roundtrip, check_roundtrip_with_root};