From b135d92b098edc455208aa2fba45d43231f03db9 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 3 Feb 2026 14:50:08 +0700 Subject: [PATCH 01/14] feat: add manifest types and structures --- crates/cluster/src/manifest/cluster.rs | 38 ++++++ crates/cluster/src/manifest/helpers.rs | 44 +++++++ crates/cluster/src/manifest/load.rs | 72 +++++++++++ crates/cluster/src/manifest/materialise.rs | 15 +++ crates/cluster/src/manifest/mod.rs | 116 +++++++++++++++++- crates/cluster/src/manifest/mutation.rs | 2 + .../src/manifest/mutationaddvalidator.rs | 32 +++++ .../src/manifest/mutationlegacylock.rs | 45 +++++++ .../src/manifest/mutationnodeapproval.rs | 27 ++++ crates/cluster/src/manifest/types.rs | 69 +++++++++++ 10 files changed, 459 insertions(+), 1 deletion(-) diff --git a/crates/cluster/src/manifest/cluster.rs b/crates/cluster/src/manifest/cluster.rs index 8b137891..bcd52898 100644 --- a/crates/cluster/src/manifest/cluster.rs +++ b/crates/cluster/src/manifest/cluster.rs @@ -1 +1,39 @@ +//! Cluster manifest helper functions for peer and validator operations. +use crate::{ + definition::NodeIdx, + manifestpb::v1::{Cluster, Validator}, +}; +use pluto_p2p::peer::Peer; + +use super::Result; + +/// Returns the cluster operators as a slice of p2p peers. +pub fn cluster_peers(_cluster: &Cluster) -> Result> { + unimplemented!("cluster_peers") +} + +/// Returns the operators p2p peer IDs. +pub fn cluster_peer_ids(_cluster: &Cluster) -> Result> { + unimplemented!("cluster_peer_ids") +} + +/// Returns the node index for the peer in the cluster. +pub fn cluster_node_idx(_cluster: &Cluster, _peer_id: &str) -> Result { + unimplemented!("cluster_node_idx") +} + +/// Returns the validator BLS group public key. +pub fn validator_public_key(_validator: &Validator) -> Result> { + unimplemented!("validator_public_key") +} + +/// Returns the validator hex group public key. +pub fn validator_public_key_hex(_validator: &Validator) -> String { + unimplemented!("validator_public_key_hex") +} + +/// Returns the validator's peerIdx'th BLS public share. +pub fn validator_public_share(_validator: &Validator, _peer_idx: usize) -> Result> { + unimplemented!("validator_public_share") +} diff --git a/crates/cluster/src/manifest/helpers.rs b/crates/cluster/src/manifest/helpers.rs index 8b137891..ca64be89 100644 --- a/crates/cluster/src/manifest/helpers.rs +++ b/crates/cluster/src/manifest/helpers.rs @@ -1 +1,45 @@ +//! Cluster manifest helper functions for hashing, signing, and conversions. +use crate::{ + definition::ValidatorAddresses, + distvalidator::DistValidator, + manifestpb::v1::{Mutation, SignedMutation, Validator}, +}; + +use super::{ManifestError, Result}; + +/// Hash length in bytes. +pub(crate) const HASH_LEN: usize = 32; + +/// Hashes a signed mutation using SHA-256. +pub(crate) fn hash_signed_mutation(_signed: &SignedMutation) -> Result> { + unimplemented!("hash_signed_mutation") +} + +/// Hashes a mutation using SHA-256. +pub(crate) fn hash_mutation(_mutation: &Mutation) -> Result> { + unimplemented!("hash_mutation") +} + +/// Verifies that the signed mutation has empty signature and signer fields. +pub(crate) fn verify_empty_sig(_signed: &SignedMutation) -> Result<()> { + unimplemented!("verify_empty_sig") +} + +/// Signs a mutation with a secp256k1 private key. +pub fn sign_k1(_mutation: &Mutation, _secret: &k256::ecdsa::SigningKey) -> Result { + unimplemented!("sign_k1") +} + +/// Verifies a k1-signed mutation. +pub(crate) fn verify_k1_signed_mutation(_signed: &SignedMutation) -> Result<()> { + unimplemented!("verify_k1_signed_mutation") +} + +/// Converts a legacy cluster validator to a protobuf validator. +pub fn validator_to_proto( + _val: &DistValidator, + _addrs: &ValidatorAddresses, +) -> Result { + unimplemented!("validator_to_proto") +} diff --git a/crates/cluster/src/manifest/load.rs b/crates/cluster/src/manifest/load.rs index 8b137891..188f879e 100644 --- a/crates/cluster/src/manifest/load.rs +++ b/crates/cluster/src/manifest/load.rs @@ -1 +1,73 @@ +//! Cluster manifest loading from disk. +use std::path::Path; + +use crate::{ + lock::Lock, + manifestpb::v1::{Cluster, SignedMutationList}, +}; + +use super::{ManifestError, Result}; + +/// Loads the current cluster state from disk. +/// +/// Reads either from cluster manifest or legacy lock file. +/// If both files are provided, both files are read and: +/// - If cluster hashes don't match, an error is returned +/// - If cluster hashes match, the cluster loaded from the manifest file is returned +/// +/// Returns an error if the cluster can't be loaded from either file. +pub fn load_cluster( + _manifest_file: P1, + _legacy_lock_file: P2, + _lock_callback: Option, +) -> Result +where + P1: AsRef, + P2: AsRef, + F: FnOnce(Lock) -> Result<()>, +{ + unimplemented!("load_cluster") +} + +/// Loads the raw cluster DAG from disk. +/// +/// Reads either from cluster manifest or legacy lock file. +/// If both files are provided, both files are read and: +/// - If cluster hashes don't match, an error is returned +/// - If cluster hashes match, the DAG loaded from the manifest file is returned +/// +/// Returns an error if the DAG can't be loaded from either file. +pub fn load_dag( + _manifest_file: P1, + _legacy_lock_file: P2, + _lock_callback: Option, +) -> Result +where + P1: AsRef, + P2: AsRef, + F: FnOnce(Lock) -> Result<()>, +{ + unimplemented!("load_dag") +} + +/// Loads the raw DAG from cluster manifest file on disk. +pub(crate) fn load_dag_from_manifest>(_filename: P) -> Result { + unimplemented!("load_dag_from_manifest") +} + +/// Loads the raw DAG from legacy lock file on disk. +pub(crate) fn load_dag_from_legacy_lock, F: FnOnce(Lock) -> Result<()>>( + _filename: P, + _lock_callback: Option, +) -> Result { + unimplemented!("load_dag_from_legacy_lock") +} + +/// Verifies that cluster hashes match between manifest and legacy DAG. +pub(crate) fn cluster_hashes_match( + _dag_manifest: &SignedMutationList, + _dag_legacy: &SignedMutationList, +) -> Result<()> { + unimplemented!("cluster_hashes_match") +} diff --git a/crates/cluster/src/manifest/materialise.rs b/crates/cluster/src/manifest/materialise.rs index 8b137891..f173e39d 100644 --- a/crates/cluster/src/manifest/materialise.rs +++ b/crates/cluster/src/manifest/materialise.rs @@ -1 +1,16 @@ +//! Cluster manifest materialisation. +use crate::manifestpb::v1::{Cluster, SignedMutationList}; + +use super::{ManifestError, Result}; + +/// Transforms a raw DAG and returns the resulting cluster manifest. +/// +/// Applies each mutation in order to build up the final cluster state. +/// Sets `initial_mutation_hash` from the first mutation and `latest_mutation_hash` +/// from the last mutation. +/// +/// Returns an error if the DAG is empty or any transformation fails. +pub fn materialise(_raw_dag: &SignedMutationList) -> Result { + unimplemented!("materialise") +} diff --git a/crates/cluster/src/manifest/mod.rs b/crates/cluster/src/manifest/mod.rs index c3712bd6..6dd7a4c9 100644 --- a/crates/cluster/src/manifest/mod.rs +++ b/crates/cluster/src/manifest/mod.rs @@ -1,4 +1,9 @@ -//! # Charon Cluster Manifest +//! Cluster manifest management and coordination. +//! +//! This module handles cluster manifest DAG (Directed Acyclic Graph) operations, +//! including loading, materializing, and transforming cluster state through mutations. + +use thiserror::Error; /// Cluster manifest management and coordination. pub mod cluster; @@ -18,3 +23,112 @@ pub mod mutationlegacylock; pub mod mutationnodeapproval; /// Cluster manifest types management and coordination. pub mod types; + +/// Manifest module error type. +#[derive(Debug, Error)] +pub enum ManifestError { + /// Empty or nil DAG. + #[error("empty raw DAG")] + EmptyDAG, + + /// No files found. + #[error("no file found (lock-file: {lock_file}, manifest-file: {manifest_file})")] + NoFileFound { + /// Lock file path. + lock_file: String, + /// Manifest file path. + manifest_file: String, + }, + + /// Manifest and legacy cluster hashes don't match. + #[error("manifest and legacy cluster hashes don't match (manifest_hash: {manifest_hash}, legacy_hash: {legacy_hash})")] + ClusterHashMismatch { + /// Manifest hash hex string. + manifest_hash: String, + /// Legacy hash hex string. + legacy_hash: String, + }, + + /// Invalid signed mutation. + #[error("invalid signed mutation: {0}")] + InvalidSignedMutation(String), + + /// Invalid mutation. + #[error("invalid mutation: {0}")] + InvalidMutation(String), + + /// Non-empty signature or signer. + #[error("{0}")] + NonEmptyField(String), + + /// Invalid mutation signature. + #[error("invalid mutation signature")] + InvalidSignature, + + /// Invalid cluster. + #[error("invalid cluster: {0}")] + InvalidCluster(String), + + /// Cluster contains duplicate peer ENRs. + #[error("cluster contains duplicate peer enrs: {enr}")] + DuplicatePeerENR { + /// ENR string. + enr: String, + }, + + /// Peer not in definition. + #[error("peer not in definition")] + PeerNotInDefinition, + + /// Invalid hex encoding. + #[error("invalid hex encoding: {0}")] + InvalidHex(String), + + /// Invalid hex length. + #[error("invalid hex length (expect: {expect}, actual: {actual})")] + InvalidHexLength { + /// Expected length. + expect: usize, + /// Actual length. + actual: usize, + }, + + /// I/O error. + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + /// Protobuf decode error. + #[error("protobuf decode error: {0}")] + ProtobufDecode(#[from] prost::DecodeError), + + /// JSON error. + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + /// Hex decode error. + #[error("hex decode error: {0}")] + HexDecode(#[from] hex::FromHexError), + + /// K1 key error. + #[error("k1 key error: {0}")] + K1Key(String), + + /// Crypto error. + #[error("crypto error: {0}")] + Crypto(String), + + /// ENR parsing error. + #[error("enr parsing error: {0}")] + EnrParse(String), + + /// P2P error. + #[error("p2p error: {0}")] + P2p(String), + + /// BLS conversion error. + #[error("bls conversion error: {0}")] + BlsConversion(String), +} + +/// Result type alias for manifest operations. +pub type Result = std::result::Result; diff --git a/crates/cluster/src/manifest/mutation.rs b/crates/cluster/src/manifest/mutation.rs index 8b137891..6f9b8867 100644 --- a/crates/cluster/src/manifest/mutation.rs +++ b/crates/cluster/src/manifest/mutation.rs @@ -1 +1,3 @@ +//! Cluster manifest mutation base types. +// This file is intentionally minimal as mutation logic is split across specific mutation files. diff --git a/crates/cluster/src/manifest/mutationaddvalidator.rs b/crates/cluster/src/manifest/mutationaddvalidator.rs index 8b137891..4ce8405a 100644 --- a/crates/cluster/src/manifest/mutationaddvalidator.rs +++ b/crates/cluster/src/manifest/mutationaddvalidator.rs @@ -1 +1,33 @@ +//! Add validators mutation implementation. +use crate::manifestpb::v1::{Cluster, SignedMutation, Validator}; + +use super::{ManifestError, Result}; + +/// Creates a new gen validators mutation. +pub fn new_gen_validators(_parent: &[u8], _validators: Vec) -> Result { + unimplemented!("new_gen_validators") +} + +/// Verifies a gen validators mutation. +pub(crate) fn verify_gen_validators(_signed: &SignedMutation) -> Result<()> { + unimplemented!("verify_gen_validators") +} + +/// Transforms a cluster with a gen validators mutation. +pub(crate) fn transform_gen_validators(_cluster: &Cluster, _signed: &SignedMutation) -> Result { + unimplemented!("transform_gen_validators") +} + +/// Creates a new add validators composite mutation. +pub fn new_add_validators( + _gen_validators: &SignedMutation, + _node_approvals: &SignedMutation, +) -> Result { + unimplemented!("new_add_validators") +} + +/// Transforms a cluster with an add validators composite mutation. +pub(crate) fn transform_add_validators(_cluster: &Cluster, _signed: &SignedMutation) -> Result { + unimplemented!("transform_add_validators") +} diff --git a/crates/cluster/src/manifest/mutationlegacylock.rs b/crates/cluster/src/manifest/mutationlegacylock.rs index 8b137891..d53a9d6b 100644 --- a/crates/cluster/src/manifest/mutationlegacylock.rs +++ b/crates/cluster/src/manifest/mutationlegacylock.rs @@ -1 +1,46 @@ +//! Legacy lock mutation implementation. +use crate::{ + lock::Lock, + manifestpb::v1::{Cluster, SignedMutation, SignedMutationList}, +}; + +use super::{ManifestError, Result}; + +/// Creates a new raw legacy lock mutation from JSON bytes. +pub fn new_raw_legacy_lock(_json_bytes: &[u8]) -> Result { + unimplemented!("new_raw_legacy_lock") +} + +/// Creates a new legacy lock mutation for testing. +pub fn new_legacy_lock_for_tests(_lock: &Lock) -> Result { + unimplemented!("new_legacy_lock_for_tests") +} + +/// Creates a new DAG from a legacy lock for testing. +pub fn new_dag_from_lock_for_tests(_lock: &Lock) -> Result { + unimplemented!("new_dag_from_lock_for_tests") +} + +/// Creates a new cluster from a legacy lock for testing. +pub fn new_cluster_from_lock_for_tests(_lock: &Lock) -> Result { + unimplemented!("new_cluster_from_lock_for_tests") +} + +/// Verifies a legacy lock mutation. +pub(crate) fn verify_legacy_lock(_signed: &SignedMutation) -> Result<()> { + unimplemented!("verify_legacy_lock") +} + +/// Transforms a cluster with a legacy lock mutation. +pub(crate) fn transform_legacy_lock(_cluster: &Cluster, _signed: &SignedMutation) -> Result { + unimplemented!("transform_legacy_lock") +} + +/// Checks if a protobuf message is zero/empty. +pub(crate) fn is_zero_proto(_msg: &T) -> bool +where + T: prost::Message + Default, +{ + unimplemented!("is_zero_proto") +} diff --git a/crates/cluster/src/manifest/mutationnodeapproval.rs b/crates/cluster/src/manifest/mutationnodeapproval.rs index 8b137891..26afd704 100644 --- a/crates/cluster/src/manifest/mutationnodeapproval.rs +++ b/crates/cluster/src/manifest/mutationnodeapproval.rs @@ -1 +1,28 @@ +//! Node approval mutation implementation. +use crate::manifestpb::v1::{Cluster, SignedMutation}; + +use super::{ManifestError, Result}; + +/// Signs a node approval mutation. +pub fn sign_node_approval( + _parent: &[u8], + _secret: &k256::ecdsa::SigningKey, +) -> Result { + unimplemented!("sign_node_approval") +} + +/// Creates a new node approvals composite mutation. +pub fn new_node_approvals_composite(_approvals: Vec) -> Result { + unimplemented!("new_node_approvals_composite") +} + +/// Verifies a node approval mutation. +pub(crate) fn verify_node_approval(_signed: &SignedMutation) -> Result<()> { + unimplemented!("verify_node_approval") +} + +/// Transforms a cluster with a node approvals composite mutation. +pub(crate) fn transform_node_approvals(_cluster: &Cluster, _signed: &SignedMutation) -> Result { + unimplemented!("transform_node_approvals") +} diff --git a/crates/cluster/src/manifest/types.rs b/crates/cluster/src/manifest/types.rs index 8b137891..ab3c0a74 100644 --- a/crates/cluster/src/manifest/types.rs +++ b/crates/cluster/src/manifest/types.rs @@ -1 +1,70 @@ +//! Cluster manifest mutation types. +use crate::manifestpb::v1::{Cluster, SignedMutation}; + +use super::{ManifestError, Result}; + +/// Mutation type enumeration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MutationType { + /// Legacy lock mutation type. + LegacyLock, + /// Node approval mutation type. + NodeApproval, + /// Node approvals composite mutation type. + NodeApprovals, + /// Generate validators mutation type. + GenValidators, + /// Add validators composite mutation type. + AddValidators, +} + +impl MutationType { + /// Returns the string representation of the mutation type. + pub fn as_str(&self) -> &'static str { + match self { + Self::LegacyLock => "dv/legacy_lock/v0.0.1", + Self::NodeApproval => "dv/node_approval/v0.0.1", + Self::NodeApprovals => "dv/node_approvals/v0.0.1", + Self::GenValidators => "dv/gen_validators/v0.0.1", + Self::AddValidators => "dv/add_validators/v0.0.1", + } + } + + /// Parses a mutation type from a string. + pub fn from_str(s: &str) -> Option { + match s { + "dv/legacy_lock/v0.0.1" => Some(Self::LegacyLock), + "dv/node_approval/v0.0.1" => Some(Self::NodeApproval), + "dv/node_approvals/v0.0.1" => Some(Self::NodeApprovals), + "dv/gen_validators/v0.0.1" => Some(Self::GenValidators), + "dv/add_validators/v0.0.1" => Some(Self::AddValidators), + _ => None, + } + } + + /// Returns true if the mutation type is valid. + /// TODO: @iamquang95 remove this if no need + pub fn valid(&self) -> bool { + true + } + + /// Transforms the cluster with the given signed mutation. + pub fn transform( + &self, + _cluster: &Cluster, + _signed: &SignedMutation, + ) -> Result { + unimplemented!("MutationType::transform") + } +} + +/// Calculates the hash of a signed mutation. +pub fn hash(_signed: &SignedMutation) -> Result> { + unimplemented!("hash") +} + +/// Transforms a cluster with a signed mutation. +pub fn transform(_cluster: &Cluster, _signed: &SignedMutation) -> Result { + unimplemented!("transform") +} From ebc2d5f0e5bc7fbe7017e37a420de72770679bb2 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 3 Feb 2026 16:50:41 +0700 Subject: [PATCH 02/14] feat: implement manifest/helper --- crates/cluster/src/manifest/helpers.rs | 137 ++++++++++++++++-- crates/cluster/src/manifest/load.rs | 8 +- crates/cluster/src/manifest/materialise.rs | 6 +- crates/cluster/src/manifest/mod.rs | 21 ++- crates/cluster/src/manifest/mutation.rs | 3 +- .../src/manifest/mutationaddvalidator.rs | 13 +- .../src/manifest/mutationlegacylock.rs | 9 +- .../src/manifest/mutationnodeapproval.rs | 7 +- crates/cluster/src/manifest/types.rs | 76 ++++++++-- 9 files changed, 236 insertions(+), 44 deletions(-) diff --git a/crates/cluster/src/manifest/helpers.rs b/crates/cluster/src/manifest/helpers.rs index ca64be89..81b97614 100644 --- a/crates/cluster/src/manifest/helpers.rs +++ b/crates/cluster/src/manifest/helpers.rs @@ -1,5 +1,11 @@ //! Cluster manifest helper functions for hashing, signing, and conversions. +use k256::{ + PublicKey, SecretKey, + sha2::{Digest, Sha256}, +}; +use prost_types::Timestamp; + use crate::{ definition::ValidatorAddresses, distvalidator::DistValidator, @@ -11,35 +17,136 @@ use super::{ManifestError, Result}; /// Hash length in bytes. pub(crate) const HASH_LEN: usize = 32; +/// Get the current timestamp. +/// +/// This function returns the current time as a protobuf Timestamp. +/// In production, it uses the system time. In tests, use dependency injection +/// to provide a custom time source instead of this function. +pub fn now() -> Timestamp { + let now = chrono::Utc::now(); + Timestamp { + seconds: now.timestamp(), + #[allow(clippy::cast_possible_wrap)] + nanos: now.timestamp_subsec_nanos() as i32, + } +} + /// Hashes a signed mutation using SHA-256. -pub(crate) fn hash_signed_mutation(_signed: &SignedMutation) -> Result> { - unimplemented!("hash_signed_mutation") +pub(crate) fn hash_signed_mutation(signed: &SignedMutation) -> Result> { + let mutation = signed + .mutation + .as_ref() + .ok_or_else(|| ManifestError::InvalidSignedMutation("mutation is nil".to_string()))?; + + let mut hasher = Sha256::new(); + + // Field 0: Mutation + let mutation_hash = hash_mutation(mutation)?; + hasher.update(&mutation_hash); + + // Field 1: Signer + hasher.update(&signed.signer); + + // Field 2: Signature + hasher.update(&signed.signature); + + Ok(hasher.finalize().to_vec()) } /// Hashes a mutation using SHA-256. -pub(crate) fn hash_mutation(_mutation: &Mutation) -> Result> { - unimplemented!("hash_mutation") +pub(crate) fn hash_mutation(m: &Mutation) -> Result> { + let data = m + .data + .as_ref() + .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; + + let mut hasher = Sha256::new(); + + // Field 0: Parent + hasher.update(&m.parent); + + // Field 1: Type + hasher.update(m.r#type.as_bytes()); + + // Field 2: Data (TypeUrl + Value) + hasher.update(data.type_url.as_bytes()); + hasher.update(&data.value); + + Ok(hasher.finalize().to_vec()) } /// Verifies that the signed mutation has empty signature and signer fields. -pub(crate) fn verify_empty_sig(_signed: &SignedMutation) -> Result<()> { - unimplemented!("verify_empty_sig") +#[allow(dead_code)] +pub(crate) fn verify_empty_sig(signed: &SignedMutation) -> Result<()> { + if !signed.signature.is_empty() { + return Err(ManifestError::NonEmptyField( + "non-empty signature".to_string(), + )); + } + + if !signed.signer.is_empty() { + return Err(ManifestError::NonEmptyField("non-empty signer".to_string())); + } + + Ok(()) } /// Signs a mutation with a secp256k1 private key. -pub fn sign_k1(_mutation: &Mutation, _secret: &k256::ecdsa::SigningKey) -> Result { - unimplemented!("sign_k1") +pub fn sign_k1(m: &Mutation, secret: &SecretKey) -> Result { + let hash = hash_mutation(m)?; + + let sig = pluto_k1util::sign(secret, &hash) + .map_err(|e| ManifestError::Crypto(format!("sign mutation: {}", e)))?; + + let pubkey = secret.public_key(); + let signer = pubkey.to_sec1_bytes().to_vec(); + + Ok(SignedMutation { + mutation: Some(m.clone()), + signer: signer.into(), + signature: sig.to_vec().into(), + }) } /// Verifies a k1-signed mutation. -pub(crate) fn verify_k1_signed_mutation(_signed: &SignedMutation) -> Result<()> { - unimplemented!("verify_k1_signed_mutation") +#[allow(dead_code)] +pub(crate) fn verify_k1_signed_mutation(signed: &SignedMutation) -> Result<()> { + let pubkey = PublicKey::from_sec1_bytes(&signed.signer) + .map_err(|e| ManifestError::K1Key(format!("parse signer pubkey: {}", e)))?; + + let mutation = signed + .mutation + .as_ref() + .ok_or_else(|| ManifestError::InvalidSignedMutation("mutation is nil".to_string()))?; + + let hash = hash_mutation(mutation)?; + + let verified = pluto_k1util::verify_65(&pubkey, &hash, &signed.signature) + .map_err(|e| ManifestError::Crypto(format!("verify signature: {}", e)))?; + + if !verified { + return Err(ManifestError::InvalidSignature); + } + + Ok(()) } /// Converts a legacy cluster validator to a protobuf validator. -pub fn validator_to_proto( - _val: &DistValidator, - _addrs: &ValidatorAddresses, -) -> Result { - unimplemented!("validator_to_proto") +pub fn validator_to_proto(val: &DistValidator, addrs: &ValidatorAddresses) -> Result { + let mut reg_json = Vec::new(); + + if !val.zero_registration() { + // Serialize the BuilderRegistration to JSON + reg_json = serde_json::to_vec(&val.builder_registration).map_err(|e| { + ManifestError::BuilderRegistration(format!("marshal builder registration: {}", e)) + })?; + } + + Ok(Validator { + public_key: val.pub_key.clone().into(), + pub_shares: val.pub_shares.iter().map(|s| s.clone().into()).collect(), + fee_recipient_address: addrs.fee_recipient_address.clone(), + withdrawal_address: addrs.withdrawal_address.clone(), + builder_registration_json: reg_json.into(), + }) } diff --git a/crates/cluster/src/manifest/load.rs b/crates/cluster/src/manifest/load.rs index 188f879e..f6ebecb3 100644 --- a/crates/cluster/src/manifest/load.rs +++ b/crates/cluster/src/manifest/load.rs @@ -7,14 +7,15 @@ use crate::{ manifestpb::v1::{Cluster, SignedMutationList}, }; -use super::{ManifestError, Result}; +use super::Result; /// Loads the current cluster state from disk. /// /// Reads either from cluster manifest or legacy lock file. /// If both files are provided, both files are read and: /// - If cluster hashes don't match, an error is returned -/// - If cluster hashes match, the cluster loaded from the manifest file is returned +/// - If cluster hashes match, the cluster loaded from the manifest file is +/// returned /// /// Returns an error if the cluster can't be loaded from either file. pub fn load_cluster( @@ -52,11 +53,13 @@ where } /// Loads the raw DAG from cluster manifest file on disk. +#[allow(dead_code)] pub(crate) fn load_dag_from_manifest>(_filename: P) -> Result { unimplemented!("load_dag_from_manifest") } /// Loads the raw DAG from legacy lock file on disk. +#[allow(dead_code)] pub(crate) fn load_dag_from_legacy_lock, F: FnOnce(Lock) -> Result<()>>( _filename: P, _lock_callback: Option, @@ -65,6 +68,7 @@ pub(crate) fn load_dag_from_legacy_lock, F: FnOnce(Lock) -> Resul } /// Verifies that cluster hashes match between manifest and legacy DAG. +#[allow(dead_code)] pub(crate) fn cluster_hashes_match( _dag_manifest: &SignedMutationList, _dag_legacy: &SignedMutationList, diff --git a/crates/cluster/src/manifest/materialise.rs b/crates/cluster/src/manifest/materialise.rs index f173e39d..bc136727 100644 --- a/crates/cluster/src/manifest/materialise.rs +++ b/crates/cluster/src/manifest/materialise.rs @@ -2,13 +2,13 @@ use crate::manifestpb::v1::{Cluster, SignedMutationList}; -use super::{ManifestError, Result}; +use super::Result; /// Transforms a raw DAG and returns the resulting cluster manifest. /// /// Applies each mutation in order to build up the final cluster state. -/// Sets `initial_mutation_hash` from the first mutation and `latest_mutation_hash` -/// from the last mutation. +/// Sets `initial_mutation_hash` from the first mutation and +/// `latest_mutation_hash` from the last mutation. /// /// Returns an error if the DAG is empty or any transformation fails. pub fn materialise(_raw_dag: &SignedMutationList) -> Result { diff --git a/crates/cluster/src/manifest/mod.rs b/crates/cluster/src/manifest/mod.rs index 6dd7a4c9..432847ee 100644 --- a/crates/cluster/src/manifest/mod.rs +++ b/crates/cluster/src/manifest/mod.rs @@ -1,7 +1,8 @@ //! Cluster manifest management and coordination. //! -//! This module handles cluster manifest DAG (Directed Acyclic Graph) operations, -//! including loading, materializing, and transforming cluster state through mutations. +//! This module handles cluster manifest DAG (Directed Acyclic Graph) +//! operations, including loading, materializing, and transforming cluster state +//! through mutations. use thiserror::Error; @@ -41,7 +42,9 @@ pub enum ManifestError { }, /// Manifest and legacy cluster hashes don't match. - #[error("manifest and legacy cluster hashes don't match (manifest_hash: {manifest_hash}, legacy_hash: {legacy_hash})")] + #[error( + "manifest and legacy cluster hashes don't match (manifest_hash: {manifest_hash}, legacy_hash: {legacy_hash})" + )] ClusterHashMismatch { /// Manifest hash hex string. manifest_hash: String, @@ -128,6 +131,18 @@ pub enum ManifestError { /// BLS conversion error. #[error("bls conversion error: {0}")] BlsConversion(String), + + /// Builder registration error. + #[error("builder registration error: {0}")] + BuilderRegistration(String), + + /// Invalid lock hash. + #[error("invalid lock hash")] + InvalidLockHash, + + /// Invalid mutation type. + #[error("invalid mutation type: {0}")] + InvalidMutationType(String), } /// Result type alias for manifest operations. diff --git a/crates/cluster/src/manifest/mutation.rs b/crates/cluster/src/manifest/mutation.rs index 6f9b8867..a271bc1c 100644 --- a/crates/cluster/src/manifest/mutation.rs +++ b/crates/cluster/src/manifest/mutation.rs @@ -1,3 +1,4 @@ //! Cluster manifest mutation base types. -// This file is intentionally minimal as mutation logic is split across specific mutation files. +// This file is intentionally minimal as mutation logic is split across specific +// mutation files. diff --git a/crates/cluster/src/manifest/mutationaddvalidator.rs b/crates/cluster/src/manifest/mutationaddvalidator.rs index 4ce8405a..5f129889 100644 --- a/crates/cluster/src/manifest/mutationaddvalidator.rs +++ b/crates/cluster/src/manifest/mutationaddvalidator.rs @@ -2,7 +2,7 @@ use crate::manifestpb::v1::{Cluster, SignedMutation, Validator}; -use super::{ManifestError, Result}; +use super::Result; /// Creates a new gen validators mutation. pub fn new_gen_validators(_parent: &[u8], _validators: Vec) -> Result { @@ -10,12 +10,16 @@ pub fn new_gen_validators(_parent: &[u8], _validators: Vec) -> Result } /// Verifies a gen validators mutation. +#[allow(dead_code)] pub(crate) fn verify_gen_validators(_signed: &SignedMutation) -> Result<()> { unimplemented!("verify_gen_validators") } /// Transforms a cluster with a gen validators mutation. -pub(crate) fn transform_gen_validators(_cluster: &Cluster, _signed: &SignedMutation) -> Result { +pub(crate) fn transform_gen_validators( + _cluster: &Cluster, + _signed: &SignedMutation, +) -> Result { unimplemented!("transform_gen_validators") } @@ -28,6 +32,9 @@ pub fn new_add_validators( } /// Transforms a cluster with an add validators composite mutation. -pub(crate) fn transform_add_validators(_cluster: &Cluster, _signed: &SignedMutation) -> Result { +pub(crate) fn transform_add_validators( + _cluster: &Cluster, + _signed: &SignedMutation, +) -> Result { unimplemented!("transform_add_validators") } diff --git a/crates/cluster/src/manifest/mutationlegacylock.rs b/crates/cluster/src/manifest/mutationlegacylock.rs index d53a9d6b..17744b59 100644 --- a/crates/cluster/src/manifest/mutationlegacylock.rs +++ b/crates/cluster/src/manifest/mutationlegacylock.rs @@ -5,7 +5,7 @@ use crate::{ manifestpb::v1::{Cluster, SignedMutation, SignedMutationList}, }; -use super::{ManifestError, Result}; +use super::Result; /// Creates a new raw legacy lock mutation from JSON bytes. pub fn new_raw_legacy_lock(_json_bytes: &[u8]) -> Result { @@ -28,16 +28,21 @@ pub fn new_cluster_from_lock_for_tests(_lock: &Lock) -> Result { } /// Verifies a legacy lock mutation. +#[allow(dead_code)] pub(crate) fn verify_legacy_lock(_signed: &SignedMutation) -> Result<()> { unimplemented!("verify_legacy_lock") } /// Transforms a cluster with a legacy lock mutation. -pub(crate) fn transform_legacy_lock(_cluster: &Cluster, _signed: &SignedMutation) -> Result { +pub(crate) fn transform_legacy_lock( + _cluster: &Cluster, + _signed: &SignedMutation, +) -> Result { unimplemented!("transform_legacy_lock") } /// Checks if a protobuf message is zero/empty. +#[allow(dead_code)] pub(crate) fn is_zero_proto(_msg: &T) -> bool where T: prost::Message + Default, diff --git a/crates/cluster/src/manifest/mutationnodeapproval.rs b/crates/cluster/src/manifest/mutationnodeapproval.rs index 26afd704..97dada34 100644 --- a/crates/cluster/src/manifest/mutationnodeapproval.rs +++ b/crates/cluster/src/manifest/mutationnodeapproval.rs @@ -2,7 +2,7 @@ use crate::manifestpb::v1::{Cluster, SignedMutation}; -use super::{ManifestError, Result}; +use super::Result; /// Signs a node approval mutation. pub fn sign_node_approval( @@ -23,6 +23,9 @@ pub(crate) fn verify_node_approval(_signed: &SignedMutation) -> Result<()> { } /// Transforms a cluster with a node approvals composite mutation. -pub(crate) fn transform_node_approvals(_cluster: &Cluster, _signed: &SignedMutation) -> Result { +pub(crate) fn transform_node_approvals( + _cluster: &Cluster, + _signed: &SignedMutation, +) -> Result { unimplemented!("transform_node_approvals") } diff --git a/crates/cluster/src/manifest/types.rs b/crates/cluster/src/manifest/types.rs index ab3c0a74..d6428bba 100644 --- a/crates/cluster/src/manifest/types.rs +++ b/crates/cluster/src/manifest/types.rs @@ -1,8 +1,19 @@ //! Cluster manifest mutation types. -use crate::manifestpb::v1::{Cluster, SignedMutation}; +use prost::Message as _; -use super::{ManifestError, Result}; +use crate::{ + lock::Lock, + manifestpb::v1::{Cluster, LegacyLock, SignedMutation}, +}; + +use super::{ + ManifestError, Result, + helpers::{HASH_LEN, hash_signed_mutation}, + mutationaddvalidator::{transform_add_validators, transform_gen_validators}, + mutationlegacylock::transform_legacy_lock, + mutationnodeapproval::{transform_node_approvals, verify_node_approval}, +}; /// Mutation type enumeration. #[derive(Debug, Clone, PartialEq, Eq)] @@ -32,7 +43,7 @@ impl MutationType { } /// Parses a mutation type from a string. - pub fn from_str(s: &str) -> Option { + pub fn parse(s: &str) -> Option { match s { "dv/legacy_lock/v0.0.1" => Some(Self::LegacyLock), "dv/node_approval/v0.0.1" => Some(Self::NodeApproval), @@ -50,21 +61,60 @@ impl MutationType { } /// Transforms the cluster with the given signed mutation. - pub fn transform( - &self, - _cluster: &Cluster, - _signed: &SignedMutation, - ) -> Result { - unimplemented!("MutationType::transform") + pub fn transform(&self, cluster: &Cluster, signed: &SignedMutation) -> Result { + match self { + Self::LegacyLock => transform_legacy_lock(cluster, signed), + Self::NodeApproval => { + verify_node_approval(signed)?; + Ok(cluster.clone()) + } + Self::NodeApprovals => transform_node_approvals(cluster, signed), + Self::GenValidators => transform_gen_validators(cluster, signed), + Self::AddValidators => transform_add_validators(cluster, signed), + } } } /// Calculates the hash of a signed mutation. -pub fn hash(_signed: &SignedMutation) -> Result> { - unimplemented!("hash") +pub fn hash(signed: &SignedMutation) -> Result> { + let mutation = signed + .mutation + .as_ref() + .ok_or_else(|| ManifestError::InvalidSignedMutation("mutation is nil".to_string()))?; + + // Special case for legacy lock: return the lock hash + if mutation.r#type == MutationType::LegacyLock.as_str() { + let data = mutation + .data + .as_ref() + .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; + + let legacy_lock = + LegacyLock::decode(&*data.value).map_err(ManifestError::ProtobufDecode)?; + + let lock: Lock = + serde_json::from_slice(&legacy_lock.json).map_err(ManifestError::Json)?; + + if lock.lock_hash.len() != HASH_LEN { + return Err(ManifestError::InvalidLockHash); + } + + return Ok(lock.lock_hash); + } + + // Otherwise, return the hash of the signed mutation + hash_signed_mutation(signed) } /// Transforms a cluster with a signed mutation. -pub fn transform(_cluster: &Cluster, _signed: &SignedMutation) -> Result { - unimplemented!("transform") +pub fn transform(cluster: &Cluster, signed: &SignedMutation) -> Result { + let mutation = signed + .mutation + .as_ref() + .ok_or_else(|| ManifestError::InvalidSignedMutation("mutation is nil".to_string()))?; + + let typ = MutationType::parse(&mutation.r#type) + .ok_or_else(|| ManifestError::InvalidMutationType(mutation.r#type.clone()))?; + + typ.transform(cluster, signed) } From e69783b1cda7cca74cc2dcf85c73a9f25630f289 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Wed, 4 Feb 2026 14:07:55 +0700 Subject: [PATCH 03/14] feat: implement load --- crates/cluster/src/manifest/load.rs | 143 +++++++++++++----- crates/cluster/src/manifest/materialise.rs | 26 +++- .../src/manifest/mutationlegacylock.rs | 43 +++++- crates/cluster/src/manifest/types.rs | 5 +- 4 files changed, 172 insertions(+), 45 deletions(-) diff --git a/crates/cluster/src/manifest/load.rs b/crates/cluster/src/manifest/load.rs index f6ebecb3..46bc2787 100644 --- a/crates/cluster/src/manifest/load.rs +++ b/crates/cluster/src/manifest/load.rs @@ -2,76 +2,151 @@ use std::path::Path; +use prost::Message as _; + use crate::{ lock::Lock, manifestpb::v1::{Cluster, SignedMutationList}, }; -use super::Result; +use super::{ + ManifestError, Result, materialise::materialise, mutationlegacylock::new_raw_legacy_lock, types, +}; -/// Loads the current cluster state from disk. +/// Returns the current cluster state from disk by reading either from cluster +/// manifest or legacy lock file. /// -/// Reads either from cluster manifest or legacy lock file. -/// If both files are provided, both files are read and: +/// If both files are provided, both files are +/// read and: /// - If cluster hashes don't match, an error is returned /// - If cluster hashes match, the cluster loaded from the manifest file is -/// returned +/// returned. /// /// Returns an error if the cluster can't be loaded from either file. -pub fn load_cluster( - _manifest_file: P1, - _legacy_lock_file: P2, - _lock_callback: Option, +pub fn load_cluster( + manifest_file: impl AsRef, + legacy_lock_file: impl AsRef, + lock_callback: Option, ) -> Result where - P1: AsRef, - P2: AsRef, F: FnOnce(Lock) -> Result<()>, { - unimplemented!("load_cluster") + let dag = load_dag(manifest_file, legacy_lock_file, lock_callback)?; + materialise(&dag) } -/// Loads the raw cluster DAG from disk. +/// Returns the raw cluster DAG from disk by reading either from cluster +/// manifest or legacy lock file. /// -/// Reads either from cluster manifest or legacy lock file. -/// If both files are provided, both files are read and: +/// If both files are provided, both files are +/// read and: /// - If cluster hashes don't match, an error is returned /// - If cluster hashes match, the DAG loaded from the manifest file is returned /// /// Returns an error if the DAG can't be loaded from either file. -pub fn load_dag( - _manifest_file: P1, - _legacy_lock_file: P2, - _lock_callback: Option, +pub fn load_dag( + manifest_file: impl AsRef, + legacy_lock_file: impl AsRef, + lock_callback: Option, ) -> Result where - P1: AsRef, - P2: AsRef, F: FnOnce(Lock) -> Result<()>, { - unimplemented!("load_dag") + let manifest_result = load_dag_from_manifest(&manifest_file); + let legacy_result = load_dag_from_legacy_lock(&legacy_lock_file, lock_callback); + + match (manifest_result, legacy_result) { + // Both files loaded successfully, check if cluster hashes match + (Ok(dag_manifest), Ok(dag_legacy)) => { + cluster_hashes_match(&dag_manifest, &dag_legacy)?; + Ok(dag_manifest.clone()) + } + // Only manifest loaded successfully + (Ok(dag_manifest), Err(_)) => Ok(dag_manifest), + // Only legacy lock loaded successfully + (Err(_), Ok(dag_legacy)) => Ok(dag_legacy), + // Both failed + (Err(err_manifest), Err(err_legacy)) => { + // Check if both files don't exist + let manifest_not_found = matches!(&err_manifest, ManifestError::Io(e) if e.kind() == std::io::ErrorKind::NotFound); + let legacy_not_found = matches!(&err_legacy, ManifestError::Io(e) if e.kind() == std::io::ErrorKind::NotFound); + + if manifest_not_found && legacy_not_found { + return Err(ManifestError::NoFileFound { + lock_file: legacy_lock_file.as_ref().display().to_string(), + manifest_file: manifest_file.as_ref().display().to_string(), + }); + } + + // Return legacy lock error if it exists but failed to load + if !legacy_not_found { + return Err(ManifestError::InvalidMutation(format!( + "couldn't load cluster from legacy lock file: {}", + err_legacy + ))); + } + + // Otherwise return manifest error + Err(ManifestError::InvalidMutation(format!( + "couldn't load cluster from manifest file: {}", + err_manifest + ))) + } + } } /// Loads the raw DAG from cluster manifest file on disk. -#[allow(dead_code)] -pub(crate) fn load_dag_from_manifest>(_filename: P) -> Result { - unimplemented!("load_dag_from_manifest") +pub(crate) fn load_dag_from_manifest(filename: impl AsRef) -> Result { + let bytes = std::fs::read(filename.as_ref())?; + let raw_dag = SignedMutationList::decode(&*bytes)?; + Ok(raw_dag) } /// Loads the raw DAG from legacy lock file on disk. -#[allow(dead_code)] -pub(crate) fn load_dag_from_legacy_lock, F: FnOnce(Lock) -> Result<()>>( - _filename: P, - _lock_callback: Option, +pub(crate) fn load_dag_from_legacy_lock Result<()>>( + filename: impl AsRef, + lock_callback: Option, ) -> Result { - unimplemented!("load_dag_from_legacy_lock") + let bytes = std::fs::read(filename)?; + + let lock: Lock = serde_json::from_slice(&bytes)?; + + if let Some(callback) = lock_callback { + callback(lock)?; + } + + let legacy = new_raw_legacy_lock(&bytes)?; + + Ok(SignedMutationList { + mutations: vec![legacy], + }) } /// Verifies that cluster hashes match between manifest and legacy DAG. -#[allow(dead_code)] pub(crate) fn cluster_hashes_match( - _dag_manifest: &SignedMutationList, - _dag_legacy: &SignedMutationList, + dag_manifest: &SignedMutationList, + dag_legacy: &SignedMutationList, ) -> Result<()> { - unimplemented!("cluster_hashes_match") + let hash_manifest = types::hash( + dag_manifest + .mutations + .first() + .ok_or(ManifestError::EmptyDAG)?, + )?; + + let hash_legacy = types::hash( + dag_legacy + .mutations + .first() + .ok_or(ManifestError::EmptyDAG)?, + )?; + + if hash_manifest != hash_legacy { + return Err(ManifestError::ClusterHashMismatch { + manifest_hash: hex::encode(&hash_manifest), + legacy_hash: hex::encode(&hash_legacy), + }); + } + + Ok(()) } diff --git a/crates/cluster/src/manifest/materialise.rs b/crates/cluster/src/manifest/materialise.rs index bc136727..e7930785 100644 --- a/crates/cluster/src/manifest/materialise.rs +++ b/crates/cluster/src/manifest/materialise.rs @@ -2,7 +2,7 @@ use crate::manifestpb::v1::{Cluster, SignedMutationList}; -use super::Result; +use super::{ManifestError, Result, types}; /// Transforms a raw DAG and returns the resulting cluster manifest. /// @@ -11,6 +11,26 @@ use super::Result; /// `latest_mutation_hash` from the last mutation. /// /// Returns an error if the DAG is empty or any transformation fails. -pub fn materialise(_raw_dag: &SignedMutationList) -> Result { - unimplemented!("materialise") +pub fn materialise(raw_dag: &SignedMutationList) -> Result { + if raw_dag.mutations.is_empty() { + return Err(ManifestError::EmptyDAG); + } + + let mut cluster = Cluster::default(); + + for signed in &raw_dag.mutations { + cluster = types::transform(&cluster, signed)?; + } + + // initial_mutation_hash is the hash of the first mutation + // SAFETY: We already checked that mutations is not empty above + cluster.initial_mutation_hash = + types::hash(raw_dag.mutations.first().ok_or(ManifestError::EmptyDAG)?)?.into(); + + // LatestMutationHash is the hash of the last mutation + // SAFETY: We already checked that mutations is not empty above + cluster.latest_mutation_hash = + types::hash(raw_dag.mutations.last().ok_or(ManifestError::EmptyDAG)?)?.into(); + + Ok(cluster) } diff --git a/crates/cluster/src/manifest/mutationlegacylock.rs b/crates/cluster/src/manifest/mutationlegacylock.rs index 17744b59..fd4fa5ea 100644 --- a/crates/cluster/src/manifest/mutationlegacylock.rs +++ b/crates/cluster/src/manifest/mutationlegacylock.rs @@ -1,15 +1,46 @@ -//! Legacy lock mutation implementation. - use crate::{ lock::Lock, - manifestpb::v1::{Cluster, SignedMutation, SignedMutationList}, + manifestpb::v1::{Cluster, LegacyLock, Mutation, SignedMutation, SignedMutationList}, }; -use super::Result; +use super::{ManifestError, Result, types::MutationType}; + +impl ::prost::Name for LegacyLock { + const NAME: &'static str = "LegacyLock"; + const PACKAGE: &'static str = "cluster.manifestpb.v1"; + + fn type_url() -> ::prost::alloc::string::String { + format!( + "type.googleapis.com/{}", + ::full_name() + ) + } +} /// Creates a new raw legacy lock mutation from JSON bytes. -pub fn new_raw_legacy_lock(_json_bytes: &[u8]) -> Result { - unimplemented!("new_raw_legacy_lock") +pub fn new_raw_legacy_lock(json_bytes: &[u8]) -> Result { + // Verify that the bytes are a valid lock by deserializing + let _: Lock = serde_json::from_slice(json_bytes) + .map_err(|e| ManifestError::InvalidMutation(format!("unmarshal lock: {}", e)))?; + + let legacy_lock = LegacyLock { + json: json_bytes.to_vec().into(), + }; + + let lock_any = prost_types::Any::from_msg(&legacy_lock) + .map_err(|e| ManifestError::InvalidMutation(format!("lock to any: {e}")))?; + + let zero_parent = vec![0u8; 32]; + + Ok(SignedMutation { + mutation: Some(Mutation { + parent: zero_parent.into(), + r#type: MutationType::LegacyLock.as_str().to_string(), + data: Some(lock_any), + }), + signer: Default::default(), + signature: Default::default(), + }) } /// Creates a new legacy lock mutation for testing. diff --git a/crates/cluster/src/manifest/types.rs b/crates/cluster/src/manifest/types.rs index d6428bba..61890308 100644 --- a/crates/cluster/src/manifest/types.rs +++ b/crates/cluster/src/manifest/types.rs @@ -76,6 +76,7 @@ impl MutationType { } /// Calculates the hash of a signed mutation. +/// NOTE: @iamquang95 this could be a method of `SignedMutation` pub fn hash(signed: &SignedMutation) -> Result> { let mutation = signed .mutation @@ -92,8 +93,7 @@ pub fn hash(signed: &SignedMutation) -> Result> { let legacy_lock = LegacyLock::decode(&*data.value).map_err(ManifestError::ProtobufDecode)?; - let lock: Lock = - serde_json::from_slice(&legacy_lock.json).map_err(ManifestError::Json)?; + let lock: Lock = serde_json::from_slice(&legacy_lock.json).map_err(ManifestError::Json)?; if lock.lock_hash.len() != HASH_LEN { return Err(ManifestError::InvalidLockHash); @@ -107,6 +107,7 @@ pub fn hash(signed: &SignedMutation) -> Result> { } /// Transforms a cluster with a signed mutation. +/// NOTE: @iamquang95 this could be a method of `SignedMutation` pub fn transform(cluster: &Cluster, signed: &SignedMutation) -> Result { let mutation = signed .mutation From 90d7248f70d63aee40450ae16de64b1653870ac3 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Thu, 5 Feb 2026 11:36:38 +0700 Subject: [PATCH 04/14] feat: implement mutations --- Cargo.lock | 1 + crates/cluster/Cargo.toml | 3 + crates/cluster/src/manifest/cluster.rs | 156 ++++++++- crates/cluster/src/manifest/helpers.rs | 4 +- crates/cluster/src/manifest/mod.rs | 29 +- .../src/manifest/mutationaddvalidator.rs | 311 +++++++++++++++++- .../src/manifest/mutationlegacylock.rs | 224 +++++++++++-- .../src/manifest/mutationnodeapproval.rs | 291 +++++++++++++++- crates/cluster/src/manifest/types.rs | 4 +- 9 files changed, 947 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3a746b0..c0b6b500 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5417,6 +5417,7 @@ dependencies = [ "pluto-eth2util", "pluto-k1util", "pluto-p2p", + "pluto-testutil", "prost 0.14.3", "prost-build", "prost-types 0.14.3", diff --git a/crates/cluster/Cargo.toml b/crates/cluster/Cargo.toml index d5b98b8e..a529a647 100644 --- a/crates/cluster/Cargo.toml +++ b/crates/cluster/Cargo.toml @@ -27,5 +27,8 @@ k256.workspace = true [build-dependencies] prost-build.workspace = true +[dev-dependencies] +pluto-testutil.workspace = true + [lints] workspace = true diff --git a/crates/cluster/src/manifest/cluster.rs b/crates/cluster/src/manifest/cluster.rs index bcd52898..53c814c8 100644 --- a/crates/cluster/src/manifest/cluster.rs +++ b/crates/cluster/src/manifest/cluster.rs @@ -1,39 +1,165 @@ -//! Cluster manifest helper functions for peer and validator operations. +use std::collections::HashSet; + +use libp2p::PeerId; +use pluto_crypto::types::{PUBLIC_KEY_LENGTH, PublicKey}; +use pluto_eth2util::enr::Record; +use pluto_p2p::peer::Peer; use crate::{ definition::NodeIdx, + helpers::to_0x_hex, manifestpb::v1::{Cluster, Validator}, }; -use pluto_p2p::peer::Peer; -use super::Result; +use super::{ManifestError, Result}; /// Returns the cluster operators as a slice of p2p peers. -pub fn cluster_peers(_cluster: &Cluster) -> Result> { - unimplemented!("cluster_peers") +pub fn cluster_peers(cluster: &Cluster) -> Result> { + if cluster.operators.is_empty() { + return Err(ManifestError::InvalidCluster); + } + + let mut resp = Vec::new(); + let mut dedup = HashSet::new(); + + for (i, operator) in cluster.operators.iter().enumerate() { + if dedup.contains(&operator.enr) { + return Err(ManifestError::DuplicatePeerENR { + enr: operator.enr.clone(), + }); + } + dedup.insert(operator.enr.clone()); + + let record = Record::try_from(operator.enr.as_str()) + .map_err(|e| ManifestError::EnrParse(format!("decode enr: {}", e)))?; + + let peer = Peer::from_enr(&record, i) + .map_err(|e| ManifestError::P2p(format!("create peer from enr: {}", e)))?; + + resp.push(peer); + } + + Ok(resp) } /// Returns the operators p2p peer IDs. -pub fn cluster_peer_ids(_cluster: &Cluster) -> Result> { - unimplemented!("cluster_peer_ids") +pub fn cluster_peer_ids(cluster: &Cluster) -> Result> { + let peers = cluster_peers(cluster)?; + Ok(peers.iter().map(|p| p.id).collect()) } /// Returns the node index for the peer in the cluster. -pub fn cluster_node_idx(_cluster: &Cluster, _peer_id: &str) -> Result { - unimplemented!("cluster_node_idx") +pub fn cluster_node_idx(cluster: &Cluster, peer_id: &PeerId) -> Result { + let peers = cluster_peers(cluster)?; + + for (i, p) in peers.iter().enumerate() { + if p.id == *peer_id { + return Ok(NodeIdx { + peer_idx: i, // 0-indexed + share_idx: i.saturating_add(1), // 1-indexed + }); + } + } + + Err(ManifestError::PeerNotInDefinition) } /// Returns the validator BLS group public key. -pub fn validator_public_key(_validator: &Validator) -> Result> { - unimplemented!("validator_public_key") +pub fn validator_public_key(validator: &Validator) -> Result { + let pk_vec = validator.public_key.to_vec(); + pk_vec + .try_into() + .map_err(|_| ManifestError::InvalidHexLength { + expect: PUBLIC_KEY_LENGTH, + actual: validator.public_key.len(), + }) } /// Returns the validator hex group public key. -pub fn validator_public_key_hex(_validator: &Validator) -> String { - unimplemented!("validator_public_key_hex") +pub fn validator_public_key_hex(validator: &Validator) -> String { + to_0x_hex(&validator.public_key) } /// Returns the validator's peerIdx'th BLS public share. -pub fn validator_public_share(_validator: &Validator, _peer_idx: usize) -> Result> { - unimplemented!("validator_public_share") +pub fn validator_public_share(validator: &Validator, peer_idx: usize) -> Result { + let share = validator + .pub_shares + .get(peer_idx) + .ok_or(ManifestError::InvalidCluster)?; + + let share_vec = share.to_vec(); + share_vec + .try_into() + .map_err(|_| ManifestError::InvalidHexLength { + expect: PUBLIC_KEY_LENGTH, + actual: share.len(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifestpb::v1::Operator; + + #[test] + fn test_cluster_peers_empty() { + let cluster = Cluster::default(); + let result = cluster_peers(&cluster); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no operators")); + } + + #[test] + fn test_cluster_peers_duplicate_enr() { + let cluster = Cluster { + operators: vec![ + Operator { + address: "0x123".to_string(), + enr: "enr:-test".to_string(), + }, + Operator { + address: "0x456".to_string(), + enr: "enr:-test".to_string(), // duplicate + }, + ], + ..Default::default() + }; + let result = cluster_peers(&cluster); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("duplicate")); + } + + #[test] + fn test_validator_public_key_hex() { + let validator = Validator { + public_key: vec![0x01, 0x02, 0x03].into(), + ..Default::default() + }; + + let hex = validator_public_key_hex(&validator); + assert_eq!(hex, "0x010203"); + } + + #[test] + fn test_validator_public_share() { + let mut share0 = vec![0u8; PUBLIC_KEY_LENGTH]; + share0[0] = 0x01; + let mut share1 = vec![0u8; PUBLIC_KEY_LENGTH]; + share1[0] = 0x02; + + let validator = Validator { + pub_shares: vec![share0.into(), share1.into()], + ..Default::default() + }; + + let result0 = validator_public_share(&validator, 0).unwrap(); + assert_eq!(result0[0], 0x01); + assert_eq!(result0.len(), PUBLIC_KEY_LENGTH); + + let result1 = validator_public_share(&validator, 1).unwrap(); + assert_eq!(result1[0], 0x02); + assert_eq!(result1.len(), PUBLIC_KEY_LENGTH); + + assert!(validator_public_share(&validator, 5).is_err()); + } } diff --git a/crates/cluster/src/manifest/helpers.rs b/crates/cluster/src/manifest/helpers.rs index 81b97614..332e5c0f 100644 --- a/crates/cluster/src/manifest/helpers.rs +++ b/crates/cluster/src/manifest/helpers.rs @@ -36,7 +36,7 @@ pub(crate) fn hash_signed_mutation(signed: &SignedMutation) -> Result> { let mutation = signed .mutation .as_ref() - .ok_or_else(|| ManifestError::InvalidSignedMutation("mutation is nil".to_string()))?; + .ok_or(ManifestError::InvalidSignedMutation)?; let mut hasher = Sha256::new(); @@ -117,7 +117,7 @@ pub(crate) fn verify_k1_signed_mutation(signed: &SignedMutation) -> Result<()> { let mutation = signed .mutation .as_ref() - .ok_or_else(|| ManifestError::InvalidSignedMutation("mutation is nil".to_string()))?; + .ok_or(ManifestError::InvalidSignedMutation)?; let hash = hash_mutation(mutation)?; diff --git a/crates/cluster/src/manifest/mod.rs b/crates/cluster/src/manifest/mod.rs index 432847ee..a992512a 100644 --- a/crates/cluster/src/manifest/mod.rs +++ b/crates/cluster/src/manifest/mod.rs @@ -52,9 +52,9 @@ pub enum ManifestError { legacy_hash: String, }, - /// Invalid signed mutation. - #[error("invalid signed mutation: {0}")] - InvalidSignedMutation(String), + /// Mutation is nil. + #[error("mutation is nil")] + InvalidSignedMutation, /// Invalid mutation. #[error("invalid mutation: {0}")] @@ -69,8 +69,8 @@ pub enum ManifestError { InvalidSignature, /// Invalid cluster. - #[error("invalid cluster: {0}")] - InvalidCluster(String), + #[error("invalid cluster")] + InvalidCluster, /// Cluster contains duplicate peer ENRs. #[error("cluster contains duplicate peer enrs: {enr}")] @@ -147,3 +147,22 @@ pub enum ManifestError { /// Result type alias for manifest operations. pub type Result = std::result::Result; + +/// Extracts and validates a mutation from a signed mutation. +pub(crate) fn extract_mutation( + signed: &crate::manifestpb::v1::SignedMutation, + expected_type: types::MutationType, +) -> Result<&crate::manifestpb::v1::Mutation> { + let mutation = signed + .mutation + .as_ref() + .ok_or(ManifestError::InvalidSignedMutation)?; + + if mutation.r#type != expected_type.as_str() { + return Err(ManifestError::InvalidMutation( + "invalid mutation type".to_string(), + )); + } + + Ok(mutation) +} diff --git a/crates/cluster/src/manifest/mutationaddvalidator.rs b/crates/cluster/src/manifest/mutationaddvalidator.rs index 5f129889..35aedd5d 100644 --- a/crates/cluster/src/manifest/mutationaddvalidator.rs +++ b/crates/cluster/src/manifest/mutationaddvalidator.rs @@ -1,40 +1,315 @@ //! Add validators mutation implementation. -use crate::manifestpb::v1::{Cluster, SignedMutation, Validator}; +use prost::Message as _; -use super::Result; +use crate::{ + helpers::from_0x_hex_str, + manifestpb::v1::{ + Cluster, Mutation, SignedMutation, SignedMutationList, Validator, ValidatorList, + }, +}; + +use super::{ + ManifestError, Result, extract_mutation, + helpers::{HASH_LEN, verify_empty_sig}, + types::{self, MutationType}, +}; + +/// Ethereum address length in bytes. +const ADDRESS_LEN: usize = 20; + +impl ::prost::Name for ValidatorList { + const NAME: &'static str = "ValidatorList"; + const PACKAGE: &'static str = "cluster.manifestpb.v1"; + + fn type_url() -> ::prost::alloc::string::String { + format!( + "type.googleapis.com/{}", + ::full_name() + ) + } +} /// Creates a new gen validators mutation. -pub fn new_gen_validators(_parent: &[u8], _validators: Vec) -> Result { - unimplemented!("new_gen_validators") +pub fn new_gen_validators(parent: &[u8], validators: Vec) -> Result { + verify_gen_validators_list(&validators)?; + + if parent.len() != HASH_LEN { + return Err(ManifestError::InvalidMutation( + "invalid parent hash".to_string(), + )); + } + + let vals_any = prost_types::Any::from_msg(&ValidatorList { validators }) + .map_err(|e| ManifestError::InvalidMutation(format!("marshal validators: {}", e)))?; + + Ok(SignedMutation { + mutation: Some(Mutation { + parent: parent.to_vec().into(), + r#type: MutationType::GenValidators.as_str().to_string(), + data: Some(vals_any), + }), + // No signer or signature + signer: Default::default(), + signature: Default::default(), + }) } -/// Verifies a gen validators mutation. -#[allow(dead_code)] -pub(crate) fn verify_gen_validators(_signed: &SignedMutation) -> Result<()> { - unimplemented!("verify_gen_validators") +/// Verifies a gen validators list, ensuring validators are populated with valid +/// addresses. +fn verify_gen_validators_list(vals: &[Validator]) -> Result<()> { + if vals.is_empty() { + return Err(ManifestError::InvalidMutation("no validators".to_string())); + } + + for validator in vals { + from_0x_hex_str(&validator.fee_recipient_address, ADDRESS_LEN).map_err(|e| { + ManifestError::InvalidMutation(format!("validate fee recipient address: {}", e)) + })?; + + from_0x_hex_str(&validator.withdrawal_address, ADDRESS_LEN).map_err(|e| { + ManifestError::InvalidMutation(format!("validate withdrawal address: {}", e)) + })?; + } + + Ok(()) } /// Transforms a cluster with a gen validators mutation. +/// NOTE: @iamquang95, should we mutate the cluster? pub(crate) fn transform_gen_validators( - _cluster: &Cluster, - _signed: &SignedMutation, + cluster: &Cluster, + signed: &SignedMutation, ) -> Result { - unimplemented!("transform_gen_validators") + verify_empty_sig(signed)?; + + let mutation = extract_mutation(signed, MutationType::GenValidators)?; + + let data = mutation + .data + .as_ref() + .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; + + let vals = ValidatorList::decode(&*data.value) + .map_err(|e| ManifestError::InvalidMutation(format!("unmarshal validators: {}", e)))?; + + let mut result = cluster.clone(); + result.validators.extend(vals.validators); + + Ok(result) } -/// Creates a new add validators composite mutation. +/// Creates a new add validators composite mutation from the provided gen +/// validators and node approvals. pub fn new_add_validators( - _gen_validators: &SignedMutation, - _node_approvals: &SignedMutation, + gen_validators: &SignedMutation, + node_approvals: &SignedMutation, ) -> Result { - unimplemented!("new_add_validators") + let gen_mutation = extract_mutation(gen_validators, MutationType::GenValidators)?; + let _node_approvals_mutation = extract_mutation(node_approvals, MutationType::NodeApprovals)?; + + let data_any = prost_types::Any::from_msg(&SignedMutationList { + mutations: vec![gen_validators.clone(), node_approvals.clone()], + }) + .map_err(|e| ManifestError::InvalidMutation(format!("marshal signed mutation list: {}", e)))?; + + Ok(SignedMutation { + mutation: Some(Mutation { + parent: gen_mutation.parent.clone(), + r#type: MutationType::AddValidators.as_str().to_string(), + data: Some(data_any), + }), + // Composite mutations have no signer or signature + signer: Default::default(), + signature: Default::default(), + }) } /// Transforms a cluster with an add validators composite mutation. pub(crate) fn transform_add_validators( - _cluster: &Cluster, - _signed: &SignedMutation, + cluster: &Cluster, + signed: &SignedMutation, ) -> Result { - unimplemented!("transform_add_validators") + verify_empty_sig(signed)?; + + let mutation = extract_mutation(signed, MutationType::AddValidators)?; + + let data = mutation + .data + .as_ref() + .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; + + let list = SignedMutationList::decode(&*data.value).map_err(|e| { + ManifestError::InvalidMutation(format!("unmarshal signed mutation list: {}", e)) + })?; + + if list.mutations.len() != 2 { + return Err(ManifestError::InvalidMutation( + "invalid mutation list length".to_string(), + )); + } + + let gen_validators = &list.mutations[0]; + let node_approvals = &list.mutations[1]; + + let gen_mutation = extract_mutation(gen_validators, MutationType::GenValidators)?; + + if mutation.parent != gen_mutation.parent { + return Err(ManifestError::InvalidMutation( + "invalid gen validators parent".to_string(), + )); + } + + let approvals_mutation = extract_mutation(node_approvals, MutationType::NodeApprovals)?; + + let gen_hash = types::hash(gen_validators)?; + if gen_hash != approvals_mutation.parent.to_vec() { + return Err(ManifestError::InvalidMutation( + "invalid node approvals parent".to_string(), + )); + } + + let result = types::transform(cluster, gen_validators)?; + + let result = types::transform(&result, node_approvals)?; + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_validator(idx: u8) -> Validator { + Validator { + public_key: vec![idx; 48].into(), + pub_shares: vec![vec![idx; 48].into()], + fee_recipient_address: format!("0x{}", "ab".repeat(20)), + withdrawal_address: format!("0x{}", "cd".repeat(20)), + builder_registration_json: vec![].into(), + } + } + + #[test] + fn test_new_gen_validators() { + let parent = [0u8; 32]; + let validators = vec![create_test_validator(1), create_test_validator(2)]; + + let signed = new_gen_validators(&parent, validators.clone()).unwrap(); + + assert!(signed.mutation.is_some()); + let mutation = signed.mutation.as_ref().unwrap(); + assert_eq!(mutation.r#type, MutationType::GenValidators.as_str()); + assert!(signed.signer.is_empty()); + assert!(signed.signature.is_empty()); + } + + #[test] + fn test_new_gen_validators_empty() { + let parent = [0u8; 32]; + let validators = vec![]; + + let result = new_gen_validators(&parent, validators); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no validators")); + } + + #[test] + fn test_new_gen_validators_invalid_parent() { + let parent = [0u8; 16]; // Invalid length + let validators = vec![create_test_validator(1)]; + + let result = new_gen_validators(&parent, validators); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid parent hash") + ); + } + + #[test] + fn test_new_gen_validators_invalid_address() { + let parent = [0u8; 32]; + let mut validator = create_test_validator(1); + validator.fee_recipient_address = "invalid".to_string(); + + let result = new_gen_validators(&parent, vec![validator]); + assert!(result.is_err()); + } + + #[test] + fn test_transform_gen_validators() { + let parent = [0u8; 32]; + let validators = vec![create_test_validator(1), create_test_validator(2)]; + + let signed = new_gen_validators(&parent, validators.clone()).unwrap(); + + let cluster = Cluster::default(); + let result = transform_gen_validators(&cluster, &signed).unwrap(); + + assert_eq!(result.validators.len(), 2); + } + + #[test] + fn test_new_add_validators_invalid_gen_type() { + // Create a mutation with wrong type + let wrong_type = SignedMutation { + mutation: Some(Mutation { + parent: vec![0u8; 32].into(), + r#type: MutationType::NodeApproval.as_str().to_string(), + data: None, + }), + ..Default::default() + }; + + let node_approvals = SignedMutation { + mutation: Some(Mutation { + parent: vec![0u8; 32].into(), + r#type: MutationType::NodeApprovals.as_str().to_string(), + data: None, + }), + ..Default::default() + }; + + let result = new_add_validators(&wrong_type, &node_approvals); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid mutation type") + ); + } + + #[test] + fn test_new_add_validators_invalid_approvals_type() { + let gen_validators = SignedMutation { + mutation: Some(Mutation { + parent: vec![0u8; 32].into(), + r#type: MutationType::GenValidators.as_str().to_string(), + data: None, + }), + ..Default::default() + }; + + let wrong_type = SignedMutation { + mutation: Some(Mutation { + parent: vec![0u8; 32].into(), + r#type: MutationType::NodeApproval.as_str().to_string(), + data: None, + }), + ..Default::default() + }; + + let result = new_add_validators(&gen_validators, &wrong_type); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid mutation type") + ); + } } diff --git a/crates/cluster/src/manifest/mutationlegacylock.rs b/crates/cluster/src/manifest/mutationlegacylock.rs index fd4fa5ea..74da2b93 100644 --- a/crates/cluster/src/manifest/mutationlegacylock.rs +++ b/crates/cluster/src/manifest/mutationlegacylock.rs @@ -1,9 +1,18 @@ +//! Legacy lock mutation implementation. + +use prost::Message as _; + use crate::{ lock::Lock, - manifestpb::v1::{Cluster, LegacyLock, Mutation, SignedMutation, SignedMutationList}, + manifestpb::v1::{Cluster, LegacyLock, Mutation, Operator, SignedMutation, SignedMutationList}, }; -use super::{ManifestError, Result, types::MutationType}; +use super::{ + ManifestError, Result, extract_mutation, + helpers::{validator_to_proto, verify_empty_sig}, + materialise::materialise, + types::MutationType, +}; impl ::prost::Name for LegacyLock { const NAME: &'static str = "LegacyLock"; @@ -44,39 +53,214 @@ pub fn new_raw_legacy_lock(json_bytes: &[u8]) -> Result { } /// Creates a new legacy lock mutation for testing. -pub fn new_legacy_lock_for_tests(_lock: &Lock) -> Result { - unimplemented!("new_legacy_lock_for_tests") +/// NOTE: @iamquang95 do we need this +/// +/// This method only supports valid locks (where re-calculating the lock hash +/// matches the existing hash). Use `new_raw_legacy_lock` for --no-verify +/// support. +pub fn new_legacy_lock_for_tests(lock: &Lock) -> Result { + // Marshalling below re-calculates the lock hash, so ensure it matches. + let mut lock_copy = lock.clone(); + lock_copy + .set_lock_hash() + .map_err(|e| ManifestError::InvalidMutation(format!("set lock hash: {}", e)))?; + + if lock_copy.lock_hash != lock.lock_hash { + return Err(ManifestError::InvalidMutation( + "this method only supports valid locks, use new_raw_legacy_lock for --no-verify support" + .to_string(), + )); + } + + let json_bytes = serde_json::to_vec(lock) + .map_err(|e| ManifestError::InvalidMutation(format!("marshal lock: {}", e)))?; + + new_raw_legacy_lock(&json_bytes) } /// Creates a new DAG from a legacy lock for testing. -pub fn new_dag_from_lock_for_tests(_lock: &Lock) -> Result { - unimplemented!("new_dag_from_lock_for_tests") +/// NOTE: @iamquang95 do we need this +pub fn new_dag_from_lock_for_tests(lock: &Lock) -> Result { + let signed = new_legacy_lock_for_tests(lock)?; + Ok(SignedMutationList { + mutations: vec![signed], + }) } /// Creates a new cluster from a legacy lock for testing. -pub fn new_cluster_from_lock_for_tests(_lock: &Lock) -> Result { - unimplemented!("new_cluster_from_lock_for_tests") +/// NOTE: @iamquang95 do we need this +pub fn new_cluster_from_lock_for_tests(lock: &Lock) -> Result { + let signed = new_legacy_lock_for_tests(lock)?; + materialise(&SignedMutationList { + mutations: vec![signed], + }) } /// Verifies a legacy lock mutation. -#[allow(dead_code)] -pub(crate) fn verify_legacy_lock(_signed: &SignedMutation) -> Result<()> { - unimplemented!("verify_legacy_lock") +pub fn verify_legacy_lock(signed: &SignedMutation) -> Result<()> { + let mutation = extract_mutation(signed, MutationType::LegacyLock)?; + + verify_empty_sig(signed)?; + + let data = mutation + .data + .as_ref() + .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; + + let legacy_lock = LegacyLock::decode(&*data.value) + .map_err(|_| ManifestError::InvalidMutation("mutation data to legacy lock".to_string()))?; + + let _lock: Lock = serde_json::from_slice(&legacy_lock.json) + .map_err(|e| ManifestError::InvalidMutation(format!("unmarshal lock: {}", e)))?; + + Ok(()) } /// Transforms a cluster with a legacy lock mutation. -pub(crate) fn transform_legacy_lock( - _cluster: &Cluster, - _signed: &SignedMutation, -) -> Result { - unimplemented!("transform_legacy_lock") +pub(crate) fn transform_legacy_lock(cluster: &Cluster, signed: &SignedMutation) -> Result { + if !is_zero_proto(cluster) { + return Err(ManifestError::InvalidMutation( + "legacy lock not first mutation".to_string(), + )); + } + + verify_legacy_lock(signed)?; + + let mutation = signed + .mutation + .as_ref() + .ok_or(ManifestError::InvalidSignedMutation)?; + + let data = mutation + .data + .as_ref() + .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; + + let legacy_lock = LegacyLock::decode(&*data.value) + .map_err(|_| ManifestError::InvalidMutation("mutation data to legacy lock".to_string()))?; + + let lock: Lock = serde_json::from_slice(&legacy_lock.json) + .map_err(|e| ManifestError::InvalidMutation(format!("unmarshal lock: {}", e)))?; + + // Build operators + let mut ops = Vec::new(); + for operator in &lock.operators { + ops.push(Operator { + address: operator.address.clone(), + enr: operator.enr.clone(), + }); + } + + // Check validator addresses length matches validators length + if lock.validator_addresses.len() != lock.distributed_validators.len() { + return Err(ManifestError::InvalidMutation( + "validator addresses and validators length mismatch".to_string(), + )); + } + + // Build validators + let mut vals = Vec::new(); + for (i, validator) in lock.distributed_validators.iter().enumerate() { + let val = validator_to_proto(validator, &lock.validator_addresses[i])?; + vals.push(val); + } + + Ok(Cluster { + name: lock.name.clone(), + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + threshold: lock.threshold as i32, + dkg_algorithm: lock.dkg_algorithm.clone(), + fork_version: lock.fork_version.clone().into(), + consensus_protocol: lock.consensus_protocol.clone(), + #[allow(clippy::cast_possible_truncation)] + target_gas_limit: lock.target_gas_limit as u32, + compounding: lock.compounding, + validators: vals, + operators: ops, + // These will be set by materialise + initial_mutation_hash: Default::default(), + latest_mutation_hash: Default::default(), + }) } /// Checks if a protobuf message is zero/empty. -#[allow(dead_code)] -pub(crate) fn is_zero_proto(_msg: &T) -> bool +pub(crate) fn is_zero_proto(msg: &T) -> bool where - T: prost::Message + Default, + T: prost::Message + Default + PartialEq, { - unimplemented!("is_zero_proto") + *msg == T::default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_zero_proto() { + let cluster = Cluster::default(); + assert!(is_zero_proto(&cluster)); + + let cluster_with_name = Cluster { + name: "test".to_string(), + ..Default::default() + }; + assert!(!is_zero_proto(&cluster_with_name)); + } + + #[test] + fn test_legacy_lock_not_first_mutation() { + let cluster = Cluster { + name: "foo".to_string(), + ..Default::default() + }; + + let result = transform_legacy_lock(&cluster, &SignedMutation::default()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("legacy lock not first mutation")); + } + + #[test] + fn test_load_legacy_lock_from_testdata() { + let lock_json = include_str!("../testdata/cluster_lock_v1_8_0.json"); + let lock: Lock = serde_json::from_str(lock_json).unwrap(); + + // Test new_legacy_lock_for_tests + let signed = new_legacy_lock_for_tests(&lock).unwrap(); + assert!(signed.mutation.is_some()); + + let mutation = signed.mutation.as_ref().unwrap(); + assert_eq!(mutation.r#type, MutationType::LegacyLock.as_str()); + assert!(signed.signer.is_empty()); + assert!(signed.signature.is_empty()); + + // Test transform + let cluster = transform_legacy_lock(&Cluster::default(), &signed).unwrap(); + assert_eq!(cluster.name, lock.name); + assert_eq!(cluster.threshold, i32::try_from(lock.threshold).unwrap()); + assert_eq!(cluster.operators.len(), lock.operators.len()); + assert_eq!(cluster.validators.len(), lock.distributed_validators.len()); + } + + #[test] + fn test_new_dag_from_lock_for_tests() { + let lock_json = include_str!("../testdata/cluster_lock_v1_8_0.json"); + let lock: Lock = serde_json::from_str(lock_json).unwrap(); + + let dag = new_dag_from_lock_for_tests(&lock).unwrap(); + assert_eq!(dag.mutations.len(), 1); + } + + #[test] + fn test_new_cluster_from_lock_for_tests() { + let lock_json = include_str!("../testdata/cluster_lock_v1_8_0.json"); + let lock: Lock = serde_json::from_str(lock_json).unwrap(); + + let cluster = new_cluster_from_lock_for_tests(&lock).unwrap(); + assert_eq!(cluster.name, lock.name); + assert!(!cluster.initial_mutation_hash.is_empty()); + assert!(!cluster.latest_mutation_hash.is_empty()); + // For a single mutation, initial and latest should be the same + assert_eq!(cluster.initial_mutation_hash, cluster.latest_mutation_hash); + } } diff --git a/crates/cluster/src/manifest/mutationnodeapproval.rs b/crates/cluster/src/manifest/mutationnodeapproval.rs index 97dada34..62dbe2aa 100644 --- a/crates/cluster/src/manifest/mutationnodeapproval.rs +++ b/crates/cluster/src/manifest/mutationnodeapproval.rs @@ -1,31 +1,294 @@ //! Node approval mutation implementation. -use crate::manifestpb::v1::{Cluster, SignedMutation}; +use k256::SecretKey; +use prost::Message as _; +use prost_types::Timestamp; -use super::Result; +use crate::manifestpb::v1::{Cluster, Mutation, SignedMutation, SignedMutationList}; + +use super::{ + ManifestError, Result, + cluster::cluster_peers, + extract_mutation, + helpers::{HASH_LEN, now, sign_k1, verify_k1_signed_mutation}, + types::{self, MutationType}, +}; + +/// Type URL for google.protobuf.Timestamp. +const TIMESTAMP_TYPE_URL: &str = "type.googleapis.com/google.protobuf.Timestamp"; + +impl ::prost::Name for SignedMutationList { + const NAME: &'static str = "SignedMutationList"; + const PACKAGE: &'static str = "cluster.manifestpb.v1"; + + fn type_url() -> ::prost::alloc::string::String { + format!( + "type.googleapis.com/{}", + ::full_name() + ) + } +} + +/// Helper to encode a Timestamp to prost_types::Any. +fn timestamp_to_any(timestamp: &Timestamp) -> Result { + let mut value = Vec::new(); + timestamp + .encode(&mut value) + .map_err(|e| ManifestError::InvalidMutation(format!("encode timestamp: {}", e)))?; + + Ok(prost_types::Any { + type_url: TIMESTAMP_TYPE_URL.to_string(), + value, + }) +} /// Signs a node approval mutation. -pub fn sign_node_approval( - _parent: &[u8], - _secret: &k256::ecdsa::SigningKey, -) -> Result { - unimplemented!("sign_node_approval") +pub fn sign_node_approval(parent: &[u8], secret: &SecretKey) -> Result { + let timestamp = now(); + + let timestamp_any = timestamp_to_any(×tamp)?; + + if parent.len() != HASH_LEN { + return Err(ManifestError::InvalidMutation( + "invalid parent hash".to_string(), + )); + } + + let mutation = Mutation { + parent: parent.to_vec().into(), + r#type: MutationType::NodeApproval.as_str().to_string(), + data: Some(timestamp_any), + }; + + sign_k1(&mutation, secret) } /// Creates a new node approvals composite mutation. -pub fn new_node_approvals_composite(_approvals: Vec) -> Result { - unimplemented!("new_node_approvals_composite") +/// +/// Note the approvals must be for all nodes in the cluster ordered by peer +/// index. +pub fn new_node_approvals_composite(approvals: Vec) -> Result { + if approvals.is_empty() { + return Err(ManifestError::InvalidMutation( + "empty node approvals".to_string(), + )); + } + + let first_mutation = approvals[0] + .mutation + .as_ref() + .ok_or(ManifestError::InvalidSignedMutation)?; + let parent = first_mutation.parent.to_vec(); + + for approval in &approvals { + let mutation = approval + .mutation + .as_ref() + .ok_or(ManifestError::InvalidSignedMutation)?; + + if mutation.parent.to_vec() != parent { + return Err(ManifestError::InvalidMutation( + "mismatching node approvals parent".to_string(), + )); + } + + verify_node_approval(approval)?; + } + + let any_list = prost_types::Any::from_msg(&SignedMutationList { + mutations: approvals.clone(), + }) + .map_err(|e| ManifestError::InvalidMutation(format!("mutations to any: {}", e)))?; + + Ok(SignedMutation { + mutation: Some(Mutation { + parent: parent.into(), + r#type: MutationType::NodeApprovals.as_str().to_string(), + data: Some(any_list), + }), + // Composite types do not have signatures + signer: Default::default(), + signature: Default::default(), + }) } /// Verifies a node approval mutation. -pub(crate) fn verify_node_approval(_signed: &SignedMutation) -> Result<()> { - unimplemented!("verify_node_approval") +pub(crate) fn verify_node_approval(signed: &SignedMutation) -> Result<()> { + let mutation = extract_mutation(signed, MutationType::NodeApproval)?; + + let data = mutation + .data + .as_ref() + .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; + + // Verify that the data is a valid timestamp + let _timestamp = Timestamp::decode(&*data.value).map_err(|e| { + ManifestError::InvalidMutation(format!("invalid node approval timestamp data: {}", e)) + })?; + + verify_k1_signed_mutation(signed) } /// Transforms a cluster with a node approvals composite mutation. pub(crate) fn transform_node_approvals( - _cluster: &Cluster, - _signed: &SignedMutation, + cluster: &Cluster, + signed: &SignedMutation, ) -> Result { - unimplemented!("transform_node_approvals") + let mutation = extract_mutation(signed, MutationType::NodeApprovals)?; + + let data = mutation + .data + .as_ref() + .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; + + let list = SignedMutationList::decode(&*data.value) + .map_err(|_| ManifestError::InvalidMutation("invalid node approval data".to_string()))?; + + let peers = cluster_peers(cluster)?; + + if peers.len() != list.mutations.len() { + return Err(ManifestError::InvalidMutation( + "invalid number of node approvals".to_string(), + )); + } + + let parent = list + .mutations + .first() + .and_then(|m| m.mutation.as_ref()) + .ok_or(ManifestError::InvalidSignedMutation)? + .parent + .as_ref(); + + let mut result = cluster.clone(); + + for (i, approval) in list.mutations.iter().enumerate() { + let approval_mutation = approval + .mutation + .as_ref() + .ok_or(ManifestError::InvalidSignedMutation)?; + + // Verify all mutations have the same parent + if approval_mutation.parent.as_ref() != parent { + return Err(ManifestError::InvalidMutation( + "mismatching node approvals parent".to_string(), + )); + } + + let pubkey = peers[i] + .public_key() + .map_err(|e| ManifestError::P2p(format!("get peer public key: {}", e)))?; + + // Compare compressed public key with signer + let expected_signer = pubkey.to_sec1_bytes(); + if expected_signer.as_ref() != approval.signer.as_ref() { + return Err(ManifestError::InvalidMutation( + "invalid node approval signer".to_string(), + )); + } + + result = types::transform(&result, approval)?; + } + + Ok(result) +} + +/// Signs a node approval with a custom timestamp (for testing). +#[cfg(test)] +pub fn sign_node_approval_with_timestamp( + parent: &[u8], + secret: &SecretKey, + timestamp: Timestamp, +) -> Result { + let timestamp_any = timestamp_to_any(×tamp)?; + + if parent.len() != HASH_LEN { + return Err(ManifestError::InvalidMutation( + "invalid parent hash".to_string(), + )); + } + + let mutation = Mutation { + parent: parent.to_vec().into(), + r#type: MutationType::NodeApproval.as_str().to_string(), + data: Some(timestamp_any), + }; + + sign_k1(&mutation, secret) +} + +#[cfg(test)] +mod tests { + use super::*; + use pluto_testutil::random::generate_insecure_k1_key; + + #[test] + fn test_sign_node_approval() { + let secret = generate_insecure_k1_key(1); + let parent = [0u8; 32]; + + let signed = sign_node_approval(&parent, &secret).unwrap(); + + assert!(signed.mutation.is_some()); + let mutation = signed.mutation.as_ref().unwrap(); + assert_eq!(mutation.r#type, MutationType::NodeApproval.as_str()); + assert!(!signed.signer.is_empty()); + assert!(!signed.signature.is_empty()); + + // Verify the signature + verify_node_approval(&signed).unwrap(); + } + + #[test] + fn test_sign_node_approval_invalid_parent() { + let secret = generate_insecure_k1_key(1); + let parent = [0u8; 16]; // Invalid length + + let result = sign_node_approval(&parent, &secret); + assert!(result.is_err()); + } + + #[test] + fn test_new_node_approvals_composite() { + let parent = [0u8; 32]; + let mut approvals = Vec::new(); + + for i in 0..3 { + let secret = generate_insecure_k1_key(i); + let approval = sign_node_approval(&parent, &secret).unwrap(); + approvals.push(approval); + } + + let composite = new_node_approvals_composite(approvals).unwrap(); + + assert!(composite.mutation.is_some()); + let mutation = composite.mutation.as_ref().unwrap(); + assert_eq!(mutation.r#type, MutationType::NodeApprovals.as_str()); + assert!(composite.signer.is_empty()); + assert!(composite.signature.is_empty()); + } + + #[test] + fn test_new_node_approvals_composite_empty() { + let result = new_node_approvals_composite(vec![]); + assert!(result.is_err()); + } + + #[test] + fn test_new_node_approvals_composite_mismatching_parent() { + let secret1 = generate_insecure_k1_key(1); + let secret2 = generate_insecure_k1_key(2); + + let approval1 = sign_node_approval(&[0u8; 32], &secret1).unwrap(); + let approval2 = sign_node_approval(&[1u8; 32], &secret2).unwrap(); + + let result = new_node_approvals_composite(vec![approval1, approval2]); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("mismatching node approvals parent") + ); + } } diff --git a/crates/cluster/src/manifest/types.rs b/crates/cluster/src/manifest/types.rs index 61890308..ea5a3c63 100644 --- a/crates/cluster/src/manifest/types.rs +++ b/crates/cluster/src/manifest/types.rs @@ -81,7 +81,7 @@ pub fn hash(signed: &SignedMutation) -> Result> { let mutation = signed .mutation .as_ref() - .ok_or_else(|| ManifestError::InvalidSignedMutation("mutation is nil".to_string()))?; + .ok_or(ManifestError::InvalidSignedMutation)?; // Special case for legacy lock: return the lock hash if mutation.r#type == MutationType::LegacyLock.as_str() { @@ -112,7 +112,7 @@ pub fn transform(cluster: &Cluster, signed: &SignedMutation) -> Result let mutation = signed .mutation .as_ref() - .ok_or_else(|| ManifestError::InvalidSignedMutation("mutation is nil".to_string()))?; + .ok_or(ManifestError::InvalidSignedMutation)?; let typ = MutationType::parse(&mutation.r#type) .ok_or_else(|| ManifestError::InvalidMutationType(mutation.r#type.clone()))?; From 75f5d58efc6aa575c4ee093a0652988a4f385d00 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Thu, 5 Feb 2026 15:27:47 +0700 Subject: [PATCH 05/14] test: more test to manifest --- Cargo.lock | 36 ++++ Cargo.toml | 1 + crates/cluster/Cargo.toml | 2 + crates/cluster/src/manifest/cluster.rs | 30 +-- crates/cluster/src/manifest/load.rs | 197 ++++++++++++++++++ .../src/manifest/mutationaddvalidator.rs | 116 +++++------ .../src/manifest/mutationlegacylock.rs | 88 +++----- .../src/manifest/mutationnodeapproval.rs | 57 ++--- crates/testutil/Cargo.toml | 1 + crates/testutil/src/random.rs | 31 +++ 10 files changed, 374 insertions(+), 185 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0b6b500..b2e715b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5425,6 +5425,8 @@ dependencies = [ "serde", "serde_json", "serde_with", + "tempfile", + "test-case", "thiserror 2.0.18", "uuid", ] @@ -5612,6 +5614,7 @@ version = "1.7.1" dependencies = [ "hex", "k256", + "rand 0.8.5", "rand_core 0.6.4", "thiserror 2.0.18", ] @@ -7234,6 +7237,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "test-case-core", +] + [[package]] name = "testcontainers" version = "0.26.3" diff --git a/Cargo.toml b/Cargo.toml index 27806bc3..71f2d2c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ reqwest = "0.13" http = "1.4" tempfile = "3.24" assert-json-diff = "0.2" +test-case = "3.3" validator = { version = "0.20", features = ["derive"] } oas3-gen-support = "0.24" bon = "3.8" diff --git a/crates/cluster/Cargo.toml b/crates/cluster/Cargo.toml index a529a647..8d864ce1 100644 --- a/crates/cluster/Cargo.toml +++ b/crates/cluster/Cargo.toml @@ -29,6 +29,8 @@ prost-build.workspace = true [dev-dependencies] pluto-testutil.workspace = true +test-case.workspace = true +tempfile.workspace = true [lints] workspace = true diff --git a/crates/cluster/src/manifest/cluster.rs b/crates/cluster/src/manifest/cluster.rs index 53c814c8..a5912a9c 100644 --- a/crates/cluster/src/manifest/cluster.rs +++ b/crates/cluster/src/manifest/cluster.rs @@ -102,46 +102,38 @@ mod tests { use crate::manifestpb::v1::Operator; #[test] - fn test_cluster_peers_empty() { + fn cluster_peers_empty() { let cluster = Cluster::default(); let result = cluster_peers(&cluster); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("no operators")); } #[test] - fn test_cluster_peers_duplicate_enr() { + fn cluster_peers_duplicate_enr() { + let duplicate_enr = "enr:-HW4QIHPUOMb34YoizKGhz7nsDNQ7hCaiuwyscmeaOQ04awdH05gDnGrZhxDfzcfHssCDeB-esi99A2RoZia6UaYBCuAgmlkgnY0iXNlY3AyNTZrMaECTUts0TYQMsqb0q652QCqTUXZ6tgKyUIzdMRRpyVNB2Y".to_string(); + let cluster = Cluster { operators: vec![ Operator { address: "0x123".to_string(), - enr: "enr:-test".to_string(), + enr: duplicate_enr.clone(), }, Operator { address: "0x456".to_string(), - enr: "enr:-test".to_string(), // duplicate + enr: duplicate_enr, // duplicate }, ], ..Default::default() }; let result = cluster_peers(&cluster); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("duplicate")); - } - - #[test] - fn test_validator_public_key_hex() { - let validator = Validator { - public_key: vec![0x01, 0x02, 0x03].into(), - ..Default::default() - }; - - let hex = validator_public_key_hex(&validator); - assert_eq!(hex, "0x010203"); + assert!(matches!( + result.unwrap_err(), + ManifestError::DuplicatePeerENR { .. } + )); } #[test] - fn test_validator_public_share() { + fn validator_public_share_test() { let mut share0 = vec![0u8; PUBLIC_KEY_LENGTH]; share0[0] = 0x01; let mut share1 = vec![0u8; PUBLIC_KEY_LENGTH]; diff --git a/crates/cluster/src/manifest/load.rs b/crates/cluster/src/manifest/load.rs index 46bc2787..7206f905 100644 --- a/crates/cluster/src/manifest/load.rs +++ b/crates/cluster/src/manifest/load.rs @@ -150,3 +150,200 @@ pub(crate) fn cluster_hashes_match( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + lock::Lock, + manifest::{materialise::materialise, mutationlegacylock::new_raw_legacy_lock}, + }; + use std::{fs, path::PathBuf}; + use test_case::test_case; + + fn testdata_path(filename: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("testdata") + .join(filename) + } + + fn manifest_testdata_path(filename: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("manifest") + .join("testdata") + .join(filename) + } + + #[test_case("", "", Some(ManifestError::NoFileFound { lock_file: String::new(), manifest_file: String::new() }) ; "no_files")] + #[test_case("manifest", "", None ; "only_manifest")] + #[test_case("", "lock.json", None ; "only_legacy_lock")] + #[test_case("manifest", "lock.json", None ; "both_files")] + #[test_case("manifest", "lock2.json", Some(ManifestError::ClusterHashMismatch { manifest_hash: String::new(), legacy_hash: String::new() }) ; "mismatching_cluster_hashes")] + fn load_manifest( + manifest_file: &str, + legacy_lock_file: &str, + expected_error: Option, + ) { + // Setup: Load legacy lock and create manifest file (shared across all tests) + let lock_path = manifest_testdata_path("lock.json"); + let lock_bytes = fs::read(&lock_path).unwrap(); + let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); + + let json_bytes = serde_json::to_vec(&lock).unwrap(); + let legacy_lock = new_raw_legacy_lock(&json_bytes).unwrap(); + let dag = SignedMutationList { + mutations: vec![legacy_lock], + }; + let expected_cluster = materialise(&dag).unwrap(); + + // Write manifest file to temp directory + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("cluster-manifest.pb"); + let manifest_bytes = dag.encode_to_vec(); + fs::write(&manifest_path, manifest_bytes).unwrap(); + + // Map test parameters to actual paths + let lock_file_path = if !legacy_lock_file.is_empty() { + Some(manifest_testdata_path(legacy_lock_file)) + } else { + None + }; + + let manifest_arg = if manifest_file == "manifest" { + manifest_path.to_str().unwrap() + } else { + manifest_file + }; + let lock_arg = lock_file_path + .as_ref() + .map(|p| p.to_str().unwrap()) + .unwrap_or(""); + + // Load raw cluster DAG from disk + let result = load_dag( + manifest_arg, + lock_arg, + Option:: Result<()>>::None, + ); + + if let Some(expected_err) = expected_error { + assert!(result.is_err()); + let err = result.unwrap_err(); + match expected_err { + ManifestError::NoFileFound { .. } => { + assert!(matches!(err, ManifestError::NoFileFound { .. })); + } + ManifestError::ClusterHashMismatch { .. } => { + assert!(matches!(err, ManifestError::ClusterHashMismatch { .. })); + } + _ => panic!("Unexpected error type"), + } + } else { + let loaded_dag = result.unwrap(); + + // The only mutation is the `legacy_lock` mutation + assert_eq!(loaded_dag.mutations.len(), 1); + + let cluster_from_dag = materialise(&loaded_dag).unwrap(); + let loaded_cluster = load_cluster( + manifest_arg, + lock_arg, + Option:: Result<()>>::None, + ) + .unwrap(); + assert_eq!(expected_cluster, loaded_cluster); + assert_eq!(expected_cluster, cluster_from_dag); + } + } + + #[test] + #[ignore] // TODO: lock3.json has null values that aren't compatible with Lock struct deserialization + fn load_modified_legacy_lock() { + // This test ensures the hard-coded hash is used for legacy locks, + // even if the lock file was modified and run with --no-verify + let lock3_path = manifest_testdata_path("lock3.json"); + let cluster = + load_cluster("", &lock3_path, Option:: Result<()>>::None).unwrap(); + + let hash_hex = hex::encode(&cluster.initial_mutation_hash); + // Verify the hash starts with expected prefix + assert_eq!(&hash_hex[..9], "4073fe542"); + } + + // Parametrized test across all supported versions + #[test_case("v1.0.0")] + #[test_case("v1.1.0")] + #[test_case("v1.2.0")] + #[test_case("v1.3.0")] + #[test_case("v1.4.0")] + #[test_case("v1.5.0")] + #[test_case("v1.6.0")] + #[test_case("v1.7.0")] + #[test_case("v1.8.0")] + #[test_case("v1.9.0")] + #[test_case("v1.10.0")] + fn load_legacy_version(version: &str) { + // Load the lock file for this version + let filename = format!("cluster_lock_{}.json", version.replace('.', "_")); + let lock_path = testdata_path(&filename); + + let lock_bytes = fs::read(&lock_path).unwrap(); + let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); + + // Create temp file for the lock + let temp_dir = tempfile::tempdir().unwrap(); + let temp_lock_path = temp_dir.path().join("lock.json"); + fs::write(&temp_lock_path, &lock_bytes).unwrap(); + + // Load cluster from the lock file + let cluster = + load_cluster("", &temp_lock_path, Option:: Result<()>>::None).unwrap(); + + // Verify loaded cluster properties match the lock + assert_eq!( + cluster.initial_mutation_hash, lock.lock_hash, + "initial mutation hash should match lock hash" + ); + assert_eq!( + cluster.latest_mutation_hash, lock.lock_hash, + "latest mutation hash should match lock hash" + ); + assert_eq!(cluster.name, lock.name); + #[allow(clippy::cast_possible_truncation)] + { + assert_eq!(cluster.threshold, lock.threshold as i32); + } + assert_eq!(cluster.dkg_algorithm, lock.dkg_algorithm); + assert_eq!(cluster.fork_version.as_ref(), lock.fork_version.as_slice()); + assert_eq!(cluster.validators.len(), lock.distributed_validators.len()); + assert_eq!(cluster.operators.len(), lock.operators.len()); + + // Verify validators + for (i, validator) in cluster.validators.iter().enumerate() { + assert_eq!( + validator.public_key.as_ref(), + lock.distributed_validators[i].pub_key.as_slice() + ); + assert_eq!( + validator.pub_shares.len(), + lock.distributed_validators[i].pub_shares.len() + ); + assert_eq!( + validator.fee_recipient_address, + lock.validator_addresses[i].fee_recipient_address + ); + assert_eq!( + validator.withdrawal_address, + lock.validator_addresses[i].withdrawal_address + ); + } + + // Verify operators + for (i, operator) in cluster.operators.iter().enumerate() { + assert_eq!(operator.address, lock.operators[i].address); + assert_eq!(operator.enr, lock.operators[i].enr); + } + } +} diff --git a/crates/cluster/src/manifest/mutationaddvalidator.rs b/crates/cluster/src/manifest/mutationaddvalidator.rs index 35aedd5d..be180049 100644 --- a/crates/cluster/src/manifest/mutationaddvalidator.rs +++ b/crates/cluster/src/manifest/mutationaddvalidator.rs @@ -179,6 +179,7 @@ pub(crate) fn transform_add_validators( #[cfg(test)] mod tests { use super::*; + use pluto_testutil::random::random_bytes32_seed; fn create_test_validator(idx: u8) -> Validator { Validator { @@ -191,8 +192,8 @@ mod tests { } #[test] - fn test_new_gen_validators() { - let parent = [0u8; 32]; + fn new_gen_validators_test() { + let parent = random_bytes32_seed(1); let validators = vec![create_test_validator(1), create_test_validator(2)]; let signed = new_gen_validators(&parent, validators.clone()).unwrap(); @@ -205,43 +206,32 @@ mod tests { } #[test] - fn test_new_gen_validators_empty() { - let parent = [0u8; 32]; + fn new_gen_validators_empty() { + let parent = random_bytes32_seed(2); let validators = vec![]; let result = new_gen_validators(&parent, validators); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("no validators")); + assert!(matches!( + result.unwrap_err(), + ManifestError::InvalidMutation(_) + )); } #[test] - fn test_new_gen_validators_invalid_parent() { + fn new_gen_validators_invalid_parent() { let parent = [0u8; 16]; // Invalid length let validators = vec![create_test_validator(1)]; let result = new_gen_validators(&parent, validators); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("invalid parent hash") - ); - } - - #[test] - fn test_new_gen_validators_invalid_address() { - let parent = [0u8; 32]; - let mut validator = create_test_validator(1); - validator.fee_recipient_address = "invalid".to_string(); - - let result = new_gen_validators(&parent, vec![validator]); - assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ManifestError::InvalidMutation(_) + )); } #[test] - fn test_transform_gen_validators() { - let parent = [0u8; 32]; + fn transform_gen_validators_test() { + let parent = random_bytes32_seed(3); let validators = vec![create_test_validator(1), create_test_validator(2)]; let signed = new_gen_validators(&parent, validators.clone()).unwrap(); @@ -253,11 +243,12 @@ mod tests { } #[test] - fn test_new_add_validators_invalid_gen_type() { + fn new_add_validators_invalid_gen_type() { // Create a mutation with wrong type + let parent = random_bytes32_seed(4); let wrong_type = SignedMutation { mutation: Some(Mutation { - parent: vec![0u8; 32].into(), + parent: parent.clone().into(), r#type: MutationType::NodeApproval.as_str().to_string(), data: None, }), @@ -266,7 +257,7 @@ mod tests { let node_approvals = SignedMutation { mutation: Some(Mutation { - parent: vec![0u8; 32].into(), + parent: parent.into(), r#type: MutationType::NodeApprovals.as_str().to_string(), data: None, }), @@ -274,42 +265,45 @@ mod tests { }; let result = new_add_validators(&wrong_type, &node_approvals); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("invalid mutation type") - ); + assert!(matches!( + result.unwrap_err(), + ManifestError::InvalidMutation(_) + )); } #[test] - fn test_new_add_validators_invalid_approvals_type() { - let gen_validators = SignedMutation { - mutation: Some(Mutation { - parent: vec![0u8; 32].into(), - r#type: MutationType::GenValidators.as_str().to_string(), - data: None, - }), - ..Default::default() - }; + fn gen_validators() { + use super::super::helpers::validator_to_proto; + use crate::lock::Lock; + use std::{fs, path::PathBuf}; + + let lock_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/manifest/testdata") + .join("lock.json"); + let lock_json = fs::read_to_string(&lock_path).unwrap(); + let lock: Lock = serde_json::from_str(&lock_json).unwrap(); + + let mut vals = Vec::new(); + for (i, validator) in lock.distributed_validators.iter().enumerate() { + let val = validator_to_proto(validator, &lock.validator_addresses[i]).unwrap(); + vals.push(val); + } - let wrong_type = SignedMutation { - mutation: Some(Mutation { - parent: vec![0u8; 32].into(), - r#type: MutationType::NodeApproval.as_str().to_string(), - data: None, - }), - ..Default::default() - }; + let parent = + hex::decode("605ec6de4f1ae997dd3545513b934c335a833f4635dc9fad7758314f79ff0fae") + .unwrap(); + + let signed = new_gen_validators(&parent, vals.clone()).unwrap(); - let result = new_add_validators(&gen_validators, &wrong_type); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("invalid mutation type") - ); + let cluster = Cluster::default(); + let result = transform_gen_validators(&cluster, &signed).unwrap(); + assert_eq!(result.validators.len(), vals.len()); + for (i, val) in vals.iter().enumerate() { + assert_eq!(result.validators[i].public_key, val.public_key); + assert_eq!( + result.validators[i].fee_recipient_address, + val.fee_recipient_address + ); + } } } diff --git a/crates/cluster/src/manifest/mutationlegacylock.rs b/crates/cluster/src/manifest/mutationlegacylock.rs index 74da2b93..9af6fde5 100644 --- a/crates/cluster/src/manifest/mutationlegacylock.rs +++ b/crates/cluster/src/manifest/mutationlegacylock.rs @@ -4,13 +4,12 @@ use prost::Message as _; use crate::{ lock::Lock, - manifestpb::v1::{Cluster, LegacyLock, Mutation, Operator, SignedMutation, SignedMutationList}, + manifestpb::v1::{Cluster, LegacyLock, Mutation, Operator, SignedMutation}, }; use super::{ ManifestError, Result, extract_mutation, helpers::{validator_to_proto, verify_empty_sig}, - materialise::materialise, types::MutationType, }; @@ -52,50 +51,6 @@ pub fn new_raw_legacy_lock(json_bytes: &[u8]) -> Result { }) } -/// Creates a new legacy lock mutation for testing. -/// NOTE: @iamquang95 do we need this -/// -/// This method only supports valid locks (where re-calculating the lock hash -/// matches the existing hash). Use `new_raw_legacy_lock` for --no-verify -/// support. -pub fn new_legacy_lock_for_tests(lock: &Lock) -> Result { - // Marshalling below re-calculates the lock hash, so ensure it matches. - let mut lock_copy = lock.clone(); - lock_copy - .set_lock_hash() - .map_err(|e| ManifestError::InvalidMutation(format!("set lock hash: {}", e)))?; - - if lock_copy.lock_hash != lock.lock_hash { - return Err(ManifestError::InvalidMutation( - "this method only supports valid locks, use new_raw_legacy_lock for --no-verify support" - .to_string(), - )); - } - - let json_bytes = serde_json::to_vec(lock) - .map_err(|e| ManifestError::InvalidMutation(format!("marshal lock: {}", e)))?; - - new_raw_legacy_lock(&json_bytes) -} - -/// Creates a new DAG from a legacy lock for testing. -/// NOTE: @iamquang95 do we need this -pub fn new_dag_from_lock_for_tests(lock: &Lock) -> Result { - let signed = new_legacy_lock_for_tests(lock)?; - Ok(SignedMutationList { - mutations: vec![signed], - }) -} - -/// Creates a new cluster from a legacy lock for testing. -/// NOTE: @iamquang95 do we need this -pub fn new_cluster_from_lock_for_tests(lock: &Lock) -> Result { - let signed = new_legacy_lock_for_tests(lock)?; - materialise(&SignedMutationList { - mutations: vec![signed], - }) -} - /// Verifies a legacy lock mutation. pub fn verify_legacy_lock(signed: &SignedMutation) -> Result<()> { let mutation = extract_mutation(signed, MutationType::LegacyLock)?; @@ -194,21 +149,16 @@ where #[cfg(test)] mod tests { use super::*; + use crate::{manifest::materialise::materialise, manifestpb::v1::SignedMutationList}; #[test] - fn test_is_zero_proto() { + fn is_zero_proto_test() { let cluster = Cluster::default(); assert!(is_zero_proto(&cluster)); - - let cluster_with_name = Cluster { - name: "test".to_string(), - ..Default::default() - }; - assert!(!is_zero_proto(&cluster_with_name)); } #[test] - fn test_legacy_lock_not_first_mutation() { + fn legacy_lock_not_first_mutation() { let cluster = Cluster { name: "foo".to_string(), ..Default::default() @@ -216,17 +166,20 @@ mod tests { let result = transform_legacy_lock(&cluster, &SignedMutation::default()); assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.to_string().contains("legacy lock not first mutation")); + assert!(matches!( + result.unwrap_err(), + ManifestError::InvalidMutation(_) + )); } #[test] - fn test_load_legacy_lock_from_testdata() { + fn load_legacy_lock_from_testdata() { let lock_json = include_str!("../testdata/cluster_lock_v1_8_0.json"); let lock: Lock = serde_json::from_str(lock_json).unwrap(); - // Test new_legacy_lock_for_tests - let signed = new_legacy_lock_for_tests(&lock).unwrap(); + // Test creating legacy lock mutation using official method + let json_bytes = serde_json::to_vec(&lock).unwrap(); + let signed = new_raw_legacy_lock(&json_bytes).unwrap(); assert!(signed.mutation.is_some()); let mutation = signed.mutation.as_ref().unwrap(); @@ -243,20 +196,29 @@ mod tests { } #[test] - fn test_new_dag_from_lock_for_tests() { + fn new_dag_from_lock() { let lock_json = include_str!("../testdata/cluster_lock_v1_8_0.json"); let lock: Lock = serde_json::from_str(lock_json).unwrap(); - let dag = new_dag_from_lock_for_tests(&lock).unwrap(); + let json_bytes = serde_json::to_vec(&lock).unwrap(); + let signed = new_raw_legacy_lock(&json_bytes).unwrap(); + let dag = SignedMutationList { + mutations: vec![signed], + }; assert_eq!(dag.mutations.len(), 1); } #[test] - fn test_new_cluster_from_lock_for_tests() { + fn new_cluster_from_lock() { let lock_json = include_str!("../testdata/cluster_lock_v1_8_0.json"); let lock: Lock = serde_json::from_str(lock_json).unwrap(); - let cluster = new_cluster_from_lock_for_tests(&lock).unwrap(); + let json_bytes = serde_json::to_vec(&lock).unwrap(); + let signed = new_raw_legacy_lock(&json_bytes).unwrap(); + let cluster = materialise(&SignedMutationList { + mutations: vec![signed], + }) + .unwrap(); assert_eq!(cluster.name, lock.name); assert!(!cluster.initial_mutation_hash.is_empty()); assert!(!cluster.latest_mutation_hash.is_empty()); diff --git a/crates/cluster/src/manifest/mutationnodeapproval.rs b/crates/cluster/src/manifest/mutationnodeapproval.rs index 62dbe2aa..f812986f 100644 --- a/crates/cluster/src/manifest/mutationnodeapproval.rs +++ b/crates/cluster/src/manifest/mutationnodeapproval.rs @@ -193,39 +193,15 @@ pub(crate) fn transform_node_approvals( Ok(result) } -/// Signs a node approval with a custom timestamp (for testing). -#[cfg(test)] -pub fn sign_node_approval_with_timestamp( - parent: &[u8], - secret: &SecretKey, - timestamp: Timestamp, -) -> Result { - let timestamp_any = timestamp_to_any(×tamp)?; - - if parent.len() != HASH_LEN { - return Err(ManifestError::InvalidMutation( - "invalid parent hash".to_string(), - )); - } - - let mutation = Mutation { - parent: parent.to_vec().into(), - r#type: MutationType::NodeApproval.as_str().to_string(), - data: Some(timestamp_any), - }; - - sign_k1(&mutation, secret) -} - #[cfg(test)] mod tests { use super::*; - use pluto_testutil::random::generate_insecure_k1_key; + use pluto_testutil::random::{generate_insecure_k1_key, random_bytes32_seed}; #[test] - fn test_sign_node_approval() { + fn sign_node_approval_test() { let secret = generate_insecure_k1_key(1); - let parent = [0u8; 32]; + let parent = random_bytes32_seed(1); let signed = sign_node_approval(&parent, &secret).unwrap(); @@ -235,22 +211,21 @@ mod tests { assert!(!signed.signer.is_empty()); assert!(!signed.signature.is_empty()); - // Verify the signature verify_node_approval(&signed).unwrap(); } #[test] - fn test_sign_node_approval_invalid_parent() { + fn sign_node_approval_invalid_parent() { let secret = generate_insecure_k1_key(1); - let parent = [0u8; 16]; // Invalid length + let parent = [0u8; HASH_LEN / 2]; // Invalid length let result = sign_node_approval(&parent, &secret); assert!(result.is_err()); } #[test] - fn test_new_node_approvals_composite() { - let parent = [0u8; 32]; + fn new_node_approvals_composite_test() { + let parent = random_bytes32_seed(1); let mut approvals = Vec::new(); for i in 0..3 { @@ -269,26 +244,24 @@ mod tests { } #[test] - fn test_new_node_approvals_composite_empty() { + fn new_node_approvals_composite_empty() { let result = new_node_approvals_composite(vec![]); assert!(result.is_err()); } #[test] - fn test_new_node_approvals_composite_mismatching_parent() { + fn new_node_approvals_composite_mismatching_parent() { let secret1 = generate_insecure_k1_key(1); let secret2 = generate_insecure_k1_key(2); - let approval1 = sign_node_approval(&[0u8; 32], &secret1).unwrap(); - let approval2 = sign_node_approval(&[1u8; 32], &secret2).unwrap(); + let approval1 = sign_node_approval(&random_bytes32_seed(1), &secret1).unwrap(); + let approval2 = sign_node_approval(&random_bytes32_seed(2), &secret2).unwrap(); let result = new_node_approvals_composite(vec![approval1, approval2]); assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("mismatching node approvals parent") - ); + assert!(matches!( + result.unwrap_err(), + ManifestError::InvalidMutation(_) + )); } } diff --git a/crates/testutil/Cargo.toml b/crates/testutil/Cargo.toml index 599bf1d7..14de2a8a 100644 --- a/crates/testutil/Cargo.toml +++ b/crates/testutil/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true [dependencies] k256.workspace = true +rand.workspace = true rand_core.workspace = true thiserror.workspace = true hex.workspace = true diff --git a/crates/testutil/src/random.rs b/crates/testutil/src/random.rs index 3b32830b..fd8e9d43 100644 --- a/crates/testutil/src/random.rs +++ b/crates/testutil/src/random.rs @@ -6,6 +6,7 @@ use k256::{ SecretKey, elliptic_curve::rand_core::{CryptoRng, Error, RngCore}, }; +use rand::{Rng, SeedableRng, rngs::StdRng}; /// A deterministic RNG that always returns the same byte value. /// This counter-acts the library's attempt at making ECDSA signatures @@ -45,6 +46,16 @@ pub fn generate_insecure_k1_key(seed: u8) -> SecretKey { SecretKey::random(&mut rng) } +/// Generates a deterministic 32-byte hash for testing using a seed. +pub fn random_bytes32_seed(seed: u8) -> Vec { + let seed_bytes = [seed; 32]; + let mut rng = StdRng::from_seed(seed_bytes); + + let mut bytes = vec![0u8; 32]; + rng.fill(&mut bytes[..]); + bytes +} + #[cfg(test)] mod tests { use super::*; @@ -82,4 +93,24 @@ mod tests { // Verify it's a valid key by deriving public key let _pubkey: PublicKey = key.public_key(); } + + #[test] + fn random_bytes32_deterministic() { + let bytes1 = random_bytes32_seed(42); + let bytes2 = random_bytes32_seed(42); + + assert_eq!(bytes1, bytes2, "Same seed should produce identical bytes"); + assert_eq!(bytes1.len(), 32); + } + + #[test] + fn random_bytes32_different_seeds() { + let bytes1 = random_bytes32_seed(1); + let bytes2 = random_bytes32_seed(2); + + assert_ne!( + bytes1, bytes2, + "Different seeds should produce different bytes" + ); + } } From aeac0eb6e7bc90757bd00c13827993cdb2eecbb6 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Thu, 5 Feb 2026 16:33:22 +0700 Subject: [PATCH 06/14] fix: clean up error type --- crates/cluster/src/manifest/helpers.rs | 4 - crates/cluster/src/manifest/load.rs | 28 +++---- crates/cluster/src/manifest/materialise.rs | 28 +++---- crates/cluster/src/manifest/mod.rs | 12 --- crates/cluster/src/manifest/mutation.rs | 4 - .../src/manifest/mutationaddvalidator.rs | 11 +-- .../src/manifest/mutationlegacylock.rs | 2 - .../src/manifest/mutationnodeapproval.rs | 6 +- crates/cluster/src/manifest/types.rs | 77 +++++++++---------- 9 files changed, 67 insertions(+), 105 deletions(-) delete mode 100644 crates/cluster/src/manifest/mutation.rs diff --git a/crates/cluster/src/manifest/helpers.rs b/crates/cluster/src/manifest/helpers.rs index 332e5c0f..6d2a85da 100644 --- a/crates/cluster/src/manifest/helpers.rs +++ b/crates/cluster/src/manifest/helpers.rs @@ -1,5 +1,3 @@ -//! Cluster manifest helper functions for hashing, signing, and conversions. - use k256::{ PublicKey, SecretKey, sha2::{Digest, Sha256}, @@ -76,7 +74,6 @@ pub(crate) fn hash_mutation(m: &Mutation) -> Result> { } /// Verifies that the signed mutation has empty signature and signer fields. -#[allow(dead_code)] pub(crate) fn verify_empty_sig(signed: &SignedMutation) -> Result<()> { if !signed.signature.is_empty() { return Err(ManifestError::NonEmptyField( @@ -109,7 +106,6 @@ pub fn sign_k1(m: &Mutation, secret: &SecretKey) -> Result { } /// Verifies a k1-signed mutation. -#[allow(dead_code)] pub(crate) fn verify_k1_signed_mutation(signed: &SignedMutation) -> Result<()> { let pubkey = PublicKey::from_sec1_bytes(&signed.signer) .map_err(|e| ManifestError::K1Key(format!("parse signer pubkey: {}", e)))?; diff --git a/crates/cluster/src/manifest/load.rs b/crates/cluster/src/manifest/load.rs index 7206f905..75476021 100644 --- a/crates/cluster/src/manifest/load.rs +++ b/crates/cluster/src/manifest/load.rs @@ -1,5 +1,3 @@ -//! Cluster manifest loading from disk. - use std::path::Path; use prost::Message as _; @@ -10,7 +8,7 @@ use crate::{ }; use super::{ - ManifestError, Result, materialise::materialise, mutationlegacylock::new_raw_legacy_lock, types, + ManifestError, Result, materialise::materialise, mutationlegacylock::new_raw_legacy_lock, }; /// Returns the current cluster state from disk by reading either from cluster @@ -127,19 +125,17 @@ pub(crate) fn cluster_hashes_match( dag_manifest: &SignedMutationList, dag_legacy: &SignedMutationList, ) -> Result<()> { - let hash_manifest = types::hash( - dag_manifest - .mutations - .first() - .ok_or(ManifestError::EmptyDAG)?, - )?; - - let hash_legacy = types::hash( - dag_legacy - .mutations - .first() - .ok_or(ManifestError::EmptyDAG)?, - )?; + let hash_manifest = dag_manifest + .mutations + .first() + .ok_or(ManifestError::EmptyDAG)? + .hash()?; + + let hash_legacy = dag_legacy + .mutations + .first() + .ok_or(ManifestError::EmptyDAG)? + .hash()?; if hash_manifest != hash_legacy { return Err(ManifestError::ClusterHashMismatch { diff --git a/crates/cluster/src/manifest/materialise.rs b/crates/cluster/src/manifest/materialise.rs index e7930785..d144c344 100644 --- a/crates/cluster/src/manifest/materialise.rs +++ b/crates/cluster/src/manifest/materialise.rs @@ -1,16 +1,8 @@ -//! Cluster manifest materialisation. - use crate::manifestpb::v1::{Cluster, SignedMutationList}; -use super::{ManifestError, Result, types}; +use super::{ManifestError, Result}; /// Transforms a raw DAG and returns the resulting cluster manifest. -/// -/// Applies each mutation in order to build up the final cluster state. -/// Sets `initial_mutation_hash` from the first mutation and -/// `latest_mutation_hash` from the last mutation. -/// -/// Returns an error if the DAG is empty or any transformation fails. pub fn materialise(raw_dag: &SignedMutationList) -> Result { if raw_dag.mutations.is_empty() { return Err(ManifestError::EmptyDAG); @@ -19,18 +11,26 @@ pub fn materialise(raw_dag: &SignedMutationList) -> Result { let mut cluster = Cluster::default(); for signed in &raw_dag.mutations { - cluster = types::transform(&cluster, signed)?; + cluster = signed.transform(&cluster)?; } // initial_mutation_hash is the hash of the first mutation // SAFETY: We already checked that mutations is not empty above - cluster.initial_mutation_hash = - types::hash(raw_dag.mutations.first().ok_or(ManifestError::EmptyDAG)?)?.into(); + cluster.initial_mutation_hash = raw_dag + .mutations + .first() + .ok_or(ManifestError::EmptyDAG)? + .hash()? + .into(); // LatestMutationHash is the hash of the last mutation // SAFETY: We already checked that mutations is not empty above - cluster.latest_mutation_hash = - types::hash(raw_dag.mutations.last().ok_or(ManifestError::EmptyDAG)?)?.into(); + cluster.latest_mutation_hash = raw_dag + .mutations + .last() + .ok_or(ManifestError::EmptyDAG)? + .hash()? + .into(); Ok(cluster) } diff --git a/crates/cluster/src/manifest/mod.rs b/crates/cluster/src/manifest/mod.rs index a992512a..03c32f06 100644 --- a/crates/cluster/src/manifest/mod.rs +++ b/crates/cluster/src/manifest/mod.rs @@ -1,9 +1,3 @@ -//! Cluster manifest management and coordination. -//! -//! This module handles cluster manifest DAG (Directed Acyclic Graph) -//! operations, including loading, materializing, and transforming cluster state -//! through mutations. - use thiserror::Error; /// Cluster manifest management and coordination. @@ -14,8 +8,6 @@ pub mod helpers; pub mod load; /// Cluster manifest materialise management and coordination. pub mod materialise; -/// Cluster manifest mutation management and coordination. -pub mod mutation; /// Cluster manifest mutation add validator management and coordination. pub mod mutationaddvalidator; /// Cluster manifest mutation legacy lock management and coordination. @@ -83,10 +75,6 @@ pub enum ManifestError { #[error("peer not in definition")] PeerNotInDefinition, - /// Invalid hex encoding. - #[error("invalid hex encoding: {0}")] - InvalidHex(String), - /// Invalid hex length. #[error("invalid hex length (expect: {expect}, actual: {actual})")] InvalidHexLength { diff --git a/crates/cluster/src/manifest/mutation.rs b/crates/cluster/src/manifest/mutation.rs deleted file mode 100644 index a271bc1c..00000000 --- a/crates/cluster/src/manifest/mutation.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Cluster manifest mutation base types. - -// This file is intentionally minimal as mutation logic is split across specific -// mutation files. diff --git a/crates/cluster/src/manifest/mutationaddvalidator.rs b/crates/cluster/src/manifest/mutationaddvalidator.rs index be180049..e2422ddc 100644 --- a/crates/cluster/src/manifest/mutationaddvalidator.rs +++ b/crates/cluster/src/manifest/mutationaddvalidator.rs @@ -1,5 +1,3 @@ -//! Add validators mutation implementation. - use prost::Message as _; use crate::{ @@ -12,7 +10,7 @@ use crate::{ use super::{ ManifestError, Result, extract_mutation, helpers::{HASH_LEN, verify_empty_sig}, - types::{self, MutationType}, + types::MutationType, }; /// Ethereum address length in bytes. @@ -76,7 +74,6 @@ fn verify_gen_validators_list(vals: &[Validator]) -> Result<()> { } /// Transforms a cluster with a gen validators mutation. -/// NOTE: @iamquang95, should we mutate the cluster? pub(crate) fn transform_gen_validators( cluster: &Cluster, signed: &SignedMutation, @@ -162,16 +159,16 @@ pub(crate) fn transform_add_validators( let approvals_mutation = extract_mutation(node_approvals, MutationType::NodeApprovals)?; - let gen_hash = types::hash(gen_validators)?; + let gen_hash = gen_validators.hash()?; if gen_hash != approvals_mutation.parent.to_vec() { return Err(ManifestError::InvalidMutation( "invalid node approvals parent".to_string(), )); } - let result = types::transform(cluster, gen_validators)?; + let result = gen_validators.transform(cluster)?; - let result = types::transform(&result, node_approvals)?; + let result = node_approvals.transform(&result)?; Ok(result) } diff --git a/crates/cluster/src/manifest/mutationlegacylock.rs b/crates/cluster/src/manifest/mutationlegacylock.rs index 9af6fde5..db4f852e 100644 --- a/crates/cluster/src/manifest/mutationlegacylock.rs +++ b/crates/cluster/src/manifest/mutationlegacylock.rs @@ -1,5 +1,3 @@ -//! Legacy lock mutation implementation. - use prost::Message as _; use crate::{ diff --git a/crates/cluster/src/manifest/mutationnodeapproval.rs b/crates/cluster/src/manifest/mutationnodeapproval.rs index f812986f..680a46c4 100644 --- a/crates/cluster/src/manifest/mutationnodeapproval.rs +++ b/crates/cluster/src/manifest/mutationnodeapproval.rs @@ -1,5 +1,3 @@ -//! Node approval mutation implementation. - use k256::SecretKey; use prost::Message as _; use prost_types::Timestamp; @@ -11,7 +9,7 @@ use super::{ cluster::cluster_peers, extract_mutation, helpers::{HASH_LEN, now, sign_k1, verify_k1_signed_mutation}, - types::{self, MutationType}, + types::MutationType, }; /// Type URL for google.protobuf.Timestamp. @@ -187,7 +185,7 @@ pub(crate) fn transform_node_approvals( )); } - result = types::transform(&result, approval)?; + result = approval.transform(&result)?; } Ok(result) diff --git a/crates/cluster/src/manifest/types.rs b/crates/cluster/src/manifest/types.rs index ea5a3c63..9b6a09f7 100644 --- a/crates/cluster/src/manifest/types.rs +++ b/crates/cluster/src/manifest/types.rs @@ -1,5 +1,3 @@ -//! Cluster manifest mutation types. - use prost::Message as _; use crate::{ @@ -53,13 +51,7 @@ impl MutationType { _ => None, } } - - /// Returns true if the mutation type is valid. - /// TODO: @iamquang95 remove this if no need - pub fn valid(&self) -> bool { - true - } - + /// Transforms the cluster with the given signed mutation. pub fn transform(&self, cluster: &Cluster, signed: &SignedMutation) -> Result { match self { @@ -75,47 +67,48 @@ impl MutationType { } } -/// Calculates the hash of a signed mutation. -/// NOTE: @iamquang95 this could be a method of `SignedMutation` -pub fn hash(signed: &SignedMutation) -> Result> { - let mutation = signed - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - - // Special case for legacy lock: return the lock hash - if mutation.r#type == MutationType::LegacyLock.as_str() { - let data = mutation - .data +impl SignedMutation { + /// Calculates the hash of this signed mutation. + pub fn hash(&self) -> Result> { + let mutation = self + .mutation .as_ref() - .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; + .ok_or(ManifestError::InvalidSignedMutation)?; + + // Special case for legacy lock: return the lock hash + if mutation.r#type == MutationType::LegacyLock.as_str() { + let data = mutation + .data + .as_ref() + .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; - let legacy_lock = - LegacyLock::decode(&*data.value).map_err(ManifestError::ProtobufDecode)?; + let legacy_lock = + LegacyLock::decode(&*data.value).map_err(ManifestError::ProtobufDecode)?; - let lock: Lock = serde_json::from_slice(&legacy_lock.json).map_err(ManifestError::Json)?; + let lock: Lock = + serde_json::from_slice(&legacy_lock.json).map_err(ManifestError::Json)?; - if lock.lock_hash.len() != HASH_LEN { - return Err(ManifestError::InvalidLockHash); + if lock.lock_hash.len() != HASH_LEN { + return Err(ManifestError::InvalidLockHash); + } + + return Ok(lock.lock_hash); } - return Ok(lock.lock_hash); + // Otherwise, return the hash of the signed mutation + hash_signed_mutation(self) } - // Otherwise, return the hash of the signed mutation - hash_signed_mutation(signed) -} - -/// Transforms a cluster with a signed mutation. -/// NOTE: @iamquang95 this could be a method of `SignedMutation` -pub fn transform(cluster: &Cluster, signed: &SignedMutation) -> Result { - let mutation = signed - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; + /// Transforms a cluster with this signed mutation. + pub fn transform(&self, cluster: &Cluster) -> Result { + let mutation = self + .mutation + .as_ref() + .ok_or(ManifestError::InvalidSignedMutation)?; - let typ = MutationType::parse(&mutation.r#type) - .ok_or_else(|| ManifestError::InvalidMutationType(mutation.r#type.clone()))?; + let typ = MutationType::parse(&mutation.r#type) + .ok_or_else(|| ManifestError::InvalidMutationType(mutation.r#type.clone()))?; - typ.transform(cluster, signed) + typ.transform(cluster, self) + } } From 9e5cecd6d521f12e35524fa78005eac3df01bd5d Mon Sep 17 00:00:00 2001 From: Quang Le Date: Thu, 5 Feb 2026 16:59:56 +0700 Subject: [PATCH 07/14] fix: using tokio fs --- Cargo.lock | 1 + crates/cluster/Cargo.toml | 1 + crates/cluster/src/manifest/cluster.rs | 2 +- crates/cluster/src/manifest/error.rs | 120 +++++++++++++++ crates/cluster/src/manifest/helpers.rs | 24 ++- crates/cluster/src/manifest/load.rs | 59 +++++--- crates/cluster/src/manifest/materialise.rs | 6 +- crates/cluster/src/manifest/mod.rs | 142 +----------------- .../src/manifest/mutationaddvalidator.rs | 4 +- .../src/manifest/mutationlegacylock.rs | 4 +- .../src/manifest/mutationnodeapproval.rs | 5 +- crates/cluster/src/manifest/types.rs | 4 +- 12 files changed, 193 insertions(+), 179 deletions(-) create mode 100644 crates/cluster/src/manifest/error.rs diff --git a/Cargo.lock b/Cargo.lock index b2e715b3..ed1dc58c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5428,6 +5428,7 @@ dependencies = [ "tempfile", "test-case", "thiserror 2.0.18", + "tokio", "uuid", ] diff --git a/crates/cluster/Cargo.toml b/crates/cluster/Cargo.toml index 8d864ce1..7c5ae01a 100644 --- a/crates/cluster/Cargo.toml +++ b/crates/cluster/Cargo.toml @@ -23,6 +23,7 @@ pluto-p2p.workspace = true pluto-eth2util.workspace = true pluto-k1util.workspace = true k256.workspace = true +tokio.workspace = true [build-dependencies] prost-build.workspace = true diff --git a/crates/cluster/src/manifest/cluster.rs b/crates/cluster/src/manifest/cluster.rs index a5912a9c..82acbd8c 100644 --- a/crates/cluster/src/manifest/cluster.rs +++ b/crates/cluster/src/manifest/cluster.rs @@ -11,7 +11,7 @@ use crate::{ manifestpb::v1::{Cluster, Validator}, }; -use super::{ManifestError, Result}; +use super::error::{ManifestError, Result}; /// Returns the cluster operators as a slice of p2p peers. pub fn cluster_peers(cluster: &Cluster) -> Result> { diff --git a/crates/cluster/src/manifest/error.rs b/crates/cluster/src/manifest/error.rs new file mode 100644 index 00000000..3269bf65 --- /dev/null +++ b/crates/cluster/src/manifest/error.rs @@ -0,0 +1,120 @@ +use thiserror::Error; + +/// Manifest module error type. +#[derive(Debug, Error)] +pub enum ManifestError { + /// Empty or nil DAG. + #[error("empty raw DAG")] + EmptyDAG, + + /// No files found. + #[error("no file found (lock-file: {lock_file}, manifest-file: {manifest_file})")] + NoFileFound { + /// Lock file path. + lock_file: String, + /// Manifest file path. + manifest_file: String, + }, + + /// Manifest and legacy cluster hashes don't match. + #[error( + "manifest and legacy cluster hashes don't match (manifest_hash: {manifest_hash}, legacy_hash: {legacy_hash})" + )] + ClusterHashMismatch { + /// Manifest hash hex string. + manifest_hash: String, + /// Legacy hash hex string. + legacy_hash: String, + }, + + /// Mutation is nil. + #[error("mutation is nil")] + InvalidSignedMutation, + + /// Invalid mutation. + #[error("invalid mutation: {0}")] + InvalidMutation(String), + + /// Non-empty signature or signer. + #[error("{0}")] + NonEmptyField(String), + + /// Invalid mutation signature. + #[error("invalid mutation signature")] + InvalidSignature, + + /// Invalid cluster. + #[error("invalid cluster")] + InvalidCluster, + + /// Cluster contains duplicate peer ENRs. + #[error("cluster contains duplicate peer enrs: {enr}")] + DuplicatePeerENR { + /// ENR string. + enr: String, + }, + + /// Peer not in definition. + #[error("peer not in definition")] + PeerNotInDefinition, + + /// Invalid hex length. + #[error("invalid hex length (expect: {expect}, actual: {actual})")] + InvalidHexLength { + /// Expected length. + expect: usize, + /// Actual length. + actual: usize, + }, + + /// I/O error. + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + /// Protobuf decode error. + #[error("protobuf decode error: {0}")] + ProtobufDecode(#[from] prost::DecodeError), + + /// JSON error. + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + /// Hex decode error. + #[error("hex decode error: {0}")] + HexDecode(#[from] hex::FromHexError), + + /// K1 key error. + #[error("k1 key error: {0}")] + K1Key(String), + + /// Crypto error. + #[error("crypto error: {0}")] + Crypto(String), + + /// ENR parsing error. + #[error("enr parsing error: {0}")] + EnrParse(String), + + /// P2P error. + #[error("p2p error: {0}")] + P2p(String), + + /// BLS conversion error. + #[error("bls conversion error: {0}")] + BlsConversion(String), + + /// Builder registration error. + #[error("builder registration error: {0}")] + BuilderRegistration(String), + + /// Invalid lock hash. + #[error("invalid lock hash")] + InvalidLockHash, + + /// Invalid mutation type. + #[error("invalid mutation type: {0}")] + InvalidMutationType(String), +} + +/// Result type alias for manifest operations. +pub type Result = std::result::Result; diff --git a/crates/cluster/src/manifest/helpers.rs b/crates/cluster/src/manifest/helpers.rs index 6d2a85da..90ebeee2 100644 --- a/crates/cluster/src/manifest/helpers.rs +++ b/crates/cluster/src/manifest/helpers.rs @@ -10,7 +10,10 @@ use crate::{ manifestpb::v1::{Mutation, SignedMutation, Validator}, }; -use super::{ManifestError, Result}; +use super::{ + error::{ManifestError, Result}, + types, +}; /// Hash length in bytes. pub(crate) const HASH_LEN: usize = 32; @@ -146,3 +149,22 @@ pub fn validator_to_proto(val: &DistValidator, addrs: &ValidatorAddresses) -> Re builder_registration_json: reg_json.into(), }) } + +/// Extracts and validates a mutation from a signed mutation. +pub(crate) fn extract_mutation( + signed: &SignedMutation, + expected_type: types::MutationType, +) -> Result<&Mutation> { + let mutation = signed + .mutation + .as_ref() + .ok_or(ManifestError::InvalidSignedMutation)?; + + if mutation.r#type != expected_type.as_str() { + return Err(ManifestError::InvalidMutation( + "invalid mutation type".to_string(), + )); + } + + Ok(mutation) +} diff --git a/crates/cluster/src/manifest/load.rs b/crates/cluster/src/manifest/load.rs index 75476021..74b89265 100644 --- a/crates/cluster/src/manifest/load.rs +++ b/crates/cluster/src/manifest/load.rs @@ -8,7 +8,9 @@ use crate::{ }; use super::{ - ManifestError, Result, materialise::materialise, mutationlegacylock::new_raw_legacy_lock, + error::{ManifestError, Result}, + materialise::materialise, + mutationlegacylock::new_raw_legacy_lock, }; /// Returns the current cluster state from disk by reading either from cluster @@ -21,7 +23,7 @@ use super::{ /// returned. /// /// Returns an error if the cluster can't be loaded from either file. -pub fn load_cluster( +pub async fn load_cluster( manifest_file: impl AsRef, legacy_lock_file: impl AsRef, lock_callback: Option, @@ -29,7 +31,7 @@ pub fn load_cluster( where F: FnOnce(Lock) -> Result<()>, { - let dag = load_dag(manifest_file, legacy_lock_file, lock_callback)?; + let dag = load_dag(manifest_file, legacy_lock_file, lock_callback).await?; materialise(&dag) } @@ -42,7 +44,7 @@ where /// - If cluster hashes match, the DAG loaded from the manifest file is returned /// /// Returns an error if the DAG can't be loaded from either file. -pub fn load_dag( +pub async fn load_dag( manifest_file: impl AsRef, legacy_lock_file: impl AsRef, lock_callback: Option, @@ -50,8 +52,8 @@ pub fn load_dag( where F: FnOnce(Lock) -> Result<()>, { - let manifest_result = load_dag_from_manifest(&manifest_file); - let legacy_result = load_dag_from_legacy_lock(&legacy_lock_file, lock_callback); + let manifest_result = load_dag_from_manifest(&manifest_file).await; + let legacy_result = load_dag_from_legacy_lock(&legacy_lock_file, lock_callback).await; match (manifest_result, legacy_result) { // Both files loaded successfully, check if cluster hashes match @@ -94,18 +96,20 @@ where } /// Loads the raw DAG from cluster manifest file on disk. -pub(crate) fn load_dag_from_manifest(filename: impl AsRef) -> Result { - let bytes = std::fs::read(filename.as_ref())?; +pub(crate) async fn load_dag_from_manifest( + filename: impl AsRef, +) -> Result { + let bytes = tokio::fs::read(filename.as_ref()).await?; let raw_dag = SignedMutationList::decode(&*bytes)?; Ok(raw_dag) } /// Loads the raw DAG from legacy lock file on disk. -pub(crate) fn load_dag_from_legacy_lock Result<()>>( +pub(crate) async fn load_dag_from_legacy_lock Result<()>>( filename: impl AsRef, lock_callback: Option, ) -> Result { - let bytes = std::fs::read(filename)?; + let bytes = tokio::fs::read(filename).await?; let lock: Lock = serde_json::from_slice(&bytes)?; @@ -154,8 +158,9 @@ mod tests { lock::Lock, manifest::{materialise::materialise, mutationlegacylock::new_raw_legacy_lock}, }; - use std::{fs, path::PathBuf}; + use std::path::PathBuf; use test_case::test_case; + use tokio::fs; fn testdata_path(filename: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -177,14 +182,15 @@ mod tests { #[test_case("", "lock.json", None ; "only_legacy_lock")] #[test_case("manifest", "lock.json", None ; "both_files")] #[test_case("manifest", "lock2.json", Some(ManifestError::ClusterHashMismatch { manifest_hash: String::new(), legacy_hash: String::new() }) ; "mismatching_cluster_hashes")] - fn load_manifest( + #[tokio::test] + async fn load_manifest( manifest_file: &str, legacy_lock_file: &str, expected_error: Option, ) { // Setup: Load legacy lock and create manifest file (shared across all tests) let lock_path = manifest_testdata_path("lock.json"); - let lock_bytes = fs::read(&lock_path).unwrap(); + let lock_bytes = fs::read(&lock_path).await.unwrap(); let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); let json_bytes = serde_json::to_vec(&lock).unwrap(); @@ -198,7 +204,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let manifest_path = temp_dir.path().join("cluster-manifest.pb"); let manifest_bytes = dag.encode_to_vec(); - fs::write(&manifest_path, manifest_bytes).unwrap(); + fs::write(&manifest_path, manifest_bytes).await.unwrap(); // Map test parameters to actual paths let lock_file_path = if !legacy_lock_file.is_empty() { @@ -222,7 +228,8 @@ mod tests { manifest_arg, lock_arg, Option:: Result<()>>::None, - ); + ) + .await; if let Some(expected_err) = expected_error { assert!(result.is_err()); @@ -248,20 +255,22 @@ mod tests { lock_arg, Option:: Result<()>>::None, ) + .await .unwrap(); assert_eq!(expected_cluster, loaded_cluster); assert_eq!(expected_cluster, cluster_from_dag); } } - #[test] + #[tokio::test] #[ignore] // TODO: lock3.json has null values that aren't compatible with Lock struct deserialization - fn load_modified_legacy_lock() { + async fn load_modified_legacy_lock() { // This test ensures the hard-coded hash is used for legacy locks, // even if the lock file was modified and run with --no-verify let lock3_path = manifest_testdata_path("lock3.json"); - let cluster = - load_cluster("", &lock3_path, Option:: Result<()>>::None).unwrap(); + let cluster = load_cluster("", &lock3_path, Option:: Result<()>>::None) + .await + .unwrap(); let hash_hex = hex::encode(&cluster.initial_mutation_hash); // Verify the hash starts with expected prefix @@ -280,22 +289,24 @@ mod tests { #[test_case("v1.8.0")] #[test_case("v1.9.0")] #[test_case("v1.10.0")] - fn load_legacy_version(version: &str) { + #[tokio::test] + async fn load_legacy_version(version: &str) { // Load the lock file for this version let filename = format!("cluster_lock_{}.json", version.replace('.', "_")); let lock_path = testdata_path(&filename); - let lock_bytes = fs::read(&lock_path).unwrap(); + let lock_bytes = fs::read(&lock_path).await.unwrap(); let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); // Create temp file for the lock let temp_dir = tempfile::tempdir().unwrap(); let temp_lock_path = temp_dir.path().join("lock.json"); - fs::write(&temp_lock_path, &lock_bytes).unwrap(); + fs::write(&temp_lock_path, &lock_bytes).await.unwrap(); // Load cluster from the lock file - let cluster = - load_cluster("", &temp_lock_path, Option:: Result<()>>::None).unwrap(); + let cluster = load_cluster("", &temp_lock_path, Option:: Result<()>>::None) + .await + .unwrap(); // Verify loaded cluster properties match the lock assert_eq!( diff --git a/crates/cluster/src/manifest/materialise.rs b/crates/cluster/src/manifest/materialise.rs index d144c344..d301f15d 100644 --- a/crates/cluster/src/manifest/materialise.rs +++ b/crates/cluster/src/manifest/materialise.rs @@ -1,6 +1,6 @@ use crate::manifestpb::v1::{Cluster, SignedMutationList}; -use super::{ManifestError, Result}; +use super::error::{ManifestError, Result}; /// Transforms a raw DAG and returns the resulting cluster manifest. pub fn materialise(raw_dag: &SignedMutationList) -> Result { @@ -15,7 +15,6 @@ pub fn materialise(raw_dag: &SignedMutationList) -> Result { } // initial_mutation_hash is the hash of the first mutation - // SAFETY: We already checked that mutations is not empty above cluster.initial_mutation_hash = raw_dag .mutations .first() @@ -23,8 +22,7 @@ pub fn materialise(raw_dag: &SignedMutationList) -> Result { .hash()? .into(); - // LatestMutationHash is the hash of the last mutation - // SAFETY: We already checked that mutations is not empty above + // latest_mutation_hash is the hash of the last mutation cluster.latest_mutation_hash = raw_dag .mutations .last() diff --git a/crates/cluster/src/manifest/mod.rs b/crates/cluster/src/manifest/mod.rs index 03c32f06..89600e11 100644 --- a/crates/cluster/src/manifest/mod.rs +++ b/crates/cluster/src/manifest/mod.rs @@ -1,7 +1,7 @@ -use thiserror::Error; - /// Cluster manifest management and coordination. pub mod cluster; +/// Cluster manifest error types. +pub mod error; /// Cluster manifest helpers management and coordination. pub mod helpers; /// Cluster manifest load management and coordination. @@ -16,141 +16,3 @@ pub mod mutationlegacylock; pub mod mutationnodeapproval; /// Cluster manifest types management and coordination. pub mod types; - -/// Manifest module error type. -#[derive(Debug, Error)] -pub enum ManifestError { - /// Empty or nil DAG. - #[error("empty raw DAG")] - EmptyDAG, - - /// No files found. - #[error("no file found (lock-file: {lock_file}, manifest-file: {manifest_file})")] - NoFileFound { - /// Lock file path. - lock_file: String, - /// Manifest file path. - manifest_file: String, - }, - - /// Manifest and legacy cluster hashes don't match. - #[error( - "manifest and legacy cluster hashes don't match (manifest_hash: {manifest_hash}, legacy_hash: {legacy_hash})" - )] - ClusterHashMismatch { - /// Manifest hash hex string. - manifest_hash: String, - /// Legacy hash hex string. - legacy_hash: String, - }, - - /// Mutation is nil. - #[error("mutation is nil")] - InvalidSignedMutation, - - /// Invalid mutation. - #[error("invalid mutation: {0}")] - InvalidMutation(String), - - /// Non-empty signature or signer. - #[error("{0}")] - NonEmptyField(String), - - /// Invalid mutation signature. - #[error("invalid mutation signature")] - InvalidSignature, - - /// Invalid cluster. - #[error("invalid cluster")] - InvalidCluster, - - /// Cluster contains duplicate peer ENRs. - #[error("cluster contains duplicate peer enrs: {enr}")] - DuplicatePeerENR { - /// ENR string. - enr: String, - }, - - /// Peer not in definition. - #[error("peer not in definition")] - PeerNotInDefinition, - - /// Invalid hex length. - #[error("invalid hex length (expect: {expect}, actual: {actual})")] - InvalidHexLength { - /// Expected length. - expect: usize, - /// Actual length. - actual: usize, - }, - - /// I/O error. - #[error("io error: {0}")] - Io(#[from] std::io::Error), - - /// Protobuf decode error. - #[error("protobuf decode error: {0}")] - ProtobufDecode(#[from] prost::DecodeError), - - /// JSON error. - #[error("json error: {0}")] - Json(#[from] serde_json::Error), - - /// Hex decode error. - #[error("hex decode error: {0}")] - HexDecode(#[from] hex::FromHexError), - - /// K1 key error. - #[error("k1 key error: {0}")] - K1Key(String), - - /// Crypto error. - #[error("crypto error: {0}")] - Crypto(String), - - /// ENR parsing error. - #[error("enr parsing error: {0}")] - EnrParse(String), - - /// P2P error. - #[error("p2p error: {0}")] - P2p(String), - - /// BLS conversion error. - #[error("bls conversion error: {0}")] - BlsConversion(String), - - /// Builder registration error. - #[error("builder registration error: {0}")] - BuilderRegistration(String), - - /// Invalid lock hash. - #[error("invalid lock hash")] - InvalidLockHash, - - /// Invalid mutation type. - #[error("invalid mutation type: {0}")] - InvalidMutationType(String), -} - -/// Result type alias for manifest operations. -pub type Result = std::result::Result; - -/// Extracts and validates a mutation from a signed mutation. -pub(crate) fn extract_mutation( - signed: &crate::manifestpb::v1::SignedMutation, - expected_type: types::MutationType, -) -> Result<&crate::manifestpb::v1::Mutation> { - let mutation = signed - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - - if mutation.r#type != expected_type.as_str() { - return Err(ManifestError::InvalidMutation( - "invalid mutation type".to_string(), - )); - } - - Ok(mutation) -} diff --git a/crates/cluster/src/manifest/mutationaddvalidator.rs b/crates/cluster/src/manifest/mutationaddvalidator.rs index e2422ddc..234b6e53 100644 --- a/crates/cluster/src/manifest/mutationaddvalidator.rs +++ b/crates/cluster/src/manifest/mutationaddvalidator.rs @@ -8,8 +8,8 @@ use crate::{ }; use super::{ - ManifestError, Result, extract_mutation, - helpers::{HASH_LEN, verify_empty_sig}, + error::{ManifestError, Result}, + helpers::{HASH_LEN, extract_mutation, verify_empty_sig}, types::MutationType, }; diff --git a/crates/cluster/src/manifest/mutationlegacylock.rs b/crates/cluster/src/manifest/mutationlegacylock.rs index db4f852e..89fe750c 100644 --- a/crates/cluster/src/manifest/mutationlegacylock.rs +++ b/crates/cluster/src/manifest/mutationlegacylock.rs @@ -6,8 +6,8 @@ use crate::{ }; use super::{ - ManifestError, Result, extract_mutation, - helpers::{validator_to_proto, verify_empty_sig}, + error::{ManifestError, Result}, + helpers::{extract_mutation, validator_to_proto, verify_empty_sig}, types::MutationType, }; diff --git a/crates/cluster/src/manifest/mutationnodeapproval.rs b/crates/cluster/src/manifest/mutationnodeapproval.rs index 680a46c4..5054f4ab 100644 --- a/crates/cluster/src/manifest/mutationnodeapproval.rs +++ b/crates/cluster/src/manifest/mutationnodeapproval.rs @@ -5,10 +5,9 @@ use prost_types::Timestamp; use crate::manifestpb::v1::{Cluster, Mutation, SignedMutation, SignedMutationList}; use super::{ - ManifestError, Result, cluster::cluster_peers, - extract_mutation, - helpers::{HASH_LEN, now, sign_k1, verify_k1_signed_mutation}, + error::{ManifestError, Result}, + helpers::{HASH_LEN, extract_mutation, now, sign_k1, verify_k1_signed_mutation}, types::MutationType, }; diff --git a/crates/cluster/src/manifest/types.rs b/crates/cluster/src/manifest/types.rs index 9b6a09f7..f21373f8 100644 --- a/crates/cluster/src/manifest/types.rs +++ b/crates/cluster/src/manifest/types.rs @@ -6,7 +6,7 @@ use crate::{ }; use super::{ - ManifestError, Result, + error::{ManifestError, Result}, helpers::{HASH_LEN, hash_signed_mutation}, mutationaddvalidator::{transform_add_validators, transform_gen_validators}, mutationlegacylock::transform_legacy_lock, @@ -51,7 +51,7 @@ impl MutationType { _ => None, } } - + /// Transforms the cluster with the given signed mutation. pub fn transform(&self, cluster: &Cluster, signed: &SignedMutation) -> Result { match self { From f402b8e49e53663775e1ce999c1c1e33f81e9050 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Fri, 6 Feb 2026 10:39:30 +0700 Subject: [PATCH 08/14] fix: only keep cluster which is needed for other crate --- crates/cluster/src/manifest/error.rs | 80 ---- crates/cluster/src/manifest/helpers.rs | 172 +-------- crates/cluster/src/manifest/load.rs | 358 +----------------- crates/cluster/src/manifest/materialise.rs | 36 +- crates/cluster/src/manifest/mod.rs | 3 + .../src/manifest/mutationaddvalidator.rs | 308 +-------------- .../src/manifest/mutationlegacylock.rs | 228 +---------- .../src/manifest/mutationnodeapproval.rs | 266 +------------ crates/cluster/src/manifest/types.rs | 116 +----- 9 files changed, 17 insertions(+), 1550 deletions(-) diff --git a/crates/cluster/src/manifest/error.rs b/crates/cluster/src/manifest/error.rs index 3269bf65..a6fcb76f 100644 --- a/crates/cluster/src/manifest/error.rs +++ b/crates/cluster/src/manifest/error.rs @@ -3,46 +3,6 @@ use thiserror::Error; /// Manifest module error type. #[derive(Debug, Error)] pub enum ManifestError { - /// Empty or nil DAG. - #[error("empty raw DAG")] - EmptyDAG, - - /// No files found. - #[error("no file found (lock-file: {lock_file}, manifest-file: {manifest_file})")] - NoFileFound { - /// Lock file path. - lock_file: String, - /// Manifest file path. - manifest_file: String, - }, - - /// Manifest and legacy cluster hashes don't match. - #[error( - "manifest and legacy cluster hashes don't match (manifest_hash: {manifest_hash}, legacy_hash: {legacy_hash})" - )] - ClusterHashMismatch { - /// Manifest hash hex string. - manifest_hash: String, - /// Legacy hash hex string. - legacy_hash: String, - }, - - /// Mutation is nil. - #[error("mutation is nil")] - InvalidSignedMutation, - - /// Invalid mutation. - #[error("invalid mutation: {0}")] - InvalidMutation(String), - - /// Non-empty signature or signer. - #[error("{0}")] - NonEmptyField(String), - - /// Invalid mutation signature. - #[error("invalid mutation signature")] - InvalidSignature, - /// Invalid cluster. #[error("invalid cluster")] InvalidCluster, @@ -67,30 +27,6 @@ pub enum ManifestError { actual: usize, }, - /// I/O error. - #[error("io error: {0}")] - Io(#[from] std::io::Error), - - /// Protobuf decode error. - #[error("protobuf decode error: {0}")] - ProtobufDecode(#[from] prost::DecodeError), - - /// JSON error. - #[error("json error: {0}")] - Json(#[from] serde_json::Error), - - /// Hex decode error. - #[error("hex decode error: {0}")] - HexDecode(#[from] hex::FromHexError), - - /// K1 key error. - #[error("k1 key error: {0}")] - K1Key(String), - - /// Crypto error. - #[error("crypto error: {0}")] - Crypto(String), - /// ENR parsing error. #[error("enr parsing error: {0}")] EnrParse(String), @@ -98,22 +34,6 @@ pub enum ManifestError { /// P2P error. #[error("p2p error: {0}")] P2p(String), - - /// BLS conversion error. - #[error("bls conversion error: {0}")] - BlsConversion(String), - - /// Builder registration error. - #[error("builder registration error: {0}")] - BuilderRegistration(String), - - /// Invalid lock hash. - #[error("invalid lock hash")] - InvalidLockHash, - - /// Invalid mutation type. - #[error("invalid mutation type: {0}")] - InvalidMutationType(String), } /// Result type alias for manifest operations. diff --git a/crates/cluster/src/manifest/helpers.rs b/crates/cluster/src/manifest/helpers.rs index 90ebeee2..5169cd93 100644 --- a/crates/cluster/src/manifest/helpers.rs +++ b/crates/cluster/src/manifest/helpers.rs @@ -1,170 +1,2 @@ -use k256::{ - PublicKey, SecretKey, - sha2::{Digest, Sha256}, -}; -use prost_types::Timestamp; - -use crate::{ - definition::ValidatorAddresses, - distvalidator::DistValidator, - manifestpb::v1::{Mutation, SignedMutation, Validator}, -}; - -use super::{ - error::{ManifestError, Result}, - types, -}; - -/// Hash length in bytes. -pub(crate) const HASH_LEN: usize = 32; - -/// Get the current timestamp. -/// -/// This function returns the current time as a protobuf Timestamp. -/// In production, it uses the system time. In tests, use dependency injection -/// to provide a custom time source instead of this function. -pub fn now() -> Timestamp { - let now = chrono::Utc::now(); - Timestamp { - seconds: now.timestamp(), - #[allow(clippy::cast_possible_wrap)] - nanos: now.timestamp_subsec_nanos() as i32, - } -} - -/// Hashes a signed mutation using SHA-256. -pub(crate) fn hash_signed_mutation(signed: &SignedMutation) -> Result> { - let mutation = signed - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - - let mut hasher = Sha256::new(); - - // Field 0: Mutation - let mutation_hash = hash_mutation(mutation)?; - hasher.update(&mutation_hash); - - // Field 1: Signer - hasher.update(&signed.signer); - - // Field 2: Signature - hasher.update(&signed.signature); - - Ok(hasher.finalize().to_vec()) -} - -/// Hashes a mutation using SHA-256. -pub(crate) fn hash_mutation(m: &Mutation) -> Result> { - let data = m - .data - .as_ref() - .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; - - let mut hasher = Sha256::new(); - - // Field 0: Parent - hasher.update(&m.parent); - - // Field 1: Type - hasher.update(m.r#type.as_bytes()); - - // Field 2: Data (TypeUrl + Value) - hasher.update(data.type_url.as_bytes()); - hasher.update(&data.value); - - Ok(hasher.finalize().to_vec()) -} - -/// Verifies that the signed mutation has empty signature and signer fields. -pub(crate) fn verify_empty_sig(signed: &SignedMutation) -> Result<()> { - if !signed.signature.is_empty() { - return Err(ManifestError::NonEmptyField( - "non-empty signature".to_string(), - )); - } - - if !signed.signer.is_empty() { - return Err(ManifestError::NonEmptyField("non-empty signer".to_string())); - } - - Ok(()) -} - -/// Signs a mutation with a secp256k1 private key. -pub fn sign_k1(m: &Mutation, secret: &SecretKey) -> Result { - let hash = hash_mutation(m)?; - - let sig = pluto_k1util::sign(secret, &hash) - .map_err(|e| ManifestError::Crypto(format!("sign mutation: {}", e)))?; - - let pubkey = secret.public_key(); - let signer = pubkey.to_sec1_bytes().to_vec(); - - Ok(SignedMutation { - mutation: Some(m.clone()), - signer: signer.into(), - signature: sig.to_vec().into(), - }) -} - -/// Verifies a k1-signed mutation. -pub(crate) fn verify_k1_signed_mutation(signed: &SignedMutation) -> Result<()> { - let pubkey = PublicKey::from_sec1_bytes(&signed.signer) - .map_err(|e| ManifestError::K1Key(format!("parse signer pubkey: {}", e)))?; - - let mutation = signed - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - - let hash = hash_mutation(mutation)?; - - let verified = pluto_k1util::verify_65(&pubkey, &hash, &signed.signature) - .map_err(|e| ManifestError::Crypto(format!("verify signature: {}", e)))?; - - if !verified { - return Err(ManifestError::InvalidSignature); - } - - Ok(()) -} - -/// Converts a legacy cluster validator to a protobuf validator. -pub fn validator_to_proto(val: &DistValidator, addrs: &ValidatorAddresses) -> Result { - let mut reg_json = Vec::new(); - - if !val.zero_registration() { - // Serialize the BuilderRegistration to JSON - reg_json = serde_json::to_vec(&val.builder_registration).map_err(|e| { - ManifestError::BuilderRegistration(format!("marshal builder registration: {}", e)) - })?; - } - - Ok(Validator { - public_key: val.pub_key.clone().into(), - pub_shares: val.pub_shares.iter().map(|s| s.clone().into()).collect(), - fee_recipient_address: addrs.fee_recipient_address.clone(), - withdrawal_address: addrs.withdrawal_address.clone(), - builder_registration_json: reg_json.into(), - }) -} - -/// Extracts and validates a mutation from a signed mutation. -pub(crate) fn extract_mutation( - signed: &SignedMutation, - expected_type: types::MutationType, -) -> Result<&Mutation> { - let mutation = signed - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - - if mutation.r#type != expected_type.as_str() { - return Err(ManifestError::InvalidMutation( - "invalid mutation type".to_string(), - )); - } - - Ok(mutation) -} +// Link to PR #4130: https://github.com/ObolNetwork/charon/pull/4130 +// The manifest is removed and there is no use in production. diff --git a/crates/cluster/src/manifest/load.rs b/crates/cluster/src/manifest/load.rs index 74b89265..5169cd93 100644 --- a/crates/cluster/src/manifest/load.rs +++ b/crates/cluster/src/manifest/load.rs @@ -1,356 +1,2 @@ -use std::path::Path; - -use prost::Message as _; - -use crate::{ - lock::Lock, - manifestpb::v1::{Cluster, SignedMutationList}, -}; - -use super::{ - error::{ManifestError, Result}, - materialise::materialise, - mutationlegacylock::new_raw_legacy_lock, -}; - -/// Returns the current cluster state from disk by reading either from cluster -/// manifest or legacy lock file. -/// -/// If both files are provided, both files are -/// read and: -/// - If cluster hashes don't match, an error is returned -/// - If cluster hashes match, the cluster loaded from the manifest file is -/// returned. -/// -/// Returns an error if the cluster can't be loaded from either file. -pub async fn load_cluster( - manifest_file: impl AsRef, - legacy_lock_file: impl AsRef, - lock_callback: Option, -) -> Result -where - F: FnOnce(Lock) -> Result<()>, -{ - let dag = load_dag(manifest_file, legacy_lock_file, lock_callback).await?; - materialise(&dag) -} - -/// Returns the raw cluster DAG from disk by reading either from cluster -/// manifest or legacy lock file. -/// -/// If both files are provided, both files are -/// read and: -/// - If cluster hashes don't match, an error is returned -/// - If cluster hashes match, the DAG loaded from the manifest file is returned -/// -/// Returns an error if the DAG can't be loaded from either file. -pub async fn load_dag( - manifest_file: impl AsRef, - legacy_lock_file: impl AsRef, - lock_callback: Option, -) -> Result -where - F: FnOnce(Lock) -> Result<()>, -{ - let manifest_result = load_dag_from_manifest(&manifest_file).await; - let legacy_result = load_dag_from_legacy_lock(&legacy_lock_file, lock_callback).await; - - match (manifest_result, legacy_result) { - // Both files loaded successfully, check if cluster hashes match - (Ok(dag_manifest), Ok(dag_legacy)) => { - cluster_hashes_match(&dag_manifest, &dag_legacy)?; - Ok(dag_manifest.clone()) - } - // Only manifest loaded successfully - (Ok(dag_manifest), Err(_)) => Ok(dag_manifest), - // Only legacy lock loaded successfully - (Err(_), Ok(dag_legacy)) => Ok(dag_legacy), - // Both failed - (Err(err_manifest), Err(err_legacy)) => { - // Check if both files don't exist - let manifest_not_found = matches!(&err_manifest, ManifestError::Io(e) if e.kind() == std::io::ErrorKind::NotFound); - let legacy_not_found = matches!(&err_legacy, ManifestError::Io(e) if e.kind() == std::io::ErrorKind::NotFound); - - if manifest_not_found && legacy_not_found { - return Err(ManifestError::NoFileFound { - lock_file: legacy_lock_file.as_ref().display().to_string(), - manifest_file: manifest_file.as_ref().display().to_string(), - }); - } - - // Return legacy lock error if it exists but failed to load - if !legacy_not_found { - return Err(ManifestError::InvalidMutation(format!( - "couldn't load cluster from legacy lock file: {}", - err_legacy - ))); - } - - // Otherwise return manifest error - Err(ManifestError::InvalidMutation(format!( - "couldn't load cluster from manifest file: {}", - err_manifest - ))) - } - } -} - -/// Loads the raw DAG from cluster manifest file on disk. -pub(crate) async fn load_dag_from_manifest( - filename: impl AsRef, -) -> Result { - let bytes = tokio::fs::read(filename.as_ref()).await?; - let raw_dag = SignedMutationList::decode(&*bytes)?; - Ok(raw_dag) -} - -/// Loads the raw DAG from legacy lock file on disk. -pub(crate) async fn load_dag_from_legacy_lock Result<()>>( - filename: impl AsRef, - lock_callback: Option, -) -> Result { - let bytes = tokio::fs::read(filename).await?; - - let lock: Lock = serde_json::from_slice(&bytes)?; - - if let Some(callback) = lock_callback { - callback(lock)?; - } - - let legacy = new_raw_legacy_lock(&bytes)?; - - Ok(SignedMutationList { - mutations: vec![legacy], - }) -} - -/// Verifies that cluster hashes match between manifest and legacy DAG. -pub(crate) fn cluster_hashes_match( - dag_manifest: &SignedMutationList, - dag_legacy: &SignedMutationList, -) -> Result<()> { - let hash_manifest = dag_manifest - .mutations - .first() - .ok_or(ManifestError::EmptyDAG)? - .hash()?; - - let hash_legacy = dag_legacy - .mutations - .first() - .ok_or(ManifestError::EmptyDAG)? - .hash()?; - - if hash_manifest != hash_legacy { - return Err(ManifestError::ClusterHashMismatch { - manifest_hash: hex::encode(&hash_manifest), - legacy_hash: hex::encode(&hash_legacy), - }); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - lock::Lock, - manifest::{materialise::materialise, mutationlegacylock::new_raw_legacy_lock}, - }; - use std::path::PathBuf; - use test_case::test_case; - use tokio::fs; - - fn testdata_path(filename: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("src") - .join("testdata") - .join(filename) - } - - fn manifest_testdata_path(filename: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("src") - .join("manifest") - .join("testdata") - .join(filename) - } - - #[test_case("", "", Some(ManifestError::NoFileFound { lock_file: String::new(), manifest_file: String::new() }) ; "no_files")] - #[test_case("manifest", "", None ; "only_manifest")] - #[test_case("", "lock.json", None ; "only_legacy_lock")] - #[test_case("manifest", "lock.json", None ; "both_files")] - #[test_case("manifest", "lock2.json", Some(ManifestError::ClusterHashMismatch { manifest_hash: String::new(), legacy_hash: String::new() }) ; "mismatching_cluster_hashes")] - #[tokio::test] - async fn load_manifest( - manifest_file: &str, - legacy_lock_file: &str, - expected_error: Option, - ) { - // Setup: Load legacy lock and create manifest file (shared across all tests) - let lock_path = manifest_testdata_path("lock.json"); - let lock_bytes = fs::read(&lock_path).await.unwrap(); - let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); - - let json_bytes = serde_json::to_vec(&lock).unwrap(); - let legacy_lock = new_raw_legacy_lock(&json_bytes).unwrap(); - let dag = SignedMutationList { - mutations: vec![legacy_lock], - }; - let expected_cluster = materialise(&dag).unwrap(); - - // Write manifest file to temp directory - let temp_dir = tempfile::tempdir().unwrap(); - let manifest_path = temp_dir.path().join("cluster-manifest.pb"); - let manifest_bytes = dag.encode_to_vec(); - fs::write(&manifest_path, manifest_bytes).await.unwrap(); - - // Map test parameters to actual paths - let lock_file_path = if !legacy_lock_file.is_empty() { - Some(manifest_testdata_path(legacy_lock_file)) - } else { - None - }; - - let manifest_arg = if manifest_file == "manifest" { - manifest_path.to_str().unwrap() - } else { - manifest_file - }; - let lock_arg = lock_file_path - .as_ref() - .map(|p| p.to_str().unwrap()) - .unwrap_or(""); - - // Load raw cluster DAG from disk - let result = load_dag( - manifest_arg, - lock_arg, - Option:: Result<()>>::None, - ) - .await; - - if let Some(expected_err) = expected_error { - assert!(result.is_err()); - let err = result.unwrap_err(); - match expected_err { - ManifestError::NoFileFound { .. } => { - assert!(matches!(err, ManifestError::NoFileFound { .. })); - } - ManifestError::ClusterHashMismatch { .. } => { - assert!(matches!(err, ManifestError::ClusterHashMismatch { .. })); - } - _ => panic!("Unexpected error type"), - } - } else { - let loaded_dag = result.unwrap(); - - // The only mutation is the `legacy_lock` mutation - assert_eq!(loaded_dag.mutations.len(), 1); - - let cluster_from_dag = materialise(&loaded_dag).unwrap(); - let loaded_cluster = load_cluster( - manifest_arg, - lock_arg, - Option:: Result<()>>::None, - ) - .await - .unwrap(); - assert_eq!(expected_cluster, loaded_cluster); - assert_eq!(expected_cluster, cluster_from_dag); - } - } - - #[tokio::test] - #[ignore] // TODO: lock3.json has null values that aren't compatible with Lock struct deserialization - async fn load_modified_legacy_lock() { - // This test ensures the hard-coded hash is used for legacy locks, - // even if the lock file was modified and run with --no-verify - let lock3_path = manifest_testdata_path("lock3.json"); - let cluster = load_cluster("", &lock3_path, Option:: Result<()>>::None) - .await - .unwrap(); - - let hash_hex = hex::encode(&cluster.initial_mutation_hash); - // Verify the hash starts with expected prefix - assert_eq!(&hash_hex[..9], "4073fe542"); - } - - // Parametrized test across all supported versions - #[test_case("v1.0.0")] - #[test_case("v1.1.0")] - #[test_case("v1.2.0")] - #[test_case("v1.3.0")] - #[test_case("v1.4.0")] - #[test_case("v1.5.0")] - #[test_case("v1.6.0")] - #[test_case("v1.7.0")] - #[test_case("v1.8.0")] - #[test_case("v1.9.0")] - #[test_case("v1.10.0")] - #[tokio::test] - async fn load_legacy_version(version: &str) { - // Load the lock file for this version - let filename = format!("cluster_lock_{}.json", version.replace('.', "_")); - let lock_path = testdata_path(&filename); - - let lock_bytes = fs::read(&lock_path).await.unwrap(); - let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); - - // Create temp file for the lock - let temp_dir = tempfile::tempdir().unwrap(); - let temp_lock_path = temp_dir.path().join("lock.json"); - fs::write(&temp_lock_path, &lock_bytes).await.unwrap(); - - // Load cluster from the lock file - let cluster = load_cluster("", &temp_lock_path, Option:: Result<()>>::None) - .await - .unwrap(); - - // Verify loaded cluster properties match the lock - assert_eq!( - cluster.initial_mutation_hash, lock.lock_hash, - "initial mutation hash should match lock hash" - ); - assert_eq!( - cluster.latest_mutation_hash, lock.lock_hash, - "latest mutation hash should match lock hash" - ); - assert_eq!(cluster.name, lock.name); - #[allow(clippy::cast_possible_truncation)] - { - assert_eq!(cluster.threshold, lock.threshold as i32); - } - assert_eq!(cluster.dkg_algorithm, lock.dkg_algorithm); - assert_eq!(cluster.fork_version.as_ref(), lock.fork_version.as_slice()); - assert_eq!(cluster.validators.len(), lock.distributed_validators.len()); - assert_eq!(cluster.operators.len(), lock.operators.len()); - - // Verify validators - for (i, validator) in cluster.validators.iter().enumerate() { - assert_eq!( - validator.public_key.as_ref(), - lock.distributed_validators[i].pub_key.as_slice() - ); - assert_eq!( - validator.pub_shares.len(), - lock.distributed_validators[i].pub_shares.len() - ); - assert_eq!( - validator.fee_recipient_address, - lock.validator_addresses[i].fee_recipient_address - ); - assert_eq!( - validator.withdrawal_address, - lock.validator_addresses[i].withdrawal_address - ); - } - - // Verify operators - for (i, operator) in cluster.operators.iter().enumerate() { - assert_eq!(operator.address, lock.operators[i].address); - assert_eq!(operator.enr, lock.operators[i].enr); - } - } -} +// Link to PR #4130: https://github.com/ObolNetwork/charon/pull/4130 +// The manifest is removed and there is no use in production. diff --git a/crates/cluster/src/manifest/materialise.rs b/crates/cluster/src/manifest/materialise.rs index d301f15d..5169cd93 100644 --- a/crates/cluster/src/manifest/materialise.rs +++ b/crates/cluster/src/manifest/materialise.rs @@ -1,34 +1,2 @@ -use crate::manifestpb::v1::{Cluster, SignedMutationList}; - -use super::error::{ManifestError, Result}; - -/// Transforms a raw DAG and returns the resulting cluster manifest. -pub fn materialise(raw_dag: &SignedMutationList) -> Result { - if raw_dag.mutations.is_empty() { - return Err(ManifestError::EmptyDAG); - } - - let mut cluster = Cluster::default(); - - for signed in &raw_dag.mutations { - cluster = signed.transform(&cluster)?; - } - - // initial_mutation_hash is the hash of the first mutation - cluster.initial_mutation_hash = raw_dag - .mutations - .first() - .ok_or(ManifestError::EmptyDAG)? - .hash()? - .into(); - - // latest_mutation_hash is the hash of the last mutation - cluster.latest_mutation_hash = raw_dag - .mutations - .last() - .ok_or(ManifestError::EmptyDAG)? - .hash()? - .into(); - - Ok(cluster) -} +// Link to PR #4130: https://github.com/ObolNetwork/charon/pull/4130 +// The manifest is removed and there is no use in production. diff --git a/crates/cluster/src/manifest/mod.rs b/crates/cluster/src/manifest/mod.rs index 89600e11..5256ce62 100644 --- a/crates/cluster/src/manifest/mod.rs +++ b/crates/cluster/src/manifest/mod.rs @@ -1,3 +1,6 @@ +// Link to PR #4130: https://github.com/ObolNetwork/charon/pull/4130 +// The manifest is removed and there is no use in production. + /// Cluster manifest management and coordination. pub mod cluster; /// Cluster manifest error types. diff --git a/crates/cluster/src/manifest/mutationaddvalidator.rs b/crates/cluster/src/manifest/mutationaddvalidator.rs index 234b6e53..5169cd93 100644 --- a/crates/cluster/src/manifest/mutationaddvalidator.rs +++ b/crates/cluster/src/manifest/mutationaddvalidator.rs @@ -1,306 +1,2 @@ -use prost::Message as _; - -use crate::{ - helpers::from_0x_hex_str, - manifestpb::v1::{ - Cluster, Mutation, SignedMutation, SignedMutationList, Validator, ValidatorList, - }, -}; - -use super::{ - error::{ManifestError, Result}, - helpers::{HASH_LEN, extract_mutation, verify_empty_sig}, - types::MutationType, -}; - -/// Ethereum address length in bytes. -const ADDRESS_LEN: usize = 20; - -impl ::prost::Name for ValidatorList { - const NAME: &'static str = "ValidatorList"; - const PACKAGE: &'static str = "cluster.manifestpb.v1"; - - fn type_url() -> ::prost::alloc::string::String { - format!( - "type.googleapis.com/{}", - ::full_name() - ) - } -} - -/// Creates a new gen validators mutation. -pub fn new_gen_validators(parent: &[u8], validators: Vec) -> Result { - verify_gen_validators_list(&validators)?; - - if parent.len() != HASH_LEN { - return Err(ManifestError::InvalidMutation( - "invalid parent hash".to_string(), - )); - } - - let vals_any = prost_types::Any::from_msg(&ValidatorList { validators }) - .map_err(|e| ManifestError::InvalidMutation(format!("marshal validators: {}", e)))?; - - Ok(SignedMutation { - mutation: Some(Mutation { - parent: parent.to_vec().into(), - r#type: MutationType::GenValidators.as_str().to_string(), - data: Some(vals_any), - }), - // No signer or signature - signer: Default::default(), - signature: Default::default(), - }) -} - -/// Verifies a gen validators list, ensuring validators are populated with valid -/// addresses. -fn verify_gen_validators_list(vals: &[Validator]) -> Result<()> { - if vals.is_empty() { - return Err(ManifestError::InvalidMutation("no validators".to_string())); - } - - for validator in vals { - from_0x_hex_str(&validator.fee_recipient_address, ADDRESS_LEN).map_err(|e| { - ManifestError::InvalidMutation(format!("validate fee recipient address: {}", e)) - })?; - - from_0x_hex_str(&validator.withdrawal_address, ADDRESS_LEN).map_err(|e| { - ManifestError::InvalidMutation(format!("validate withdrawal address: {}", e)) - })?; - } - - Ok(()) -} - -/// Transforms a cluster with a gen validators mutation. -pub(crate) fn transform_gen_validators( - cluster: &Cluster, - signed: &SignedMutation, -) -> Result { - verify_empty_sig(signed)?; - - let mutation = extract_mutation(signed, MutationType::GenValidators)?; - - let data = mutation - .data - .as_ref() - .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; - - let vals = ValidatorList::decode(&*data.value) - .map_err(|e| ManifestError::InvalidMutation(format!("unmarshal validators: {}", e)))?; - - let mut result = cluster.clone(); - result.validators.extend(vals.validators); - - Ok(result) -} - -/// Creates a new add validators composite mutation from the provided gen -/// validators and node approvals. -pub fn new_add_validators( - gen_validators: &SignedMutation, - node_approvals: &SignedMutation, -) -> Result { - let gen_mutation = extract_mutation(gen_validators, MutationType::GenValidators)?; - let _node_approvals_mutation = extract_mutation(node_approvals, MutationType::NodeApprovals)?; - - let data_any = prost_types::Any::from_msg(&SignedMutationList { - mutations: vec![gen_validators.clone(), node_approvals.clone()], - }) - .map_err(|e| ManifestError::InvalidMutation(format!("marshal signed mutation list: {}", e)))?; - - Ok(SignedMutation { - mutation: Some(Mutation { - parent: gen_mutation.parent.clone(), - r#type: MutationType::AddValidators.as_str().to_string(), - data: Some(data_any), - }), - // Composite mutations have no signer or signature - signer: Default::default(), - signature: Default::default(), - }) -} - -/// Transforms a cluster with an add validators composite mutation. -pub(crate) fn transform_add_validators( - cluster: &Cluster, - signed: &SignedMutation, -) -> Result { - verify_empty_sig(signed)?; - - let mutation = extract_mutation(signed, MutationType::AddValidators)?; - - let data = mutation - .data - .as_ref() - .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; - - let list = SignedMutationList::decode(&*data.value).map_err(|e| { - ManifestError::InvalidMutation(format!("unmarshal signed mutation list: {}", e)) - })?; - - if list.mutations.len() != 2 { - return Err(ManifestError::InvalidMutation( - "invalid mutation list length".to_string(), - )); - } - - let gen_validators = &list.mutations[0]; - let node_approvals = &list.mutations[1]; - - let gen_mutation = extract_mutation(gen_validators, MutationType::GenValidators)?; - - if mutation.parent != gen_mutation.parent { - return Err(ManifestError::InvalidMutation( - "invalid gen validators parent".to_string(), - )); - } - - let approvals_mutation = extract_mutation(node_approvals, MutationType::NodeApprovals)?; - - let gen_hash = gen_validators.hash()?; - if gen_hash != approvals_mutation.parent.to_vec() { - return Err(ManifestError::InvalidMutation( - "invalid node approvals parent".to_string(), - )); - } - - let result = gen_validators.transform(cluster)?; - - let result = node_approvals.transform(&result)?; - - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - use pluto_testutil::random::random_bytes32_seed; - - fn create_test_validator(idx: u8) -> Validator { - Validator { - public_key: vec![idx; 48].into(), - pub_shares: vec![vec![idx; 48].into()], - fee_recipient_address: format!("0x{}", "ab".repeat(20)), - withdrawal_address: format!("0x{}", "cd".repeat(20)), - builder_registration_json: vec![].into(), - } - } - - #[test] - fn new_gen_validators_test() { - let parent = random_bytes32_seed(1); - let validators = vec![create_test_validator(1), create_test_validator(2)]; - - let signed = new_gen_validators(&parent, validators.clone()).unwrap(); - - assert!(signed.mutation.is_some()); - let mutation = signed.mutation.as_ref().unwrap(); - assert_eq!(mutation.r#type, MutationType::GenValidators.as_str()); - assert!(signed.signer.is_empty()); - assert!(signed.signature.is_empty()); - } - - #[test] - fn new_gen_validators_empty() { - let parent = random_bytes32_seed(2); - let validators = vec![]; - - let result = new_gen_validators(&parent, validators); - assert!(matches!( - result.unwrap_err(), - ManifestError::InvalidMutation(_) - )); - } - - #[test] - fn new_gen_validators_invalid_parent() { - let parent = [0u8; 16]; // Invalid length - let validators = vec![create_test_validator(1)]; - - let result = new_gen_validators(&parent, validators); - assert!(matches!( - result.unwrap_err(), - ManifestError::InvalidMutation(_) - )); - } - - #[test] - fn transform_gen_validators_test() { - let parent = random_bytes32_seed(3); - let validators = vec![create_test_validator(1), create_test_validator(2)]; - - let signed = new_gen_validators(&parent, validators.clone()).unwrap(); - - let cluster = Cluster::default(); - let result = transform_gen_validators(&cluster, &signed).unwrap(); - - assert_eq!(result.validators.len(), 2); - } - - #[test] - fn new_add_validators_invalid_gen_type() { - // Create a mutation with wrong type - let parent = random_bytes32_seed(4); - let wrong_type = SignedMutation { - mutation: Some(Mutation { - parent: parent.clone().into(), - r#type: MutationType::NodeApproval.as_str().to_string(), - data: None, - }), - ..Default::default() - }; - - let node_approvals = SignedMutation { - mutation: Some(Mutation { - parent: parent.into(), - r#type: MutationType::NodeApprovals.as_str().to_string(), - data: None, - }), - ..Default::default() - }; - - let result = new_add_validators(&wrong_type, &node_approvals); - assert!(matches!( - result.unwrap_err(), - ManifestError::InvalidMutation(_) - )); - } - - #[test] - fn gen_validators() { - use super::super::helpers::validator_to_proto; - use crate::lock::Lock; - use std::{fs, path::PathBuf}; - - let lock_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("src/manifest/testdata") - .join("lock.json"); - let lock_json = fs::read_to_string(&lock_path).unwrap(); - let lock: Lock = serde_json::from_str(&lock_json).unwrap(); - - let mut vals = Vec::new(); - for (i, validator) in lock.distributed_validators.iter().enumerate() { - let val = validator_to_proto(validator, &lock.validator_addresses[i]).unwrap(); - vals.push(val); - } - - let parent = - hex::decode("605ec6de4f1ae997dd3545513b934c335a833f4635dc9fad7758314f79ff0fae") - .unwrap(); - - let signed = new_gen_validators(&parent, vals.clone()).unwrap(); - - let cluster = Cluster::default(); - let result = transform_gen_validators(&cluster, &signed).unwrap(); - assert_eq!(result.validators.len(), vals.len()); - for (i, val) in vals.iter().enumerate() { - assert_eq!(result.validators[i].public_key, val.public_key); - assert_eq!( - result.validators[i].fee_recipient_address, - val.fee_recipient_address - ); - } - } -} +// Link to PR #4130: https://github.com/ObolNetwork/charon/pull/4130 +// The manifest is removed and there is no use in production. diff --git a/crates/cluster/src/manifest/mutationlegacylock.rs b/crates/cluster/src/manifest/mutationlegacylock.rs index 89fe750c..5169cd93 100644 --- a/crates/cluster/src/manifest/mutationlegacylock.rs +++ b/crates/cluster/src/manifest/mutationlegacylock.rs @@ -1,226 +1,2 @@ -use prost::Message as _; - -use crate::{ - lock::Lock, - manifestpb::v1::{Cluster, LegacyLock, Mutation, Operator, SignedMutation}, -}; - -use super::{ - error::{ManifestError, Result}, - helpers::{extract_mutation, validator_to_proto, verify_empty_sig}, - types::MutationType, -}; - -impl ::prost::Name for LegacyLock { - const NAME: &'static str = "LegacyLock"; - const PACKAGE: &'static str = "cluster.manifestpb.v1"; - - fn type_url() -> ::prost::alloc::string::String { - format!( - "type.googleapis.com/{}", - ::full_name() - ) - } -} - -/// Creates a new raw legacy lock mutation from JSON bytes. -pub fn new_raw_legacy_lock(json_bytes: &[u8]) -> Result { - // Verify that the bytes are a valid lock by deserializing - let _: Lock = serde_json::from_slice(json_bytes) - .map_err(|e| ManifestError::InvalidMutation(format!("unmarshal lock: {}", e)))?; - - let legacy_lock = LegacyLock { - json: json_bytes.to_vec().into(), - }; - - let lock_any = prost_types::Any::from_msg(&legacy_lock) - .map_err(|e| ManifestError::InvalidMutation(format!("lock to any: {e}")))?; - - let zero_parent = vec![0u8; 32]; - - Ok(SignedMutation { - mutation: Some(Mutation { - parent: zero_parent.into(), - r#type: MutationType::LegacyLock.as_str().to_string(), - data: Some(lock_any), - }), - signer: Default::default(), - signature: Default::default(), - }) -} - -/// Verifies a legacy lock mutation. -pub fn verify_legacy_lock(signed: &SignedMutation) -> Result<()> { - let mutation = extract_mutation(signed, MutationType::LegacyLock)?; - - verify_empty_sig(signed)?; - - let data = mutation - .data - .as_ref() - .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; - - let legacy_lock = LegacyLock::decode(&*data.value) - .map_err(|_| ManifestError::InvalidMutation("mutation data to legacy lock".to_string()))?; - - let _lock: Lock = serde_json::from_slice(&legacy_lock.json) - .map_err(|e| ManifestError::InvalidMutation(format!("unmarshal lock: {}", e)))?; - - Ok(()) -} - -/// Transforms a cluster with a legacy lock mutation. -pub(crate) fn transform_legacy_lock(cluster: &Cluster, signed: &SignedMutation) -> Result { - if !is_zero_proto(cluster) { - return Err(ManifestError::InvalidMutation( - "legacy lock not first mutation".to_string(), - )); - } - - verify_legacy_lock(signed)?; - - let mutation = signed - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - - let data = mutation - .data - .as_ref() - .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; - - let legacy_lock = LegacyLock::decode(&*data.value) - .map_err(|_| ManifestError::InvalidMutation("mutation data to legacy lock".to_string()))?; - - let lock: Lock = serde_json::from_slice(&legacy_lock.json) - .map_err(|e| ManifestError::InvalidMutation(format!("unmarshal lock: {}", e)))?; - - // Build operators - let mut ops = Vec::new(); - for operator in &lock.operators { - ops.push(Operator { - address: operator.address.clone(), - enr: operator.enr.clone(), - }); - } - - // Check validator addresses length matches validators length - if lock.validator_addresses.len() != lock.distributed_validators.len() { - return Err(ManifestError::InvalidMutation( - "validator addresses and validators length mismatch".to_string(), - )); - } - - // Build validators - let mut vals = Vec::new(); - for (i, validator) in lock.distributed_validators.iter().enumerate() { - let val = validator_to_proto(validator, &lock.validator_addresses[i])?; - vals.push(val); - } - - Ok(Cluster { - name: lock.name.clone(), - #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] - threshold: lock.threshold as i32, - dkg_algorithm: lock.dkg_algorithm.clone(), - fork_version: lock.fork_version.clone().into(), - consensus_protocol: lock.consensus_protocol.clone(), - #[allow(clippy::cast_possible_truncation)] - target_gas_limit: lock.target_gas_limit as u32, - compounding: lock.compounding, - validators: vals, - operators: ops, - // These will be set by materialise - initial_mutation_hash: Default::default(), - latest_mutation_hash: Default::default(), - }) -} - -/// Checks if a protobuf message is zero/empty. -pub(crate) fn is_zero_proto(msg: &T) -> bool -where - T: prost::Message + Default + PartialEq, -{ - *msg == T::default() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{manifest::materialise::materialise, manifestpb::v1::SignedMutationList}; - - #[test] - fn is_zero_proto_test() { - let cluster = Cluster::default(); - assert!(is_zero_proto(&cluster)); - } - - #[test] - fn legacy_lock_not_first_mutation() { - let cluster = Cluster { - name: "foo".to_string(), - ..Default::default() - }; - - let result = transform_legacy_lock(&cluster, &SignedMutation::default()); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ManifestError::InvalidMutation(_) - )); - } - - #[test] - fn load_legacy_lock_from_testdata() { - let lock_json = include_str!("../testdata/cluster_lock_v1_8_0.json"); - let lock: Lock = serde_json::from_str(lock_json).unwrap(); - - // Test creating legacy lock mutation using official method - let json_bytes = serde_json::to_vec(&lock).unwrap(); - let signed = new_raw_legacy_lock(&json_bytes).unwrap(); - assert!(signed.mutation.is_some()); - - let mutation = signed.mutation.as_ref().unwrap(); - assert_eq!(mutation.r#type, MutationType::LegacyLock.as_str()); - assert!(signed.signer.is_empty()); - assert!(signed.signature.is_empty()); - - // Test transform - let cluster = transform_legacy_lock(&Cluster::default(), &signed).unwrap(); - assert_eq!(cluster.name, lock.name); - assert_eq!(cluster.threshold, i32::try_from(lock.threshold).unwrap()); - assert_eq!(cluster.operators.len(), lock.operators.len()); - assert_eq!(cluster.validators.len(), lock.distributed_validators.len()); - } - - #[test] - fn new_dag_from_lock() { - let lock_json = include_str!("../testdata/cluster_lock_v1_8_0.json"); - let lock: Lock = serde_json::from_str(lock_json).unwrap(); - - let json_bytes = serde_json::to_vec(&lock).unwrap(); - let signed = new_raw_legacy_lock(&json_bytes).unwrap(); - let dag = SignedMutationList { - mutations: vec![signed], - }; - assert_eq!(dag.mutations.len(), 1); - } - - #[test] - fn new_cluster_from_lock() { - let lock_json = include_str!("../testdata/cluster_lock_v1_8_0.json"); - let lock: Lock = serde_json::from_str(lock_json).unwrap(); - - let json_bytes = serde_json::to_vec(&lock).unwrap(); - let signed = new_raw_legacy_lock(&json_bytes).unwrap(); - let cluster = materialise(&SignedMutationList { - mutations: vec![signed], - }) - .unwrap(); - assert_eq!(cluster.name, lock.name); - assert!(!cluster.initial_mutation_hash.is_empty()); - assert!(!cluster.latest_mutation_hash.is_empty()); - // For a single mutation, initial and latest should be the same - assert_eq!(cluster.initial_mutation_hash, cluster.latest_mutation_hash); - } -} +// Link to PR #4130: https://github.com/ObolNetwork/charon/pull/4130 +// The manifest is removed and there is no use in production. diff --git a/crates/cluster/src/manifest/mutationnodeapproval.rs b/crates/cluster/src/manifest/mutationnodeapproval.rs index 5054f4ab..5169cd93 100644 --- a/crates/cluster/src/manifest/mutationnodeapproval.rs +++ b/crates/cluster/src/manifest/mutationnodeapproval.rs @@ -1,264 +1,2 @@ -use k256::SecretKey; -use prost::Message as _; -use prost_types::Timestamp; - -use crate::manifestpb::v1::{Cluster, Mutation, SignedMutation, SignedMutationList}; - -use super::{ - cluster::cluster_peers, - error::{ManifestError, Result}, - helpers::{HASH_LEN, extract_mutation, now, sign_k1, verify_k1_signed_mutation}, - types::MutationType, -}; - -/// Type URL for google.protobuf.Timestamp. -const TIMESTAMP_TYPE_URL: &str = "type.googleapis.com/google.protobuf.Timestamp"; - -impl ::prost::Name for SignedMutationList { - const NAME: &'static str = "SignedMutationList"; - const PACKAGE: &'static str = "cluster.manifestpb.v1"; - - fn type_url() -> ::prost::alloc::string::String { - format!( - "type.googleapis.com/{}", - ::full_name() - ) - } -} - -/// Helper to encode a Timestamp to prost_types::Any. -fn timestamp_to_any(timestamp: &Timestamp) -> Result { - let mut value = Vec::new(); - timestamp - .encode(&mut value) - .map_err(|e| ManifestError::InvalidMutation(format!("encode timestamp: {}", e)))?; - - Ok(prost_types::Any { - type_url: TIMESTAMP_TYPE_URL.to_string(), - value, - }) -} - -/// Signs a node approval mutation. -pub fn sign_node_approval(parent: &[u8], secret: &SecretKey) -> Result { - let timestamp = now(); - - let timestamp_any = timestamp_to_any(×tamp)?; - - if parent.len() != HASH_LEN { - return Err(ManifestError::InvalidMutation( - "invalid parent hash".to_string(), - )); - } - - let mutation = Mutation { - parent: parent.to_vec().into(), - r#type: MutationType::NodeApproval.as_str().to_string(), - data: Some(timestamp_any), - }; - - sign_k1(&mutation, secret) -} - -/// Creates a new node approvals composite mutation. -/// -/// Note the approvals must be for all nodes in the cluster ordered by peer -/// index. -pub fn new_node_approvals_composite(approvals: Vec) -> Result { - if approvals.is_empty() { - return Err(ManifestError::InvalidMutation( - "empty node approvals".to_string(), - )); - } - - let first_mutation = approvals[0] - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - let parent = first_mutation.parent.to_vec(); - - for approval in &approvals { - let mutation = approval - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - - if mutation.parent.to_vec() != parent { - return Err(ManifestError::InvalidMutation( - "mismatching node approvals parent".to_string(), - )); - } - - verify_node_approval(approval)?; - } - - let any_list = prost_types::Any::from_msg(&SignedMutationList { - mutations: approvals.clone(), - }) - .map_err(|e| ManifestError::InvalidMutation(format!("mutations to any: {}", e)))?; - - Ok(SignedMutation { - mutation: Some(Mutation { - parent: parent.into(), - r#type: MutationType::NodeApprovals.as_str().to_string(), - data: Some(any_list), - }), - // Composite types do not have signatures - signer: Default::default(), - signature: Default::default(), - }) -} - -/// Verifies a node approval mutation. -pub(crate) fn verify_node_approval(signed: &SignedMutation) -> Result<()> { - let mutation = extract_mutation(signed, MutationType::NodeApproval)?; - - let data = mutation - .data - .as_ref() - .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; - - // Verify that the data is a valid timestamp - let _timestamp = Timestamp::decode(&*data.value).map_err(|e| { - ManifestError::InvalidMutation(format!("invalid node approval timestamp data: {}", e)) - })?; - - verify_k1_signed_mutation(signed) -} - -/// Transforms a cluster with a node approvals composite mutation. -pub(crate) fn transform_node_approvals( - cluster: &Cluster, - signed: &SignedMutation, -) -> Result { - let mutation = extract_mutation(signed, MutationType::NodeApprovals)?; - - let data = mutation - .data - .as_ref() - .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; - - let list = SignedMutationList::decode(&*data.value) - .map_err(|_| ManifestError::InvalidMutation("invalid node approval data".to_string()))?; - - let peers = cluster_peers(cluster)?; - - if peers.len() != list.mutations.len() { - return Err(ManifestError::InvalidMutation( - "invalid number of node approvals".to_string(), - )); - } - - let parent = list - .mutations - .first() - .and_then(|m| m.mutation.as_ref()) - .ok_or(ManifestError::InvalidSignedMutation)? - .parent - .as_ref(); - - let mut result = cluster.clone(); - - for (i, approval) in list.mutations.iter().enumerate() { - let approval_mutation = approval - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - - // Verify all mutations have the same parent - if approval_mutation.parent.as_ref() != parent { - return Err(ManifestError::InvalidMutation( - "mismatching node approvals parent".to_string(), - )); - } - - let pubkey = peers[i] - .public_key() - .map_err(|e| ManifestError::P2p(format!("get peer public key: {}", e)))?; - - // Compare compressed public key with signer - let expected_signer = pubkey.to_sec1_bytes(); - if expected_signer.as_ref() != approval.signer.as_ref() { - return Err(ManifestError::InvalidMutation( - "invalid node approval signer".to_string(), - )); - } - - result = approval.transform(&result)?; - } - - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - use pluto_testutil::random::{generate_insecure_k1_key, random_bytes32_seed}; - - #[test] - fn sign_node_approval_test() { - let secret = generate_insecure_k1_key(1); - let parent = random_bytes32_seed(1); - - let signed = sign_node_approval(&parent, &secret).unwrap(); - - assert!(signed.mutation.is_some()); - let mutation = signed.mutation.as_ref().unwrap(); - assert_eq!(mutation.r#type, MutationType::NodeApproval.as_str()); - assert!(!signed.signer.is_empty()); - assert!(!signed.signature.is_empty()); - - verify_node_approval(&signed).unwrap(); - } - - #[test] - fn sign_node_approval_invalid_parent() { - let secret = generate_insecure_k1_key(1); - let parent = [0u8; HASH_LEN / 2]; // Invalid length - - let result = sign_node_approval(&parent, &secret); - assert!(result.is_err()); - } - - #[test] - fn new_node_approvals_composite_test() { - let parent = random_bytes32_seed(1); - let mut approvals = Vec::new(); - - for i in 0..3 { - let secret = generate_insecure_k1_key(i); - let approval = sign_node_approval(&parent, &secret).unwrap(); - approvals.push(approval); - } - - let composite = new_node_approvals_composite(approvals).unwrap(); - - assert!(composite.mutation.is_some()); - let mutation = composite.mutation.as_ref().unwrap(); - assert_eq!(mutation.r#type, MutationType::NodeApprovals.as_str()); - assert!(composite.signer.is_empty()); - assert!(composite.signature.is_empty()); - } - - #[test] - fn new_node_approvals_composite_empty() { - let result = new_node_approvals_composite(vec![]); - assert!(result.is_err()); - } - - #[test] - fn new_node_approvals_composite_mismatching_parent() { - let secret1 = generate_insecure_k1_key(1); - let secret2 = generate_insecure_k1_key(2); - - let approval1 = sign_node_approval(&random_bytes32_seed(1), &secret1).unwrap(); - let approval2 = sign_node_approval(&random_bytes32_seed(2), &secret2).unwrap(); - - let result = new_node_approvals_composite(vec![approval1, approval2]); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ManifestError::InvalidMutation(_) - )); - } -} +// Link to PR #4130: https://github.com/ObolNetwork/charon/pull/4130 +// The manifest is removed and there is no use in production. diff --git a/crates/cluster/src/manifest/types.rs b/crates/cluster/src/manifest/types.rs index f21373f8..5169cd93 100644 --- a/crates/cluster/src/manifest/types.rs +++ b/crates/cluster/src/manifest/types.rs @@ -1,114 +1,2 @@ -use prost::Message as _; - -use crate::{ - lock::Lock, - manifestpb::v1::{Cluster, LegacyLock, SignedMutation}, -}; - -use super::{ - error::{ManifestError, Result}, - helpers::{HASH_LEN, hash_signed_mutation}, - mutationaddvalidator::{transform_add_validators, transform_gen_validators}, - mutationlegacylock::transform_legacy_lock, - mutationnodeapproval::{transform_node_approvals, verify_node_approval}, -}; - -/// Mutation type enumeration. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MutationType { - /// Legacy lock mutation type. - LegacyLock, - /// Node approval mutation type. - NodeApproval, - /// Node approvals composite mutation type. - NodeApprovals, - /// Generate validators mutation type. - GenValidators, - /// Add validators composite mutation type. - AddValidators, -} - -impl MutationType { - /// Returns the string representation of the mutation type. - pub fn as_str(&self) -> &'static str { - match self { - Self::LegacyLock => "dv/legacy_lock/v0.0.1", - Self::NodeApproval => "dv/node_approval/v0.0.1", - Self::NodeApprovals => "dv/node_approvals/v0.0.1", - Self::GenValidators => "dv/gen_validators/v0.0.1", - Self::AddValidators => "dv/add_validators/v0.0.1", - } - } - - /// Parses a mutation type from a string. - pub fn parse(s: &str) -> Option { - match s { - "dv/legacy_lock/v0.0.1" => Some(Self::LegacyLock), - "dv/node_approval/v0.0.1" => Some(Self::NodeApproval), - "dv/node_approvals/v0.0.1" => Some(Self::NodeApprovals), - "dv/gen_validators/v0.0.1" => Some(Self::GenValidators), - "dv/add_validators/v0.0.1" => Some(Self::AddValidators), - _ => None, - } - } - - /// Transforms the cluster with the given signed mutation. - pub fn transform(&self, cluster: &Cluster, signed: &SignedMutation) -> Result { - match self { - Self::LegacyLock => transform_legacy_lock(cluster, signed), - Self::NodeApproval => { - verify_node_approval(signed)?; - Ok(cluster.clone()) - } - Self::NodeApprovals => transform_node_approvals(cluster, signed), - Self::GenValidators => transform_gen_validators(cluster, signed), - Self::AddValidators => transform_add_validators(cluster, signed), - } - } -} - -impl SignedMutation { - /// Calculates the hash of this signed mutation. - pub fn hash(&self) -> Result> { - let mutation = self - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - - // Special case for legacy lock: return the lock hash - if mutation.r#type == MutationType::LegacyLock.as_str() { - let data = mutation - .data - .as_ref() - .ok_or_else(|| ManifestError::InvalidMutation("data is nil".to_string()))?; - - let legacy_lock = - LegacyLock::decode(&*data.value).map_err(ManifestError::ProtobufDecode)?; - - let lock: Lock = - serde_json::from_slice(&legacy_lock.json).map_err(ManifestError::Json)?; - - if lock.lock_hash.len() != HASH_LEN { - return Err(ManifestError::InvalidLockHash); - } - - return Ok(lock.lock_hash); - } - - // Otherwise, return the hash of the signed mutation - hash_signed_mutation(self) - } - - /// Transforms a cluster with this signed mutation. - pub fn transform(&self, cluster: &Cluster) -> Result { - let mutation = self - .mutation - .as_ref() - .ok_or(ManifestError::InvalidSignedMutation)?; - - let typ = MutationType::parse(&mutation.r#type) - .ok_or_else(|| ManifestError::InvalidMutationType(mutation.r#type.clone()))?; - - typ.transform(cluster, self) - } -} +// Link to PR #4130: https://github.com/ObolNetwork/charon/pull/4130 +// The manifest is removed and there is no use in production. From c75fdb4a1034b6a66f5555f8e3ee4e675343f0ce Mon Sep 17 00:00:00 2001 From: Quang Le Date: Fri, 6 Feb 2026 11:19:57 +0700 Subject: [PATCH 09/14] fix: remove unused crates --- Cargo.lock | 36 ------------------------- Cargo.toml | 1 - crates/cluster/Cargo.toml | 5 ---- crates/cluster/src/manifest/mod.rs | 2 ++ crates/cluster/src/manifest/mutation.rs | 2 ++ 5 files changed, 4 insertions(+), 42 deletions(-) create mode 100644 crates/cluster/src/manifest/mutation.rs diff --git a/Cargo.lock b/Cargo.lock index 8bc4adab..56025141 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5417,7 +5417,6 @@ dependencies = [ "pluto-eth2util", "pluto-k1util", "pluto-p2p", - "pluto-testutil", "prost 0.14.3", "prost-build", "prost-types 0.14.3", @@ -5425,8 +5424,6 @@ dependencies = [ "serde", "serde_json", "serde_with", - "tempfile", - "test-case", "thiserror 2.0.18", "tokio", "uuid", @@ -7238,39 +7235,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "test-case" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" -dependencies = [ - "test-case-macros", -] - -[[package]] -name = "test-case-core" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "test-case-macros" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "test-case-core", -] - [[package]] name = "testcontainers" version = "0.26.3" diff --git a/Cargo.toml b/Cargo.toml index 71f2d2c6..27806bc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,6 @@ reqwest = "0.13" http = "1.4" tempfile = "3.24" assert-json-diff = "0.2" -test-case = "3.3" validator = { version = "0.20", features = ["derive"] } oas3-gen-support = "0.24" bon = "3.8" diff --git a/crates/cluster/Cargo.toml b/crates/cluster/Cargo.toml index 7c5ae01a..589bcef0 100644 --- a/crates/cluster/Cargo.toml +++ b/crates/cluster/Cargo.toml @@ -28,10 +28,5 @@ tokio.workspace = true [build-dependencies] prost-build.workspace = true -[dev-dependencies] -pluto-testutil.workspace = true -test-case.workspace = true -tempfile.workspace = true - [lints] workspace = true diff --git a/crates/cluster/src/manifest/mod.rs b/crates/cluster/src/manifest/mod.rs index 5256ce62..701bf0ae 100644 --- a/crates/cluster/src/manifest/mod.rs +++ b/crates/cluster/src/manifest/mod.rs @@ -11,6 +11,8 @@ pub mod helpers; pub mod load; /// Cluster manifest materialise management and coordination. pub mod materialise; +/// Cluster manifest mutation management and coordination. +pub mod mutation; /// Cluster manifest mutation add validator management and coordination. pub mod mutationaddvalidator; /// Cluster manifest mutation legacy lock management and coordination. diff --git a/crates/cluster/src/manifest/mutation.rs b/crates/cluster/src/manifest/mutation.rs new file mode 100644 index 00000000..5169cd93 --- /dev/null +++ b/crates/cluster/src/manifest/mutation.rs @@ -0,0 +1,2 @@ +// Link to PR #4130: https://github.com/ObolNetwork/charon/pull/4130 +// The manifest is removed and there is no use in production. From 46e87937f7a5a64149aac97f6a4d89ab6e7ce10d Mon Sep 17 00:00:00 2001 From: Quang Le Date: Fri, 6 Feb 2026 13:09:40 +0700 Subject: [PATCH 10/14] fix: remove tokio --- Cargo.lock | 1 - crates/cluster/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 56025141..36fb7984 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5425,7 +5425,6 @@ dependencies = [ "serde_json", "serde_with", "thiserror 2.0.18", - "tokio", "uuid", ] diff --git a/crates/cluster/Cargo.toml b/crates/cluster/Cargo.toml index 589bcef0..d5b98b8e 100644 --- a/crates/cluster/Cargo.toml +++ b/crates/cluster/Cargo.toml @@ -23,7 +23,6 @@ pluto-p2p.workspace = true pluto-eth2util.workspace = true pluto-k1util.workspace = true k256.workspace = true -tokio.workspace = true [build-dependencies] prost-build.workspace = true From 86674ef9cfae3da476b553a3c3aa6fa06eab6764 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Sun, 15 Feb 2026 22:32:36 +0700 Subject: [PATCH 11/14] fix: comment in detail --- crates/cluster/src/manifest/mod.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/cluster/src/manifest/mod.rs b/crates/cluster/src/manifest/mod.rs index 701bf0ae..d00b57ac 100644 --- a/crates/cluster/src/manifest/mod.rs +++ b/crates/cluster/src/manifest/mod.rs @@ -1,5 +1,14 @@ // Link to PR #4130: https://github.com/ObolNetwork/charon/pull/4130 // The manifest is removed and there is no use in production. +// +// The following modules are no longer required: +// - load +// - materialise +// - mutation +// - mutationaddvalidator +// - mutationlegacylock +// - mutationnodeapproval +// - types /// Cluster manifest management and coordination. pub mod cluster; From 9c93f0404d662803efa6e4a6109f298c049385c3 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Sun, 15 Feb 2026 22:36:17 +0700 Subject: [PATCH 12/14] fix: update error --- crates/cluster/src/manifest/cluster.rs | 8 +++----- crates/cluster/src/manifest/error.rs | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/cluster/src/manifest/cluster.rs b/crates/cluster/src/manifest/cluster.rs index 82acbd8c..4c06146d 100644 --- a/crates/cluster/src/manifest/cluster.rs +++ b/crates/cluster/src/manifest/cluster.rs @@ -28,13 +28,11 @@ pub fn cluster_peers(cluster: &Cluster) -> Result> { enr: operator.enr.clone(), }); } - dedup.insert(operator.enr.clone()); + dedup.insert(&operator.enr); - let record = Record::try_from(operator.enr.as_str()) - .map_err(|e| ManifestError::EnrParse(format!("decode enr: {}", e)))?; + let record = Record::try_from(operator.enr.as_str())?; - let peer = Peer::from_enr(&record, i) - .map_err(|e| ManifestError::P2p(format!("create peer from enr: {}", e)))?; + let peer = Peer::from_enr(&record, i)?; resp.push(peer); } diff --git a/crates/cluster/src/manifest/error.rs b/crates/cluster/src/manifest/error.rs index a6fcb76f..e3ae58c4 100644 --- a/crates/cluster/src/manifest/error.rs +++ b/crates/cluster/src/manifest/error.rs @@ -29,11 +29,11 @@ pub enum ManifestError { /// ENR parsing error. #[error("enr parsing error: {0}")] - EnrParse(String), + EnrParse(#[from] pluto_eth2util::enr::RecordError), /// P2P error. #[error("p2p error: {0}")] - P2p(String), + P2p(#[from] pluto_p2p::peer::PeerError), } /// Result type alias for manifest operations. From 84580c05df18440e9d390a127d67bd26e2e483d7 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Sun, 15 Feb 2026 22:57:15 +0700 Subject: [PATCH 13/14] fix: refactor to impl Cluster and impl Validator --- crates/cluster/src/manifest/cluster.rs | 144 +++++++++++++------------ 1 file changed, 74 insertions(+), 70 deletions(-) diff --git a/crates/cluster/src/manifest/cluster.rs b/crates/cluster/src/manifest/cluster.rs index 4c06146d..0e6c2db0 100644 --- a/crates/cluster/src/manifest/cluster.rs +++ b/crates/cluster/src/manifest/cluster.rs @@ -13,85 +13,89 @@ use crate::{ use super::error::{ManifestError, Result}; -/// Returns the cluster operators as a slice of p2p peers. -pub fn cluster_peers(cluster: &Cluster) -> Result> { - if cluster.operators.is_empty() { - return Err(ManifestError::InvalidCluster); - } - - let mut resp = Vec::new(); - let mut dedup = HashSet::new(); - - for (i, operator) in cluster.operators.iter().enumerate() { - if dedup.contains(&operator.enr) { - return Err(ManifestError::DuplicatePeerENR { - enr: operator.enr.clone(), - }); +impl Cluster { + /// Returns the cluster operators as a slice of p2p peers. + pub fn peers(&self) -> Result> { + if self.operators.is_empty() { + return Err(ManifestError::InvalidCluster); } - dedup.insert(&operator.enr); - let record = Record::try_from(operator.enr.as_str())?; + let mut resp = Vec::new(); + let mut dedup = HashSet::new(); - let peer = Peer::from_enr(&record, i)?; + for (i, operator) in self.operators.iter().enumerate() { + if dedup.contains(&operator.enr) { + return Err(ManifestError::DuplicatePeerENR { + enr: operator.enr.clone(), + }); + } + dedup.insert(&operator.enr); - resp.push(peer); - } + let record = Record::try_from(operator.enr.as_str())?; - Ok(resp) -} + let peer = Peer::from_enr(&record, i)?; -/// Returns the operators p2p peer IDs. -pub fn cluster_peer_ids(cluster: &Cluster) -> Result> { - let peers = cluster_peers(cluster)?; - Ok(peers.iter().map(|p| p.id).collect()) -} + resp.push(peer); + } -/// Returns the node index for the peer in the cluster. -pub fn cluster_node_idx(cluster: &Cluster, peer_id: &PeerId) -> Result { - let peers = cluster_peers(cluster)?; + Ok(resp) + } - for (i, p) in peers.iter().enumerate() { - if p.id == *peer_id { - return Ok(NodeIdx { - peer_idx: i, // 0-indexed - share_idx: i.saturating_add(1), // 1-indexed - }); - } + /// Returns the operators p2p peer IDs. + pub fn peer_ids(&self) -> Result> { + let peers = self.peers()?; + Ok(peers.iter().map(|p| p.id).collect()) } - Err(ManifestError::PeerNotInDefinition) -} + /// Returns the node index for the peer in the cluster. + pub fn node_idx(&self, peer_id: &PeerId) -> Result { + let peers = self.peers()?; + + for (i, p) in peers.iter().enumerate() { + if p.id == *peer_id { + return Ok(NodeIdx { + peer_idx: i, // 0-indexed + share_idx: i.saturating_add(1), // 1-indexed + }); + } + } -/// Returns the validator BLS group public key. -pub fn validator_public_key(validator: &Validator) -> Result { - let pk_vec = validator.public_key.to_vec(); - pk_vec - .try_into() - .map_err(|_| ManifestError::InvalidHexLength { - expect: PUBLIC_KEY_LENGTH, - actual: validator.public_key.len(), - }) + Err(ManifestError::PeerNotInDefinition) + } } -/// Returns the validator hex group public key. -pub fn validator_public_key_hex(validator: &Validator) -> String { - to_0x_hex(&validator.public_key) -} +impl Validator { + /// Returns the validator BLS group public key. + pub fn public_key(&self) -> Result { + let pk_vec = self.public_key.to_vec(); + pk_vec + .try_into() + .map_err(|_| ManifestError::InvalidHexLength { + expect: PUBLIC_KEY_LENGTH, + actual: self.public_key.len(), + }) + } + + /// Returns the validator hex group public key. + pub fn public_key_hex(&self) -> String { + to_0x_hex(&self.public_key) + } -/// Returns the validator's peerIdx'th BLS public share. -pub fn validator_public_share(validator: &Validator, peer_idx: usize) -> Result { - let share = validator - .pub_shares - .get(peer_idx) - .ok_or(ManifestError::InvalidCluster)?; - - let share_vec = share.to_vec(); - share_vec - .try_into() - .map_err(|_| ManifestError::InvalidHexLength { - expect: PUBLIC_KEY_LENGTH, - actual: share.len(), - }) + /// Returns the validator's peerIdx'th BLS public share. + pub fn public_share(&self, peer_idx: usize) -> Result { + let share = self + .pub_shares + .get(peer_idx) + .ok_or(ManifestError::InvalidCluster)?; + + let share_vec = share.to_vec(); + share_vec + .try_into() + .map_err(|_| ManifestError::InvalidHexLength { + expect: PUBLIC_KEY_LENGTH, + actual: share.len(), + }) + } } #[cfg(test)] @@ -102,7 +106,7 @@ mod tests { #[test] fn cluster_peers_empty() { let cluster = Cluster::default(); - let result = cluster_peers(&cluster); + let result = cluster.peers(); assert!(result.is_err()); } @@ -123,7 +127,7 @@ mod tests { ], ..Default::default() }; - let result = cluster_peers(&cluster); + let result = cluster.peers(); assert!(matches!( result.unwrap_err(), ManifestError::DuplicatePeerENR { .. } @@ -142,14 +146,14 @@ mod tests { ..Default::default() }; - let result0 = validator_public_share(&validator, 0).unwrap(); + let result0 = validator.public_share(0).unwrap(); assert_eq!(result0[0], 0x01); assert_eq!(result0.len(), PUBLIC_KEY_LENGTH); - let result1 = validator_public_share(&validator, 1).unwrap(); + let result1 = validator.public_share(1).unwrap(); assert_eq!(result1[0], 0x02); assert_eq!(result1.len(), PUBLIC_KEY_LENGTH); - assert!(validator_public_share(&validator, 5).is_err()); + assert!(validator.public_share(5).is_err()); } } From b2c938175b9f2a997facdb8cef52bad34efbdd4b Mon Sep 17 00:00:00 2001 From: Quang Le Date: Sun, 15 Feb 2026 23:37:49 +0700 Subject: [PATCH 14/14] fix: add more test --- crates/cluster/src/manifest/cluster.rs | 61 ++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/cluster/src/manifest/cluster.rs b/crates/cluster/src/manifest/cluster.rs index 0e6c2db0..631b43e2 100644 --- a/crates/cluster/src/manifest/cluster.rs +++ b/crates/cluster/src/manifest/cluster.rs @@ -156,4 +156,65 @@ mod tests { assert!(validator.public_share(5).is_err()); } + + #[test] + fn cluster_node_idx_test() { + let enr0 = "enr:-HW4QMOF6QNn4DRhSznyqhoRitA0R1P_p-Cf8I_phn-qR5EQEqFVV0_OtVuSWPj_HjGPd8lcXmcTen8j-9VT9hadVFyAgmlkgnY0iXNlY3AyNTZrMaECOx8LaV0436lNYE4XiqbGbVmXrEhUTg73e3M7HdRUWao".to_string(); + let enr1 = "enr:-HW4QKFO6PyCQdVXUdNEn80MJL7O048nRgZvheMhdT4LL9DGPjXlhrP1beyj8OEfZrapZVWNPEjfkUJubybvOPqkEhmAgmlkgnY0iXNlY3AyNTZrMaECGzgOLCm1ShATtBj1sh0VvshUOPkGW20ruTPPo5N_HZM".to_string(); + let enr2 = "enr:-HW4QJV3uqiuCqreW6nn794r-SxTC1fTXCnZQ4smu3l5F4DofbW566Zo8G0A9WL_wfGzkGRPPdGu6vYT7JfskEmbjIKAgmlkgnY0iXNlY3AyNTZrMaECh69y5mTVFNZQSh8Kc_57VwcK39WfY68y2F2WkeLa7EY".to_string(); + + let cluster = Cluster { + operators: vec![ + Operator { + address: "0x123".to_string(), + enr: enr0, + }, + Operator { + address: "0x456".to_string(), + enr: enr1, + }, + Operator { + address: "0x789".to_string(), + enr: enr2, + }, + ], + ..Default::default() + }; + + let peers = cluster.peers().unwrap(); + let peer_id = peers[1].id; + + let node_idx = cluster.node_idx(&peer_id).unwrap(); + assert_eq!(node_idx.peer_idx, 1); + assert_eq!(node_idx.share_idx, 2); + } + + #[test] + fn validator_public_key_test() { + let public_key = vec![0x42u8; PUBLIC_KEY_LENGTH]; + let validator = Validator { + public_key: public_key.clone().into(), + ..Default::default() + }; + + let result = validator.public_key().unwrap(); + assert_eq!(result[0], 0x42); + assert_eq!(result.len(), PUBLIC_KEY_LENGTH); + } + + #[test] + fn validator_public_key_hex_test() { + let mut public_key = vec![0u8; PUBLIC_KEY_LENGTH]; + public_key[0] = 0xab; + public_key[1] = 0xcd; + + let validator = Validator { + public_key: public_key.into(), + ..Default::default() + }; + + let hex = validator.public_key_hex(); + let expected = "0xabcd".to_string() + &"00".repeat(PUBLIC_KEY_LENGTH - 2); + assert_eq!(hex, expected); + } }