diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..c9ee26d06 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "anchor/spec_tests/ssv-spec"] + path = anchor/spec_tests/ssv-spec + url = https://github.com/ssvlabs/ssv-spec diff --git a/Cargo.lock b/Cargo.lock index f5c98d385..b818c5389 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6915,6 +6915,15 @@ dependencies = [ "cipher 0.3.0", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.27" @@ -7399,6 +7408,34 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "spec_tests" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "bls", + "eth2", + "ethereum_ssz", + "ethereum_ssz_derive", + "futures", + "hex", + "indexmap 2.10.0", + "openssl", + "operator_key", + "parking_lot", + "rand 0.9.2", + "serde", + "serde_json", + "sha2 0.10.9", + "ssv_types", + "thiserror 2.0.14", + "tokio", + "tree_hash", + "tree_hash_derive", + "types", + "walkdir", +] + [[package]] name = "spin" version = "0.9.8" @@ -8507,6 +8544,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -8701,6 +8748,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index c2e3f8efc..1f206d7fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "anchor/processor", "anchor/qbft_manager", "anchor/signature_collector", + "anchor/spec_tests", "anchor/subnet_service", "anchor/validator_store", ] diff --git a/anchor/common/ssv_types/src/cluster.rs b/anchor/common/ssv_types/src/cluster.rs index 599386b1b..d6e060afe 100644 --- a/anchor/common/ssv_types/src/cluster.rs +++ b/anchor/common/ssv_types/src/cluster.rs @@ -2,13 +2,14 @@ use std::fmt::Debug; use derive_more::{Deref, Display, From}; use indexmap::IndexSet; +use serde::Deserialize; use ssz_derive::{Decode, Encode}; use types::{Address, Graffiti, PublicKeyBytes}; use crate::{OperatorId, committee::CommitteeId}; /// Unique identifier for a cluster -#[derive(Clone, Copy, Default, Eq, PartialEq, Hash, From, Deref)] +#[derive(Clone, Copy, Default, Eq, PartialEq, Hash, From, Deref, Deserialize)] pub struct ClusterId(pub [u8; 32]); impl Debug for ClusterId { @@ -67,7 +68,19 @@ pub struct ClusterMember { /// Index of the validator in the validator registry. #[derive( - Clone, Copy, Display, Debug, Default, Eq, PartialEq, Hash, From, Deref, Encode, Decode, + Clone, + Copy, + Display, + Debug, + Default, + Eq, + PartialEq, + Hash, + From, + Deref, + Encode, + Decode, + Deserialize, )] #[ssz(struct_behaviour = "transparent")] pub struct ValidatorIndex(pub usize); diff --git a/anchor/common/ssv_types/src/committee.rs b/anchor/common/ssv_types/src/committee.rs index 6d1ee9dff..f99e09a41 100644 --- a/anchor/common/ssv_types/src/committee.rs +++ b/anchor/common/ssv_types/src/committee.rs @@ -2,6 +2,7 @@ use std::fmt::{Debug, Formatter}; use derive_more::{Deref, From}; use indexmap::IndexSet; +use serde::Deserialize; use sha2::{Digest, Sha256}; use crate::{OperatorId, ValidatorIndex}; @@ -16,7 +17,7 @@ pub struct CommitteeInfo { } /// Unique identifier for a committee -#[derive(Clone, Copy, Default, Eq, PartialEq, Hash, From, Deref)] +#[derive(Clone, Copy, Default, Eq, PartialEq, Hash, From, Deref, Deserialize)] pub struct CommitteeId(pub [u8; COMMITTEE_ID_LEN]); impl Debug for CommitteeId { diff --git a/anchor/common/ssv_types/src/consensus.rs b/anchor/common/ssv_types/src/consensus.rs index 544570c76..ba4d43c81 100644 --- a/anchor/common/ssv_types/src/consensus.rs +++ b/anchor/common/ssv_types/src/consensus.rs @@ -9,6 +9,7 @@ use std::{ use derive_more::{From, Into}; use eth2::types::FullBlockContents; +use serde::Deserialize; use sha2::{Digest, Sha256}; use slashing_protection::{NotSafe, SlashingDatabase}; use ssz::{Decode, DecodeError, Encode}; @@ -406,7 +407,7 @@ impl From for DataValidationError { } } -#[derive(Clone, Debug, TreeHash, PartialEq, Encode, Decode)] +#[derive(Clone, Debug, TreeHash, PartialEq, Encode, Decode, Deserialize)] pub struct ValidatorDuty { pub r#type: BeaconRole, pub pub_key: PublicKeyBytes, @@ -419,7 +420,7 @@ pub struct ValidatorDuty { pub validator_sync_committee_indices: VariableList, } -#[derive(Clone, Copy, Debug, PartialEq, Encode, Decode)] +#[derive(Clone, Copy, Debug, PartialEq, Encode, Decode, Deserialize)] #[ssz(struct_behaviour = "transparent")] pub struct BeaconRole(u64); diff --git a/anchor/common/ssv_types/src/deserializers.rs b/anchor/common/ssv_types/src/deserializers.rs new file mode 100644 index 000000000..1ba323bd7 --- /dev/null +++ b/anchor/common/ssv_types/src/deserializers.rs @@ -0,0 +1,213 @@ +use base64::prelude::*; +use serde::{Deserialize, Deserializer, de::Error}; +use serde_json::Value; +use ssz_types::VariableList; +use types::{Hash256, Slot}; + +use crate::{ + ValidatorIndex, + message::{SSVMessageDataLen, SignatureList}, + msgid::MessageId, + partial_sig::PartialSignatureKind, + try_to_variable_list, +}; + +pub fn deserialize_base64_or_empty<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: TryFrom>, +{ + let value = Value::deserialize(deserializer)?; + + match value { + Value::Null => Ok(Vec::new()), // Return empty Vec for null values + Value::String(s) => BASE64_STANDARD + .decode(s.as_bytes()) + .map_err(D::Error::custom), + _ => Err(D::Error::custom("Expected null or a base64 string")), + } + .and_then(|vec| { + vec.try_into() + .map_err(|_| D::Error::custom("Failed to convert from Vec to actual type")) + }) +} + +pub fn deserialize_base64_signatures<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let string_vec: Vec = serde::Deserialize::deserialize(deserializer)?; + + let mut signatures = VariableList::empty(); + + for string in string_vec { + let decoded_bytes = BASE64_STANDARD + .decode(&string) + .map_err(serde::de::Error::custom)?; + + let signature_variable_list = VariableList::new(decoded_bytes) + .map_err(|e| D::Error::custom(format!("Signature too long: {e:?}")))?; + + if let Err(err) = signatures.push(signature_variable_list) { + return Err(D::Error::custom(format!("Too many signatures: {err:?}"))); + } + } + + Ok(signatures) +} + +pub fn deserialize_base64_message_data<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + + match value { + Value::Null => { + Ok( + try_to_variable_list(vec![], |_, _| D::Error::custom("Empty vec too large")) + .unwrap(), + ) + } // Empty vec always fits + Value::String(s) => { + let decoded = BASE64_STANDARD + .decode(s.as_bytes()) + .map_err(D::Error::custom)?; + try_to_variable_list(decoded, |actual, max| { + D::Error::custom(format!( + "Data too large for VariableList: {} > {}", + actual, max + )) + }) + } + _ => Err(D::Error::custom("Expected null or a base64 string")), + } +} + +pub fn deserialize_hex_message_id<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let hex_str = String::deserialize(deserializer)?; + let hex_str = hex_str.strip_prefix("0x").unwrap_or(&hex_str); + let bytes = + hex::decode(hex_str).map_err(|e| Error::custom(format!("Failed to decode hex: {e}")))?; + + if bytes.len() != 56 { + return Err(Error::custom(format!( + "Expected 56 bytes for MessageId, got {}", + bytes.len() + ))); + } + + let array: [u8; 56] = bytes + .try_into() + .map_err(|_| Error::custom("Failed to convert to array"))?; + Ok(MessageId::from(array)) +} + +pub fn deserialize_slot<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let slot_str = String::deserialize(deserializer)?; + slot_str + .parse::() + .map(Slot::new) + .map_err(|e| Error::custom(format!("Failed to parse slot: {e}"))) +} + +pub fn deserialize_partial_signature_kind<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = u64::deserialize(deserializer)?; + match value { + 0 => Ok(PartialSignatureKind::PostConsensus), + 1 => Ok(PartialSignatureKind::RandaoPartialSig), + 2 => Ok(PartialSignatureKind::SelectionProofPartialSig), + 3 => Ok(PartialSignatureKind::ContributionProofs), + 4 => Ok(PartialSignatureKind::ValidatorRegistration), + 5 => Ok(PartialSignatureKind::VoluntaryExit), + _ => Err(Error::custom(format!( + "Invalid PartialSignatureKind value: {}", + value + ))), + } +} + +pub fn deserialize_signature<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let sig_opt: Option = Option::deserialize(deserializer)?; + match sig_opt { + Some(sig_str) => { + // Handle empty string as empty signature (for invalid test cases) + if sig_str.is_empty() { + return Ok(types::Signature::empty()); + } + + let sig_bytes = if let Some(stripped) = sig_str.strip_prefix("0x") { + // Handle hex string with 0x prefix + hex::decode(stripped) + .map_err(|e| Error::custom(format!("Failed to decode hex signature: {e}")))? + } else if sig_str.chars().all(|c| c.is_ascii_hexdigit()) && sig_str.len() % 2 == 0 { + // Try hex without prefix if all characters are hex digits and even length + hex::decode(&sig_str) + .map_err(|e| Error::custom(format!("Failed to decode hex signature: {e}")))? + } else { + // Fall back to base64 for backward compatibility + BASE64_STANDARD + .decode(&sig_str) + .map_err(|e| Error::custom(format!("Failed to decode base64 signature: {e}")))? + }; + + if sig_bytes.len() != 96 { + return Err(Error::custom(format!( + "Signature must be 96 bytes, got {}", + sig_bytes.len() + ))); + } + + Ok(types::Signature::deserialize(&sig_bytes) + .map_err(|e| Error::custom(format!("Failed to parse signature: {e:?}")))?) + } + None => { + // Return empty signature for null values + Ok(types::Signature::empty()) + } + } +} + +pub fn deserialize_hash256<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let hash_str = String::deserialize(deserializer)?; + let hash_str = hash_str.strip_prefix("0x").unwrap_or(&hash_str); + let bytes = + hex::decode(hash_str).map_err(|e| Error::custom(format!("Failed to decode hex: {e}")))?; + if bytes.len() != 32 { + return Err(Error::custom(format!( + "Expected 32 bytes for Hash256, got {}", + bytes.len() + ))); + } + Ok(Hash256::from_slice(&bytes)) +} + +pub fn deserialize_validator_index<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let index_str = String::deserialize(deserializer)?; + index_str + .parse::() + .map(ValidatorIndex) + .map_err(|e| Error::custom(format!("Failed to parse validator index: {e}"))) +} diff --git a/anchor/common/ssv_types/src/lib.rs b/anchor/common/ssv_types/src/lib.rs index b905afc14..7adbc533a 100644 --- a/anchor/common/ssv_types/src/lib.rs +++ b/anchor/common/ssv_types/src/lib.rs @@ -5,6 +5,7 @@ pub use share::Share; mod cluster; mod committee; pub mod consensus; +pub mod deserializers; pub mod domain_type; pub mod message; pub mod msgid; diff --git a/anchor/common/ssv_types/src/message.rs b/anchor/common/ssv_types/src/message.rs index 28677bcb1..ddc09120a 100644 --- a/anchor/common/ssv_types/src/message.rs +++ b/anchor/common/ssv_types/src/message.rs @@ -3,6 +3,7 @@ use std::{ fmt::{Debug, Display, Formatter}, }; +use serde::{Deserialize, Deserializer}; use ssz::{Decode, DecodeError, Encode}; use ssz_derive::{Decode, Encode}; use ssz_types::VariableList; @@ -18,6 +19,7 @@ use types::{ use crate::{ MAX_SIGNATURES, OperatorId, RSA_SIGNATURE_SIZE, consensus::{PrepareJustificationLength, RoundChangeJustificationLength}, + deserializers::*, msgid::MessageId, try_to_variable_list, }; @@ -70,6 +72,22 @@ pub enum MsgType { SSVPartialSignatureMsgType = 1, } +impl<'de> Deserialize<'de> for MsgType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = u64::deserialize(deserializer)?; + match value { + 0 => Ok(MsgType::SSVConsensusMsgType), + 1 => Ok(MsgType::SSVPartialSignatureMsgType), + _ => Err(serde::de::Error::custom(format!( + "Invalid MsgType value: {value}" + ))), + } + } +} + impl TreeHash for MsgType { fn tree_hash_type() -> TreeHashType { TreeHashType::Basic @@ -157,11 +175,14 @@ pub enum SSVMessageError { } /// Represents a bare SSVMessage with a type, ID, and data. -#[derive(Encode, Decode, Clone, PartialEq, Eq, TreeHash)] +#[derive(Encode, Decode, Clone, PartialEq, Eq, Deserialize, TreeHash)] #[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] pub struct SSVMessage { + #[serde(rename = "MsgType")] msg_type: MsgType, + #[serde(rename = "MsgID", deserialize_with = "deserialize_hex_message_id")] msg_id: MessageId, + #[serde(rename = "Data", deserialize_with = "deserialize_base64_message_data")] data: VariableList, } @@ -321,11 +342,20 @@ pub type SignatureList = VariableList, U13>; /// Represents a signed SSV Message with signatures, operator IDs, the message itself, and full /// data. -#[derive(Encode, Decode, Clone, PartialEq, Eq, TreeHash)] +#[derive(Encode, Decode, Clone, PartialEq, Eq, Deserialize, TreeHash)] pub struct SignedSSVMessage { + #[serde(rename = "Signatures")] + #[serde(deserialize_with = "deserialize_base64_signatures")] signatures: SignatureList, + + #[serde(rename = "OperatorIDs")] operator_ids: VariableList, + + #[serde(rename = "SSVMessage")] ssv_message: SSVMessage, + + #[serde(rename = "FullData")] + #[serde(deserialize_with = "deserialize_base64_or_empty")] full_data: VariableList, } diff --git a/anchor/common/ssv_types/src/msgid.rs b/anchor/common/ssv_types/src/msgid.rs index 490ff8056..096a2132c 100644 --- a/anchor/common/ssv_types/src/msgid.rs +++ b/anchor/common/ssv_types/src/msgid.rs @@ -1,6 +1,7 @@ use std::fmt::{Debug, Formatter}; use derive_more::{Display, From, Into}; +use serde::{Deserialize, Deserializer}; use ssz::{Decode, DecodeError, Encode}; use tree_hash::{PackedEncoding, TreeHash, TreeHashType}; use types::{PublicKeyBytes, VariableList, typenum::U56}; @@ -87,6 +88,21 @@ impl TreeHash for MessageId { } } +impl<'de> Deserialize<'de> for MessageId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // First deserialize as a Vec + let vec = Vec::::deserialize(deserializer)?; + + // Then try to convert to [u8; 56] + vec.try_into() + .map(MessageId) + .map_err(|_| serde::de::Error::custom("Expected array of 56 bytes".to_string())) + } +} + impl Debug for MessageId { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", hex::encode(self.0)) diff --git a/anchor/common/ssv_types/src/operator.rs b/anchor/common/ssv_types/src/operator.rs index 4a2d00501..3a7876a15 100644 --- a/anchor/common/ssv_types/src/operator.rs +++ b/anchor/common/ssv_types/src/operator.rs @@ -2,6 +2,7 @@ use std::{cmp::Eq, fmt::Debug, hash::Hash}; use derive_more::{Deref, Display, From}; use openssl::{pkey::Public, rsa::Rsa}; +use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use tree_hash::{Hash256, PackedEncoding, TreeHash, TreeHashType}; use types::Address; @@ -22,6 +23,8 @@ use types::Address; Ord, PartialOrd, Display, + Serialize, + Deserialize, )] #[ssz(struct_behaviour = "transparent")] #[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] diff --git a/anchor/common/ssv_types/src/partial_sig.rs b/anchor/common/ssv_types/src/partial_sig.rs index b82a01864..180a49cd2 100644 --- a/anchor/common/ssv_types/src/partial_sig.rs +++ b/anchor/common/ssv_types/src/partial_sig.rs @@ -1,3 +1,4 @@ +use serde::Deserialize; use ssz::{Decode, DecodeError, Encode}; use ssz_derive::{Decode, Encode}; use tree_hash::{PackedEncoding, TreeHash, TreeHashType}; @@ -7,13 +8,14 @@ use types::{ typenum::{Sum, U512, U1000}, }; -use crate::{OperatorId, ValidatorIndex}; +use crate::{OperatorId, ValidatorIndex, deserializers::*}; /// Maximum number of partial signature messages: 1512 /// Calculated as 1000 + 512 = 1512 pub type PartialSignatureMessagesLen = Sum; #[derive(Clone, Copy, Debug, PartialEq, Eq)] +//#[serde(from = "u64", into = "u64")] #[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] pub enum PartialSignatureKind { // PostConsensusPartialSig is a partial signature over a decided duty (attestation data, @@ -32,19 +34,9 @@ pub enum PartialSignatureKind { VoluntaryExit = 5, } -impl TryFrom for PartialSignatureKind { - type Error = (); - - fn try_from(value: u64) -> Result { - match value { - 0 => Ok(PartialSignatureKind::PostConsensus), - 1 => Ok(PartialSignatureKind::RandaoPartialSig), - 2 => Ok(PartialSignatureKind::SelectionProofPartialSig), - 3 => Ok(PartialSignatureKind::ContributionProofs), - 4 => Ok(PartialSignatureKind::ValidatorRegistration), - 5 => Ok(PartialSignatureKind::VoluntaryExit), - _ => Err(()), - } +impl From for u64 { + fn from(kind: PartialSignatureKind) -> Self { + kind as u64 } } @@ -85,7 +77,15 @@ impl Decode for PartialSignatureKind { }); } let value = u64::from_le_bytes(bytes.try_into().unwrap()); - value.try_into().map_err(|_| DecodeError::NoMatchingVariant) + match value { + 0 => Ok(PartialSignatureKind::PostConsensus), + 1 => Ok(PartialSignatureKind::RandaoPartialSig), + 2 => Ok(PartialSignatureKind::SelectionProofPartialSig), + 3 => Ok(PartialSignatureKind::ContributionProofs), + 4 => Ok(PartialSignatureKind::ValidatorRegistration), + 5 => Ok(PartialSignatureKind::VoluntaryExit), + _ => Err(DecodeError::NoMatchingVariant), + } } } @@ -110,17 +110,33 @@ impl TreeHash for PartialSignatureKind { } // A partial signature specific message -#[derive(Clone, Debug, PartialEq, Encode, Decode, TreeHash)] +#[derive(Clone, Debug, PartialEq, Encode, Decode, TreeHash, Deserialize)] pub struct PartialSignatureMessages { + #[serde( + rename = "Type", + deserialize_with = "deserialize_partial_signature_kind" + )] pub kind: PartialSignatureKind, + #[serde(rename = "Slot", deserialize_with = "deserialize_slot")] pub slot: Slot, + #[serde(rename = "Messages")] pub messages: VariableList, } -#[derive(Clone, Debug, PartialEq, Encode, Decode, TreeHash)] +#[derive(Clone, Debug, PartialEq, Encode, Decode, TreeHash, Deserialize)] pub struct PartialSignatureMessage { + #[serde( + rename = "PartialSignature", + deserialize_with = "deserialize_signature" + )] pub partial_signature: Signature, + #[serde(rename = "SigningRoot", deserialize_with = "deserialize_hash256")] pub signing_root: Hash256, + #[serde(rename = "Signer")] pub signer: OperatorId, + #[serde( + rename = "ValidatorIndex", + deserialize_with = "deserialize_validator_index" + )] pub validator_index: ValidatorIndex, } diff --git a/anchor/spec_tests/Cargo.toml b/anchor/spec_tests/Cargo.toml new file mode 100644 index 000000000..84c19c67e --- /dev/null +++ b/anchor/spec_tests/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "spec_tests" +version = "0.1.0" +edition = { workspace = true } +authors = ["Sigma Prime "] + +[dependencies] +base64 = { workspace = true } +bls = { workspace = true } +eth2 = { workspace = true } +ethereum_ssz = { workspace = true } +ethereum_ssz_derive = { workspace = true } +futures = { workspace = true } +hex = { workspace = true } +indexmap = { workspace = true } +openssl = { workspace = true } +operator_key = { path = "../common/operator_key" } +parking_lot = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +ssv_types = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tree_hash = { workspace = true } +tree_hash_derive = { workspace = true } +types = { workspace = true } +walkdir = "2.5.0" diff --git a/anchor/spec_tests/src/lib.rs b/anchor/spec_tests/src/lib.rs new file mode 100644 index 000000000..7ec6e3ded --- /dev/null +++ b/anchor/spec_tests/src/lib.rs @@ -0,0 +1,257 @@ +#![allow(dead_code)] +mod types; +mod utils; + +use std::{ + collections::{HashMap, HashSet}, + fmt, fs, + path::Path, + sync::LazyLock, +}; + +use serde::de::DeserializeOwned; +use types::TypesSpecTestType; +use walkdir::WalkDir; + +use crate::types::*; + +// All Spec Test Variants. Maps to an inner variant type that describes specific tests +#[derive(Eq, PartialEq, Hash, Debug)] +enum SpecTestType { + Types(TypesSpecTestType), +} + +// Maps a test category to its respective spec test location. Do not change! +impl fmt::Display for SpecTestType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + SpecTestType::Types(_) => write!(f, "ssv-spec/types/spectest/generate/tests"), + } + } +} + +impl SpecTestType { + /// Some tests are encoding tests. They share a prefix but have a different fielname and + /// structure + pub fn is_encoding(&self) -> bool { + matches!(self, SpecTestType::Types(type_test) if type_test.is_encoding()) + } +} + +// Import the debug_encoding module + +// Core trait to orchestrate setting up and running spec tests. The spec tests are broken up into +// different categories with different file strucutres. For each file structure, implementing the +// required functions allows for a smooth testing process +trait SpecTest { + // Setup a runner for the test. This will configure and construct eveything required to + // execute the test. Default implementation does nothing. + fn setup(&mut self) {} + + // Run the test and verify that the output is what we were expecting. + fn run(&self) -> bool; + + // Return the type of this test. Used as a Key for the loaders and path construction + fn test_type() -> SpecTestType + where + Self: Sized; +} + +// Abstract away repeated logic for registering a test type with the loader +macro_rules! register_test_loaders { + ($($test_type:ty),* $(,)?) => { + LazyLock::new(|| { + let mut loaders = HashMap::new(); + $( + register_test::<$test_type>(&mut loaders); + )* + loaders + }) + }; +} + +type Loaders = HashMap Box>; +static TEST_LOADERS: LazyLock = register_test_loaders!( + // Types tests + // ----------- + BeaconVoteEncodingTest, + ConsensusDataProposerTest, + EncryptionSpecTest, + PartialSigMsgSpecTest, + PartialSigMessageEncodingTest, + SignedSSVMessageTest, + SignedSSVMessageEncodingTest, + SSVMessageTest, + SSVMessageEncodingTest, + ValidatorConsensusDataEncodingTest, +); + +// Register a test in the loader. This inserts a mapping from SpecTestType -> loading closure +// into a map for later access. This is needed to that we can parse from an arbitrary test file to a +// specific test type T +fn register_test(map: &mut Loaders) { + map.insert(T::test_type(), |path| { + let contents = + fs::read_to_string(path).unwrap_or_else(|_| panic!("Failed to read test file: {path}")); + + let test: T = serde_json::from_str(&contents).unwrap_or_else(|e| { + eprintln!("=== JSON PARSING ERROR ==="); + eprintln!("File: {path}"); + eprintln!("Error: {e}"); + eprintln!("========================"); + panic!("Failed to parse test {path}: {e}") + }); + + Box::new(test) + }); +} + +// Core function to run the tests. Given a SpecTestType, it will navigate to the proper directory, +// read in all of the tests, make sure they are all setup, and then run each one +fn run_tests(test_type: SpecTestType) -> bool { + let dir_name = test_type.to_string(); + let test_dir = Path::new(&dir_name); + + let tests: Vec> = WalkDir::new(test_dir) + .into_iter() + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + + // Check if it is an encoding test + let is_encoding = test_type.is_encoding(); + + // Get the inner variant string to check in filenames + let variant = match &test_type { + SpecTestType::Types(inner) => inner.to_string(), + }; + + if path.is_file() { + let filename = path.file_name().map(|name| name.to_string_lossy()); + let matches = filename + .as_ref() + .map(|name| { + let split: HashSet = name.split('.').map(String::from).collect(); + + // Check if any chunk contains the variant as a prefix to avoid false + // matches (e.g., "ssvmsg" matching "signedssvmsg") + let contains_prefix = split + .iter() + .any(|chunk| chunk.starts_with(&variant) || chunk == &variant); + + if is_encoding { + // if it is an encoding tests, we also have to check that the file + // conatins "EncodingTest" + contains_prefix & name.contains("EncodingTest") + } else { + contains_prefix & !name.contains("EncodingTest") + } + }) + .unwrap_or(false); + + if matches { + let loader = TEST_LOADERS + .get(&test_type) + .unwrap_or_else(|| panic!("No loader registered for: {test_type}")); + return Some(loader(&path.to_string_lossy())); + } + } + None + }) + .collect(); + + let mut result = true; + for mut test in tests { + test.setup(); + result &= test.run(); + } + result +} + +#[cfg(test)] +mod spec_tests { + use super::*; + + // All type specific spec tests + mod type_tests { + use super::*; + + #[test] + // Beacon vote encoding + fn test_types_encoding_beacon_vote() { + assert!(run_tests(SpecTestType::Types( + TypesSpecTestType::BeaconVoteEncoding + ))) + } + + #[test] + // Consensus data proposer test + fn test_types_consensus_data_proposer() { + assert!(run_tests(SpecTestType::Types( + TypesSpecTestType::ConsensusDataProposer + ))) + } + + #[test] + // Encryption test + fn test_types_encryption_test() { + assert!(run_tests(SpecTestType::Types( + TypesSpecTestType::Encryption + ))) + } + + #[test] + // Partial sig message encoding + fn test_types_partial_sig_message() { + assert!(run_tests(SpecTestType::Types( + TypesSpecTestType::PartialSigMessage + ))) + } + + #[test] + // Partial sig message encoding + fn test_types_encoding_partial_sig_message() { + assert!(run_tests(SpecTestType::Types( + TypesSpecTestType::PartialSigMessageEncoding + ))) + } + + #[test] + // Signed ssv message test + fn test_types_signed_ssv_message() { + assert!(run_tests(SpecTestType::Types( + TypesSpecTestType::SignedSSVMsg + ))) + } + + #[test] + // Signed SSV Message Encoding + fn test_types_encoding_signed_ssv_message() { + assert!(run_tests(SpecTestType::Types( + TypesSpecTestType::SignedSSVMsgEncoding + ))) + } + + #[test] + // SSV Message test + fn test_types_ssv_message() { + assert!(run_tests(SpecTestType::Types(TypesSpecTestType::SSVMsg))) + } + + #[test] + // Signed SSV Message Encoding + fn test_types_encoding_ssv_message() { + assert!(run_tests(SpecTestType::Types( + TypesSpecTestType::SSVMsgEncoding + ))) + } + + #[test] + // Validator consensus data encoding + fn test_types_encoding_validator_consensus_data() { + assert!(run_tests(SpecTestType::Types( + TypesSpecTestType::ValidatorConsensusDataEncoding + ))) + } + } +} diff --git a/anchor/spec_tests/src/types/beacon_vote_encoding.rs b/anchor/spec_tests/src/types/beacon_vote_encoding.rs new file mode 100644 index 000000000..4f0343623 --- /dev/null +++ b/anchor/spec_tests/src/types/beacon_vote_encoding.rs @@ -0,0 +1,57 @@ +use serde::Deserialize; +use ssv_types::consensus::BeaconVote; +use ssz::{Decode, Encode}; +use tree_hash::TreeHash; +use types::Hash256; + +use crate::{ + SpecTest, SpecTestType, + types::TypesSpecTestType, + utils::deserializers::{deserialize_base64, deserialize_bytes_to_hash256}, +}; + +// BeaconVote encoding test +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct BeaconVoteEncodingTest { + #[serde(deserialize_with = "deserialize_base64")] + pub data: Vec, + #[serde(deserialize_with = "deserialize_bytes_to_hash256")] + pub expected_root: Hash256, +} + +impl SpecTest for BeaconVoteEncodingTest { + fn run(&self) -> bool { + // Decode the BeaconVote from the provided data + let beacon_vote = match BeaconVote::from_ssz_bytes(&self.data) { + Ok(bv) => bv, + Err(_) => { + return false; + } + }; + + // Compute the hash tree root and verify it matches the expected root + if self.expected_root != beacon_vote.tree_hash_root() { + return false; + } + + // Test round trip encoding + let re_encoded = beacon_vote.as_ssz_bytes(); + match BeaconVote::from_ssz_bytes(&re_encoded) { + Ok(re_decoded) => { + if re_decoded != beacon_vote { + return false; + } + } + Err(_) => { + return false; + } + } + + true + } + + fn test_type() -> SpecTestType { + SpecTestType::Types(TypesSpecTestType::BeaconVoteEncoding) + } +} diff --git a/anchor/spec_tests/src/types/consensus_data_proposer.rs b/anchor/spec_tests/src/types/consensus_data_proposer.rs new file mode 100644 index 000000000..06ef32cdf --- /dev/null +++ b/anchor/spec_tests/src/types/consensus_data_proposer.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; +use ssv_types::consensus::ValidatorConsensusData; +use ssz::{Decode, Encode}; + +use crate::{ + SpecTest, SpecTestType, types::TypesSpecTestType, utils::deserializers::deserialize_base64, +}; + +#[derive(Debug, Deserialize)] +pub struct ConsensusDataProposerTest { + #[serde(rename = "DataCd", deserialize_with = "deserialize_base64")] + pub data_cd: Vec, + #[serde(rename = "ExpectedError")] + pub expected_error: String, +} + +impl SpecTest for ConsensusDataProposerTest { + fn run(&self) -> bool { + let consensus_data = match ValidatorConsensusData::from_ssz_bytes(&self.data_cd) { + Ok(data) => data, + Err(_) => { + let has_error = !self.expected_error.is_empty(); + if !has_error { + return false; + } + return true; + } + }; + + // Test roundtrip encoding + let re_encoded = consensus_data.as_ssz_bytes(); + if re_encoded != self.data_cd { + return false; + } + + true + } + + fn test_type() -> SpecTestType { + SpecTestType::Types(TypesSpecTestType::ConsensusDataProposer) + } +} diff --git a/anchor/spec_tests/src/types/encryption.rs b/anchor/spec_tests/src/types/encryption.rs new file mode 100644 index 000000000..310f180ae --- /dev/null +++ b/anchor/spec_tests/src/types/encryption.rs @@ -0,0 +1,57 @@ +use base64::prelude::*; +use operator_key::{encrypted::EncryptedKey, unencrypted}; +use serde::Deserialize; + +use crate::{ + SpecTest, SpecTestType, types::TypesSpecTestType, utils::deserializers::deserialize_base64, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct EncryptionSpecTest { + #[serde(rename = "SKPem", deserialize_with = "deserialize_base64")] + pub sk_pem: Vec, + #[serde(deserialize_with = "deserialize_base64")] + pub plain_text: Vec, +} + +impl EncryptionSpecTest {} + +impl SpecTest for EncryptionSpecTest { + fn run(&self) -> bool { + // Parse the private key using operator_key's unencrypted module + let sk_pem_base64 = BASE64_STANDARD.encode(&self.sk_pem); + let private_key = match unencrypted::from_base64(sk_pem_base64.as_bytes()) { + Ok(key) => key, + Err(_) => return false, + }; + + // Use the plaintext as a password to test the actual client encryption logic + // If it's not valid UTF-8, base64 encode it to make it a valid password string + let password = String::from_utf8(self.plain_text.clone()) + .unwrap_or_else(|_| BASE64_STANDARD.encode(&self.plain_text)); + + // Test the actual client key encryption logic: encrypt the private key with the password + let encrypted_key = match EncryptedKey::encrypt(&private_key, &password) { + Ok(key) => key, + Err(_) => return false, + }; + + // Test the actual client key decryption logic: decrypt back to the original key + let decrypted_key = match encrypted_key.decrypt(&password) { + Ok(key) => key, + Err(_) => return false, + }; + + // Verify round-trip: decrypted key should match original private key + if private_key.p() != decrypted_key.p() || private_key.q() != decrypted_key.q() { + return false; + } + + true + } + + fn test_type() -> SpecTestType { + SpecTestType::Types(TypesSpecTestType::Encryption) + } +} diff --git a/anchor/spec_tests/src/types/mod.rs b/anchor/spec_tests/src/types/mod.rs new file mode 100644 index 000000000..e709d59a6 --- /dev/null +++ b/anchor/spec_tests/src/types/mod.rs @@ -0,0 +1,72 @@ +mod beacon_vote_encoding; +mod consensus_data_proposer; +mod encryption; +mod partial_sig_message; +mod partial_sig_message_encoding; +mod signed_ssv_msg; +mod signed_ssv_msg_encoding; +mod ssv_msg; +mod ssv_msg_encoding; +mod validator_consensus_data_encoding; +use std::fmt; + +// Re-export test implementations +pub use beacon_vote_encoding::*; +pub use consensus_data_proposer::*; +pub use encryption::*; +pub use partial_sig_message::*; +pub use partial_sig_message_encoding::*; +pub use signed_ssv_msg::*; +pub use signed_ssv_msg_encoding::*; +pub use ssv_msg::*; +pub use ssv_msg_encoding::*; +pub use validator_consensus_data_encoding::*; + +// Types-specific test type enumeration +#[derive(Eq, PartialEq, Hash, Debug)] +pub(crate) enum TypesSpecTestType { + BeaconVoteEncoding, + ConsensusDataProposer, + Encryption, + PartialSigMessage, + PartialSigMessageEncoding, + SignedSSVMsg, + SignedSSVMsgEncoding, + SSVMsg, + SSVMsgEncoding, + ValidatorConsensusDataEncoding, +} + +impl TypesSpecTestType { + // Determine if this is an encoding test + pub fn is_encoding(&self) -> bool { + matches!( + self, + TypesSpecTestType::BeaconVoteEncoding + | TypesSpecTestType::PartialSigMessageEncoding + | TypesSpecTestType::SignedSSVMsgEncoding + | TypesSpecTestType::SSVMsgEncoding + | TypesSpecTestType::ValidatorConsensusDataEncoding + ) + } +} + +// Contains specific identifier for the test file matching Go test naming +impl fmt::Display for TypesSpecTestType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + TypesSpecTestType::BeaconVoteEncoding => write!(f, "beaconvote"), + TypesSpecTestType::ConsensusDataProposer => write!(f, "consensusdataproposer"), + TypesSpecTestType::Encryption => write!(f, "encryption"), + TypesSpecTestType::PartialSigMessage => write!(f, "partialsigmessage"), + TypesSpecTestType::PartialSigMessageEncoding => write!(f, "partialsigmessage"), + TypesSpecTestType::SignedSSVMsg => write!(f, "signedssvmsg"), + TypesSpecTestType::SignedSSVMsgEncoding => write!(f, "signedssvmsg"), + TypesSpecTestType::SSVMsg => write!(f, "ssvmsg"), + TypesSpecTestType::SSVMsgEncoding => write!(f, "ssvmsg"), + TypesSpecTestType::ValidatorConsensusDataEncoding => { + write!(f, "validatorconsensusdata") + } + } + } +} diff --git a/anchor/spec_tests/src/types/partial_sig_message.rs b/anchor/spec_tests/src/types/partial_sig_message.rs new file mode 100644 index 000000000..cf096f69f --- /dev/null +++ b/anchor/spec_tests/src/types/partial_sig_message.rs @@ -0,0 +1,105 @@ +use serde::Deserialize; +use ssv_types::partial_sig::PartialSignatureMessages; +use ssz::{Decode, Encode}; +use tree_hash::TreeHash; +use types::Hash256; + +use crate::{ + SpecTest, SpecTestType, + types::TypesSpecTestType, + utils::deserializers::{deserialize_base64_list_option, deserialize_hash256_list_option}, +}; + +// Partial signature message test +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct PartialSigMsgSpecTest { + pub messages: Vec, + #[serde(deserialize_with = "deserialize_base64_list_option", default)] + pub encoded_messages: Option>>, + #[serde(deserialize_with = "deserialize_hash256_list_option", default)] + pub expected_roots: Option>, + pub expected_error: String, +} + +impl SpecTest for PartialSigMsgSpecTest { + fn run(&self) -> bool { + let mut last_error: Option = None; + + for (i, msg) in self.messages.iter().enumerate() { + if let Err(err) = validate_message(msg) { + last_error = Some(err); + } + + // Test encoding/decoding if we have encoded messages + if let Some(ref encoded_messages) = self.encoded_messages { + // Test encoding + let encoded = msg.as_ssz_bytes(); + if encoded != encoded_messages[i] { + return false; + } + + // Test decoding + let decoded = match PartialSignatureMessages::from_ssz_bytes(&encoded) { + Ok(decoded) => decoded, + Err(_) => return false, + }; + + // Verify decoded matches original + if decoded != *msg { + return false; + } + + // Verify tree hash roots match + if decoded.tree_hash_root() != msg.tree_hash_root() { + return false; + } + } + + // Test expected roots if provided + if let Some(ref expected_roots) = self.expected_roots + && msg.tree_hash_root() != expected_roots[i] + { + return false; + } + } + + if !self.expected_error.is_empty() { + // We do not have an error when we expected one + match last_error { + Some(error) => self.expected_error == error, + None => false, + } + } else { + // If we do do not have an expected error, then last_error should be None. + last_error.is_none() + } + } + + fn test_type() -> SpecTestType { + SpecTestType::Types(TypesSpecTestType::PartialSigMessage) + } +} + +// Handeled by message validator +fn validate_message(msg: &PartialSignatureMessages) -> Result<(), String> { + if msg.messages.is_empty() { + return Err("no PartialSignatureMessages messages".to_string()); + } + + let signer = msg.messages[0].signer; + // Validate each message and check consistency + for message in &msg.messages { + // Check signer consistency + if message.signer != signer { + return Err("inconsistent signers".to_string()); + } + + // Signer ID 0 is not allowed + if message.signer.0 == 0 { + return Err("message invalid: signer ID 0 not allowed".to_string()); + } + } + + Ok(()) +} 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..2923aa636 --- /dev/null +++ b/anchor/spec_tests/src/types/partial_sig_message_encoding.rs @@ -0,0 +1,57 @@ +use serde::Deserialize; +use ssv_types::partial_sig::PartialSignatureMessages; +use ssz::{Decode, Encode}; +use tree_hash::TreeHash; + +use crate::{ + SpecTest, SpecTestType, + types::TypesSpecTestType, + utils::deserializers::{deserialize_base64, deserialize_bytes_to_hash256}, +}; + +// Encoding test for partial signature messages +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct PartialSigMessageEncodingTest { + #[serde(deserialize_with = "deserialize_base64")] + pub data: Vec, + #[serde(deserialize_with = "deserialize_bytes_to_hash256")] + pub expected_root: types::Hash256, +} + +impl SpecTest for PartialSigMessageEncodingTest { + fn run(&self) -> bool { + // Decode the PartialSignatureMessages from the provided data + let partial_sig_messages = match PartialSignatureMessages::from_ssz_bytes(&self.data) { + Ok(psm) => psm, + Err(_) => { + return false; + } + }; + + // Compute tree hash root and compare with expected + let computed_root = partial_sig_messages.tree_hash_root(); + if self.expected_root != computed_root { + return false; + } + + // Test roundtrip encoding + let re_encoded = partial_sig_messages.as_ssz_bytes(); + match PartialSignatureMessages::from_ssz_bytes(&re_encoded) { + Ok(re_decoded) => { + if re_decoded != partial_sig_messages { + return false; + } + } + Err(_) => { + return false; + } + } + + true + } + + fn test_type() -> SpecTestType { + SpecTestType::Types(TypesSpecTestType::PartialSigMessageEncoding) + } +} diff --git a/anchor/spec_tests/src/types/signed_ssv_msg.rs b/anchor/spec_tests/src/types/signed_ssv_msg.rs new file mode 100644 index 000000000..6b0423463 --- /dev/null +++ b/anchor/spec_tests/src/types/signed_ssv_msg.rs @@ -0,0 +1,162 @@ +use openssl::{hash::MessageDigest, pkey::PKey, sign::Verifier}; +use operator_key::public; +use serde::Deserialize; +use ssv_types::{ + OperatorId, + message::{SSVMessage, SignedSSVMessage, SignedSSVMessageError}, +}; +use ssz::Encode; + +use crate::{ + SpecTest, SpecTestType, + types::TypesSpecTestType, + utils::deserializers::{deserialize_base64_list, deserialize_hex_option}, +}; + +// Test message structure that directly handles null SSVMessage +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct TestSignedSSVMessage { + #[serde(deserialize_with = "deserialize_base64_list")] + pub signatures: Vec>, + #[serde(rename = "OperatorIDs")] + pub operator_ids: Vec, + #[serde(rename = "SSVMessage")] + pub ssv_message: Option, + #[serde(deserialize_with = "deserialize_hex_option", default)] + pub full_data: Option>, +} + +// SignedSSVMessage validation tests +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SignedSSVMessageTest { + pub messages: Vec, + pub expected_error: String, + #[serde(rename = "RSAPublicKey")] + pub rsa_public_key: Option>, +} + +impl SpecTest for SignedSSVMessageTest { + fn run(&self) -> bool { + for test_msg in &self.messages { + if let Err(error) = self.validate_message(test_msg) { + return self.check_expected_error(&error); + } + } + true + } + + fn test_type() -> SpecTestType { + SpecTestType::Types(TypesSpecTestType::SignedSSVMsg) + } +} + +impl SignedSSVMessageTest { + fn validate_message(&self, test_msg: &TestSignedSSVMessage) -> Result<(), String> { + // Handle null SSVMessage case + let ssv_message = test_msg.ssv_message.as_ref().ok_or("nil SSVMessage")?; + + // Convert and validate signatures + let signatures = self.prepare_signatures(test_msg)?; + + // Create SignedSSVMessage + let full_data = test_msg.full_data.clone().unwrap_or_default(); + let signed_msg = SignedSSVMessage::new( + signatures, + test_msg.operator_ids.clone(), + ssv_message.clone(), + full_data, + ) + .map_err(|e| self.error_to_string(&e))?; + + // Validate the message by calling our internal validate function + signed_msg + .validate() + .map_err(|_| "validation failed".to_string())?; + + // Verify RSA signatures if provided + self.verify_rsa_signatures(&signed_msg, ssv_message) + } + + fn prepare_signatures( + &self, + test_msg: &TestSignedSSVMessage, + ) -> Result, String> { + let mut signatures = Vec::new(); + + for sig_bytes in &test_msg.signatures { + if sig_bytes.is_empty() { + return Err("empty signature".to_string()); + } + + // Pad or truncate signature to 256 bytes for RSA signature format + let mut sig_array = [0u8; 256]; + if sig_bytes.len() <= 256 { + sig_array[..sig_bytes.len()].copy_from_slice(sig_bytes); + } else { + sig_array.copy_from_slice(&sig_bytes[..256]); + } + signatures.push(sig_array); + } + + Ok(signatures) + } + + fn verify_rsa_signatures( + &self, + signed_msg: &SignedSSVMessage, + ssv_message: &SSVMessage, + ) -> Result<(), String> { + let Some(ref pk_strings) = self.rsa_public_key else { + return Ok(()); + }; + + let encoded_ssv_msg = ssv_message.as_ssz_bytes(); + + for (i, pk_string) in pk_strings.iter().enumerate() { + let rsa_key = public::from_base64(pk_string.as_bytes()) + .map_err(|_| "failed to parse RSA public key")?; + + let pkey = PKey::from_rsa(rsa_key).map_err(|_| "failed to convert RSA key to PKey")?; + + let mut verifier = Verifier::new(MessageDigest::sha256(), &pkey) + .map_err(|_| "failed to create verifier")?; + + verifier + .update(&encoded_ssv_msg) + .map_err(|_| "failed to update verifier")?; + + let signature: &[u8] = &signed_msg.signatures()[i]; + verifier + .verify(signature) + .map_err(|_| "signature verification failed")?; + } + + Ok(()) + } + + fn error_to_string(&self, error: &SignedSSVMessageError) -> String { + match error { + SignedSSVMessageError::NoSigners => "no signers".to_string(), + SignedSSVMessageError::ZeroSigner => "signer ID 0 not allowed".to_string(), + SignedSSVMessageError::DuplicatedSigner => "non unique signer".to_string(), + SignedSSVMessageError::SignersAndSignaturesWithDifferentLength => { + "number of signatures is different than number of signers".to_string() + } + SignedSSVMessageError::NoSignatures => "no signatures".to_string(), + SignedSSVMessageError::TooManySignatures { .. } => "too many signatures".to_string(), + SignedSSVMessageError::WrongRSASignatureSize { .. } => { + "wrong RSA signature size".to_string() + } + SignedSSVMessageError::TooManyOperatorIDs { .. } => "too many operator IDs".to_string(), + SignedSSVMessageError::FullDataTooLong { .. } => "full data too long".to_string(), + SignedSSVMessageError::SignersNotSorted => "signers not sorted".to_string(), + SignedSSVMessageError::SSVMessageError(_) => "invalid SSV message".to_string(), + } + } + + fn check_expected_error(&self, error_msg: &str) -> bool { + !self.expected_error.is_empty() && self.expected_error == error_msg + } +} 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..4484ff869 --- /dev/null +++ b/anchor/spec_tests/src/types/signed_ssv_msg_encoding.rs @@ -0,0 +1,35 @@ +use serde::Deserialize; +use ssv_types::message::SignedSSVMessage; +use ssz::{Decode, Encode}; + +use crate::{ + SpecTest, SpecTestType, types::TypesSpecTestType, utils::deserializers::deserialize_base64, +}; + +// Encoding test structure +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SignedSSVMessageEncodingTest { + #[serde(deserialize_with = "deserialize_base64")] + pub data: Vec, +} + +impl SpecTest for SignedSSVMessageEncodingTest { + fn run(&self) -> bool { + let signed_message = match SignedSSVMessage::from_ssz_bytes(&self.data) { + Ok(msg) => msg, + Err(_) => return false, + }; + + // Test roundtrip encoding + let re_encoded = signed_message.as_ssz_bytes(); + match SignedSSVMessage::from_ssz_bytes(&re_encoded) { + Ok(re_decoded) => re_decoded == signed_message, + Err(_) => false, + } + } + + fn test_type() -> SpecTestType { + SpecTestType::Types(TypesSpecTestType::SignedSSVMsgEncoding) + } +} diff --git a/anchor/spec_tests/src/types/ssv_msg.rs b/anchor/spec_tests/src/types/ssv_msg.rs new file mode 100644 index 000000000..1c0491652 --- /dev/null +++ b/anchor/spec_tests/src/types/ssv_msg.rs @@ -0,0 +1,46 @@ +use serde::Deserialize; +use ssv_types::msgid::{DutyExecutor, MessageId}; + +use crate::{ + SpecTest, SpecTestType, + types::TypesSpecTestType, + utils::{TESTING_VALIDATOR_PUBKEY, deserializers::deserialize_hex_message_id_list}, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SSVMessageTest { + #[serde( + rename = "MessageIDs", + deserialize_with = "deserialize_hex_message_id_list" + )] + pub message_ids: Vec, + pub belongs_to_validator: bool, +} + +impl SpecTest for SSVMessageTest { + fn run(&self) -> bool { + // Setup the 4 share set + let mut result = true; + for msg_id in &self.message_ids { + // Some of message ids have an invalid role + if let Some(duty_executor) = msg_id.duty_executor() { + let validator_pubkey = match duty_executor { + DutyExecutor::Validator(key) => key, + _ => return false, + }; + + if self.belongs_to_validator { + result &= validator_pubkey == *TESTING_VALIDATOR_PUBKEY; + } else { + result &= validator_pubkey != *TESTING_VALIDATOR_PUBKEY; + } + } + } + result + } + + fn test_type() -> SpecTestType { + SpecTestType::Types(TypesSpecTestType::SSVMsg) + } +} diff --git a/anchor/spec_tests/src/types/ssv_msg_encoding.rs b/anchor/spec_tests/src/types/ssv_msg_encoding.rs new file mode 100644 index 000000000..00534f190 --- /dev/null +++ b/anchor/spec_tests/src/types/ssv_msg_encoding.rs @@ -0,0 +1,47 @@ +use serde::Deserialize; +use ssv_types::message::SSVMessage; +use ssz::{Decode, Encode}; +use tree_hash::TreeHash; +use types::Hash256; + +use crate::{ + SpecTest, SpecTestType, + types::TypesSpecTestType, + utils::deserializers::{deserialize_base64, deserialize_bytes_to_hash256}, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SSVMessageEncodingTest { + #[serde(deserialize_with = "deserialize_base64")] + pub data: Vec, + #[serde(deserialize_with = "deserialize_bytes_to_hash256")] + pub expected_root: Hash256, +} + +impl SpecTest for SSVMessageEncodingTest { + fn run(&self) -> bool { + // Decode the SSVMessage from the provided data + let ssv_message = match SSVMessage::from_ssz_bytes(&self.data) { + Ok(bv) => bv, + Err(_) => return false, + }; + + // Compute tree hash root and compare with expected + let computed_root = ssv_message.tree_hash_root(); + if self.expected_root != computed_root { + return false; + } + + // Test roundtrip encoding + let re_encoded = ssv_message.as_ssz_bytes(); + match SSVMessage::from_ssz_bytes(&re_encoded) { + Ok(re_decoded) => re_decoded == ssv_message, + Err(_) => false, + } + } + + fn test_type() -> SpecTestType { + SpecTestType::Types(TypesSpecTestType::SSVMsgEncoding) + } +} diff --git a/anchor/spec_tests/src/types/validator_consensus_data_encoding.rs b/anchor/spec_tests/src/types/validator_consensus_data_encoding.rs new file mode 100644 index 000000000..4919a9393 --- /dev/null +++ b/anchor/spec_tests/src/types/validator_consensus_data_encoding.rs @@ -0,0 +1,48 @@ +use serde::Deserialize; +use ssv_types::consensus::ValidatorConsensusData; +use ssz::{Decode, Encode}; +use tree_hash::TreeHash; +use types::Hash256; + +use crate::{ + SpecTest, SpecTestType, + types::TypesSpecTestType, + utils::deserializers::{deserialize_base64, deserialize_bytes_to_hash256}, +}; + +// Validator consensus data encoding test +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ValidatorConsensusDataEncodingTest { + #[serde(deserialize_with = "deserialize_base64")] + pub data: Vec, + #[serde(deserialize_with = "deserialize_bytes_to_hash256")] + pub expected_root: Hash256, +} + +impl SpecTest for ValidatorConsensusDataEncodingTest { + fn run(&self) -> bool { + // Decode the ValidatorConsensusData from SSZ bytes + let consensus_data = match ValidatorConsensusData::from_ssz_bytes(&self.data) { + Ok(data) => data, + Err(_) => return false, + }; + + // Compute tree hash root and compare with expected + let computed_root = consensus_data.tree_hash_root(); + if self.expected_root != computed_root { + return false; + } + + // Test roundtrip encoding + let re_encoded = consensus_data.as_ssz_bytes(); + match ValidatorConsensusData::from_ssz_bytes(&re_encoded) { + Ok(re_decoded) => re_decoded == consensus_data, + Err(_) => false, + } + } + + fn test_type() -> SpecTestType { + SpecTestType::Types(TypesSpecTestType::ValidatorConsensusDataEncoding) + } +} diff --git a/anchor/spec_tests/src/utils/deserializers.rs b/anchor/spec_tests/src/utils/deserializers.rs new file mode 100644 index 000000000..0879a144a --- /dev/null +++ b/anchor/spec_tests/src/utils/deserializers.rs @@ -0,0 +1,129 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Deserializer, de::Error}; +use ssv_types::{deserializers::deserialize_hex_message_id, msgid::MessageId}; +use types::Hash256; + +/// Deserialize a base64 string to bytes +pub fn deserialize_base64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let base64_string = String::deserialize(deserializer)?; + STANDARD + .decode(&base64_string) + .map_err(|e| Error::custom(format!("Failed to decode base64: {e}"))) +} + +/// Deserialize a vector of base64 strings to vector of byte arrays +pub fn deserialize_base64_list<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let strings: Vec = Vec::deserialize(deserializer)?; + let mut result = Vec::with_capacity(strings.len()); + for s in strings { + let bytes = STANDARD + .decode(&s) + .map_err(|e| Error::custom(format!("Failed to decode base64: {e}")))?; + result.push(bytes); + } + Ok(result) +} + +/// Deserialize an optional vector of base64 strings to optional vector of byte arrays +pub fn deserialize_base64_list_option<'de, D>( + deserializer: D, +) -> Result>>, D::Error> +where + D: Deserializer<'de>, +{ + let opt: Option> = Option::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(strings) => { + let mut result = Vec::with_capacity(strings.len()); + for s in strings { + let bytes = STANDARD + .decode(&s) + .map_err(|e| Error::custom(format!("Failed to decode base64: {e}")))?; + result.push(bytes); + } + Ok(Some(result)) + } + } +} + +/// Deserialize an optional hex string to optional bytes +pub fn deserialize_hex_option<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(hex_str) => { + let hex_str = hex_str.strip_prefix("0x").unwrap_or(&hex_str); + hex::decode(hex_str) + .map(Some) + .map_err(|e| Error::custom(format!("Failed to decode hex: {e}"))) + } + } +} + +/// Convert byte array to Hash256 +pub fn deserialize_bytes_to_hash256<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let bytes = >::deserialize(deserializer)?; + if bytes.len() != 32 { + return Err(Error::custom(format!( + "Expected 32 bytes for Hash256, got {}", + bytes.len() + ))); + } + Ok(Hash256::from_slice(&bytes)) +} + +/// Deserialize optional vector of Hash256 from byte arrays +pub fn deserialize_hash256_list_option<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let opt: Option>> = Option::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(byte_arrays) => { + let mut result = Vec::with_capacity(byte_arrays.len()); + for bytes in byte_arrays { + if bytes.len() != 32 { + return Err(Error::custom(format!( + "Expected 32 bytes for Hash256, got {}", + bytes.len() + ))); + } + result.push(Hash256::from_slice(&bytes)); + } + Ok(Some(result)) + } + } +} + +/// Deserialize vector of MessageIds from hex strings +pub fn deserialize_hex_message_id_list<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let hex_strings: Vec = Vec::deserialize(deserializer)?; + let mut result = Vec::with_capacity(hex_strings.len()); + + for hex_str in hex_strings { + result.push(deserialize_hex_message_id( + serde::de::value::StrDeserializer::::new(&hex_str), + )?); + } + + Ok(result) +} diff --git a/anchor/spec_tests/src/utils/mod.rs b/anchor/spec_tests/src/utils/mod.rs new file mode 100644 index 000000000..30859196f --- /dev/null +++ b/anchor/spec_tests/src/utils/mod.rs @@ -0,0 +1,9 @@ +use std::{str::FromStr, sync::LazyLock}; + +use types::PublicKeyBytes; + +pub mod deserializers; + +pub static TESTING_VALIDATOR_PUBKEY: LazyLock = LazyLock::new(|| { + PublicKeyBytes::from_str("0x8e80066551a81b318258709edaf7dd1f63cd686a0e4db8b29bbb7acfe65608677af5a527d9448ee47835485e02b50bc0").expect("Failed to create public key") +}); diff --git a/anchor/spec_tests/ssv-spec b/anchor/spec_tests/ssv-spec new file mode 160000 index 000000000..4e2f944d1 --- /dev/null +++ b/anchor/spec_tests/ssv-spec @@ -0,0 +1 @@ +Subproject commit 4e2f944d196cb1956777044ba932724c6d62f16f