From 9772c707089d9191fba7b9d3793861bf68cfa595 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:02:43 +0200 Subject: [PATCH 01/12] wip: create cluster --- .claude/skills/rust-style/SKILL.md | 15 +- Cargo.lock | 8 + crates/cli/Cargo.toml | 8 + crates/cli/src/cli.rs | 7 + crates/cli/src/commands/create_cluster.rs | 905 ++++++++++++++++++++++ crates/cli/src/commands/create_dkg.rs | 69 ++ crates/cli/src/commands/mod.rs | 2 + crates/cli/src/error.rs | 267 +++++++ crates/cli/src/main.rs | 1 + crates/cluster/src/helpers.rs | 16 + 10 files changed, 1295 insertions(+), 3 deletions(-) create mode 100644 crates/cli/src/commands/create_cluster.rs create mode 100644 crates/cli/src/commands/create_dkg.rs diff --git a/.claude/skills/rust-style/SKILL.md b/.claude/skills/rust-style/SKILL.md index 8f14ad36..165eccab 100644 --- a/.claude/skills/rust-style/SKILL.md +++ b/.claude/skills/rust-style/SKILL.md @@ -61,16 +61,25 @@ let x = a.checked_add(b).ok_or(Error::Overflow)?; ## Casts -No lossy or unchecked casts — use fallible conversions: +**Never use `as` for numeric type conversions** — use fallible conversions with `try_from`: ```rust -// Bad +// Bad - will cause clippy errors let x = value as u32; +let y = some_usize as u64; -// Good +// Good - use try_from with proper error handling let x = u32::try_from(value)?; +let y = u64::try_from(some_usize).expect("message explaining why this is safe"); ``` +Rules: + +- Always use `TryFrom`/`try_from` for numeric conversions between different types +- Handle conversion failures explicitly (either with `?` or `expect` with justification) +- The only acceptable use of `expect` is when the conversion is guaranteed to succeed (e.g., `usize` to `u64` on 64-bit platforms) +- Clippy will error on unchecked `as` casts: `cast_possible_truncation`, `cast_possible_wrap`, `cast_sign_loss` + --- ## Async / Tokio diff --git a/Cargo.lock b/Cargo.lock index a450ceff..bddb3262 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5447,7 +5447,9 @@ name = "pluto-cli" version = "1.7.1" dependencies = [ "backon", + "chrono", "clap", + "flate2", "hex", "humantime", "k256", @@ -5455,6 +5457,9 @@ dependencies = [ "pluto-app", "pluto-cluster", "pluto-core", + "pluto-crypto", + "pluto-eth1wrap", + "pluto-eth2api", "pluto-eth2util", "pluto-k1util", "pluto-p2p", @@ -5466,12 +5471,15 @@ dependencies = [ "serde", "serde_json", "serde_with", + "tar", "tempfile", "test-case", "thiserror 2.0.18", "tokio", "tokio-util", "tracing", + "url", + "uuid", ] [[package]] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 13c7c012..6b611b89 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -19,10 +19,13 @@ humantime.workspace = true tokio.workspace = true pluto-app.workspace = true pluto-cluster.workspace = true +pluto-crypto.workspace = true pluto-relay-server.workspace = true pluto-tracing.workspace = true pluto-core.workspace = true pluto-p2p.workspace = true +pluto-eth1wrap.workspace = true +pluto-eth2api.workspace = true pluto-eth2util.workspace = true pluto-k1util.workspace = true pluto-ssz.workspace = true @@ -35,6 +38,11 @@ serde_with = { workspace = true, features = ["base64"] } rand.workspace = true tempfile.workspace = true reqwest.workspace = true +url.workspace = true +chrono.workspace = true +uuid.workspace = true +flate2.workspace = true +tar.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 5c4956f0..8d629cb2 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -3,6 +3,7 @@ use clap::{Parser, Subcommand}; use crate::commands::{ + create_cluster::CreateClusterArgs, create_enr::CreateEnrArgs, enr::EnrArgs, relay::RelayArgs, @@ -135,4 +136,10 @@ pub enum CreateCommands { /// Create an Ethereum Node Record (ENR) private key to identify this charon /// client Enr(CreateEnrArgs), + + #[command( + about = "Create private keys and configuration files needed to run a distributed validator cluster locally", + long_about = "Creates a local charon cluster configuration including validator keys, charon p2p keys, cluster-lock.json and deposit-data.json file(s). See flags for supported features." + )] + Cluster(CreateClusterArgs), } diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs new file mode 100644 index 00000000..c957aea7 --- /dev/null +++ b/crates/cli/src/commands/create_cluster.rs @@ -0,0 +1,905 @@ +//! Create cluster command implementation. +//! +//! This module implements the `pluto create cluster` command, which creates a +//! local distributed validator cluster configuration including validator keys, +//! threshold BLS key shares, p2p private keys, cluster-lock files, and deposit +//! data files. + +use std::{ + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, +}; + +use k256::SecretKey; +use pluto_cluster::{definition::Definition, helpers::fetch_definition, manifest::cluster, operator::Operator}; +use pluto_core::consensus::protocols; +use pluto_crypto::{ + blst_impl::BlstImpl, + tbls::Tbls, + types::{PrivateKey, PublicKey}, +}; +use pluto_eth1wrap as eth1wrap; +use pluto_eth2util::{ + self as eth2util, deposit, enr::Record, keystore::{load_files_recursively, load_files_unordered}, network +}; +use pluto_p2p::k1::new_saved_priv_key; +use pluto_ssz::to_0x_hex; +use rand::rngs::OsRng; +use tracing::{debug, info, warn}; + +use crate::{ + commands::create_dkg::validate_withdrawal_addrs, + error::{CreateClusterError, InvalidNetworkConfigError, Result as CliResult, ThresholdError}, +}; + +/// Minimum number of nodes required in a cluster. +pub const MIN_NODES: u64 = 3; +/// Minimum threshold value. +pub const MIN_THRESHOLD: u64 = 2; +/// Zero ethereum address (not allowed on mainnet/gnosis). +pub const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; +/// HTTP scheme. +const HTTP_SCHEME: &str = "http"; +/// HTTPS scheme. +const HTTPS_SCHEME: &str = "https"; + +type Result = std::result::Result; + +/// Ethereum network options. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +#[value(rename_all = "lowercase")] +pub enum Network { + /// Ethereum mainnet + #[default] + Mainnet, + /// Prater testnet (alias for Goerli) + Prater, + /// Goerli testnet + Goerli, + /// Sepolia testnet + Sepolia, + /// Hoodi testnet + Hoodi, + /// Holesky testnet + Holesky, + /// Gnosis chain + Gnosis, + /// Chiado testnet + Chiado, +} + +impl Network { + /// Returns the canonical network name. + pub fn as_str(&self) -> &'static str { + match self { + Network::Mainnet => "mainnet", + Network::Goerli | Network::Prater => "goerli", + Network::Sepolia => "sepolia", + Network::Hoodi => "hoodi", + Network::Holesky => "holesky", + Network::Gnosis => "gnosis", + Network::Chiado => "chiado", + } + } +} + +impl TryFrom<&str> for Network { + type Error = InvalidNetworkConfigError; + + fn try_from(value: &str) -> std::result::Result { + match value { + "mainnet" => Ok(Network::Mainnet), + "prater" => Ok(Network::Prater), + "goerli" => Ok(Network::Goerli), + "sepolia" => Ok(Network::Sepolia), + "hoodi" => Ok(Network::Hoodi), + "holesky" => Ok(Network::Holesky), + "gnosis" => Ok(Network::Gnosis), + "chiado" => Ok(Network::Chiado), + _ => Err(InvalidNetworkConfigError::InvalidNetworkSpecified { + network: value.to_string(), + }), + } + } +} + +impl std::fmt::Display for Network { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Custom testnet configuration. +#[derive(Debug, Clone, Default, clap::Args)] +pub struct TestnetConfig { + /// Chain ID of the custom test network + #[arg( + long = "testnet-chain-id", + help = "Chain ID of the custom test network." + )] + pub chain_id: Option, + + /// Genesis fork version of the custom test network (in hex) + #[arg( + long = "testnet-fork-version", + help = "Genesis fork version of the custom test network (in hex)." + )] + pub fork_version: Option, + + /// Genesis timestamp of the custom test network + #[arg( + long = "testnet-genesis-timestamp", + help = "Genesis timestamp of the custom test network." + )] + pub genesis_timestamp: Option, + + /// Name of the custom test network + #[arg(long = "testnet-name", help = "Name of the custom test network.")] + pub testnet_name: Option, +} + +impl TestnetConfig { + pub fn is_empty(&self) -> bool { + self.testnet_name.is_none() + && self.fork_version.is_none() + && self.chain_id.is_none() + && self.genesis_timestamp.is_none() + } +} + +/// Arguments for the create cluster command +#[derive(clap::Args)] +pub struct CreateClusterArgs { + /// The target folder to create the cluster in. + #[arg( + long = "cluster-dir", + default_value = "./", + help = "The target folder to create the cluster in." + )] + pub cluster_dir: PathBuf, + + /// Enable compounding rewards for validators + #[arg( + long = "compounding", + help = "Enable compounding rewards for validators by using 0x02 withdrawal credentials." + )] + pub compounding: bool, + + /// Preferred consensus protocol name for the cluster + #[arg( + long = "consensus-protocol", + help = "Preferred consensus protocol name for the cluster. Selected automatically when not specified." + )] + pub consensus_protocol: Option, + + /// Path to a cluster definition file or HTTP URL + #[arg( + long = "definition-file", + help = "Optional path to a cluster definition file or an HTTP URL. This overrides all other configuration flags." + )] + pub definition_file: Option, + + /// List of partial deposit amounts (integers) in ETH + #[arg( + long = "deposit-amounts", + value_delimiter = ',', + help = "List of partial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH." + )] + pub deposit_amounts: Vec, + + /// The address of the execution engine JSON-RPC API + #[arg( + long = "execution-client-rpc-endpoint", + help = "The address of the execution engine JSON-RPC API." + )] + pub execution_engine_addr: Option, + + /// Comma separated list of fee recipient addresses + #[arg( + long = "fee-recipient-addresses", + value_delimiter = ',', + help = "Comma separated list of Ethereum addresses of the fee recipient for each validator. Either provide a single fee recipient address or fee recipient addresses for each validator." + )] + pub fee_recipient_addrs: Vec, + + /// Generates insecure keystore files (testing only) + #[arg( + long = "insecure-keys", + help = "Generates insecure keystore files. This should never be used. It is not supported on mainnet." + )] + pub insecure_keys: bool, + + /// Comma separated list of keymanager URLs + #[arg( + long = "keymanager-addresses", + value_delimiter = ',', + help = "Comma separated list of keymanager URLs to import validator key shares to. Note that multiple addresses are required, one for each node in the cluster." + )] + pub keymanager_addrs: Vec, + + /// Authentication bearer tokens for keymanager URLs + #[arg( + long = "keymanager-auth-tokens", + value_delimiter = ',', + help = "Authentication bearer tokens to interact with the keymanager URLs. Don't include the \"Bearer\" symbol, only include the api-token." + )] + pub keymanager_auth_tokens: Vec, + + /// The cluster name + #[arg(long = "name")] + pub name: Option, + + /// Ethereum network to create validators for + #[arg(long = "network", help = "Ethereum network to create validators for.")] + pub network: Option, + + /// The number of charon nodes in the cluster + #[arg( + long = "nodes", + help = "The number of charon nodes in the cluster. Minimum is 3." + )] + pub nodes: Option, + + /// The number of distributed validators needed in the cluster + #[arg( + long = "num-validators", + help = "The number of distributed validators needed in the cluster." + )] + pub num_validators: Option, + + /// Publish lock file to obol-api + #[arg(long = "publish", help = "Publish lock file to obol-api.")] + pub publish: bool, + + /// The URL to publish the lock file to + #[arg( + long = "publish-address", + default_value = "https://api.obol.tech/v1", + help = "The URL to publish the lock file to." + )] + pub publish_address: String, + + /// Split an existing validator's private key + #[arg( + long = "split-existing-keys", + help = "Split an existing validator's private key into a set of distributed validator private key shares. Does not re-create deposit data for this key." + )] + pub split_keys: bool, + + /// Directory containing keys to split + #[arg( + long = "split-keys-dir", + help = "Directory containing keys to split. Expects keys in keystore-*.json and passwords in keystore-*.txt. Requires --split-existing-keys." + )] + pub split_keys_dir: Option, + + /// Preferred target gas limit for transactions + #[arg( + long = "target-gas-limit", + default_value = "60000000", + help = "Preferred target gas limit for transactions." + )] + pub target_gas_limit: u64, + + /// Custom testnet configuration + #[command(flatten)] + pub testnet_config: TestnetConfig, + + /// Optional override of threshold + #[arg( + long = "threshold", + help = "Optional override of threshold required for signature reconstruction. Defaults to ceil(n*2/3) if zero. Warning, non-default values decrease security." + )] + pub threshold: Option, + + /// Comma separated list of withdrawal addresses + #[arg( + long = "withdrawal-addresses", + value_delimiter = ',', + help = "Comma separated list of Ethereum addresses to receive the returned stake and accrued rewards for each validator. Either provide a single withdrawal address or withdrawal addresses for each validator." + )] + pub withdrawal_addrs: Vec, + + /// Create a tar archive compressed with gzip + #[arg( + long = "zipped", + help = "Create a tar archive compressed with gzip of the cluster directory after creation." + )] + pub zipped: bool, +} + +impl From for network::Network { + fn from(config: TestnetConfig) -> Self { + network::Network { + chain_id: config.chain_id.unwrap_or(0), + name: Box::leak( + config + .testnet_name + .as_ref() + .unwrap_or(&String::new()) + .clone() + .into_boxed_str(), + ), + genesis_fork_version_hex: Box::leak( + config + .fork_version + .as_ref() + .unwrap_or(&String::new()) + .clone() + .into_boxed_str(), + ), + genesis_timestamp: config.genesis_timestamp.unwrap_or(0), + capella_hard_fork: "", + } + } +} + +fn validate_threshold(args: &CreateClusterArgs) -> Result<()> { + let Some(threshold) = args.threshold else { + return Ok(()); + }; + + if threshold < MIN_THRESHOLD { + return Err(ThresholdError::ThresholdTooLow { threshold }.into()); + } + + let number_of_nodes = args.nodes.unwrap_or(0); + if threshold > number_of_nodes { + return Err(ThresholdError::ThresholdTooHigh { + threshold, + number_of_nodes, + } + .into()); + } + + Ok(()) +} + +/// Runs the create cluster command +pub async fn run(mut args: CreateClusterArgs) -> CliResult<()> { + validate_threshold(&args)?; + + validate_create_config(&args)?; + + let mut secrets: Vec = Vec::new(); + + // If we're splitting keys, read them from `split_keys_dir` and set + // args.num_validators to the amount of secrets we read. + // If `split_keys` wasn't set, we wouldn't have reached this part of code + // because `validate_create_config()` would've already errored. + if args.split_keys == true { + let use_sequence_keys = args.withdrawal_addrs.len() > 1; + + let Some(split_keys_dir) = &args.split_keys_dir else { + return Err(CreateClusterError::MissingSplitKeysDir.into()); + }; + + secrets = get_keys(&split_keys_dir, use_sequence_keys).await?; + + debug!( + "Read {} secrets from {}", + secrets.len(), + split_keys_dir.display() + ); + + // Needed if --split-existing-keys is called without a definition file. + // It's safe to unwrap here because we know the length is less than u64::MAX. + args.num_validators = + Some(u64::try_from(secrets.len()).expect("secrets length is too large")); + } + + // Get a cluster definition, either from a definition file or from the config. + let definition_file = args.definition_file.clone(); + let (def, mut deposit_amounts) = if let Some(definition_file) = definition_file { + let Some(addr) = args.execution_engine_addr.clone() else { + return Err(CreateClusterError::MissingExecutionEngineAddress.into()); + }; + + let eth1cl = eth1wrap::EthClient::new(addr).await?; + + let def = load_definition(&definition_file, ð1cl).await?; + + // Should not happen, if it does - it won't affect the runtime, because the + // validation will fail. + args.nodes = + Some(u64::try_from(def.operators.len()).expect("operators length is too large")); + args.threshold = Some(def.threshold); + + validate_definition(&def, args.insecure_keys, &args.keymanager_addrs, ð1cl).await?; + + let network = eth2util::network::fork_version_to_network(&def.fork_version)?; + + args.network = Some( + Network::try_from(network.as_str()) + .map_err(CreateClusterError::InvalidNetworkConfig)?, + ); + + let deposit_amounts = def.deposit_amounts.clone(); + + (def, deposit_amounts) + } else { + // Create new definition from cluster config + let def = new_def_from_config(&args).await?; + + let deposit_amounts = deposit::eths_to_gweis(&args.deposit_amounts); + + (def, deposit_amounts) + }; + + if deposit_amounts.len() == 0 { + deposit_amounts = deposit::default_deposit_amounts(args.compounding); + } + + if secrets.len() == 0 { + // This is the case in which split-keys is undefined and user passed validator + // amount on CLI + secrets = generate_keys(def.num_validators)?; + } + + let num_validators_usize = + usize::try_from(def.num_validators).map_err(|_| CreateClusterError::ValueExceedsU8 { + value: def.num_validators, + })?; + + if secrets.len() != num_validators_usize { + return Err(CreateClusterError::KeyCountMismatch { + disk_keys: secrets.len(), + definition_keys: def.num_validators, + } + .into()); + } + + let num_nodes = u64::try_from(def.operators.len()).expect("operators length is too large"); + + // Generate threshold bls key shares + + let (pub_keys, share_sets) = get_tss_shares(&secrets, def.threshold, num_nodes)?; + + // Create cluster directory at the given location + tokio::fs::create_dir_all(&args.cluster_dir).await?; + + // Set directory permissions to 0o755 + let permissions = std::fs::Permissions::from_mode(0o755); + tokio::fs::set_permissions(&args.cluster_dir, permissions).await?; + + // Create operators and their enr node keys + let (ops, node_keys) = get_operators(num_nodes, args.cluster_dir)?; + + Ok(()) +} + +fn generate_keys(num_validators: u64) -> Result> { + let tbls = BlstImpl; + let mut secrets = Vec::new(); + + for _ in 0..num_validators { + let secret = tbls.generate_secret_key(OsRng)?; + secrets.push(secret); + } + + Ok(secrets) +} + +fn get_operators(num_nodes: u64, cluster_dir: impl AsRef) -> Result<(Vec, Vec)> { + let mut ops = Vec::new(); + let mut node_keys = Vec::new(); + + for i in 0..num_nodes { + let (record, identity_key) = new_peer(&cluster_dir, i)?; + + ops.push(Operator { enr: record.to_string(), ..Default::default() }); + node_keys.push(identity_key); + } + + Ok((ops, node_keys)) +} + +fn new_peer(cluster_dir: impl AsRef, peer_idx: u64) -> Result<(Record, SecretKey)> { + let dir = node_dir(cluster_dir.as_ref(), peer_idx); + + let p2p_key = new_saved_priv_key(&dir)?; + + let record = Record::new(&p2p_key, Vec::new())?; + + Ok((record, p2p_key)) +} + +async fn get_keys( + split_keys_dir: impl AsRef, + use_sequence_keys: bool, +) -> Result> { + if use_sequence_keys { + let files = load_files_unordered(&split_keys_dir).await?; + Ok(files.sequenced_keys()?) + } else { + let files = load_files_recursively(&split_keys_dir).await?; + Ok(files.keys()) + } +} + +/// Creates a new cluster definition from the provided configuration. +async fn new_def_from_config(args: &CreateClusterArgs) -> Result { + let num_validators = args + .num_validators + .ok_or(CreateClusterError::MissingNumValidatorsOrDefinitionFile)?; + + let (fee_recipient_addrs, withdrawal_addrs) = validate_addresses( + num_validators, + &args.fee_recipient_addrs, + &args.withdrawal_addrs, + )?; + + let fork_version = if let Some(network) = args.network { + eth2util::network::network_to_fork_version(network.as_str())? + } else if let Some(ref fork_version_hex) = args.testnet_config.fork_version { + fork_version_hex.clone() + } else { + return Err(CreateClusterError::InvalidNetworkConfig( + InvalidNetworkConfigError::MissingNetworkFlagAndNoTestnetConfigFlag, + )); + }; + + let num_nodes = args + .nodes + .ok_or(CreateClusterError::MissingNodesOrDefinitionFile)?; + + let operators = vec![ + pluto_cluster::operator::Operator::default(); + usize::try_from(num_nodes).expect("num_nodes should fit in usize") + ]; + let threshold = safe_threshold(num_nodes, args.threshold); + + let name = args + .name + .clone() + .unwrap_or(String::new()); + + let consensus_protocol = args.consensus_protocol.clone().unwrap_or_default(); + + let def = pluto_cluster::definition::Definition::new( + name, + num_validators, + threshold, + fee_recipient_addrs, + withdrawal_addrs, + fork_version, + pluto_cluster::definition::Creator::default(), + operators, + args.deposit_amounts.clone(), + consensus_protocol, + args.target_gas_limit, + args.compounding, + vec![], + )?; + + Ok(def) +} + +fn get_tss_shares( + secrets: &[PrivateKey], + threshold: u64, + num_nodes: u64, +) -> Result<(Vec, Vec>)> { + let tbls = BlstImpl; + let mut dvs = Vec::new(); + let mut splits = Vec::new(); + + let num_nodes = u8::try_from(num_nodes) + .map_err(|_| CreateClusterError::ValueExceedsU8 { value: num_nodes })?; + let threshold = u8::try_from(threshold) + .map_err(|_| CreateClusterError::ValueExceedsU8 { value: threshold })?; + + for secret in secrets { + let shares = tbls.threshold_split(secret, num_nodes, threshold)?; + + // Preserve order when transforming from map of private shares to array of + // private keys + let mut secret_set = vec![PrivateKey::default(); shares.len()]; + for i in 1..=shares.len() { + let i_u64 = u64::try_from(i).expect("shares length should fit in u64 on all platforms"); + let idx = + u8::try_from(i).map_err(|_| CreateClusterError::ValueExceedsU8 { value: i_u64 })?; + secret_set[i - 1] = shares[&idx].clone(); + } + + splits.push(secret_set); + + let pubkey = tbls.secret_to_public_key(secret)?; + dvs.push(pubkey); + } + + Ok((dvs, splits)) +} + +async fn validate_definition( + def: &Definition, + insecure_keys: bool, + keymanager_addrs: &[String], + eth1cl: ð1wrap::EthClient, +) -> Result<()> { + if def.num_validators == 0 { + return Err(CreateClusterError::ZeroValidators); + } + + let num_operators = + u64::try_from(def.operators.len()).expect("operators length should fit in u64"); + if num_operators < MIN_NODES { + return Err(CreateClusterError::TooFewNodes { + num_nodes: num_operators, + }); + } + + if !keymanager_addrs.is_empty() && (keymanager_addrs.len() != def.operators.len()) { + return Err(CreateClusterError::InsufficientKeymanagerAddresses { + expected: def.operators.len(), + got: keymanager_addrs.len(), + }); + } + + if !def.deposit_amounts.is_empty() { + deposit::verify_deposit_amounts(&def.deposit_amounts, def.compounding)?; + } + + let network_name = network::fork_version_to_network(&def.fork_version)?; + + if insecure_keys && is_main_or_gnosis(&network_name) { + return Err(CreateClusterError::InsecureKeysOnMainnetOrGnosis); + } else if insecure_keys { + tracing::warn!("Insecure keystores configured. ONLY DO THIS DURING TESTING"); + } + + if def.name.is_empty() { + return Err(CreateClusterError::DefinitionNameNotProvided); + } + + def.verify_hashes()?; + + def.verify_signatures(eth1cl).await?; + + if !network::valid_network(&network_name) { + return Err(CreateClusterError::UnsupportedNetwork { + network: network_name.to_string(), + }); + } + + if !def.consensus_protocol.is_empty() + && !protocols::is_supported_protocol_name(&def.consensus_protocol) + { + return Err(CreateClusterError::UnsupportedConsensusProtocol { + consensus_protocol: def.consensus_protocol.clone(), + }); + } + + validate_withdrawal_addrs(&def.withdrawal_addresses(), &network_name)?; + + Ok(()) +} + +pub fn is_main_or_gnosis(network: &str) -> bool { + network == network::MAINNET.name || network == network::GNOSIS.name +} + +fn validate_create_config(args: &CreateClusterArgs) -> Result<()> { + if args.nodes.is_none() && args.definition_file.is_none() { + return Err(CreateClusterError::MissingNodesOrDefinitionFile); + } + + // Check for valid network configuration. + validate_network_config(args)?; + + detect_node_dirs(&args.cluster_dir, args.nodes.unwrap_or(0))?; + + // Ensure sufficient auth tokens are provided for the keymanager addresses + if args.keymanager_addrs.len() != args.keymanager_auth_tokens.len() { + return Err(CreateClusterError::InvalidKeymanagerConfig { + keymanager_addrs: args.keymanager_addrs.len(), + keymanager_auth_tokens: args.keymanager_auth_tokens.len(), + }); + } + + if args.deposit_amounts.len() > 0 { + let amount = eth2util::deposit::eths_to_gweis(&args.deposit_amounts); + + eth2util::deposit::verify_deposit_amounts(&amount, args.compounding)?; + } + + for addr in &args.keymanager_addrs { + let keymanager_url = + url::Url::parse(addr).map_err(CreateClusterError::InvalidKeymanagerUrl)?; + + if keymanager_url.scheme() != HTTP_SCHEME { + return Err(CreateClusterError::InvalidKeymanagerUrlScheme { addr: addr.clone() }); + } + } + + if args.split_keys && !args.num_validators.is_none() { + return Err(CreateClusterError::CannotSpecifyNumValidatorsWithSplitKeys); + } else if !args.split_keys && args.num_validators.is_none() && args.definition_file.is_none() { + return Err(CreateClusterError::MissingNumValidatorsOrDefinitionFile); + } + + // Don't allow cluster size to be less than `MIN_NODES`. + let num_nodes = args.nodes.unwrap_or(0); + if num_nodes < MIN_NODES { + return Err(CreateClusterError::TooFewNodes { num_nodes }); + } + + if let Some(consensus_protocol) = &args.consensus_protocol + && !protocols::is_supported_protocol_name(&consensus_protocol) + { + return Err(CreateClusterError::UnsupportedConsensusProtocol { + consensus_protocol: consensus_protocol.clone(), + }); + } + + Ok(()) +} + +fn detect_node_dirs(cluster_dir: impl AsRef, node_amount: u64) -> Result<()> { + for i in 0..node_amount { + let abs_path = std::path::absolute(node_dir(cluster_dir.as_ref(), i)) + .map_err(CreateClusterError::AbsolutePathError)?; + + if std::fs::exists(abs_path.join("cluster-lock.json")) + .map_err(CreateClusterError::IoError)? + { + return Err( + CreateClusterError::NodeDirectoryAlreadyExists { node_dir: abs_path }.into(), + ); + } + } + + Ok(()) +} + +fn node_dir(cluster_dir: impl AsRef, node_index: u64) -> PathBuf { + cluster_dir.as_ref().join(format!("node{}", node_index)) +} + +/// Validates the network configuration. +fn validate_network_config(args: &CreateClusterArgs) -> Result<()> { + if let Some(network) = args.network { + if eth2util::network::valid_network(network.as_str()) { + return Ok(()); + } + + return Err(InvalidNetworkConfigError::InvalidNetworkSpecified { + network: network.to_string(), + } + .into()); + } + + // Check if custom testnet configuration is provided. + if !args.testnet_config.is_empty() { + // Add testnet config to supported networks. + eth2util::network::add_test_network(args.testnet_config.clone().into())?; + + return Ok(()); + } + + Err(InvalidNetworkConfigError::MissingNetworkFlagAndNoTestnetConfigFlag.into()) +} + +/// Returns true if the input string is a valid HTTP/HTTPS URI. +fn is_valid_uri(s: impl AsRef) -> bool { + if let Ok(url) = url::Url::parse(s.as_ref()) { + (url.scheme() == HTTP_SCHEME || url.scheme() == HTTPS_SCHEME) + && !url.host_str().unwrap_or("").is_empty() + } else { + false + } +} + +/// Loads and validates the cluster definition from disk or an HTTP URL. +/// +/// It fetches the definition, verifies signatures and hashes, and checks +/// that at least one validator is specified before returning. +async fn load_definition( + definition_file: impl AsRef, + eth1cl: ð1wrap::EthClient, +) -> Result { + let def_file = definition_file.as_ref(); + + // Fetch definition from network if URI is provided + let def = if is_valid_uri(def_file) { + let def = fetch_definition(def_file).await?; + + info!( + url = def_file, + definition_hash = to_0x_hex(&def.definition_hash), + "Cluster definition downloaded from URL" + ); + + def + } else { + // Fetch definition from disk + let buf = tokio::fs::read(def_file).await?; + let def: Definition = serde_json::from_slice(&buf)?; + + info!( + path = def_file, + definition_hash = to_0x_hex(&def.definition_hash), + "Cluster definition loaded from disk", + ); + + def + }; + + def.verify_signatures(eth1cl).await?; + def.verify_hashes()?; + + if def.num_validators == 0 { + return Err(CreateClusterError::NoValidatorsInDefinition.into()); + } + + Ok(def) +} + +/// Validates that addresses match the number of validators. +/// If only one address is provided, it fills the slice to match num_validators. +/// +/// Returns an error if the number of addresses doesn't match and isn't exactly +/// 1. +fn validate_addresses( + num_validators: u64, + fee_recipient_addrs: &[String], + withdrawal_addrs: &[String], +) -> Result<(Vec, Vec)> { + let num_validators_usize = + usize::try_from(num_validators).map_err(|_| CreateClusterError::ValueExceedsU8 { + value: num_validators, + })?; + + if fee_recipient_addrs.len() != num_validators_usize && fee_recipient_addrs.len() != 1 { + return Err(CreateClusterError::MismatchingFeeRecipientAddresses { + num_validators, + addresses: fee_recipient_addrs.len(), + } + .into()); + } + + if withdrawal_addrs.len() != num_validators_usize && withdrawal_addrs.len() != 1 { + return Err(CreateClusterError::MismatchingWithdrawalAddresses { + num_validators, + addresses: withdrawal_addrs.len(), + } + .into()); + } + + let mut fee_addrs = fee_recipient_addrs.to_vec(); + let mut withdraw_addrs = withdrawal_addrs.to_vec(); + + // Expand single address to match num_validators + if fee_addrs.len() == 1 { + let addr = fee_addrs[0].clone(); + fee_addrs = vec![addr; num_validators_usize]; + } + + if withdraw_addrs.len() == 1 { + let addr = withdraw_addrs[0].clone(); + withdraw_addrs = vec![addr; num_validators_usize]; + } + + Ok((fee_addrs, withdraw_addrs)) +} + +/// Returns the safe threshold, logging a warning if a non-standard threshold is +/// provided. +fn safe_threshold(num_nodes: u64, threshold: Option) -> u64 { + let safe = pluto_cluster::helpers::threshold(num_nodes); + + match threshold { + Some(0) | None => safe, + Some(t) => { + if t != safe { + warn!( + num_nodes = num_nodes, + threshold = t, + safe_threshold = safe, + "Non standard threshold provided, this will affect cluster safety" + ); + } + t + } + } +} diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs new file mode 100644 index 00000000..d8419e17 --- /dev/null +++ b/crates/cli/src/commands/create_dkg.rs @@ -0,0 +1,69 @@ +//! Create DKG command utilities. +//! +//! This module provides utilities for the `pluto create dkg` command, +//! including validation functions for withdrawal addresses. + +use pluto_eth2util::{self as eth2util}; +use thiserror::Error; + +use crate::commands::create_cluster::{ZERO_ADDRESS, is_main_or_gnosis}; + +/// Errors that can occur during withdrawal address validation. +#[derive(Error, Debug)] +pub enum WithdrawalValidationError { + /// Invalid withdrawal address. + #[error("Invalid withdrawal address: {address}")] + InvalidWithdrawalAddress { + /// The invalid address. + address: String, + }, + + /// Invalid checksummed address. + #[error("Invalid checksummed address: {address}")] + InvalidChecksummedAddress { + /// The address with invalid checksum. + address: String, + }, + + /// Zero address forbidden on mainnet/gnosis. + #[error("Zero address forbidden on this network: {network}")] + ZeroAddressForbiddenOnNetwork { + /// The network name. + network: String, + }, + + /// Eth2util helpers error. + #[error("Eth2util helpers error: {0}")] + Eth2utilHelperError(#[from] eth2util::helpers::HelperError), +} + +/// Validates withdrawal addresses for the given network. +/// +/// Returns an error if any of the provided withdrawal addresses is invalid. +pub fn validate_withdrawal_addrs( + addrs: &[String], + network: &str, +) -> std::result::Result<(), WithdrawalValidationError> { + for addr in addrs { + let checksum_addr = eth2util::helpers::checksum_address(addr).map_err(|_| { + WithdrawalValidationError::InvalidWithdrawalAddress { + address: addr.clone(), + } + })?; + + if checksum_addr != *addr { + return Err(WithdrawalValidationError::InvalidChecksummedAddress { + address: addr.clone(), + }); + } + + // We cannot allow a zero withdrawal address on mainnet or gnosis. + if is_main_or_gnosis(network) && addr == ZERO_ADDRESS { + return Err(WithdrawalValidationError::ZeroAddressForbiddenOnNetwork { + network: network.to_string(), + }); + } + } + + Ok(()) +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index e18c3e1f..a04b320d 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,3 +1,5 @@ +pub mod create_cluster; +pub mod create_dkg; pub mod create_enr; pub mod enr; pub mod relay; diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index e049863e..06e3e560 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -5,8 +5,11 @@ use std::{ process::{ExitCode, Termination}, }; +use pluto_eth2util as eth2util; use thiserror::Error; +use crate::commands::create_cluster::{MIN_NODES, MIN_THRESHOLD}; + /// Result type for CLI operations. pub type Result = std::result::Result; @@ -26,6 +29,7 @@ impl Termination for ExitResult { /// Errors that can occur in the Pluto CLI. #[derive(Error, Debug)] +#[allow(dead_code)] pub enum CliError { /// Private key file not found. #[error( @@ -94,4 +98,267 @@ pub enum CliError { /// Relay P2P error. #[error("Relay P2P error: {0}")] RelayP2PError(#[from] pluto_relay_server::error::RelayP2PError), + + /// Create cluster error. + #[error("Create cluster error: {0}")] + CreateClusterError(#[from] CreateClusterError), + + /// Eth1wrap error. + #[error("Eth1wrap error: {0}")] + Eth1wrapError(#[from] pluto_eth1wrap::EthClientError), + + /// Eth2util network error. + #[error("Eth2util network error: {0}")] + Eth2utilNetworkError(#[from] eth2util::network::NetworkError), +} + +#[derive(Error, Debug)] +pub enum CreateClusterError { + /// Invalid threshold. + #[error("Invalid threshold: {0}")] + InvalidThreshold(#[from] ThresholdError), + + /// Missing nodes or definition file. + #[error("Missing --nodes or --definition-file flag")] + MissingNodesOrDefinitionFile, + + /// Invalid network configuration. + #[error("Invalid network configuration: {0}")] + InvalidNetworkConfig(InvalidNetworkConfigError), + + /// Absolute path error. + #[error("Absolute path retrieval error: {0}")] + AbsolutePathError(std::io::Error), + + /// IO error. + #[error("IO error: {0}")] + IoError(std::io::Error), + + /// Node directory already exists. + #[error( + "Existing node directory found, please delete it before running this command: node_dir={node_dir}" + )] + NodeDirectoryAlreadyExists { + /// Node directory. + node_dir: PathBuf, + }, + + /// Invalid keymanager configuration. + #[error( + "number of --keymanager-addresses={keymanager_addrs} do not match --keymanager-auth-tokens={keymanager_auth_tokens}. Please fix configuration flags" + )] + InvalidKeymanagerConfig { + /// Number of keymanager addresses. + keymanager_addrs: usize, + /// Number of keymanager auth tokens. + keymanager_auth_tokens: usize, + }, + + /// Invalid deposit amounts. + #[error("Invalid deposit amounts: {0}")] + InvalidDepositAmounts(#[from] eth2util::deposit::DepositError), + + /// Invalid keymanager URL. + #[error("Invalid keymanager URL: {0}")] + InvalidKeymanagerUrl(#[from] url::ParseError), + + // todo(varex83): 1-to-1 replication of go impl, possible bug here. consider changing https to + // http. + /// Invalid keymanager URL scheme. + #[error("Keymanager URL does not use https protocol: {addr}")] + InvalidKeymanagerUrlScheme { + /// Keymanager URL. + addr: String, + }, + + /// Cannot specify --num-validators with --split-existing-keys. + #[error("Cannot specify --num-validators with --split-existing-keys")] + CannotSpecifyNumValidatorsWithSplitKeys, + + /// Missing --num-validators or --definition-file flag. + #[error("Missing --num-validators or --definition-file flag")] + MissingNumValidatorsOrDefinitionFile, + + /// Too few nodes. + #[error("Too few nodes: {num_nodes}. Minimum is {MIN_NODES}")] + TooFewNodes { + /// Number of nodes. + num_nodes: u64, + }, + + /// Unsupported consensus protocol. + #[error("Unsupported consensus protocol: {consensus_protocol}")] + UnsupportedConsensusProtocol { + /// Consensus protocol. + consensus_protocol: String, + }, + + /// Missing --split-keys-dir flag. + #[error("--split-keys-dir is required when splitting keys")] + MissingSplitKeysDir, + + /// Missing --execution-client-rpc-endpoint flag. + #[error("--execution-client-rpc-endpoint is required when creating a new cluster")] + MissingExecutionEngineAddress, + + /// Amount of keys read from disk differs from cluster definition. + #[error( + "Amount of keys read from disk differs from cluster definition: disk={disk_keys}, definition={definition_keys}" + )] + KeyCountMismatch { + /// Number of keys read from disk. + disk_keys: usize, + /// Number of validators in the definition. + definition_keys: u64, + }, + + /// Crypto error. + #[error("Crypto error: {0}")] + CryptoError(#[from] pluto_crypto::types::Error), + + /// Value exceeds u8::MAX. + #[error("Value {value} exceeds u8::MAX (255)")] + ValueExceedsU8 { + /// The value that exceeds u8::MAX. + value: u64, + }, + + /// Keystore error. + #[error("Keystore error: {0}")] + KeystoreError(#[from] eth2util::keystore::KeystoreError), + + /// Cannot create cluster with zero validators. + #[error("Cannot create cluster with zero validators, specify at least one")] + ZeroValidators, + + /// Insufficient keymanager addresses. + #[error("Insufficient number of keymanager addresses: expected={expected}, got={got}")] + InsufficientKeymanagerAddresses { + /// Expected number of keymanager addresses. + expected: usize, + /// Actual number of keymanager addresses. + got: usize, + }, + + /// Insecure keys not supported on mainnet/gnosis. + #[error("Insecure keys not supported on mainnet or gnosis")] + InsecureKeysOnMainnetOrGnosis, + + /// Definition name not provided. + #[error("Name not provided in cluster definition")] + DefinitionNameNotProvided, + + /// Definition error. + #[error("Definition error: {0}")] + DefinitionError(#[from] pluto_cluster::definition::DefinitionError), + + /// Unsupported network. + #[error("Unsupported network: {network}")] + UnsupportedNetwork { + /// Network name. + network: String, + }, + + /// Withdrawal validation error. + #[error("Withdrawal validation error: {0}")] + WithdrawalValidationError(#[from] crate::commands::create_dkg::WithdrawalValidationError), + + /// Failed to read definition file. + #[error("Failed to read definition file: {0}")] + ReadDefinitionFile(#[from] std::io::Error), + + /// Failed to parse definition JSON. + #[error("Failed to parse definition JSON: {0}")] + ParseDefinitionJson(#[from] serde_json::Error), + + /// Cluster fetch error. + #[error("Failed to fetch cluster definition: {0}")] + FetchDefinition(#[from] pluto_cluster::helpers::FetchError), + + /// No validators specified in definition. + #[error("No validators specified in the given definition")] + NoValidatorsInDefinition, + + /// Mismatching number of fee recipient addresses. + #[error( + "mismatching --num-validators and --fee-recipient-addresses: num_validators={num_validators}, addresses={addresses}" + )] + MismatchingFeeRecipientAddresses { + /// Number of validators. + num_validators: u64, + /// Number of addresses. + addresses: usize, + }, + + /// Mismatching number of withdrawal addresses. + #[error( + "mismatching --num-validators and --withdrawal-addresses: num_validators={num_validators}, addresses={addresses}" + )] + MismatchingWithdrawalAddresses { + /// Number of validators. + num_validators: u64, + /// Number of addresses. + addresses: usize, + }, + + /// K1 error. + #[error("K1 error: {0}")] + K1Error(#[from] pluto_p2p::k1::K1Error), + + /// Record error. + #[error("Record error: {0}")] + RecordError(#[from] eth2util::enr::RecordError), +} + +#[derive(Error, Debug)] +pub enum ThresholdError { + /// Threshold must be greater than {MIN_THRESHOLD}. + #[error("Threshold must be greater than {MIN_THRESHOLD}, got {threshold}")] + ThresholdTooLow { + /// Threshold value. + threshold: u64, + }, + + /// Threshold must be less than the number of nodes. + #[error( + "Threshold cannot be greater than number of operators (nodes): Threshold={threshold}, Number of nodes={number_of_nodes}" + )] + ThresholdTooHigh { + /// Threshold value. + threshold: u64, + /// Number of operators (nodes). + number_of_nodes: u64, + }, +} + +#[derive(Error, Debug)] +pub enum InvalidNetworkConfigError { + /// Invalid network name. + #[error("Invalid network name: {0}")] + InvalidNetworkName(#[from] eth2util::network::NetworkError), + + /// Invalid network specified. + #[error("Invalid network specified: network={network}")] + InvalidNetworkSpecified { + /// Network name. + network: String, + }, + + /// Missing --network flag or testnet config flags. + #[error("Missing --network flag and no testnet config flag")] + MissingNetworkFlagAndNoTestnetConfigFlag, +} + +impl From for CreateClusterError { + fn from(error: InvalidNetworkConfigError) -> Self { + CreateClusterError::InvalidNetworkConfig(error) + } +} + +impl From for CreateClusterError { + fn from(error: eth2util::network::NetworkError) -> Self { + CreateClusterError::InvalidNetworkConfig(InvalidNetworkConfigError::InvalidNetworkName( + error, + )) + } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index cd3b9e7d..bbb1db14 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -39,6 +39,7 @@ async fn main() -> ExitResult { let result = match cli.command { Commands::Create(args) => match args.command { CreateCommands::Enr(args) => commands::create_enr::run(args), + CreateCommands::Cluster(args) => commands::create_cluster::run(args).await, }, Commands::Enr(args) => commands::enr::run(args), Commands::Version(args) => commands::version::run(args), diff --git a/crates/cluster/src/helpers.rs b/crates/cluster/src/helpers.rs index c9a06778..ec8fa41d 100644 --- a/crates/cluster/src/helpers.rs +++ b/crates/cluster/src/helpers.rs @@ -153,6 +153,22 @@ pub fn sign_operator( Ok(()) } +/// Returns minimum threshold required for a cluster with given nodes. +/// This formula has been taken from: +/// +/// Computes ceil(2*nodes / 3) using integer arithmetic to avoid floating point +/// conversions. +pub fn threshold(nodes: u64) -> u64 { + // Integer ceiling division: ceil(a/b) = (a + b - 1) / b + // Here we compute: ceil(2*nodes / 3) = (2*nodes + 3 - 1) / 3 = (2*nodes + 2) / + // 3 + let numerator = nodes.checked_mul(2).expect("threshold: nodes * 2 overflow"); + let adjusted = numerator + .checked_add(2) + .expect("threshold: numerator + 2 overflow"); + adjusted / 3 +} + /// Returns a BLS aggregate signature of the message signed by all the shares. pub fn agg_sign( secrets: &[Vec], From 5e02cb3176c6e62872713d5e7db816fd5d43e8b0 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:26:39 +0200 Subject: [PATCH 02/12] feat: create cluster finish some functions --- crates/cli/src/cli.rs | 4 +- crates/cli/src/commands/create_cluster.rs | 628 +++++++++++++++++++++- crates/cli/src/error.rs | 63 +++ crates/cli/src/main.rs | 5 +- crates/cluster/src/definition.rs | 2 +- crates/cluster/src/lock.rs | 2 +- 6 files changed, 673 insertions(+), 31 deletions(-) diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 8d629cb2..fd7694f7 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -41,7 +41,7 @@ pub enum Commands { about = "Create artifacts for a distributed validator cluster", long_about = "Create artifacts for a distributed validator cluster. These commands can be used to facilitate the creation of a distributed validator cluster between a group of operators by performing a distributed key generation ceremony, or they can be used to create a local cluster for single operator use cases." )] - Create(CreateArgs), + Create(Box), #[command(about = "Print version and exit", long_about = "Output version info")] Version(VersionArgs), @@ -141,5 +141,5 @@ pub enum CreateCommands { about = "Create private keys and configuration files needed to run a distributed validator cluster locally", long_about = "Creates a local charon cluster configuration including validator keys, charon p2p keys, cluster-lock.json and deposit-data.json file(s). See flags for supported features." )] - Cluster(CreateClusterArgs), + Cluster(Box), } diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index c957aea7..90b221bb 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -6,12 +6,23 @@ //! data files. use std::{ + collections::HashMap, + io::Write, os::unix::fs::PermissionsExt as _, path::{Path, PathBuf}, }; +use chrono::Utc; use k256::SecretKey; -use pluto_cluster::{definition::Definition, helpers::fetch_definition, manifest::cluster, operator::Operator}; +use pluto_cluster::{ + definition::Definition, + deposit::DepositData, + distvalidator::DistValidator, + helpers::{create_validator_keys_dir, fetch_definition}, + lock::Lock, + operator::Operator, + registration::{BuilderRegistration, Registration}, +}; use pluto_core::consensus::protocols; use pluto_crypto::{ blst_impl::BlstImpl, @@ -19,8 +30,18 @@ use pluto_crypto::{ types::{PrivateKey, PublicKey}, }; use pluto_eth1wrap as eth1wrap; + +use pluto_app::{obolapi, utils as app_utils}; use pluto_eth2util::{ - self as eth2util, deposit, enr::Record, keystore::{load_files_recursively, load_files_unordered}, network + self as eth2util, + deposit::{self, Gwei}, + enr::Record, + keymanager, + keystore::{ + CONFIRM_INSECURE_KEYS, Keystore, encrypt as keystore_encrypt, load_files_recursively, + load_files_unordered, store_keys, store_keys_insecure, + }, + network, registration as eth2util_registration, }; use pluto_p2p::k1::new_saved_priv_key; use pluto_ssz::to_0x_hex; @@ -356,7 +377,7 @@ fn validate_threshold(args: &CreateClusterArgs) -> Result<()> { } /// Runs the create cluster command -pub async fn run(mut args: CreateClusterArgs) -> CliResult<()> { +pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<()> { validate_threshold(&args)?; validate_create_config(&args)?; @@ -367,7 +388,7 @@ pub async fn run(mut args: CreateClusterArgs) -> CliResult<()> { // args.num_validators to the amount of secrets we read. // If `split_keys` wasn't set, we wouldn't have reached this part of code // because `validate_create_config()` would've already errored. - if args.split_keys == true { + if args.split_keys { let use_sequence_keys = args.withdrawal_addrs.len() > 1; let Some(split_keys_dir) = &args.split_keys_dir else { @@ -390,7 +411,7 @@ pub async fn run(mut args: CreateClusterArgs) -> CliResult<()> { // Get a cluster definition, either from a definition file or from the config. let definition_file = args.definition_file.clone(); - let (def, mut deposit_amounts) = if let Some(definition_file) = definition_file { + let (mut def, mut deposit_amounts) = if let Some(definition_file) = definition_file { let Some(addr) = args.execution_engine_addr.clone() else { return Err(CreateClusterError::MissingExecutionEngineAddress.into()); }; @@ -426,11 +447,11 @@ pub async fn run(mut args: CreateClusterArgs) -> CliResult<()> { (def, deposit_amounts) }; - if deposit_amounts.len() == 0 { + if deposit_amounts.is_empty() { deposit_amounts = deposit::default_deposit_amounts(args.compounding); } - if secrets.len() == 0 { + if secrets.is_empty() { // This is the case in which split-keys is undefined and user passed validator // amount on CLI secrets = generate_keys(def.num_validators)?; @@ -463,11 +484,377 @@ pub async fn run(mut args: CreateClusterArgs) -> CliResult<()> { tokio::fs::set_permissions(&args.cluster_dir, permissions).await?; // Create operators and their enr node keys - let (ops, node_keys) = get_operators(num_nodes, args.cluster_dir)?; + let (ops, node_keys) = get_operators(num_nodes, &args.cluster_dir)?; + + def.operators = ops; + + let keys_to_disk = args.keymanager_addrs.is_empty(); + + // Pre-compute public shares and clone private shares before share_sets is + // consumed by the write step. The private share clone is needed for the + // aggregate BLS signature; the public shares are needed for DistValidator. + // Each entry is [share_node0, share_node1, ...] indexed by validator. + let tbls = BlstImpl; + let pub_shares_sets: Vec> = share_sets + .clone() + .iter() + .map(|shares| { + shares + .iter() + .map(|s| tbls.secret_to_public_key(s)) + .collect::, _>>() + }) + .collect::, _>>() + .map_err(CreateClusterError::CryptoError)?; + + if keys_to_disk { + write_keys_to_disk( + num_nodes, + &args.cluster_dir, + args.insecure_keys, + &share_sets, + ) + .await?; + } else { + write_keys_to_keymanager(&args, num_nodes, &share_sets).await?; + } + + let network = eth2util::network::fork_version_to_network(&def.fork_version)?; + + let deposit_datas = create_deposit_datas( + &def.withdrawal_addresses(), + &network, + &secrets, + &deposit_amounts, + def.compounding, + )?; + + let eth2util_deposit_datas = deposit_datas + .iter() + .map(|dd| cluster_deposit_data_to_eth2util_deposit_data(dd)) + .collect::>(); + + // Write deposit-data files + eth2util::deposit::write_cluster_deposit_data_files( + ð2util_deposit_datas, + network, + &args.cluster_dir, + usize::try_from(num_nodes).expect("num_nodes should fit in usize"), + ) + .await?; + + let val_regs = create_validator_registrations( + &def.fee_recipient_addresses(), + &secrets, + &def.fork_version, + args.split_keys, + args.target_gas_limit, + )?; + + let vals = get_validators(&pub_keys, &pub_shares_sets, &deposit_datas, val_regs)?; + + let mut lock = Lock { + definition: def, + distributed_validators: vals, + ..Default::default() + }; + + lock.set_lock_hash().map_err(CreateClusterError::from)?; + + lock.signature_aggregate = agg_sign(&share_sets, &lock.lock_hash)?; + + for op_key in &node_keys { + let node_sig = + pluto_k1util::sign(op_key, &lock.lock_hash).map_err(CreateClusterError::K1UtilError)?; + lock.node_signatures.push(node_sig.to_vec()); + } + + let mut dashboard_url = String::new(); + if args.publish { + match write_lock_to_api(&args.publish_address, &lock).await { + Ok(url) => dashboard_url = url, + Err(err) => { + warn!(error = %err, "Failed to publish lock file to Obol API"); + } + } + } + + write_lock(&lock, &args.cluster_dir, num_nodes).await?; + + if args.zipped { + app_utils::bundle_output(&args.cluster_dir, "cluster.tar.gz") + .map_err(CreateClusterError::BundleOutputError)?; + } + + if args.split_keys { + write_split_keys_warning(w)?; + } + + write_output( + w, + args.split_keys, + &args.cluster_dir, + num_nodes, + keys_to_disk, + args.zipped, + )?; + + if !dashboard_url.is_empty() { + info!( + "You can find your newly-created cluster dashboard here: {}", + dashboard_url + ); + } + + Ok(()) +} + +async fn write_lock_to_api(publish_addr: &str, lock: &Lock) -> Result { + let client = obolapi::Client::new(publish_addr, obolapi::ClientOptions::default()) + .map_err(CreateClusterError::ObolApiError)?; + match client.publish_lock(lock.clone()).await { + Ok(()) => { + info!(addr = publish_addr, "Published lock file"); + match client.launchpad_url_for_lock(lock) { + Ok(url) => Ok(url), + Err(err) => Err(CreateClusterError::ObolApiError(err)), + } + } + Err(err) => Err(CreateClusterError::ObolApiError(err)), + } +} + +fn create_validator_registrations( + fee_recipient_addresses: &[String], + secrets: &[PrivateKey], + fork_version: &[u8], + split_keys: bool, + target_gas_limit: u64, +) -> Result> { + if fee_recipient_addresses.len() != secrets.len() { + return Err(CreateClusterError::InsufficientFeeAddresses { + expected: secrets.len(), + got: fee_recipient_addresses.len(), + }); + } + + let effective_gas_limit = if target_gas_limit == 0 { + warn!( + default_gas_limit = eth2util_registration::DEFAULT_GAS_LIMIT, + "Custom target gas limit not supported, setting to default" + ); + eth2util_registration::DEFAULT_GAS_LIMIT + } else { + target_gas_limit + }; + + let fork_version_arr: [u8; 4] = fork_version + .try_into() + .map_err(|_| CreateClusterError::InvalidForkVersionLength)?; + + let tbls = BlstImpl; + let mut registrations = Vec::with_capacity(secrets.len()); + + for (secret, fee_address) in secrets.iter().zip(fee_recipient_addresses.iter()) { + let timestamp = if split_keys { + Utc::now() + } else { + eth2util::network::fork_version_to_genesis_time(fork_version)? + }; + + let pk = tbls.secret_to_public_key(secret)?; + + let unsigned_reg = eth2util_registration::new_message( + pk, + fee_address, + effective_gas_limit, + u64::try_from(timestamp.timestamp()).unwrap_or(0), + )?; + + let sig_root = + eth2util_registration::get_message_signing_root(&unsigned_reg, fork_version_arr); + + let sig = tbls.sign(secret, &sig_root)?; + + registrations.push(BuilderRegistration { + message: Registration { + fee_recipient: unsigned_reg.fee_recipient, + gas_limit: unsigned_reg.gas_limit, + timestamp, + pub_key: unsigned_reg.pubkey, + }, + signature: sig, + }); + } + + Ok(registrations) +} + +fn cluster_deposit_data_to_eth2util_deposit_data( + deposit_datas: &[DepositData], +) -> Vec { + deposit_datas + .iter() + .map(|dd| eth2util::deposit::DepositData { + pubkey: dd.pub_key, + withdrawal_credentials: dd.withdrawal_credentials, + amount: dd.amount, + signature: dd.signature, + }) + .collect() +} + +async fn write_keys_to_disk( + num_nodes: u64, + cluster_dir: impl AsRef, + insecure_keys: bool, + share_sets: &[Vec], +) -> Result<()> { + for i in 0..num_nodes { + let i_usize = usize::try_from(i).expect("node index should fit in usize on all platforms"); + + let mut secrets: Vec = Vec::new(); + for shares in share_sets { + secrets.push(shares[i_usize]); + } + + let keys_dir = create_validator_keys_dir(node_dir(cluster_dir.as_ref(), i)) + .await + .map_err(CreateClusterError::IoError)?; + + if insecure_keys { + store_keys_insecure(&secrets, &keys_dir, &CONFIRM_INSECURE_KEYS).await?; + } else { + store_keys(&secrets, &keys_dir).await?; + } + } Ok(()) } +fn random_hex64() -> Result { + let mut bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut OsRng, &mut bytes); + Ok(hex::encode(bytes)) +} + +async fn write_keys_to_keymanager( + args: &CreateClusterArgs, + num_nodes: u64, + share_sets: &[Vec], +) -> Result<()> { + // Create and verify all keymanager clients first. + let mut clients: Vec = Vec::new(); + for i in 0..num_nodes { + let i_usize = usize::try_from(i).expect("node index should fit in usize on all platforms"); + let cl = keymanager::Client::new( + &args.keymanager_addrs[i_usize], + &args.keymanager_auth_tokens[i_usize], + )?; + cl.verify_connection().await?; + clients.push(cl); + } + + // For each node, build keystores from this node's share of each validator, + // then import them into that node's keymanager. + for i in 0..num_nodes { + let i_usize = usize::try_from(i).expect("node index should fit in usize on all platforms"); + + let mut keystores: Vec = Vec::new(); + let mut passwords: Vec = Vec::new(); + + // share_sets[validator_idx][node_idx] + for shares in share_sets { + let password = random_hex64()?; + let pbkdf2_c = if args.insecure_keys { + Some(16u32) + } else { + None + }; + let store = keystore_encrypt(&shares[i_usize], &password, pbkdf2_c, &mut OsRng)?; + passwords.push(password); + keystores.push(store); + } + + clients[i_usize] + .import_keystores(&keystores, &passwords) + .await + .inspect_err(|_| { + tracing::error!( + addr = %args.keymanager_addrs[i_usize], + "Failed to import keys", + ); + })?; + + info!( + node = format!("node{}", i), + addr = %args.keymanager_addrs[i_usize], + "Imported key shares to keymanager", + ); + } + + info!("Imported all validator keys to respective keymanagers"); + + Ok(()) +} + +fn create_deposit_datas( + withdrawal_addresses: &[String], + network: impl AsRef, + secrets: &[PrivateKey], + deposit_amounts: &[Gwei], + compounding: bool, +) -> Result>> { + if secrets.len() != withdrawal_addresses.len() { + return Err(CreateClusterError::InsufficientWithdrawalAddresses); + } + if deposit_amounts.is_empty() { + return Err(CreateClusterError::EmptyDepositAmounts); + } + let deduped = deposit::dedup_amounts(deposit_amounts); + sign_deposit_datas( + secrets, + withdrawal_addresses, + network.as_ref(), + &deduped, + compounding, + ) +} + +fn sign_deposit_datas( + secrets: &[PrivateKey], + withdrawal_addresses: &[String], + network: &str, + deposit_amounts: &[Gwei], + compounding: bool, +) -> Result>> { + if secrets.len() != withdrawal_addresses.len() { + return Err(CreateClusterError::InsufficientWithdrawalAddresses); + } + if deposit_amounts.is_empty() { + return Err(CreateClusterError::EmptyDepositAmounts); + } + let tbls = BlstImpl; + let mut dd = Vec::new(); + for &deposit_amount in deposit_amounts { + let mut datas = Vec::new(); + for (secret, withdrawal_addr) in secrets.iter().zip(withdrawal_addresses.iter()) { + let pk = tbls.secret_to_public_key(secret)?; + let msg = deposit::new_message(pk, withdrawal_addr, deposit_amount, compounding)?; + let sig_root = deposit::get_message_signing_root(&msg, network)?; + let sig = tbls.sign(secret, &sig_root)?; + datas.push(DepositData { + pub_key: msg.pubkey, + withdrawal_credentials: msg.withdrawal_credentials, + amount: msg.amount, + signature: sig, + }); + } + dd.push(datas); + } + Ok(dd) +} + fn generate_keys(num_validators: u64) -> Result> { let tbls = BlstImpl; let mut secrets = Vec::new(); @@ -480,14 +867,20 @@ fn generate_keys(num_validators: u64) -> Result> { Ok(secrets) } -fn get_operators(num_nodes: u64, cluster_dir: impl AsRef) -> Result<(Vec, Vec)> { +fn get_operators( + num_nodes: u64, + cluster_dir: impl AsRef, +) -> Result<(Vec, Vec)> { let mut ops = Vec::new(); let mut node_keys = Vec::new(); for i in 0..num_nodes { let (record, identity_key) = new_peer(&cluster_dir, i)?; - ops.push(Operator { enr: record.to_string(), ..Default::default() }); + ops.push(Operator { + enr: record.to_string(), + ..Default::default() + }); node_keys.push(identity_key); } @@ -549,10 +942,7 @@ async fn new_def_from_config(args: &CreateClusterArgs) -> Result { ]; let threshold = safe_threshold(num_nodes, args.threshold); - let name = args - .name - .clone() - .unwrap_or(String::new()); + let name = args.name.clone().unwrap_or_default(); let consensus_protocol = args.consensus_protocol.clone().unwrap_or_default(); @@ -599,7 +989,7 @@ fn get_tss_shares( let i_u64 = u64::try_from(i).expect("shares length should fit in u64 on all platforms"); let idx = u8::try_from(i).map_err(|_| CreateClusterError::ValueExceedsU8 { value: i_u64 })?; - secret_set[i - 1] = shares[&idx].clone(); + secret_set[i.saturating_sub(1)] = shares[&idx]; } splits.push(secret_set); @@ -697,7 +1087,7 @@ fn validate_create_config(args: &CreateClusterArgs) -> Result<()> { }); } - if args.deposit_amounts.len() > 0 { + if !args.deposit_amounts.is_empty() { let amount = eth2util::deposit::eths_to_gweis(&args.deposit_amounts); eth2util::deposit::verify_deposit_amounts(&amount, args.compounding)?; @@ -712,7 +1102,7 @@ fn validate_create_config(args: &CreateClusterArgs) -> Result<()> { } } - if args.split_keys && !args.num_validators.is_none() { + if args.split_keys && args.num_validators.is_some() { return Err(CreateClusterError::CannotSpecifyNumValidatorsWithSplitKeys); } else if !args.split_keys && args.num_validators.is_none() && args.definition_file.is_none() { return Err(CreateClusterError::MissingNumValidatorsOrDefinitionFile); @@ -725,7 +1115,7 @@ fn validate_create_config(args: &CreateClusterArgs) -> Result<()> { } if let Some(consensus_protocol) = &args.consensus_protocol - && !protocols::is_supported_protocol_name(&consensus_protocol) + && !protocols::is_supported_protocol_name(consensus_protocol) { return Err(CreateClusterError::UnsupportedConsensusProtocol { consensus_protocol: consensus_protocol.clone(), @@ -743,9 +1133,7 @@ fn detect_node_dirs(cluster_dir: impl AsRef, node_amount: u64) -> Result<( if std::fs::exists(abs_path.join("cluster-lock.json")) .map_err(CreateClusterError::IoError)? { - return Err( - CreateClusterError::NodeDirectoryAlreadyExists { node_dir: abs_path }.into(), - ); + return Err(CreateClusterError::NodeDirectoryAlreadyExists { node_dir: abs_path }); } } @@ -829,7 +1217,7 @@ async fn load_definition( def.verify_hashes()?; if def.num_validators == 0 { - return Err(CreateClusterError::NoValidatorsInDefinition.into()); + return Err(CreateClusterError::NoValidatorsInDefinition); } Ok(def) @@ -854,16 +1242,14 @@ fn validate_addresses( return Err(CreateClusterError::MismatchingFeeRecipientAddresses { num_validators, addresses: fee_recipient_addrs.len(), - } - .into()); + }); } if withdrawal_addrs.len() != num_validators_usize && withdrawal_addrs.len() != 1 { return Err(CreateClusterError::MismatchingWithdrawalAddresses { num_validators, addresses: withdrawal_addrs.len(), - } - .into()); + }); } let mut fee_addrs = fee_recipient_addrs.to_vec(); @@ -903,3 +1289,193 @@ fn safe_threshold(num_nodes: u64, threshold: Option) -> u64 { } } } + +/// Builds the list of `DistValidator`s from the DV public keys, precomputed +/// public shares, deposit data and validator registrations. +fn get_validators( + dv_pubkeys: &[PublicKey], + pub_shares_sets: &[Vec], + deposit_datas: &[Vec], + val_regs: Vec, +) -> Result> { + let mut deposit_datas_map: HashMap> = HashMap::new(); + for amount_level in deposit_datas { + for dd in amount_level { + deposit_datas_map + .entry(dd.pub_key) + .or_default() + .push(dd.clone()); + } + } + + let mut vals = Vec::with_capacity(dv_pubkeys.len()); + + for (idx, dv_pubkey) in dv_pubkeys.iter().enumerate() { + // Public shares for this validator's nodes. + let pub_shares: Vec> = pub_shares_sets + .get(idx) + .map(|shares| shares.iter().map(|pk| pk.to_vec()).collect()) + .unwrap_or_default(); + + // Builder registration — same index as the validator. + let builder_registration = val_regs + .get(idx) + .cloned() + .ok_or(CreateClusterError::ValidatorRegistrationNotFound { index: idx })?; + + // Partial deposit data for this DV pubkey. + let partial_deposit_data = deposit_datas_map.remove(dv_pubkey).ok_or_else(|| { + CreateClusterError::DepositDataNotFound { + dv: hex::encode(dv_pubkey), + } + })?; + + vals.push(DistValidator { + pub_key: dv_pubkey.to_vec(), + pub_shares, + partial_deposit_data, + builder_registration, + }); + } + + Ok(vals) +} + +/// Aggregates BLS signatures over `message` produced by every private share +/// across all validators, mirroring Go's `aggSign`. +/// +/// `share_sets` — outer dimension is per-validator, inner is per-node private +/// key share. +fn agg_sign(share_sets: &[Vec], message: &[u8]) -> Result> { + use pluto_crypto::types::Signature; + + let tbls = BlstImpl; + let mut sigs: Vec = Vec::new(); + + for shares in share_sets { + for share in shares { + let sig = tbls + .sign(share, message) + .map_err(CreateClusterError::CryptoError)?; + sigs.push(sig); + } + } + + if sigs.is_empty() { + return Ok(Vec::new()); + } + + let agg = tbls + .aggregate(&sigs) + .map_err(CreateClusterError::CryptoError)?; + Ok(agg.to_vec()) +} + +/// Writes `cluster-lock.json` to every node directory under `cluster_dir`. +/// The file is created with 0o400 (owner read-only) permissions. +async fn write_lock(lock: &Lock, cluster_dir: impl AsRef, num_nodes: u64) -> Result<()> { + let json = serde_json::to_string_pretty(lock)?; + let bytes = json.into_bytes(); + + for i in 0..num_nodes { + let lock_path = node_dir(cluster_dir.as_ref(), i).join("cluster-lock.json"); + + tokio::fs::write(&lock_path, &bytes) + .await + .map_err(CreateClusterError::IoError)?; + + let perms = std::fs::Permissions::from_mode(0o400); + tokio::fs::set_permissions(&lock_path, perms) + .await + .map_err(CreateClusterError::IoError)?; + } + + Ok(()) +} + +fn write_output( + w: &mut dyn Write, + split_keys: bool, + cluster_dir: impl AsRef, + num_nodes: u64, + keys_to_disk: bool, + zipped: bool, +) -> Result<()> { + let abs_cluster_dir = + std::path::absolute(cluster_dir.as_ref()).map_err(CreateClusterError::AbsolutePathError)?; + let abs_str = abs_cluster_dir.display().to_string(); + let abs_str = abs_str.trim_end_matches('/'); + + writeln!(w, "Created charon cluster:").map_err(CreateClusterError::IoError)?; + writeln!(w, " --split-existing-keys={}", split_keys).map_err(CreateClusterError::IoError)?; + writeln!(w).map_err(CreateClusterError::IoError)?; + writeln!(w, "{}/", abs_str).map_err(CreateClusterError::IoError)?; + writeln!( + w, + "├─ node[0-{}]/\t\t\tDirectory for each node", + num_nodes.saturating_sub(1) + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + "│ ├─ charon-enr-private-key\tCharon networking private key for node authentication" + ) + .map_err(CreateClusterError::IoError)?; + writeln!(w, "│ ├─ cluster-lock.json\t\tCluster lock defines the cluster lock file which is signed by all nodes").map_err(CreateClusterError::IoError)?; + writeln!(w, "│ ├─ deposit-data-*.json\tDeposit data files are used to activate a Distributed Validator on the DV Launchpad").map_err(CreateClusterError::IoError)?; + if keys_to_disk { + writeln!( + w, + "│ ├─ validator_keys\t\tValidator keystores and password" + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + "│ │ ├─ keystore-*.json\tValidator private share key for duty signing" + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + "│ │ ├─ keystore-*.txt\t\tKeystore password files for keystore-*.json" + ) + .map_err(CreateClusterError::IoError)?; + } + if zipped { + writeln!(w).map_err(CreateClusterError::IoError)?; + writeln!(w, "Files compressed and archived to:").map_err(CreateClusterError::IoError)?; + writeln!(w, "{}/cluster.tar.gz", abs_str).map_err(CreateClusterError::IoError)?; + } + + Ok(()) +} + +fn write_split_keys_warning(w: &mut dyn Write) -> Result<()> { + writeln!(w).map_err(CreateClusterError::IoError)?; + writeln!( + w, + "***************** WARNING: Splitting keys **********************" + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + " Please make sure any existing validator has been shut down for" + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + " at least 2 finalised epochs before starting the charon cluster," + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + " otherwise slashing could occur. " + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + "****************************************************************" + ) + .map_err(CreateClusterError::IoError)?; + writeln!(w).map_err(CreateClusterError::IoError)?; + Ok(()) +} diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 06e3e560..a37590e5 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -110,6 +110,10 @@ pub enum CliError { /// Eth2util network error. #[error("Eth2util network error: {0}")] Eth2utilNetworkError(#[from] eth2util::network::NetworkError), + + /// Eth2util deposit error. + #[error("Eth2util deposit error: {0}")] + Eth2utilDepositError(#[from] eth2util::deposit::DepositError), } #[derive(Error, Debug)] @@ -308,6 +312,65 @@ pub enum CreateClusterError { /// Record error. #[error("Record error: {0}")] RecordError(#[from] eth2util::enr::RecordError), + + /// Insufficient withdrawal addresses. + #[error("Insufficient withdrawal addresses")] + InsufficientWithdrawalAddresses, + + /// Empty deposit amounts. + #[error("Empty deposit amounts")] + EmptyDepositAmounts, + + /// Keymanager error. + #[error("Keymanager error: {0}")] + KeymanagerError(#[from] eth2util::keymanager::KeymanagerError), + + /// Insufficient fee addresses. + #[error("Insufficient fee addresses: expected {expected}, got {got}")] + InsufficientFeeAddresses { + /// Expected number of fee addresses. + expected: usize, + /// Actual number of fee addresses. + got: usize, + }, + + /// Invalid fork version length. + #[error("Invalid fork version length: expected 4 bytes")] + InvalidForkVersionLength, + + /// Registration error. + #[error("Registration error: {0}")] + RegistrationError(#[from] eth2util::registration::RegistrationError), + + /// Validator registration not found at the given index. + #[error("Validator registration not found at index {index}")] + ValidatorRegistrationNotFound { + /// Index that was out of bounds. + index: usize, + }, + + /// Deposit data not found for the given distributed validator pubkey. + #[error("Deposit data not found for distributed validator pubkey: {dv}")] + DepositDataNotFound { + /// Hex-encoded distributed validator pubkey. + dv: String, + }, + + /// Lock error (e.g. set_lock_hash failed). + #[error("Lock error: {0}")] + LockError(#[from] pluto_cluster::lock::LockError), + + /// K1 utility signing error. + #[error("K1 util signing error: {0}")] + K1UtilError(#[from] pluto_k1util::K1UtilError), + + /// Obol API error (publish_lock / launchpad URL). + #[error("Obol API error: {0}")] + ObolApiError(#[from] pluto_app::obolapi::ObolApiError), + + /// Bundle output (tar.gz archival) error. + #[error("Bundle output error: {0}")] + BundleOutputError(#[from] pluto_app::utils::UtilsError), } #[derive(Error, Debug)] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index bbb1db14..f5554d95 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -39,7 +39,10 @@ async fn main() -> ExitResult { let result = match cli.command { Commands::Create(args) => match args.command { CreateCommands::Enr(args) => commands::create_enr::run(args), - CreateCommands::Cluster(args) => commands::create_cluster::run(args).await, + CreateCommands::Cluster(args) => { + let mut stdout = std::io::stdout(); + commands::create_cluster::run(&mut stdout, *args).await + } }, Commands::Enr(args) => commands::enr::run(args), Commands::Version(args) => commands::version::run(args), diff --git a/crates/cluster/src/definition.rs b/crates/cluster/src/definition.rs index 2aa54b66..fdb00cc5 100644 --- a/crates/cluster/src/definition.rs +++ b/crates/cluster/src/definition.rs @@ -45,7 +45,7 @@ pub struct NodeIdx { /// Definition defines an intended charon cluster configuration excluding /// validators. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Definition { /// Human-readable random unique identifier. Max 64 chars. pub uuid: String, diff --git a/crates/cluster/src/lock.rs b/crates/cluster/src/lock.rs index a370a738..8ac6d91f 100644 --- a/crates/cluster/src/lock.rs +++ b/crates/cluster/src/lock.rs @@ -145,7 +145,7 @@ type Result = std::result::Result; /// Lock extends the cluster config Definition with bls threshold public keys /// and checksums. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Lock { /// Definition is embedded and extended by Lock. pub definition: Definition, From 096b8632fc6747bd4402db798dc49ac1230cc387 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:15:26 +0200 Subject: [PATCH 03/12] fix create cluster review feedback --- crates/cli/src/commands/create_cluster.rs | 345 ++++++++++++---------- crates/cli/src/error.rs | 46 +-- 2 files changed, 221 insertions(+), 170 deletions(-) diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index 90b221bb..39c7cb3f 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -18,7 +18,7 @@ use pluto_cluster::{ definition::Definition, deposit::DepositData, distvalidator::DistValidator, - helpers::{create_validator_keys_dir, fetch_definition}, + helpers, lock::Lock, operator::Operator, registration::{BuilderRegistration, Registration}, @@ -37,20 +37,20 @@ use pluto_eth2util::{ deposit::{self, Gwei}, enr::Record, keymanager, - keystore::{ - CONFIRM_INSECURE_KEYS, Keystore, encrypt as keystore_encrypt, load_files_recursively, - load_files_unordered, store_keys, store_keys_insecure, - }, + keystore::{self, CONFIRM_INSECURE_KEYS, Keystore}, network, registration as eth2util_registration, }; -use pluto_p2p::k1::new_saved_priv_key; +use pluto_p2p::k1 as p2p_k1; use pluto_ssz::to_0x_hex; use rand::rngs::OsRng; use tracing::{debug, info, warn}; use crate::{ - commands::create_dkg::validate_withdrawal_addrs, - error::{CreateClusterError, InvalidNetworkConfigError, Result as CliResult, ThresholdError}, + commands::create_dkg, + error::{ + CliError, CreateClusterError, InvalidNetworkConfigError, Result as CliResult, + ThresholdError, + }, }; /// Minimum number of nodes required in a cluster. @@ -257,16 +257,18 @@ pub struct CreateClusterArgs { /// The number of charon nodes in the cluster #[arg( long = "nodes", + default_value = "0", help = "The number of charon nodes in the cluster. Minimum is 3." )] - pub nodes: Option, + pub nodes: u64, /// The number of distributed validators needed in the cluster #[arg( long = "num-validators", + default_value = "0", help = "The number of distributed validators needed in the cluster." )] - pub num_validators: Option, + pub num_validators: u64, /// Publish lock file to obol-api #[arg(long = "publish", help = "Publish lock file to obol-api.")] @@ -355,6 +357,13 @@ impl From for network::Network { } } +fn init_tracing() -> CliResult<()> { + match pluto_tracing::init(&pluto_tracing::TracingConfig::default()) { + Ok(_) | Err(pluto_tracing::init::Error::InitError(_)) => Ok(()), + Err(err) => Err(CliError::from(err)), + } +} + fn validate_threshold(args: &CreateClusterArgs) -> Result<()> { let Some(threshold) = args.threshold else { return Ok(()); @@ -364,7 +373,7 @@ fn validate_threshold(args: &CreateClusterArgs) -> Result<()> { return Err(ThresholdError::ThresholdTooLow { threshold }.into()); } - let number_of_nodes = args.nodes.unwrap_or(0); + let number_of_nodes = args.nodes; if threshold > number_of_nodes { return Err(ThresholdError::ThresholdTooHigh { threshold, @@ -378,6 +387,30 @@ fn validate_threshold(args: &CreateClusterArgs) -> Result<()> { /// Runs the create cluster command pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<()> { + init_tracing()?; + + let mut definition_input = None; + + if let Some(definition_file) = args.definition_file.as_ref() { + let Some(addr) = args.execution_engine_addr.as_ref() else { + return Err(CreateClusterError::MissingExecutionEngineAddress.into()); + }; + + let eth1cl = eth1wrap::EthClient::new(addr.clone()).await?; + let def = load_definition(definition_file, ð1cl).await?; + + args.nodes = u64::try_from(def.operators.len()).expect("operators length is too large"); + args.threshold = Some(def.threshold); + + let network_name = eth2util::network::fork_version_to_network(&def.fork_version)?; + args.network = Some( + Network::try_from(network_name.as_str()) + .map_err(CreateClusterError::InvalidNetworkConfig)?, + ); + + definition_input = Some((def, eth1cl)); + } + validate_threshold(&args)?; validate_create_config(&args)?; @@ -405,42 +438,19 @@ pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<() // Needed if --split-existing-keys is called without a definition file. // It's safe to unwrap here because we know the length is less than u64::MAX. - args.num_validators = - Some(u64::try_from(secrets.len()).expect("secrets length is too large")); + args.num_validators = u64::try_from(secrets.len()).expect("secrets length is too large"); } // Get a cluster definition, either from a definition file or from the config. - let definition_file = args.definition_file.clone(); - let (mut def, mut deposit_amounts) = if let Some(definition_file) = definition_file { - let Some(addr) = args.execution_engine_addr.clone() else { - return Err(CreateClusterError::MissingExecutionEngineAddress.into()); - }; - - let eth1cl = eth1wrap::EthClient::new(addr).await?; - - let def = load_definition(&definition_file, ð1cl).await?; - - // Should not happen, if it does - it won't affect the runtime, because the - // validation will fail. - args.nodes = - Some(u64::try_from(def.operators.len()).expect("operators length is too large")); - args.threshold = Some(def.threshold); - + let (mut def, mut deposit_amounts) = if let Some((def, eth1cl)) = definition_input { validate_definition(&def, args.insecure_keys, &args.keymanager_addrs, ð1cl).await?; - let network = eth2util::network::fork_version_to_network(&def.fork_version)?; - - args.network = Some( - Network::try_from(network.as_str()) - .map_err(CreateClusterError::InvalidNetworkConfig)?, - ); - let deposit_amounts = def.deposit_amounts.clone(); (def, deposit_amounts) } else { // Create new definition from cluster config - let def = new_def_from_config(&args).await?; + let def = new_def_from_config(&args)?; let deposit_amounts = deposit::eths_to_gweis(&args.deposit_amounts); @@ -458,7 +468,7 @@ pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<() } let num_validators_usize = - usize::try_from(def.num_validators).map_err(|_| CreateClusterError::ValueExceedsU8 { + usize::try_from(def.num_validators).map_err(|_| CreateClusterError::ValueExceedsUsize { value: def.num_validators, })?; @@ -490,23 +500,6 @@ pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<() let keys_to_disk = args.keymanager_addrs.is_empty(); - // Pre-compute public shares and clone private shares before share_sets is - // consumed by the write step. The private share clone is needed for the - // aggregate BLS signature; the public shares are needed for DistValidator. - // Each entry is [share_node0, share_node1, ...] indexed by validator. - let tbls = BlstImpl; - let pub_shares_sets: Vec> = share_sets - .clone() - .iter() - .map(|shares| { - shares - .iter() - .map(|s| tbls.secret_to_public_key(s)) - .collect::, _>>() - }) - .collect::, _>>() - .map_err(CreateClusterError::CryptoError)?; - if keys_to_disk { write_keys_to_disk( num_nodes, @@ -551,7 +544,7 @@ pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<() args.target_gas_limit, )?; - let vals = get_validators(&pub_keys, &pub_shares_sets, &deposit_datas, val_regs)?; + let vals = get_validators(&pub_keys, &share_sets, &deposit_datas, val_regs)?; let mut lock = Lock { definition: def, @@ -587,7 +580,7 @@ pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<() } if args.split_keys { - write_split_keys_warning(w)?; + write_split_keys_warning(w).map_err(CreateClusterError::IoError)?; } write_output( @@ -597,7 +590,8 @@ pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<() num_nodes, keys_to_disk, args.zipped, - )?; + ) + .map_err(CreateClusterError::IoError)?; if !dashboard_url.is_empty() { info!( @@ -648,7 +642,7 @@ fn create_validator_registrations( target_gas_limit }; - let fork_version_arr: [u8; 4] = fork_version + let fork_version: [u8; 4] = fork_version .try_into() .map_err(|_| CreateClusterError::InvalidForkVersionLength)?; @@ -659,7 +653,7 @@ fn create_validator_registrations( let timestamp = if split_keys { Utc::now() } else { - eth2util::network::fork_version_to_genesis_time(fork_version)? + eth2util::network::fork_version_to_genesis_time(&fork_version)? }; let pk = tbls.secret_to_public_key(secret)?; @@ -668,11 +662,10 @@ fn create_validator_registrations( pk, fee_address, effective_gas_limit, - u64::try_from(timestamp.timestamp()).unwrap_or(0), + u64::try_from(timestamp.timestamp()).expect("timestamp should fit in u64"), )?; - let sig_root = - eth2util_registration::get_message_signing_root(&unsigned_reg, fork_version_arr); + let sig_root = eth2util_registration::get_message_signing_root(&unsigned_reg, fork_version); let sig = tbls.sign(secret, &sig_root)?; @@ -718,14 +711,14 @@ async fn write_keys_to_disk( secrets.push(shares[i_usize]); } - let keys_dir = create_validator_keys_dir(node_dir(cluster_dir.as_ref(), i)) + let keys_dir = helpers::create_validator_keys_dir(node_dir(cluster_dir.as_ref(), i)) .await .map_err(CreateClusterError::IoError)?; if insecure_keys { - store_keys_insecure(&secrets, &keys_dir, &CONFIRM_INSECURE_KEYS).await?; + keystore::store_keys_insecure(&secrets, &keys_dir, &CONFIRM_INSECURE_KEYS).await?; } else { - store_keys(&secrets, &keys_dir).await?; + keystore::store_keys(&secrets, &keys_dir).await?; } } @@ -767,11 +760,12 @@ async fn write_keys_to_keymanager( for shares in share_sets { let password = random_hex64()?; let pbkdf2_c = if args.insecure_keys { + // Match Charon's `keystorev4.WithCost(..., 4)` => 2^4 iterations. Some(16u32) } else { None }; - let store = keystore_encrypt(&shares[i_usize], &password, pbkdf2_c, &mut OsRng)?; + let store = keystore::encrypt(&shares[i_usize], &password, pbkdf2_c, &mut OsRng)?; passwords.push(password); keystores.push(store); } @@ -839,8 +833,9 @@ fn sign_deposit_datas( for &deposit_amount in deposit_amounts { let mut datas = Vec::new(); for (secret, withdrawal_addr) in secrets.iter().zip(withdrawal_addresses.iter()) { + let withdrawal_addr = eth2util::helpers::checksum_address(withdrawal_addr)?; let pk = tbls.secret_to_public_key(secret)?; - let msg = deposit::new_message(pk, withdrawal_addr, deposit_amount, compounding)?; + let msg = deposit::new_message(pk, &withdrawal_addr, deposit_amount, compounding)?; let sig_root = deposit::get_message_signing_root(&msg, network)?; let sig = tbls.sign(secret, &sig_root)?; datas.push(DepositData { @@ -890,7 +885,7 @@ fn get_operators( fn new_peer(cluster_dir: impl AsRef, peer_idx: u64) -> Result<(Record, SecretKey)> { let dir = node_dir(cluster_dir.as_ref(), peer_idx); - let p2p_key = new_saved_priv_key(&dir)?; + let p2p_key = p2p_k1::new_saved_priv_key(&dir)?; let record = Record::new(&p2p_key, Vec::new())?; @@ -902,19 +897,20 @@ async fn get_keys( use_sequence_keys: bool, ) -> Result> { if use_sequence_keys { - let files = load_files_unordered(&split_keys_dir).await?; + let files = keystore::load_files_unordered(split_keys_dir).await?; Ok(files.sequenced_keys()?) } else { - let files = load_files_recursively(&split_keys_dir).await?; + let files = keystore::load_files_recursively(split_keys_dir).await?; Ok(files.keys()) } } /// Creates a new cluster definition from the provided configuration. -async fn new_def_from_config(args: &CreateClusterArgs) -> Result { - let num_validators = args - .num_validators - .ok_or(CreateClusterError::MissingNumValidatorsOrDefinitionFile)?; +fn new_def_from_config(args: &CreateClusterArgs) -> Result { + let num_validators = args.num_validators; + if num_validators == 0 { + return Err(CreateClusterError::MissingNumValidatorsOrDefinitionFile); + } let (fee_recipient_addrs, withdrawal_addrs) = validate_addresses( num_validators, @@ -932,9 +928,10 @@ async fn new_def_from_config(args: &CreateClusterArgs) -> Result { )); }; - let num_nodes = args - .nodes - .ok_or(CreateClusterError::MissingNodesOrDefinitionFile)?; + let num_nodes = args.nodes; + if num_nodes == 0 { + return Err(CreateClusterError::MissingNodesOrDefinitionFile); + } let operators = vec![ pluto_cluster::operator::Operator::default(); @@ -961,7 +958,6 @@ async fn new_def_from_config(args: &CreateClusterArgs) -> Result { args.compounding, vec![], )?; - Ok(def) } @@ -984,13 +980,9 @@ fn get_tss_shares( // Preserve order when transforming from map of private shares to array of // private keys - let mut secret_set = vec![PrivateKey::default(); shares.len()]; - for i in 1..=shares.len() { - let i_u64 = u64::try_from(i).expect("shares length should fit in u64 on all platforms"); - let idx = - u8::try_from(i).map_err(|_| CreateClusterError::ValueExceedsU8 { value: i_u64 })?; - secret_set[i.saturating_sub(1)] = shares[&idx]; - } + let mut entries: Vec<_> = shares.into_iter().collect(); + entries.sort_by_key(|(idx, _)| *idx); + let secret_set = entries.into_iter().map(|(_, share)| share).collect(); splits.push(secret_set); @@ -1060,7 +1052,7 @@ async fn validate_definition( }); } - validate_withdrawal_addrs(&def.withdrawal_addresses(), &network_name)?; + create_dkg::validate_withdrawal_addrs(&def.withdrawal_addresses(), &network_name)?; Ok(()) } @@ -1070,14 +1062,14 @@ pub fn is_main_or_gnosis(network: &str) -> bool { } fn validate_create_config(args: &CreateClusterArgs) -> Result<()> { - if args.nodes.is_none() && args.definition_file.is_none() { + if args.nodes == 0 && args.definition_file.is_none() { return Err(CreateClusterError::MissingNodesOrDefinitionFile); } // Check for valid network configuration. validate_network_config(args)?; - detect_node_dirs(&args.cluster_dir, args.nodes.unwrap_or(0))?; + detect_node_dirs(&args.cluster_dir, args.nodes)?; // Ensure sufficient auth tokens are provided for the keymanager addresses if args.keymanager_addrs.len() != args.keymanager_auth_tokens.len() { @@ -1097,19 +1089,19 @@ fn validate_create_config(args: &CreateClusterArgs) -> Result<()> { let keymanager_url = url::Url::parse(addr).map_err(CreateClusterError::InvalidKeymanagerUrl)?; - if keymanager_url.scheme() != HTTP_SCHEME { - return Err(CreateClusterError::InvalidKeymanagerUrlScheme { addr: addr.clone() }); + if keymanager_url.scheme() == HTTP_SCHEME { + warn!(addr, "Keymanager URL does not use https protocol"); } } - if args.split_keys && args.num_validators.is_some() { + if args.split_keys && args.num_validators != 0 { return Err(CreateClusterError::CannotSpecifyNumValidatorsWithSplitKeys); - } else if !args.split_keys && args.num_validators.is_none() && args.definition_file.is_none() { + } else if !args.split_keys && args.num_validators == 0 && args.definition_file.is_none() { return Err(CreateClusterError::MissingNumValidatorsOrDefinitionFile); } // Don't allow cluster size to be less than `MIN_NODES`. - let num_nodes = args.nodes.unwrap_or(0); + let num_nodes = args.nodes; if num_nodes < MIN_NODES { return Err(CreateClusterError::TooFewNodes { num_nodes }); } @@ -1190,7 +1182,7 @@ async fn load_definition( // Fetch definition from network if URI is provided let def = if is_valid_uri(def_file) { - let def = fetch_definition(def_file).await?; + let def = helpers::fetch_definition(def_file).await?; info!( url = def_file, @@ -1234,7 +1226,7 @@ fn validate_addresses( withdrawal_addrs: &[String], ) -> Result<(Vec, Vec)> { let num_validators_usize = - usize::try_from(num_validators).map_err(|_| CreateClusterError::ValueExceedsU8 { + usize::try_from(num_validators).map_err(|_| CreateClusterError::ValueExceedsUsize { value: num_validators, })?; @@ -1294,7 +1286,7 @@ fn safe_threshold(num_nodes: u64, threshold: Option) -> u64 { /// public shares, deposit data and validator registrations. fn get_validators( dv_pubkeys: &[PublicKey], - pub_shares_sets: &[Vec], + dv_priv_shares: &[Vec], deposit_datas: &[Vec], val_regs: Vec, ) -> Result> { @@ -1309,13 +1301,22 @@ fn get_validators( } let mut vals = Vec::with_capacity(dv_pubkeys.len()); + let tbls = BlstImpl; for (idx, dv_pubkey) in dv_pubkeys.iter().enumerate() { - // Public shares for this validator's nodes. - let pub_shares: Vec> = pub_shares_sets + let pub_shares: Vec> = dv_priv_shares .get(idx) - .map(|shares| shares.iter().map(|pk| pk.to_vec()).collect()) - .unwrap_or_default(); + .map(|shares| { + shares + .iter() + .map(|share| tbls.secret_to_public_key(share)) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default() + .into_iter() + .map(|share| share.to_vec()) + .collect(); // Builder registration — same index as the validator. let builder_registration = val_regs @@ -1341,22 +1342,16 @@ fn get_validators( Ok(vals) } -/// Aggregates BLS signatures over `message` produced by every private share -/// across all validators, mirroring Go's `aggSign`. -/// -/// `share_sets` — outer dimension is per-validator, inner is per-node private -/// key share. -fn agg_sign(share_sets: &[Vec], message: &[u8]) -> Result> { +/// Returns a BLS aggregate signature of the message signed by all the shares. +fn agg_sign(secrets: &[Vec], message: &[u8]) -> Result> { use pluto_crypto::types::Signature; let tbls = BlstImpl; let mut sigs: Vec = Vec::new(); - for shares in share_sets { + for shares in secrets { for share in shares { - let sig = tbls - .sign(share, message) - .map_err(CreateClusterError::CryptoError)?; + let sig = tbls.sign(share, message)?; sigs.push(sig); } } @@ -1365,9 +1360,7 @@ fn agg_sign(share_sets: &[Vec], message: &[u8]) -> Result> { return Ok(Vec::new()); } - let agg = tbls - .aggregate(&sigs) - .map_err(CreateClusterError::CryptoError)?; + let agg = tbls.aggregate(&sigs)?; Ok(agg.to_vec()) } @@ -1400,82 +1393,136 @@ fn write_output( num_nodes: u64, keys_to_disk: bool, zipped: bool, -) -> Result<()> { - let abs_cluster_dir = - std::path::absolute(cluster_dir.as_ref()).map_err(CreateClusterError::AbsolutePathError)?; +) -> std::io::Result<()> { + let abs_cluster_dir = std::path::absolute(cluster_dir.as_ref())?; let abs_str = abs_cluster_dir.display().to_string(); let abs_str = abs_str.trim_end_matches('/'); - writeln!(w, "Created charon cluster:").map_err(CreateClusterError::IoError)?; - writeln!(w, " --split-existing-keys={}", split_keys).map_err(CreateClusterError::IoError)?; - writeln!(w).map_err(CreateClusterError::IoError)?; - writeln!(w, "{}/", abs_str).map_err(CreateClusterError::IoError)?; + writeln!(w, "Created charon cluster:")?; + writeln!(w, " --split-existing-keys={}", split_keys)?; + writeln!(w)?; + writeln!(w, "{}/", abs_str)?; writeln!( w, "├─ node[0-{}]/\t\t\tDirectory for each node", num_nodes.saturating_sub(1) - ) - .map_err(CreateClusterError::IoError)?; + )?; writeln!( w, "│ ├─ charon-enr-private-key\tCharon networking private key for node authentication" - ) - .map_err(CreateClusterError::IoError)?; - writeln!(w, "│ ├─ cluster-lock.json\t\tCluster lock defines the cluster lock file which is signed by all nodes").map_err(CreateClusterError::IoError)?; - writeln!(w, "│ ├─ deposit-data-*.json\tDeposit data files are used to activate a Distributed Validator on the DV Launchpad").map_err(CreateClusterError::IoError)?; + )?; + writeln!( + w, + "│ ├─ cluster-lock.json\t\tCluster lock defines the cluster lock file which is signed by all nodes" + )?; + writeln!( + w, + "│ ├─ deposit-data-*.json\tDeposit data files are used to activate a Distributed Validator on the DV Launchpad" + )?; if keys_to_disk { writeln!( w, "│ ├─ validator_keys\t\tValidator keystores and password" - ) - .map_err(CreateClusterError::IoError)?; + )?; writeln!( w, "│ │ ├─ keystore-*.json\tValidator private share key for duty signing" - ) - .map_err(CreateClusterError::IoError)?; + )?; writeln!( w, "│ │ ├─ keystore-*.txt\t\tKeystore password files for keystore-*.json" - ) - .map_err(CreateClusterError::IoError)?; + )?; } if zipped { - writeln!(w).map_err(CreateClusterError::IoError)?; - writeln!(w, "Files compressed and archived to:").map_err(CreateClusterError::IoError)?; - writeln!(w, "{}/cluster.tar.gz", abs_str).map_err(CreateClusterError::IoError)?; + writeln!(w)?; + writeln!(w, "Files compressed and archived to:")?; + writeln!(w, "{}/cluster.tar.gz", abs_str)?; } Ok(()) } -fn write_split_keys_warning(w: &mut dyn Write) -> Result<()> { - writeln!(w).map_err(CreateClusterError::IoError)?; +fn write_split_keys_warning(w: &mut dyn Write) -> std::io::Result<()> { + writeln!(w)?; writeln!( w, "***************** WARNING: Splitting keys **********************" - ) - .map_err(CreateClusterError::IoError)?; + )?; writeln!( w, " Please make sure any existing validator has been shut down for" - ) - .map_err(CreateClusterError::IoError)?; + )?; writeln!( w, " at least 2 finalised epochs before starting the charon cluster," - ) - .map_err(CreateClusterError::IoError)?; + )?; writeln!( w, " otherwise slashing could occur. " - ) - .map_err(CreateClusterError::IoError)?; + )?; writeln!( w, "****************************************************************" - ) - .map_err(CreateClusterError::IoError)?; - writeln!(w).map_err(CreateClusterError::IoError)?; + )?; + writeln!(w)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_args(cluster_dir: PathBuf) -> CreateClusterArgs { + CreateClusterArgs { + cluster_dir, + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: Vec::new(), + execution_engine_addr: None, + fee_recipient_addrs: vec!["0x000000000000000000000000000000000000dead".to_string()], + insecure_keys: false, + keymanager_addrs: Vec::new(), + keymanager_auth_tokens: Vec::new(), + name: Some("test-cluster".to_string()), + network: Some(Network::Mainnet), + nodes: 3, + num_validators: 1, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: false, + split_keys_dir: None, + target_gas_limit: 60_000_000, + testnet_config: TestnetConfig::default(), + threshold: None, + withdrawal_addrs: vec!["0x000000000000000000000000000000000000dead".to_string()], + zipped: false, + } + } + + #[test] + fn validate_create_config_allows_http_keymanager_urls() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut args = test_args(tempdir.path().to_path_buf()); + args.keymanager_addrs = vec![ + "http://127.0.0.1:3600".to_string(), + "http://127.0.0.1:3601".to_string(), + "http://127.0.0.1:3602".to_string(), + ]; + args.keymanager_auth_tokens = vec!["a".into(), "b".into(), "c".into()]; + + assert!(validate_create_config(&args).is_ok()); + } + + #[test] + fn validate_create_config_rejects_zero_num_validators_without_definition() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut args = test_args(tempdir.path().to_path_buf()); + args.num_validators = 0; + + assert!(matches!( + validate_create_config(&args), + Err(CreateClusterError::MissingNumValidatorsOrDefinitionFile) + )); + } +} diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index a37590e5..772a7bae 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -5,7 +5,6 @@ use std::{ process::{ExitCode, Termination}, }; -use pluto_eth2util as eth2util; use thiserror::Error; use crate::commands::create_cluster::{MIN_NODES, MIN_THRESHOLD}; @@ -29,7 +28,6 @@ impl Termination for ExitResult { /// Errors that can occur in the Pluto CLI. #[derive(Error, Debug)] -#[allow(dead_code)] pub enum CliError { /// Private key file not found. #[error( @@ -109,11 +107,15 @@ pub enum CliError { /// Eth2util network error. #[error("Eth2util network error: {0}")] - Eth2utilNetworkError(#[from] eth2util::network::NetworkError), + Eth2utilNetworkError(#[from] pluto_eth2util::network::NetworkError), /// Eth2util deposit error. #[error("Eth2util deposit error: {0}")] - Eth2utilDepositError(#[from] eth2util::deposit::DepositError), + Eth2utilDepositError(#[from] pluto_eth2util::deposit::DepositError), + + /// Tracing initialization error. + #[error("Tracing initialization error: {0}")] + TracingInitError(#[from] pluto_tracing::init::Error), } #[derive(Error, Debug)] @@ -160,21 +162,12 @@ pub enum CreateClusterError { /// Invalid deposit amounts. #[error("Invalid deposit amounts: {0}")] - InvalidDepositAmounts(#[from] eth2util::deposit::DepositError), + InvalidDepositAmounts(#[from] pluto_eth2util::deposit::DepositError), /// Invalid keymanager URL. #[error("Invalid keymanager URL: {0}")] InvalidKeymanagerUrl(#[from] url::ParseError), - // todo(varex83): 1-to-1 replication of go impl, possible bug here. consider changing https to - // http. - /// Invalid keymanager URL scheme. - #[error("Keymanager URL does not use https protocol: {addr}")] - InvalidKeymanagerUrlScheme { - /// Keymanager URL. - addr: String, - }, - /// Cannot specify --num-validators with --split-existing-keys. #[error("Cannot specify --num-validators with --split-existing-keys")] CannotSpecifyNumValidatorsWithSplitKeys, @@ -227,9 +220,16 @@ pub enum CreateClusterError { value: u64, }, + /// Value exceeds usize::MAX. + #[error("Value {value} exceeds usize::MAX")] + ValueExceedsUsize { + /// The value that exceeds usize::MAX. + value: u64, + }, + /// Keystore error. #[error("Keystore error: {0}")] - KeystoreError(#[from] eth2util::keystore::KeystoreError), + KeystoreError(#[from] pluto_eth2util::keystore::KeystoreError), /// Cannot create cluster with zero validators. #[error("Cannot create cluster with zero validators, specify at least one")] @@ -311,7 +311,11 @@ pub enum CreateClusterError { /// Record error. #[error("Record error: {0}")] - RecordError(#[from] eth2util::enr::RecordError), + RecordError(#[from] pluto_eth2util::enr::RecordError), + + /// Eth2util helper error. + #[error("Eth2util helper error: {0}")] + Eth2utilHelperError(#[from] pluto_eth2util::helpers::HelperError), /// Insufficient withdrawal addresses. #[error("Insufficient withdrawal addresses")] @@ -323,7 +327,7 @@ pub enum CreateClusterError { /// Keymanager error. #[error("Keymanager error: {0}")] - KeymanagerError(#[from] eth2util::keymanager::KeymanagerError), + KeymanagerError(#[from] pluto_eth2util::keymanager::KeymanagerError), /// Insufficient fee addresses. #[error("Insufficient fee addresses: expected {expected}, got {got}")] @@ -340,7 +344,7 @@ pub enum CreateClusterError { /// Registration error. #[error("Registration error: {0}")] - RegistrationError(#[from] eth2util::registration::RegistrationError), + RegistrationError(#[from] pluto_eth2util::registration::RegistrationError), /// Validator registration not found at the given index. #[error("Validator registration not found at index {index}")] @@ -398,7 +402,7 @@ pub enum ThresholdError { pub enum InvalidNetworkConfigError { /// Invalid network name. #[error("Invalid network name: {0}")] - InvalidNetworkName(#[from] eth2util::network::NetworkError), + InvalidNetworkName(#[from] pluto_eth2util::network::NetworkError), /// Invalid network specified. #[error("Invalid network specified: network={network}")] @@ -418,8 +422,8 @@ impl From for CreateClusterError { } } -impl From for CreateClusterError { - fn from(error: eth2util::network::NetworkError) -> Self { +impl From for CreateClusterError { + fn from(error: pluto_eth2util::network::NetworkError) -> Self { CreateClusterError::InvalidNetworkConfig(InvalidNetworkConfigError::InvalidNetworkName( error, )) From 5aa1ef669e3d22f2c47be3f4c0c2edc8013880ec Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:18:05 +0200 Subject: [PATCH 04/12] fix: review comments --- crates/cli/src/commands/create_cluster.rs | 25 ++++++++--------------- crates/cli/src/error.rs | 10 +-------- 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index 39c7cb3f..61fa0447 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -580,7 +580,7 @@ pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<() } if args.split_keys { - write_split_keys_warning(w).map_err(CreateClusterError::IoError)?; + write_split_keys_warning(w)?; } write_output( @@ -590,8 +590,7 @@ pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<() num_nodes, keys_to_disk, args.zipped, - ) - .map_err(CreateClusterError::IoError)?; + )?; if !dashboard_url.is_empty() { info!( @@ -711,9 +710,8 @@ async fn write_keys_to_disk( secrets.push(shares[i_usize]); } - let keys_dir = helpers::create_validator_keys_dir(node_dir(cluster_dir.as_ref(), i)) - .await - .map_err(CreateClusterError::IoError)?; + let keys_dir = + helpers::create_validator_keys_dir(node_dir(cluster_dir.as_ref(), i)).await?; if insecure_keys { keystore::store_keys_insecure(&secrets, &keys_dir, &CONFIRM_INSECURE_KEYS).await?; @@ -1119,12 +1117,9 @@ fn validate_create_config(args: &CreateClusterArgs) -> Result<()> { fn detect_node_dirs(cluster_dir: impl AsRef, node_amount: u64) -> Result<()> { for i in 0..node_amount { - let abs_path = std::path::absolute(node_dir(cluster_dir.as_ref(), i)) - .map_err(CreateClusterError::AbsolutePathError)?; + let abs_path = std::path::absolute(node_dir(cluster_dir.as_ref(), i))?; - if std::fs::exists(abs_path.join("cluster-lock.json")) - .map_err(CreateClusterError::IoError)? - { + if std::fs::exists(abs_path.join("cluster-lock.json"))? { return Err(CreateClusterError::NodeDirectoryAlreadyExists { node_dir: abs_path }); } } @@ -1373,14 +1368,10 @@ async fn write_lock(lock: &Lock, cluster_dir: impl AsRef, num_nodes: u64) for i in 0..num_nodes { let lock_path = node_dir(cluster_dir.as_ref(), i).join("cluster-lock.json"); - tokio::fs::write(&lock_path, &bytes) - .await - .map_err(CreateClusterError::IoError)?; + tokio::fs::write(&lock_path, &bytes).await?; let perms = std::fs::Permissions::from_mode(0o400); - tokio::fs::set_permissions(&lock_path, perms) - .await - .map_err(CreateClusterError::IoError)?; + tokio::fs::set_permissions(&lock_path, perms).await?; } Ok(()) diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 772a7bae..e97cf608 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -132,13 +132,9 @@ pub enum CreateClusterError { #[error("Invalid network configuration: {0}")] InvalidNetworkConfig(InvalidNetworkConfigError), - /// Absolute path error. - #[error("Absolute path retrieval error: {0}")] - AbsolutePathError(std::io::Error), - /// IO error. #[error("IO error: {0}")] - IoError(std::io::Error), + IoError(#[from] std::io::Error), /// Node directory already exists. #[error( @@ -267,10 +263,6 @@ pub enum CreateClusterError { #[error("Withdrawal validation error: {0}")] WithdrawalValidationError(#[from] crate::commands::create_dkg::WithdrawalValidationError), - /// Failed to read definition file. - #[error("Failed to read definition file: {0}")] - ReadDefinitionFile(#[from] std::io::Error), - /// Failed to parse definition JSON. #[error("Failed to parse definition JSON: {0}")] ParseDefinitionJson(#[from] serde_json::Error), From 427e870f3956adf05c8b1bacc7e02f493e7f62b7 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:15:10 +0200 Subject: [PATCH 05/12] fix: review comments --- Cargo.lock | 3 --- crates/cli/Cargo.toml | 3 --- 2 files changed, 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02c0c5dd..68136556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5455,7 +5455,6 @@ dependencies = [ "backon", "chrono", "clap", - "flate2", "hex", "humantime", "k256", @@ -5477,7 +5476,6 @@ dependencies = [ "serde", "serde_json", "serde_with", - "tar", "tempfile", "test-case", "thiserror 2.0.18", @@ -5485,7 +5483,6 @@ dependencies = [ "tokio-util", "tracing", "url", - "uuid", "wiremock", ] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index feb5183c..0c715593 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -40,9 +40,6 @@ tempfile.workspace = true reqwest.workspace = true url.workspace = true chrono.workspace = true -uuid.workspace = true -flate2.workspace = true -tar.workspace = true [dev-dependencies] tempfile.workspace = true From 1733554016361145573b23884b0d59e1baba95db Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:17:41 +0200 Subject: [PATCH 06/12] fix: review comment --- crates/cli/src/commands/create_cluster.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index 61fa0447..16fc47d4 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -950,7 +950,7 @@ fn new_def_from_config(args: &CreateClusterArgs) -> Result { fork_version, pluto_cluster::definition::Creator::default(), operators, - args.deposit_amounts.clone(), + deposit::eths_to_gweis(&args.deposit_amounts), consensus_protocol, args.target_gas_limit, args.compounding, From e7afc37a216a4a40e4a23985962db353abd52794 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:42:56 +0200 Subject: [PATCH 07/12] feat: add create cluster test --- crates/cli/src/commands/create_cluster.rs | 529 ++++++++++++++++++++-- crates/cluster/src/definition.rs | 3 + crates/cluster/src/lock.rs | 7 +- 3 files changed, 494 insertions(+), 45 deletions(-) diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index 16fc47d4..e0df85cc 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -497,6 +497,8 @@ pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<() let (ops, node_keys) = get_operators(num_nodes, &args.cluster_dir)?; def.operators = ops; + def.set_definition_hashes() + .map_err(CreateClusterError::DefinitionError)?; let keys_to_disk = args.keymanager_addrs.is_empty(); @@ -1461,59 +1463,500 @@ fn write_split_keys_warning(w: &mut dyn Write) -> std::io::Result<()> { #[cfg(test)] mod tests { + use std::collections::HashSet; + + use pluto_cluster::{lock::Lock, version::versions::*}; + use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls as _}; + use pluto_eth1wrap::EthClient; + use pluto_eth2util::{ + deposit, + keystore::{self, CONFIRM_INSECURE_KEYS}, + }; + use rand::Rng as _; + use tempfile::TempDir; + use super::*; - fn test_args(cluster_dir: PathBuf) -> CreateClusterArgs { - CreateClusterArgs { - cluster_dir, + const DEF_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cluster/src/examples/cluster-definition-006.json" + ); + const DEF_PATH_TWO_NODES: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cluster/src/examples/cluster-definition-001.json" + ); + const DEFAULT_NETWORK: &str = "mainnet"; + + #[derive(Debug, Clone)] + struct TestCaseConfig { + num_nodes: u64, + threshold: Option, + num_dvs: u64, + network: Option<&'static str>, + deposit_amounts: Vec, + split_keys: bool, + def_file_path: Option<&'static str>, + name: Option<&'static str>, + consensus_protocol: Option<&'static str>, + target_gas_limit: u64, + testnet_chain_id: Option, + testnet_name: Option<&'static str>, + testnet_fork_version: Option<&'static str>, + } + + #[derive(Debug, Clone, Copy)] + enum PrepKind { + None, + SplitKeys { num_keys: usize }, + RandomAddrs, + } + + #[derive(Debug, Clone, Copy)] + enum DefProvider { + None, + Def006, + DefTwoNodes, + } + + async fn test_eth1_client() -> EthClient { + EthClient::new("http://127.0.0.1:8545").await.unwrap() + } + + fn random_checksummed_eth_address() -> String { + let mut bytes = [0u8; 20]; + rand::thread_rng().fill(&mut bytes[..]); + let hex_addr = format!("0x{}", hex::encode(bytes)); + pluto_eth2util::helpers::checksum_address(&hex_addr).unwrap() + } + + /// Port of Go's testCreateCluster helper. + async fn run_test_create_cluster( + config: TestCaseConfig, + prep: PrepKind, + def_provider: DefProvider, + expected_err: Option<&str>, + ) { + let dir = TempDir::new().unwrap(); + + // Load reference definition for post-creation checks (used when def_file_path + // is set). Inject defaults for fields absent from older JSON examples + // (e.g. `compounding`). + let ref_def: pluto_cluster::definition::Definition = { + let bytes = tokio::fs::read(DEF_PATH).await.unwrap(); + let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + if value.get("compounding").is_none() { + value["compounding"] = serde_json::json!(false); + } + serde_json::from_value(value).unwrap() + }; + + // Start a mock HTTP server when the test case needs to serve a definition over + // network. + let mock_server = match def_provider { + DefProvider::None => None, + path @ DefProvider::Def006 | path @ DefProvider::DefTwoNodes => { + let server = wiremock::MockServer::start().await; + let json = if matches!(path, DefProvider::Def006) { + tokio::fs::read(DEF_PATH).await.unwrap() + } else { + tokio::fs::read(DEF_PATH_TWO_NODES).await.unwrap() + }; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(json)) + .mount(&server) + .await; + Some(server) + } + }; + + // Apply defaults matching the Go test loop. + let def_url = mock_server.as_ref().map(|s| s.uri()); + let definition_file = if let Some(url) = &def_url { + Some(url.clone()) + } else { + config.def_file_path.map(String::from) + }; + + let mut fee_addrs = vec![ZERO_ADDRESS.to_string()]; + let mut withdrawal_addrs = vec![ZERO_ADDRESS.to_string()]; + + let target_gas_limit = if config.target_gas_limit != 0 { + config.target_gas_limit + } else if def_url.is_none() { + 30_000_000 + } else { + 0 + }; + + // Split keys temp dir kept alive for the full duration of the test. + let split_keys_temp = TempDir::new().unwrap(); + let mut split_keys_dir: Option = None; + + match prep { + PrepKind::None => {} + PrepKind::SplitKeys { num_keys } => { + let tbls = BlstImpl; + let mut keys = Vec::new(); + for _ in 0..num_keys { + keys.push(tbls.generate_secret_key(rand::thread_rng()).unwrap()); + } + keystore::store_keys_insecure( + &keys, + split_keys_temp.path(), + &CONFIRM_INSECURE_KEYS, + ) + .await + .unwrap(); + split_keys_dir = Some(split_keys_temp.path().to_path_buf()); + } + PrepKind::RandomAddrs => { + fee_addrs = vec![random_checksummed_eth_address()]; + withdrawal_addrs = vec![random_checksummed_eth_address()]; + } + } + + let testnet_config = TestnetConfig { + chain_id: config.testnet_chain_id, + fork_version: config.testnet_fork_version.map(String::from), + genesis_timestamp: config.testnet_chain_id.map(|_| { + u64::try_from(chrono::Utc::now().timestamp()).expect("timestamp fits u64") + }), + testnet_name: config.testnet_name.map(String::from), + }; + + let network = config.network.and_then(|n| Network::try_from(n).ok()); + let execution_engine_addr = definition_file + .as_ref() + .map(|_| "http://127.0.0.1:8545".to_string()); + + let args = CreateClusterArgs { + cluster_dir: dir.path().to_path_buf(), compounding: false, - consensus_protocol: None, - definition_file: None, - deposit_amounts: Vec::new(), - execution_engine_addr: None, - fee_recipient_addrs: vec!["0x000000000000000000000000000000000000dead".to_string()], - insecure_keys: false, - keymanager_addrs: Vec::new(), - keymanager_auth_tokens: Vec::new(), - name: Some("test-cluster".to_string()), - network: Some(Network::Mainnet), - nodes: 3, - num_validators: 1, + consensus_protocol: config.consensus_protocol.map(String::from), + definition_file, + deposit_amounts: config.deposit_amounts.clone(), + execution_engine_addr, + fee_recipient_addrs: fee_addrs, + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: config.name.map(String::from), + network, + nodes: config.num_nodes, + num_validators: config.num_dvs, publish: false, publish_address: "https://api.obol.tech/v1".to_string(), - split_keys: false, - split_keys_dir: None, - target_gas_limit: 60_000_000, - testnet_config: TestnetConfig::default(), - threshold: None, - withdrawal_addrs: vec!["0x000000000000000000000000000000000000dead".to_string()], + split_keys: config.split_keys, + split_keys_dir, + target_gas_limit, + testnet_config, + threshold: config.threshold, + withdrawal_addrs, zipped: false, + }; + + let mut output = Vec::new(); + let result = run(&mut output, args).await; + + if let Some(expected) = expected_err { + let err = result.unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains(expected), + "expected error containing '{expected}', got: {err_str}" + ); + return; } - } - #[test] - fn validate_create_config_allows_http_keymanager_urls() { - let tempdir = tempfile::tempdir().expect("tempdir should be created"); - let mut args = test_args(tempdir.path().to_path_buf()); - args.keymanager_addrs = vec![ - "http://127.0.0.1:3600".to_string(), - "http://127.0.0.1:3601".to_string(), - "http://127.0.0.1:3602".to_string(), - ]; - args.keymanager_auth_tokens = vec!["a".into(), "b".into(), "c".into()]; + result.unwrap(); - assert!(validate_create_config(&args).is_ok()); - } + // Validate lock (port of Go's t.Run("valid lock", ...)). + let eth1 = test_eth1_client().await; + let lock_bytes = tokio::fs::read(dir.path().join("node0/cluster-lock.json")) + .await + .unwrap(); + let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); - #[test] - fn validate_create_config_rejects_zero_num_validators_without_definition() { - let tempdir = tempfile::tempdir().expect("tempdir should be created"); - let mut args = test_args(tempdir.path().to_path_buf()); - args.num_validators = 0; + lock.verify_hashes().unwrap(); + lock.verify_signatures(ð1).await.unwrap(); - assert!(matches!( - validate_create_config(&args), - Err(CreateClusterError::MissingNumValidatorsOrDefinitionFile) - )); + // Check validators have unique public keys. + let pub_keys: HashSet> = lock + .distributed_validators + .iter() + .map(|v| v.pub_key.clone()) + .collect(); + assert_eq!( + pub_keys.len(), + usize::try_from(lock.definition.num_validators).unwrap() + ); + + // Check partial deposit data amounts match expected. + let mut amounts = deposit::dedup_amounts(&deposit::eths_to_gweis(&config.deposit_amounts)); + if amounts.is_empty() { + amounts = deposit::default_deposit_amounts(false); + } + for val in &lock.distributed_validators { + assert_eq!(val.partial_deposit_data.len(), amounts.len()); + for (pdd, &expected_amount) in val.partial_deposit_data.iter().zip(amounts.iter()) { + assert_eq!(pdd.amount, expected_amount); + } + } + + // If a definition file was loaded from disk, config hash and creator must be + // preserved, and operators must have their ENRs populated. + if config.def_file_path.is_some() { + assert_eq!(lock.definition.config_hash, ref_def.config_hash); + assert_eq!(lock.definition.creator, ref_def.creator); + for op in &lock.operators { + assert!(!op.enr.is_empty()); + } + } + + const PREV_VERSIONS: &[&str] = &[V1_0, V1_1, V1_2, V1_3, V1_4, V1_5]; + + // Builder registrations must be populated (v1.7+, always true for v1.10). + for val in &lock.distributed_validators { + if PREV_VERSIONS.contains(&lock.definition.version.as_str()) { + continue; + } + + if matches!(lock.definition.version.as_str(), V1_6 | V1_7) { + assert_eq!(val.partial_deposit_data.len(), 1); + } + + if lock.definition.version.as_str() == V1_7 { + assert!(!val.builder_registration.signature.is_empty()); + } + + if config.split_keys { + // For SplitKeys mode the timestamp must be close to now, not a genesis time. + let reg_ts = val.builder_registration.message.timestamp; + let diff = chrono::Utc::now().signed_duration_since(reg_ts); + assert!( + diff.num_minutes().abs() < 5, + "builder registration timestamp likely a genesis time" + ); + } + } + + // Node signatures must be populated (v1.7+). + if lock.version.as_str() == V1_7 { + assert!(!lock.node_signatures.is_empty()); + for sig in &lock.node_signatures { + assert!(!sig.is_empty()); + } + } + } + + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 1, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "simnet" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 1, + network: Some("goerli"), deposit_amounts: vec![31, 1], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "two partial deposits" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 1, + network: Some("goerli"), deposit_amounts: vec![8, 8, 8, 8], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "four partial deposits" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 0, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: true, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::SplitKeys { num_keys: 2 }, DefProvider::None, None + ; "splitkeys" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 0, threshold: None, num_dvs: 0, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: true, def_file_path: Some(DEF_PATH), name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::SplitKeys { num_keys: 2 }, DefProvider::None, None + ; "splitkeys with cluster definition" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 0, threshold: None, num_dvs: 0, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: true, def_file_path: Some(DEF_PATH), name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::SplitKeys { num_keys: 1 }, DefProvider::None, + Some("Amount of keys read from disk") + ; "splitkeys with cluster definition but amount of keys read from disk differ" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 0, threshold: None, num_dvs: 0, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, Some("Missing --nodes") + ; "missing nodes amount flag" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: None, num_dvs: 0, + network: None, deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, Some("Missing --network flag") + ; "missing network flag" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 0, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, Some("Missing --num-validators") + ; "missing numdvs with no split keys set" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 1, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: true, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, Some("--split-existing-keys") + ; "splitkeys with numdvs set" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: MIN_NODES, threshold: Some(3), num_dvs: 2, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "goerli" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 0, threshold: None, num_dvs: 0, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: Some(DEF_PATH), name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "solo flow definition from disk" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 0, threshold: None, num_dvs: 0, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: None, + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::Def006, None + ; "solo flow definition from network" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 3, threshold: Some(3), num_dvs: 5, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: Some("test_cluster"), + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::RandomAddrs, DefProvider::None, None + ; "test with fee recipient and withdrawal addresses" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 3, + network: None, deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: Some("testnet"), + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: Some(243), + testnet_name: Some("obolnetwork"), + testnet_fork_version: Some("0x00000101"), + }, + PrepKind::None, DefProvider::None, None + ; "custom testnet flags" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 3, + network: Some(DEFAULT_NETWORK), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: Some("test_cluster"), + consensus_protocol: Some("unreal"), target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, Some("Unsupported consensus protocol") + ; "preferred consensus protocol" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 2, threshold: Some(2), num_dvs: 1, + network: Some("goerli"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: Some("test_cluster"), + consensus_protocol: None, target_gas_limit: 0, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::DefTwoNodes, Some("Too few nodes") + ; "test with number of nodes below minimum" + )] + #[test_case::test_case( + TestCaseConfig { + num_nodes: 4, threshold: Some(3), num_dvs: 3, + network: Some("holesky"), deposit_amounts: vec![], + split_keys: false, def_file_path: None, name: Some("test_cluster"), + consensus_protocol: None, target_gas_limit: 36_000_000, + testnet_chain_id: None, testnet_name: None, testnet_fork_version: None, + }, + PrepKind::None, DefProvider::None, None + ; "custom target gas limit" + )] + #[tokio::test] + async fn test_create_cluster( + config: TestCaseConfig, + prep: PrepKind, + def_provider: DefProvider, + expected_err: Option<&'static str>, + ) { + run_test_create_cluster(config, prep, def_provider, expected_err).await; } } diff --git a/crates/cluster/src/definition.rs b/crates/cluster/src/definition.rs index fdb00cc5..d6dfc943 100644 --- a/crates/cluster/src/definition.rs +++ b/crates/cluster/src/definition.rs @@ -1037,9 +1037,11 @@ pub struct DefinitionV1x4 { pub threshold: u64, /// Fee recipient address for the /// validator. + #[serde(default)] pub fee_recipient_address: String, /// Withdrawal address for the /// validator. + #[serde(default)] pub withdrawal_address: String, /// DKG algorithm to use for key generation. Max 32 chars. pub dkg_algorithm: String, @@ -1484,6 +1486,7 @@ pub struct DefinitionV1x10 { pub target_gas_limit: u64, /// Compounding flag enables compounding rewards for validators by using /// 0x02 withdrawal credentials. + #[serde(default)] pub compounding: bool, /// Config hash uniquely identifies a cluster definition excluding operator /// ENRs and signatures. diff --git a/crates/cluster/src/lock.rs b/crates/cluster/src/lock.rs index 8ac6d91f..c664abe3 100644 --- a/crates/cluster/src/lock.rs +++ b/crates/cluster/src/lock.rs @@ -23,7 +23,6 @@ use serde_with::{ serde_as, }; -const EMPTY_FEE_RECIPIENT: [u8; 20] = [0; 20]; const EMPTY_VALIDATOR_PUBKEY: pluto_eth2api::spec::phase0::BLSPubKey = [0; 48]; const EMPTY_SIGNATURE: pluto_eth2api::spec::phase0::BLSSignature = [0; 96]; @@ -357,8 +356,12 @@ impl Lock { let fee_recipient_addresses = self.fee_recipient_addresses(); for (validator_idx, validator) in self.distributed_validators.iter().enumerate() { + // In Go, `noRegistration` checks `len == 0` (empty slice), which catches fields + // missing from JSON. The zero Ethereum address ([0;20]) is a valid + // fee_recipient (len=20 in Go, passes the check). Only BLS + // signature and pubkey can never be legitimately all-zero for a + // real registration. let no_registration = validator.builder_registration.signature == EMPTY_SIGNATURE - || validator.builder_registration.message.fee_recipient == EMPTY_FEE_RECIPIENT || validator.builder_registration.message.pub_key == EMPTY_VALIDATOR_PUBKEY; if matches!( From 077f2c3e26b661a3c8a2939f0cadaa6b89872d80 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:52:28 +0200 Subject: [PATCH 08/12] feat: add golden tests --- crates/cli/src/commands/create_cluster.rs | 99 +++++++++++++++++++ ...uster_custom_target_gas_limit_files.golden | 26 +++++ ...ster_custom_target_gas_limit_output.golden | 11 +++ ..._cluster_custom_testnet_flags_files.golden | 26 +++++ ...cluster_custom_testnet_flags_output.golden | 11 +++ ...cluster_four_partial_deposits_files.golden | 22 +++++ ...luster_four_partial_deposits_output.golden | 11 +++ .../test_create_cluster_goerli_files.golden | 20 ++++ .../test_create_cluster_goerli_output.golden | 11 +++ .../test_create_cluster_simnet_files.golden | 26 +++++ .../test_create_cluster_simnet_output.golden | 11 +++ ...olo_flow_definition_from_disk_files.golden | 26 +++++ ...lo_flow_definition_from_disk_output.golden | 11 +++ ..._flow_definition_from_network_files.golden | 26 +++++ ...flow_definition_from_network_output.golden | 11 +++ ...test_create_cluster_splitkeys_files.golden | 26 +++++ ...est_create_cluster_splitkeys_output.golden | 18 ++++ ...tkeys_with_cluster_definition_files.golden | 26 +++++ ...keys_with_cluster_definition_output.golden | 18 ++++ ...ient_and_withdrawal_addresses_files.golden | 20 ++++ ...ent_and_withdrawal_addresses_output.golden | 11 +++ ..._cluster_two_partial_deposits_files.golden | 26 +++++ ...cluster_two_partial_deposits_output.golden | 11 +++ 23 files changed, 504 insertions(+) create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_output.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_output.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_output.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_goerli_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_goerli_output.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_simnet_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_simnet_output.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_output.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_output.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_splitkeys_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_splitkeys_output.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_output.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_output.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_files.golden create mode 100644 crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_output.golden diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index e0df85cc..ef60d016 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -1487,6 +1487,55 @@ mod tests { ); const DEFAULT_NETWORK: &str = "mainnet"; + const TESTDATA_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/commands/testdata"); + + /// Returns the path for a golden file. + fn golden_path(test_name: &str, subtest: &str) -> std::path::PathBuf { + std::path::Path::new(TESTDATA_DIR).join(format!( + "test_create_cluster_{}_{}.golden", + test_name, subtest + )) + } + + /// Compares `actual` against a golden file. + /// + /// Set `UPDATE_GOLDEN=1` in the environment to regenerate golden files. + fn require_golden_bytes(test_name: &str, subtest: &str, actual: &[u8]) { + let path = golden_path(test_name, subtest); + if std::env::var("UPDATE_GOLDEN").as_deref() == Ok("1") { + std::fs::create_dir_all(TESTDATA_DIR).unwrap(); + std::fs::write(&path, actual).unwrap(); + return; + } + let expected = std::fs::read(&path).unwrap_or_else(|_| { + panic!( + "golden file not found: {}. Run with UPDATE_GOLDEN=1 to create it.", + path.display() + ) + }); + assert_eq!( + String::from_utf8_lossy(actual), + String::from_utf8_lossy(&expected), + "golden file mismatch: {}", + path.display() + ); + } + + /// Serializes `data` as a JSON array with 1-space indent (matching Go's + /// `json.MarshalIndent(data, "", " ")`) and compares against a golden file. + fn require_golden_json(test_name: &str, subtest: &str, data: &[String]) { + let json = if data.is_empty() { + "[]".to_string() + } else { + let items: Vec = data + .iter() + .map(|s| format!(" {}", serde_json::to_string(s).unwrap())) + .collect(); + format!("[\n{}\n]", items.join(",\n")) + }; + require_golden_bytes(test_name, subtest, json.as_bytes()); + } + #[derive(Debug, Clone)] struct TestCaseConfig { num_nodes: u64, @@ -1747,6 +1796,56 @@ mod tests { assert!(!sig.is_empty()); } } + + // --- Golden tests --- + + // Extract the leaf test-case name from the thread name + // (e.g. "...::tests::simnet" → "simnet"). + let test_name = std::thread::current() + .name() + .unwrap_or("") + .rsplit("::") + .next() + .unwrap_or("unknown") + .to_string(); + + // Output golden: replace the temp cluster-dir path with "pluto" to + // produce a deterministic, portable snapshot. + let abs_dir = std::path::absolute(dir.path()).unwrap(); + let output_str = String::from_utf8(output).unwrap(); + let output_replaced = output_str.replace(abs_dir.to_string_lossy().as_ref(), "pluto"); + require_golden_bytes(&test_name, "output", output_replaced.as_bytes()); + + // Files golden: list cluster-dir contents two levels deep (sorted), + // L1 entries first then L2 entries — mirrors Go's + // `filepath.Glob(dir+"/*")` + `filepath.Glob(dir+"/*/*")`. + let mut l1: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + l1.sort_by_key(|e| e.file_name()); + + let mut files: Vec = l1 + .iter() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + + for entry1 in &l1 { + let mut l2: Vec<_> = std::fs::read_dir(entry1.path()) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + l2.sort_by_key(|e| e.file_name()); + for entry2 in &l2 { + files.push(format!( + "{}/{}", + entry1.file_name().to_string_lossy(), + entry2.file_name().to_string_lossy() + )); + } + } + + require_golden_json(&test_name, "files", &files); } #[test_case::test_case( diff --git a/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_custom_target_gas_limit_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_custom_testnet_flags_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_files.golden new file mode 100644 index 00000000..f53d1513 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_files.golden @@ -0,0 +1,22 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-8eth.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-8eth.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-8eth.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-8eth.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_four_partial_deposits_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_goerli_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_goerli_files.golden new file mode 100644 index 00000000..2caa6c3e --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_goerli_files.golden @@ -0,0 +1,20 @@ +[ + "node0", + "node1", + "node2", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_goerli_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_goerli_output.golden new file mode 100644 index 00000000..a053b82f --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_goerli_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-2]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_simnet_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_simnet_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_simnet_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_simnet_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_simnet_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_simnet_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_disk_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_solo_flow_definition_from_network_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_output.golden new file mode 100644 index 00000000..7f101ca2 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_output.golden @@ -0,0 +1,18 @@ + +***************** WARNING: Splitting keys ********************** + Please make sure any existing validator has been shut down for + at least 2 finalised epochs before starting the charon cluster, + otherwise slashing could occur. +**************************************************************** + +Created charon cluster: + --split-existing-keys=true + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_files.golden new file mode 100644 index 00000000..9dc10f10 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_output.golden new file mode 100644 index 00000000..7f101ca2 --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_splitkeys_with_cluster_definition_output.golden @@ -0,0 +1,18 @@ + +***************** WARNING: Splitting keys ********************** + Please make sure any existing validator has been shut down for + at least 2 finalised epochs before starting the charon cluster, + otherwise slashing could occur. +**************************************************************** + +Created charon cluster: + --split-existing-keys=true + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_files.golden new file mode 100644 index 00000000..2caa6c3e --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_files.golden @@ -0,0 +1,20 @@ +[ + "node0", + "node1", + "node2", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data.json", + "node2/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_output.golden new file mode 100644 index 00000000..a053b82f --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_test_with_fee_recipient_and_withdrawal_addresses_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-2]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json diff --git a/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_files.golden b/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_files.golden new file mode 100644 index 00000000..181e3b4d --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_files.golden @@ -0,0 +1,26 @@ +[ + "node0", + "node1", + "node2", + "node3", + "node0/charon-enr-private-key", + "node0/cluster-lock.json", + "node0/deposit-data-1eth.json", + "node0/deposit-data-31eth.json", + "node0/validator_keys", + "node1/charon-enr-private-key", + "node1/cluster-lock.json", + "node1/deposit-data-1eth.json", + "node1/deposit-data-31eth.json", + "node1/validator_keys", + "node2/charon-enr-private-key", + "node2/cluster-lock.json", + "node2/deposit-data-1eth.json", + "node2/deposit-data-31eth.json", + "node2/validator_keys", + "node3/charon-enr-private-key", + "node3/cluster-lock.json", + "node3/deposit-data-1eth.json", + "node3/deposit-data-31eth.json", + "node3/validator_keys" +] \ No newline at end of file diff --git a/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_output.golden b/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_output.golden new file mode 100644 index 00000000..e395341c --- /dev/null +++ b/crates/cli/src/commands/testdata/test_create_cluster_two_partial_deposits_output.golden @@ -0,0 +1,11 @@ +Created charon cluster: + --split-existing-keys=false + +pluto/ +├─ node[0-3]/ Directory for each node +│ ├─ charon-enr-private-key Charon networking private key for node authentication +│ ├─ cluster-lock.json Cluster lock defines the cluster lock file which is signed by all nodes +│ ├─ deposit-data-*.json Deposit data files are used to activate a Distributed Validator on the DV Launchpad +│ ├─ validator_keys Validator keystores and password +│ │ ├─ keystore-*.json Validator private share key for duty signing +│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json From 1a7912c7af2879f3c7265422a0cb08bbea6bacee Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:44:19 +0200 Subject: [PATCH 09/12] feat: add test validate def --- crates/cli/src/commands/create_cluster.rs | 187 +++++++++++++++++++++- 1 file changed, 186 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index ef60d016..4e0c3ae6 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -1800,7 +1800,7 @@ mod tests { // --- Golden tests --- // Extract the leaf test-case name from the thread name - // (e.g. "...::tests::simnet" → "simnet"). + // (e.g. "...::tests::simnet" -> "simnet"). let test_name = std::thread::current() .name() .unwrap_or("") @@ -2058,4 +2058,189 @@ mod tests { ) { run_test_create_cluster(config, prep, def_provider, expected_err).await; } + + const DEF_PATH_002: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cluster/src/examples/cluster-definition-002.json" + ); + + #[tokio::test] + async fn test_validate_definition() { + let num_dvs = 4u64; + let fee_recipient_addrs: Vec = (0..num_dvs) + .map(|_| random_checksummed_eth_address()) + .collect(); + let withdrawal_addrs: Vec = + vec![ZERO_ADDRESS.to_string(); usize::try_from(num_dvs).unwrap()]; + + let args = CreateClusterArgs { + cluster_dir: std::path::PathBuf::from("./"), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs, + insecure_keys: false, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: Some("test".to_string()), + network: Some(Network::Goerli), + nodes: 4, + num_validators: num_dvs, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: false, + split_keys_dir: None, + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(3), + withdrawal_addrs, + zipped: false, + }; + + let definition = super::new_def_from_config(&args).unwrap(); + + let remote_def: pluto_cluster::definition::Definition = { + let bytes = tokio::fs::read(DEF_PATH_002).await.unwrap(); + let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + if value.get("compounding").is_none() { + value["compounding"] = serde_json::json!(false); + } + serde_json::from_value(value).unwrap() + }; + + let eth1 = test_eth1_client().await; + let keymanager_addrs: Vec = vec![]; + + // "zero address": gnosis fork version with zero withdrawal addrs -> error + { + let mut def = definition.clone(); + def.fork_version = vec![0x00, 0x00, 0x00, 0x64]; // gnosis + assert!( + super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .is_err() + ); + } + + // "fork versions": goerli -> ok; mainnet with zero withdrawal addrs -> error + { + let def = definition.clone(); + super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap(); + + let mut def = definition.clone(); + def.fork_version = vec![0x00, 0x00, 0x00, 0x00]; // mainnet + assert!( + super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .is_err() + ); + } + + // "insufficient keymanager addresses": 1 addr for 4-operator cluster -> error + { + let def = definition.clone(); + let km_addrs = vec!["127.0.0.1:1234".to_string()]; + assert!( + super::validate_definition(&def, true, &km_addrs, ð1) + .await + .is_err() + ); + } + + // "insecure keys": insecure_keys=true, goerli -> ok + { + let def = definition.clone(); + super::validate_definition(&def, true, &keymanager_addrs, ð1) + .await + .unwrap(); + } + + // "insufficient number of nodes": no operators -> "Too few nodes" + { + let mut def = definition.clone(); + def.operators = vec![]; + let err = super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("Too few nodes"), + "expected 'Too few nodes', got: {err_str}" + ); + } + + // "name not provided": empty name -> "Name not provided" + { + let mut def = definition.clone(); + def.name = String::new(); + let err = super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("Name not provided"), + "expected 'Name not provided', got: {err_str}" + ); + } + + // "zero validators provided": num_validators=0 -> "zero validators" + { + let mut def = definition.clone(); + def.num_validators = 0; + let err = super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("zero validators"), + "expected 'zero validators', got: {err_str}" + ); + } + + // "invalid hash": remote def with modified num_validators -> "Invalid config + // hash" + { + let mut def = remote_def.clone(); + def.num_validators = 3; + let err = super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("Invalid config hash"), + "expected 'Invalid config hash', got: {err_str}" + ); + } + + // "invalid config signatures": remote def with modified num_validators + rehash + // -> "invalid creator config signature" + { + let mut def = remote_def.clone(); + def.num_validators = 3; + def.set_definition_hashes().unwrap(); + let err = super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("invalid creator config signature"), + "expected 'invalid creator config signature', got: {err_str}" + ); + } + + // "unsupported consensus protocol": unrecognised protocol -> error + { + let mut def = definition.clone(); + def.consensus_protocol = "unreal".to_string(); + assert!( + super::validate_definition(&def, false, &keymanager_addrs, ð1) + .await + .is_err() + ); + } + } } From 8093d66f722148961e2497807ed83d3fc05ed78e Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:11:23 +0200 Subject: [PATCH 10/12] feat: add test split keys --- crates/cli/src/commands/create_cluster.rs | 90 +++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index 4e0c3ae6..2994ca7b 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -2059,6 +2059,96 @@ mod tests { run_test_create_cluster(config, prep, def_provider, expected_err).await; } + const DEF_PATH_004: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cluster/src/examples/cluster-definition-004.json" + ); + + /// Port of Go's TestSplitKeys. + /// + /// Verifies that `run` correctly handles split-keys mode both when a + /// cluster definition is loaded from disk and when it is built from + /// inline config. For each case the test generates BLS private keys, + /// stores them insecurely, runs cluster creation, then checks the + /// produced lock for hash/signature validity and that the number of + /// distributed validators equals the number of keys that were split. + #[test_case::test_case( + 2, Some(DEF_PATH_004), 4, None, None + ; "split keys from local definition" + )] + #[test_case::test_case( + 3, None, MIN_NODES, Some(3), Some("test split keys") + ; "split keys from config with one num-validators" + )] + #[tokio::test] + async fn test_split_keys( + num_split_keys: usize, + def_file_path: Option<&'static str>, + num_nodes: u64, + threshold: Option, + cluster_name: Option<&'static str>, + ) { + let dir = TempDir::new().unwrap(); + let split_keys_temp = TempDir::new().unwrap(); + + // Generate and store split keys insecurely. + let tbls_impl = BlstImpl; + let mut keys = Vec::new(); + for _ in 0..num_split_keys { + keys.push(tbls_impl.generate_secret_key(rand::thread_rng()).unwrap()); + } + keystore::store_keys_insecure(&keys, split_keys_temp.path(), &CONFIRM_INSECURE_KEYS) + .await + .unwrap(); + + let execution_engine_addr = def_file_path.map(|_| "http://127.0.0.1:8545".to_string()); + + let args = CreateClusterArgs { + cluster_dir: dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: def_file_path.map(String::from), + deposit_amounts: vec![], + execution_engine_addr, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: cluster_name.map(String::from), + network: Some(Network::Goerli), + nodes: num_nodes, + num_validators: 0, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: true, + split_keys_dir: Some(split_keys_temp.path().to_path_buf()), + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold, + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }; + + let mut output = Vec::new(); + run(&mut output, args).await.unwrap(); + + // Since `cluster-lock.json` is copied into each node directory, use node0. + let lock_bytes = tokio::fs::read(dir.path().join("node0/cluster-lock.json")) + .await + .unwrap(); + let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); + + lock.verify_hashes().unwrap(); + + let eth1 = test_eth1_client().await; + lock.verify_signatures(ð1).await.unwrap(); + + assert_eq!( + usize::try_from(lock.definition.num_validators).unwrap(), + num_split_keys, + ); + } + const DEF_PATH_002: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../cluster/src/examples/cluster-definition-002.json" From 7d131978fe963da8c325540dd32d64b3d1ffe910 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:53:46 +0200 Subject: [PATCH 11/12] feat: add test target gas limit --- crates/cli/src/commands/create_cluster.rs | 134 ++++++++++++++++++ crates/cluster/src/definition.rs | 28 ++++ .../src/examples/cluster-definition-005.json | 2 +- 3 files changed, 163 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index 2994ca7b..f86d1cd3 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -2333,4 +2333,138 @@ mod tests { ); } } + + /// Port of Go's TestMultipleAddresses. + #[tokio::test] + async fn test_multiple_addresses() { + // "insufficient fee recipient addresses": 0 addrs for 4 validators → error + { + let err = super::validate_addresses(4, &[], &[]).unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("mismatching --num-validators and --fee-recipient-addresses"), + "expected fee-recipient error, got: {err_str}" + ); + } + + // "insufficient withdrawal addresses": 0 withdrawal addrs for 1 validator → + // error + { + let fee_addr = "0x0000000000000000000000000000000000000000".to_string(); + let err = super::validate_addresses(1, &[fee_addr], &[]).unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("mismatching --num-validators and --withdrawal-addresses"), + "expected withdrawal error, got: {err_str}" + ); + } + + // "insufficient addresses from remote URL": deserializing a definition + // with num_validators=2 but empty validators list must fail with the + // Go-compatible error message. Testing at the JSON-parse level mirrors + // what Go's runCreateCluster triggers when it calls unmarshalDefinitionV1x10. + { + let def_json = tokio::fs::read(DEF_PATH).await.unwrap(); + let mut def_value: serde_json::Value = serde_json::from_slice(&def_json).unwrap(); + // Clear the validators list while keeping num_validators=2 to create a + // mismatch that mirrors the Go test (d.ValidatorAddresses = []). + def_value["validators"] = serde_json::json!([]); + let modified_json = serde_json::to_vec(&def_value).unwrap(); + + let err = + serde_json::from_slice::(&modified_json) + .unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains("num_validators not matching validators length"), + "expected validator length error, got: {err_str}" + ); + } + } + + const DEF_PATH_005: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cluster/src/examples/cluster-definition-005.json" + ); + + #[test_case::test_case( + Some(DEF_PATH_005), 0, 0, None + ; "target gas limit from unsupported version" + )] + #[test_case::test_case( + Some(DEF_PATH), 0, 30_000_000, None + ; "target gas limit from supported version" + )] + #[test_case::test_case( + None, 36_000_000, 36_000_000, None + ; "target gas limit with default version" + )] + #[test_case::test_case( + None, 0, 0, Some("target gas limit should be set") + ; "no target gas limit with default version" + )] + #[tokio::test] + async fn test_target_gas_limit( + def_file_path: Option<&'static str>, + target_gas_limit: u64, + expected_gas_limit: u64, + expected_err: Option<&'static str>, + ) { + let dir = TempDir::new().unwrap(); + + let execution_engine_addr = def_file_path.map(|_| "http://127.0.0.1:8545".to_string()); + + let args = CreateClusterArgs { + cluster_dir: dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: def_file_path.map(String::from), + deposit_amounts: vec![], + execution_engine_addr, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: Some("test target gas limit".to_string()), + network: Some(Network::Mainnet), + nodes: 4, + num_validators: 1, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: false, + split_keys_dir: None, + target_gas_limit, + testnet_config: TestnetConfig::default(), + threshold: Some(3), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }; + + let mut output = Vec::new(); + let result = run(&mut output, args).await; + + if let Some(expected) = expected_err { + let err = result.unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains(expected), + "expected error containing '{expected}', got: {err_str}" + ); + return; + } + + result.unwrap(); + + let lock_bytes = tokio::fs::read(dir.path().join("node0/cluster-lock.json")) + .await + .unwrap(); + let lock: Lock = serde_json::from_slice(&lock_bytes).unwrap(); + + lock.verify_hashes().unwrap(); + + let eth1 = test_eth1_client().await; + lock.verify_signatures(ð1).await.unwrap(); + + assert_eq!(lock.target_gas_limit, expected_gas_limit); + } } diff --git a/crates/cluster/src/definition.rs b/crates/cluster/src/definition.rs index d6dfc943..7aa558b7 100644 --- a/crates/cluster/src/definition.rs +++ b/crates/cluster/src/definition.rs @@ -161,21 +161,49 @@ impl<'de> Deserialize<'de> for Definition { V1_5 | V1_6 | V1_7 => { let definition: DefinitionV1x5to7 = serde_json::from_value(value).map_err(Error::custom)?; + if usize::try_from(definition.num_validators) + .map_or(true, |n| definition.validator_addresses.len() != n) + { + return Err(Error::custom( + "num_validators not matching validators length", + )); + } Ok(definition.into()) } V1_8 => { let definition: DefinitionV1x8 = serde_json::from_value(value).map_err(Error::custom)?; + if usize::try_from(definition.num_validators) + .map_or(true, |n| definition.validator_addresses.len() != n) + { + return Err(Error::custom( + "num_validators not matching validators length", + )); + } Ok(definition.into()) } V1_9 => { let definition: DefinitionV1x9 = serde_json::from_value(value).map_err(Error::custom)?; + if usize::try_from(definition.num_validators) + .map_or(true, |n| definition.validator_addresses.len() != n) + { + return Err(Error::custom( + "num_validators not matching validators length", + )); + } Ok(definition.into()) } V1_10 => { let definition: DefinitionV1x10 = serde_json::from_value(value).map_err(Error::custom)?; + if usize::try_from(definition.num_validators) + .map_or(true, |n| definition.validator_addresses.len() != n) + { + return Err(Error::custom( + "num_validators not matching validators length", + )); + } Ok(definition.into()) } _ => Err(Error::custom(format!("Unsupported version: {}", version))), diff --git a/crates/cluster/src/examples/cluster-definition-005.json b/crates/cluster/src/examples/cluster-definition-005.json index 61d89bbd..596bc3d0 100644 --- a/crates/cluster/src/examples/cluster-definition-005.json +++ b/crates/cluster/src/examples/cluster-definition-005.json @@ -32,7 +32,7 @@ ], "uuid": "B6AE17B3-78F5-147B-2C37-2572B93437DF", "version": "v1.8.0", - "timestamp": "2023-05-18T15:12:46+02:00", + "timestamp": "2023-05-18T15: 12: 46+02: 00", "num_validators": 2, "threshold": 3, "validators": [ From d8abd9113d6e4965503ba15dea0df9fbb231da5b Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:21:03 +0200 Subject: [PATCH 12/12] feat: add create cluster tests --- crates/cli/src/commands/create_cluster.rs | 377 ++++++++++++++++++++++ crates/cluster/src/definition.rs | 8 +- 2 files changed, 381 insertions(+), 4 deletions(-) diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index f86d1cd3..68cbe001 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -2467,4 +2467,381 @@ mod tests { assert_eq!(lock.target_gas_limit, expected_gas_limit); } + + #[tokio::test] + async fn test_keymanager() { + #[derive(serde::Deserialize)] + struct MockKeymanagerReq { + keystores: Vec, + passwords: Vec, + } + + const TEST_AUTH_TOKEN: &str = "api-token-test"; + + let tbls_impl = BlstImpl; + + let original_secret = tbls_impl.generate_secret_key(rand::thread_rng()).unwrap(); + let key_dir = TempDir::new().unwrap(); + keystore::store_keys_insecure( + std::slice::from_ref(&original_secret), + key_dir.path(), + &CONFIRM_INSECURE_KEYS, + ) + .await + .unwrap(); + + let auth_tokens: Vec = + vec![TEST_AUTH_TOKEN.to_string(); usize::try_from(MIN_NODES).unwrap()]; + + // --- all successful --- + { + let mut servers = Vec::new(); + let mut addrs = Vec::new(); + for _ in 0..MIN_NODES { + let server = wiremock::MockServer::start().await; + addrs.push(server.uri()); + servers.push(server); + } + for server in &servers { + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/eth/v1/keystores")) + .respond_with(wiremock::ResponseTemplate::new(200)) + .mount(server) + .await; + } + + let cluster_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: addrs, + keymanager_auth_tokens: auth_tokens.clone(), + name: Some("TestKeymanager".to_string()), + network: Some(Network::Goerli), + nodes: MIN_NODES, + num_validators: 0, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: true, + split_keys_dir: Some(key_dir.path().to_path_buf()), + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(MIN_THRESHOLD), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }, + ) + .await + .unwrap(); + + let mut shares = std::collections::HashMap::new(); + for (i, server) in servers.iter().enumerate() { + let requests = server.received_requests().await.unwrap(); + assert_eq!( + requests.len(), + 1, + "server {i} should have received exactly 1 request" + ); + + let req: MockKeymanagerReq = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(req.keystores.len(), 1, "only 1 key was split"); + assert_eq!(req.passwords.len(), 1); + + let ks: keystore::Keystore = serde_json::from_str(&req.keystores[0]).unwrap(); + let secret = keystore::decrypt(&ks, &req.passwords[0]).unwrap(); + shares.insert(u8::try_from(i + 1).unwrap(), secret); + } + + let recovered = tbls_impl.recover_secret(&shares).unwrap(); + assert_eq!(recovered, original_secret); + } + + // --- some unsuccessful --- + { + let mut addrs = vec!["http://127.0.0.1:1".to_string()]; + let mut servers = Vec::new(); + for _ in 1..MIN_NODES { + let server = wiremock::MockServer::start().await; + addrs.push(server.uri()); + servers.push(server); + } + for server in &servers { + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/eth/v1/keystores")) + .respond_with(wiremock::ResponseTemplate::new(200)) + .mount(server) + .await; + } + + let cluster_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + let err = run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: addrs, + keymanager_auth_tokens: auth_tokens.clone(), + name: Some("TestKeymanager".to_string()), + network: Some(Network::Goerli), + nodes: MIN_NODES, + num_validators: 0, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: true, + split_keys_dir: Some(key_dir.path().to_path_buf()), + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(MIN_THRESHOLD), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }, + ) + .await + .unwrap_err(); + + let err_str = format!("{err}"); + assert!( + err_str.contains("cannot ping address"), + "expected 'cannot ping address' in error, got: {err_str}" + ); + } + + // --- lengths don't match --- + { + let mut addrs = Vec::new(); + let mut servers = Vec::new(); + for _ in 0..MIN_NODES { + let server = wiremock::MockServer::start().await; + addrs.push(server.uri()); + servers.push(server); + } + + let short_tokens = auth_tokens[1..].to_vec(); + + let cluster_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + let err = run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: addrs, + keymanager_auth_tokens: short_tokens, + name: Some("TestKeymanager".to_string()), + network: Some(Network::Goerli), + nodes: MIN_NODES, + num_validators: 0, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: true, + split_keys_dir: Some(key_dir.path().to_path_buf()), + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(MIN_THRESHOLD), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }, + ) + .await + .unwrap_err(); + + let err_str = format!("{err}"); + assert!( + err_str.contains("--keymanager-addresses") + && err_str.contains("do not match --keymanager-auth-tokens"), + "expected mismatch error, got: {err_str}" + ); + } + } + + #[tokio::test] + async fn test_publish() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/lock")) + .respond_with(wiremock::ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + let cluster_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: Some("TestPublish".to_string()), + network: Some(Network::Goerli), + nodes: MIN_NODES, + num_validators: 1, + publish: true, + publish_address: server.uri(), + split_keys: false, + split_keys_dir: None, + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(MIN_THRESHOLD), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }, + ) + .await + .unwrap(); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1, "expected exactly 1 publish request"); + } + + const VALID_ETH_ADDR: &str = "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359"; + + #[test_case::test_case(Some(1), 3, Some("Threshold must be greater than") ; "threshold below minimum")] + #[test_case::test_case(Some(5), 4, Some("Threshold cannot be greater than number of operators") ; "threshold above maximum")] + #[test_case::test_case(None, 3, None ; "no threshold provided")] + #[tokio::test] + async fn test_cluster_cli( + threshold: Option, + nodes: u64, + expected_err: Option<&'static str>, + ) { + let cluster_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + let result = run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![VALID_ETH_ADDR.to_string()], + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: None, + network: Some(Network::Holesky), + nodes, + num_validators: 1, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: false, + split_keys_dir: None, + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold, + withdrawal_addrs: vec![VALID_ETH_ADDR.to_string()], + zipped: false, + }, + ) + .await; + + if let Some(expected) = expected_err { + let err = result.unwrap_err(); + let err_str = format!("{err}"); + assert!( + err_str.contains(expected), + "expected error containing '{expected}', got: {err_str}" + ); + } else { + result.unwrap(); + } + } + + fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?; + } else { + std::fs::copy(entry.path(), dst.join(entry.file_name()))?; + } + } + Ok(()) + } + + #[tokio::test] + async fn test_zipped() { + let cluster_dir = TempDir::new().unwrap(); + let backup_dir = TempDir::new().unwrap(); + + let mut output = Vec::new(); + run( + &mut output, + CreateClusterArgs { + cluster_dir: cluster_dir.path().to_path_buf(), + compounding: false, + consensus_protocol: None, + definition_file: None, + deposit_amounts: vec![], + execution_engine_addr: None, + fee_recipient_addrs: vec![ZERO_ADDRESS.to_string()], + insecure_keys: true, + keymanager_addrs: vec![], + keymanager_auth_tokens: vec![], + name: Some("test".to_string()), + network: Some(Network::Goerli), + nodes: 4, + num_validators: 4, + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + split_keys: false, + split_keys_dir: None, + target_gas_limit: 30_000_000, + testnet_config: TestnetConfig::default(), + threshold: Some(3), + withdrawal_addrs: vec![ZERO_ADDRESS.to_string()], + zipped: false, + }, + ) + .await + .unwrap(); + + copy_dir_all(cluster_dir.path(), backup_dir.path()).unwrap(); + + app_utils::bundle_output(cluster_dir.path(), "cluster.tar.gz").unwrap(); + + let unzipped_dir = TempDir::new().unwrap(); + app_utils::extract_archive( + cluster_dir.path().join("cluster.tar.gz"), + unzipped_dir.path(), + ) + .unwrap(); + + std::fs::remove_file(cluster_dir.path().join("cluster.tar.gz")).unwrap(); + + app_utils::compare_directories(backup_dir.path(), unzipped_dir.path()).unwrap(); + } } diff --git a/crates/cluster/src/definition.rs b/crates/cluster/src/definition.rs index 7aa558b7..dfbe49b2 100644 --- a/crates/cluster/src/definition.rs +++ b/crates/cluster/src/definition.rs @@ -162,7 +162,7 @@ impl<'de> Deserialize<'de> for Definition { let definition: DefinitionV1x5to7 = serde_json::from_value(value).map_err(Error::custom)?; if usize::try_from(definition.num_validators) - .map_or(true, |n| definition.validator_addresses.len() != n) + != Ok(definition.validator_addresses.len()) { return Err(Error::custom( "num_validators not matching validators length", @@ -174,7 +174,7 @@ impl<'de> Deserialize<'de> for Definition { let definition: DefinitionV1x8 = serde_json::from_value(value).map_err(Error::custom)?; if usize::try_from(definition.num_validators) - .map_or(true, |n| definition.validator_addresses.len() != n) + != Ok(definition.validator_addresses.len()) { return Err(Error::custom( "num_validators not matching validators length", @@ -186,7 +186,7 @@ impl<'de> Deserialize<'de> for Definition { let definition: DefinitionV1x9 = serde_json::from_value(value).map_err(Error::custom)?; if usize::try_from(definition.num_validators) - .map_or(true, |n| definition.validator_addresses.len() != n) + != Ok(definition.validator_addresses.len()) { return Err(Error::custom( "num_validators not matching validators length", @@ -198,7 +198,7 @@ impl<'de> Deserialize<'de> for Definition { let definition: DefinitionV1x10 = serde_json::from_value(value).map_err(Error::custom)?; if usize::try_from(definition.num_validators) - .map_or(true, |n| definition.validator_addresses.len() != n) + != Ok(definition.validator_addresses.len()) { return Err(Error::custom( "num_validators not matching validators length",