From ffe9533f1e92ee5839ffdb78698387429306679b Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 1 Apr 2026 16:06:03 +0200 Subject: [PATCH 01/14] Create DKG initial commit. --- Cargo.lock | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/cli.rs | 5 + crates/cli/src/commands/create_dkg.rs | 802 ++++++++++++++++++++++++++ crates/cli/src/commands/mod.rs | 1 + crates/cli/src/error.rs | 20 + crates/cli/src/main.rs | 1 + 7 files changed, 831 insertions(+) create mode 100644 crates/cli/src/commands/create_dkg.rs diff --git a/Cargo.lock b/Cargo.lock index 72a586e2..de386e13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5455,6 +5455,7 @@ dependencies = [ "pluto-app", "pluto-cluster", "pluto-core", + "pluto-eth1wrap", "pluto-eth2util", "pluto-k1util", "pluto-p2p", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 13c7c012..cb384073 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -18,6 +18,7 @@ hex.workspace = true humantime.workspace = true tokio.workspace = true pluto-app.workspace = true +pluto-eth1wrap.workspace = true pluto-cluster.workspace = true pluto-relay-server.workspace = true pluto-tracing.workspace = true diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 5c4956f0..4530aaae 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_dkg::CreateDkgArgs, create_enr::CreateEnrArgs, enr::EnrArgs, relay::RelayArgs, @@ -132,6 +133,10 @@ pub struct CreateArgs { /// Create subcommands #[derive(Subcommand)] pub enum CreateCommands { + /// Create a cluster definition file for a new Distributed Key Generation + /// ceremony + Dkg(Box), + /// Create an Ethereum Node Record (ENR) private key to identify this charon /// client Enr(CreateEnrArgs), diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs new file mode 100644 index 00000000..52bdfa94 --- /dev/null +++ b/crates/cli/src/commands/create_dkg.rs @@ -0,0 +1,802 @@ +//! Create DKG command implementation. +//! +//! This module implements the `pluto create dkg` command, which creates the +//! configuration for a new Distributed Key Generation ceremony. + +use std::path::PathBuf; + +use k256::{SecretKey, elliptic_curve::rand_core::OsRng}; +use pluto_app::obolapi::{Client, ClientOptions}; +use pluto_cluster::{ + definition::{Creator, Definition}, + eip712sigs::{sign_cluster_definition_hash, sign_terms_and_conditions}, + operator::Operator, +}; +use pluto_core::consensus::protocols::is_supported_protocol_name; +use pluto_eth2util::{ + deposit::{eths_to_gweis, verify_deposit_amounts}, + enr::Record, + helpers::{checksum_address, public_key_to_address}, + network::{PRATER, network_to_fork_version, valid_network}, +}; +use tracing::{info, warn}; + +use crate::error::{CliError, Result}; + +const DEFAULT_NETWORK: &str = "mainnet"; +const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; +const MIN_NODES: usize = 3; +const MIN_THRESHOLD: u64 = 2; + +/// Arguments for the `pluto create dkg` command. +#[derive(clap::Args)] +#[command( + about = "Create the configuration for a new Distributed Key Generation ceremony using pluto dkg", + long_about = "Create a cluster definition file that will be used by all participants of a DKG." +)] +pub struct CreateDkgArgs { + /// The folder to write the output cluster-definition.json file to. + #[arg(long, default_value = ".charon")] + pub output_dir: PathBuf, + + /// Optional cosmetic cluster name. + #[arg(long, default_value = "")] + pub name: String, + + /// The number of distributed validators the cluster will manage (32ETH+ + /// staked for each). + #[arg(long, default_value_t = 1)] + pub num_validators: u64, + + /// Optional override of threshold required for signature reconstruction. + /// Defaults to ceil(n*2/3) if zero. Warning, non-default values + /// decrease security. + #[arg(long, short = 't', default_value_t = 0)] + pub threshold: u64, + + /// Comma separated list of Ethereum addresses of the fee recipient for each + /// validator. Either provide a single fee recipient address or one per + /// validator. + #[arg(long, value_delimiter = ',')] + pub fee_recipient_addresses: Vec, + + /// Comma separated list of Ethereum addresses to receive the returned stake + /// and accrued rewards for each validator. Either provide a single + /// withdrawal address or one per validator. + #[arg(long, value_delimiter = ',')] + pub withdrawal_addresses: Vec, + + /// Ethereum network to create validators for. + /// Options: mainnet, goerli, sepolia, hoodi, holesky, gnosis, chiado. + #[arg(long, default_value = DEFAULT_NETWORK)] + pub network: String, + + /// DKG algorithm to use; default, frost. + #[arg(long = "dkg-algorithm", default_value = "default")] + pub dkg_algo: String, + + /// List of partial deposit amounts (integers) in ETH. Values must sum up to + /// at least 32ETH. + #[arg(long, value_delimiter = ',')] + pub deposit_amounts: Vec, + + /// Comma-separated list of each operator's Charon ENR address. + #[arg(long, value_delimiter = ',')] + pub operator_enrs: Vec, + + /// Preferred consensus protocol name for the cluster. Selected + /// automatically when not specified. + #[arg(long, default_value = "")] + pub consensus_protocol: String, + + /// Preferred target gas limit for transactions. + #[arg(long, default_value_t = 60_000_000)] + pub target_gas_limit: u64, + + /// Enable compounding rewards for validators by using 0x02 withdrawal + /// credentials. + #[arg(long, default_value_t = false)] + pub compounding: bool, + + /// The address of the execution engine JSON-RPC API. + #[arg(long = "execution-client-rpc-endpoint", default_value = "")] + pub execution_engine_addr: String, + + /// Creates an invitation to the DKG ceremony on the DV Launchpad. + /// Terms and conditions apply. + #[arg(long, default_value_t = false)] + pub publish: bool, + + /// The URL to publish the cluster to. + #[arg(long, default_value = "https://api.obol.tech/v1")] + pub publish_address: String, + + /// Comma-separated list of each operator's Ethereum address. + #[arg(long, value_delimiter = ',')] + pub operator_addresses: Vec, +} + +/// Runs the create dkg command. +pub async fn run(args: CreateDkgArgs) -> Result<()> { + if args.threshold != 0 { + if args.threshold < MIN_THRESHOLD { + return Err(CliError::Other( + "threshold must be greater than 1".to_string(), + )); + } + let num_enrs = u64::try_from(args.operator_enrs.len()) + .map_err(|_| CliError::Other("operator count overflow".to_string()))?; + if args.threshold > num_enrs { + return Err(CliError::Other( + "threshold cannot be greater than number of operators".to_string(), + )); + } + } + + if !args.operator_enrs.is_empty() && !args.operator_addresses.is_empty() { + return Err(CliError::Other( + "cannot provide both --operator-enrs and --operator-addresses".to_string(), + )); + } + + if args.publish { + if args.operator_enrs.is_empty() && args.operator_addresses.is_empty() { + return Err(CliError::Other( + r#"required flag(s) "operator-enrs" or "operator-addresses" not set"#.to_string(), + )); + } + } else if args.operator_enrs.is_empty() { + return Err(CliError::Other( + r#"required flag(s) "operator-enrs" not set"#.to_string(), + )); + } + + run_create_dkg(args).await +} + +async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<()> { + // Map prater to goerli to ensure backwards compatibility with older cluster + // definitions. + if args.network == PRATER { + args.network = "goerli".to_string(); + } + + let operators_len = if !args.operator_enrs.is_empty() { + args.operator_enrs.len() + } else { + args.operator_addresses.len() + }; + + validate_dkg_config( + operators_len, + &args.network, + &args.deposit_amounts, + &args.consensus_protocol, + args.compounding, + )?; + + let (fee_recipient_addrs, withdrawal_addrs) = validate_addresses( + args.num_validators, + args.fee_recipient_addresses.clone(), + args.withdrawal_addresses.clone(), + )?; + + validate_withdrawal_addrs(&withdrawal_addrs, &args.network)?; + + info!("Charon create DKG starting"); + + let def_path = args.output_dir.join("cluster-definition.json"); + if def_path.exists() { + return Err(CliError::Other( + "existing cluster-definition.json found. Try again after deleting it".to_string(), + )); + } + + let mut operators: Vec = Vec::new(); + + for enr_str in &args.operator_enrs { + Record::try_from(enr_str.as_str()) + .map_err(|e| CliError::Other(format!("invalid ENR: {e}")))?; + + operators.push(Operator { + enr: enr_str.clone(), + ..Default::default() + }); + } + + for (i, addr) in args.operator_addresses.iter().enumerate() { + let checksum_addr = checksum_address(addr).map_err(|e| { + CliError::Other(format!("invalid operator address: {e} (operator {i})")) + })?; + operators.push(Operator { + address: checksum_addr, + ..Default::default() + }); + } + + let num_operators = u64::try_from(operators.len()) + .map_err(|_| CliError::Other("operator count overflow".to_string()))?; + let safe_thresh = safe_threshold(num_operators)?; + let threshold = if args.threshold == 0 { + safe_thresh + } else { + warn!( + threshold = args.threshold, + safe_threshold = safe_thresh, + "Non standard `--threshold` flag provided, this will affect cluster safety" + ); + args.threshold + }; + + let fork_version_hex = network_to_fork_version(&args.network)?; + + let (priv_key, creator) = if args.publish { + let key = SecretKey::random(&mut OsRng); + let addr = public_key_to_address(&key.public_key()); + ( + Some(key), + Creator { + address: addr, + ..Default::default() + }, + ) + } else { + (None, Creator::default()) + }; + + let deposit_amounts_gwei: Vec = eths_to_gweis(&args.deposit_amounts); + + let mut def = Definition::new( + args.name.clone(), + args.num_validators, + threshold, + fee_recipient_addrs, + withdrawal_addrs, + fork_version_hex, + creator, + operators, + deposit_amounts_gwei, + args.consensus_protocol.clone(), + args.target_gas_limit, + args.compounding, + vec![], + )?; + + // Apply DKG algorithm override (mirrors Go's cluster.WithDKGAlgorithm). + def.dkg_algorithm = args.dkg_algo.clone(); + def.set_definition_hashes()?; + + if let Some(key) = &priv_key { + def.creator.config_signature = sign_cluster_definition_hash(key, &def)?; + } + + // Verify signatures when an ETH1 endpoint is available. Skipped when the + // endpoint is empty because the client cannot connect — safe for DKG create + // since operators have no signatures at this stage. + if !args.publish && !args.execution_engine_addr.is_empty() { + let eth1 = pluto_eth1wrap::EthClient::new(&args.execution_engine_addr).await?; + def.verify_signatures(ð1).await?; + } + + if args.publish { + let key = priv_key.expect("publish requires a private key"); + return publish_partial_definition(args, key, def).await; + } + + let json = serde_json::to_string_pretty(&def)?; + + tokio::fs::create_dir_all(&args.output_dir).await?; + tokio::fs::write(&def_path, json.as_bytes()).await?; + + // Set file to read-only (best-effort). + let mut perms = tokio::fs::metadata(&def_path).await?.permissions(); + perms.set_readonly(true); + let _ = tokio::fs::set_permissions(&def_path, perms).await; + + Ok(()) +} + +fn validate_dkg_config( + num_operators: usize, + network: &str, + deposit_amounts: &[u64], + consensus_protocol: &str, + compounding: bool, +) -> Result<()> { + if num_operators < MIN_NODES { + return Err(CliError::Other( + "number of operators is below minimum".to_string(), + )); + } + + if !valid_network(network) { + return Err(CliError::Other("unsupported network".to_string())); + } + + if !deposit_amounts.is_empty() { + let gweis = eths_to_gweis(deposit_amounts); + verify_deposit_amounts(&gweis, compounding)?; + } + + if !consensus_protocol.is_empty() && !is_supported_protocol_name(consensus_protocol) { + return Err(CliError::Other( + "unsupported consensus protocol".to_string(), + )); + } + + Ok(()) +} + +fn validate_addresses( + num_validators: u64, + mut fee_recipient_addrs: Vec, + mut withdrawal_addrs: Vec, +) -> Result<(Vec, Vec)> { + let num_vals = num_validators; + let num_fee = u64::try_from(fee_recipient_addrs.len()) + .map_err(|_| CliError::Other("address count overflow".to_string()))?; + let num_wa = u64::try_from(withdrawal_addrs.len()) + .map_err(|_| CliError::Other("address count overflow".to_string()))?; + + if num_fee != num_vals && num_fee != 1 { + return Err(CliError::Other( + "mismatching --num-validators and --fee-recipient-addresses".to_string(), + )); + } + + if num_wa != num_vals && num_wa != 1 { + return Err(CliError::Other( + "mismatching --num-validators and --withdrawal-addresses".to_string(), + )); + } + + if fee_recipient_addrs.len() == 1 { + let addr = fee_recipient_addrs[0].clone(); + for _ in 1..num_validators { + fee_recipient_addrs.push(addr.clone()); + } + } + + if withdrawal_addrs.len() == 1 { + let addr = withdrawal_addrs[0].clone(); + for _ in 1..num_validators { + withdrawal_addrs.push(addr.clone()); + } + } + + Ok((fee_recipient_addrs, withdrawal_addrs)) +} + +fn validate_withdrawal_addrs(addrs: &[String], network: &str) -> Result<()> { + for addr in addrs { + let checksum = checksum_address(addr) + .map_err(|e| CliError::Other(format!("invalid withdrawal address: {e}")))?; + + if checksum != *addr { + return Err(CliError::Other(format!( + "invalid checksummed address: {addr}" + ))); + } + + if is_main_or_gnosis(network) && addr == ZERO_ADDRESS { + return Err(CliError::Other(format!( + "zero address forbidden on this network: {network}" + ))); + } + } + + Ok(()) +} + +fn is_main_or_gnosis(network: &str) -> bool { + network == "mainnet" || network == "gnosis" +} + +fn safe_threshold(num_operators: u64) -> Result { + let two_n = num_operators + .checked_mul(2) + .ok_or_else(|| CliError::Other("threshold overflow".to_string()))?; + Ok(two_n + .checked_add(2) + .ok_or_else(|| CliError::Other("threshold overflow".to_string()))? + / 3) +} + +fn generate_launchpad_link(config_hash: &[u8], network: &str) -> String { + let network_prefix = match network { + "holesky" => "holesky.", + "hoodi" => "hoodi.", + "sepolia" => "sepolia.", + _ => "", + }; + format!( + "https://{}launchpad.obol.org/dv#0x{}", + network_prefix, + hex::encode(config_hash) + ) +} + +fn generate_api_link(config_hash: &[u8]) -> String { + format!( + "https://api.obol.tech/v1/definition/0x{}", + hex::encode(config_hash) + ) +} + +async fn publish_partial_definition( + args: CreateDkgArgs, + priv_key: SecretKey, + def: Definition, +) -> Result<()> { + let api_client = Client::new( + &args.publish_address, + ClientOptions::builder() + .timeout(std::time::Duration::from_secs(10)) + .build(), + )?; + + let sig = sign_terms_and_conditions(&priv_key, &def)?; + + api_client + .sign_terms_and_conditions(&def.creator.address, &def.fork_version, &sig) + .await?; + + info!("Creator successfully signed Obol's terms and conditions"); + + api_client + .publish_definition(def.clone(), &def.creator.config_signature) + .await?; + + info!("Cluster Invitation Prepared"); + + if args.operator_enrs.is_empty() { + info!( + "Direct the Node Operators to: {} to review the cluster configuration and begin the distributed key generation ceremony.", + generate_launchpad_link(&def.config_hash, &args.network) + ); + } else { + let api_link = generate_api_link(&def.config_hash); + info!( + "Distributed Key Generation configuration created. Run one of the following commands from the directories where the associated .charon/charon-enr-private-key(s) that match these ENRs are: \ + (Without docker): `pluto dkg --definition-file={api_link}` \ + (With docker): `docker run --rm -v \"$(pwd)/.charon:/opt/charon/.charon\" obolnetwork/charon:latest dkg --definition-file={api_link}`" + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + use test_case::test_case; + + use super::*; + + const VALID_ETH_ADDR: &str = "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359"; + + const VALID_ENRS: &[&str] = &[ + "enr:-JG4QFI0llFYxSoTAHm24OrbgoVx77dL6Ehl1Ydys39JYoWcBhiHrRhtGXDTaygWNsEWFb1cL7a1Bk0klIdaNuXplKWGAYGv0Gt7gmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQL6bcis0tFXnbqG4KuywxT5BLhtmijPFApKCDJNl3mXFYN0Y3CCDhqDdWRwgg4u", + "enr:-JG4QPnqHa7FU3PBqGxpV5L0hjJrTUqv8Wl6_UTHt-rELeICWjvCfcVfwmax8xI_eJ0ntI3ly9fgxAsmABud6-yBQiuGAYGv0iYPgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQMLLCMZ5Oqi_sdnBfdyhmysZMfFm78PgF7Y9jitTJPSroN0Y3CCPoODdWRwgj6E", + "enr:-JG4QDKNYm_JK-w6NuRcUFKvJAlq2L4CwkECelzyCVrMWji4YnVRn8AqQEL5fTQotPL2MKxiKNmn2k6XEINtq-6O3Z2GAYGvzr_LgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKlO7fSaBa3h48CdM-qb_Xb2_hSrJOy6nNjR0mapAqMboN0Y3CCDhqDdWRwgg4u", + "enr:-JG4QKu734_MXQklKrNHe9beXIsIV5bqv58OOmsjWmp6CF5vJSHNinYReykn7-IIkc5-YsoF8Hva1Q3pl7_gUj5P9cOGAYGv0jBLgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQMM3AvPhXGCUIzBl9VFOw7VQ6_m8dGifVfJ1YXrvZsaZoN0Y3CCDhqDdWRwgg4u", + ]; + + fn temp_dir() -> TempDir { + tempfile::tempdir().expect("create temp dir") + } + + #[tokio::test] + async fn test_create_dkg_valid() { + let dir = temp_dir(); + let args = CreateDkgArgs { + output_dir: dir.path().to_path_buf(), + name: String::new(), + num_validators: 1, + threshold: 3, + fee_recipient_addresses: vec![VALID_ETH_ADDR.to_string()], + withdrawal_addresses: vec![VALID_ETH_ADDR.to_string()], + network: DEFAULT_NETWORK.to_string(), + dkg_algo: "default".to_string(), + deposit_amounts: vec![8, 16, 4, 4], + operator_enrs: VALID_ENRS.iter().map(|s| s.to_string()).collect(), + consensus_protocol: "qbft".to_string(), + target_gas_limit: 30_000_000, + compounding: false, + execution_engine_addr: String::new(), + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + operator_addresses: vec![], + }; + + run_create_dkg(args).await.unwrap(); + assert!(dir.path().join("cluster-definition.json").exists()); + } + + #[test_case( + CreateDkgArgs { + operator_enrs: { + let mut v = vec!["-JG4QDKNYm_JK-w6NuRcUFKvJAlq2L4CwkECelzyCVrMWji4YnVRn8AqQEL5fTQotPL2MKxiKNmn2k6XEINtq-6O3Z2GAYGvzr_LgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKlO7fSaBa3h48CdM-qb_Xb2_hSrJOy6nNjR0mapAqMboN0Y3CCDhqDdWRwgg4u".to_string()]; + v.extend(VALID_ENRS.iter().map(|s| s.to_string())); + v + }, + threshold: 3, network: DEFAULT_NETWORK.to_string(), + ..default_args() + }, + "invalid ENR: The format of the record is invalid: Record does not start with 'enr:'" ; + "missing_enr_prefix_dash" + )] + #[test_case( + CreateDkgArgs { + operator_enrs: { + let mut v = vec!["enr:JG4QDKNYm_JK-w6NuRcUFKvJAlq2L4CwkECelzyCVrMWji4YnVRn8AqQEL5fTQotPL2MKxiKNmn2k6XEINtq-6O3Z2GAYGvzr_LgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKlO7fSaBa3h48CdM-qb_Xb2_hSrJOy6nNjR0mapAqMboN0Y3CCDhqDdWRwgg4u".to_string()]; + v.extend(VALID_ENRS.iter().map(|s| s.to_string())); + v + }, + threshold: 3, network: DEFAULT_NETWORK.to_string(), + ..default_args() + }, + "invalid ENR: Failed to decode the base64 encoded data: Invalid last symbol 117, offset 194." ; + "enr_colon_no_dash" + )] + #[test_case( + CreateDkgArgs { + operator_enrs: { + let mut v = vec!["enrJG4QDKNYm_JK-w6NuRcUFKvJAlq2L4CwkECelzyCVrMWji4YnVRn8AqQEL5fTQotPL2MKxiKNmn2k6XEINtq-6O3Z2GAYGvzr_LgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKlO7fSaBa3h48CdM-qb_Xb2_hSrJOy6nNjR0mapAqMboN0Y3CCDhqDdWRwgg4u".to_string()]; + v.extend(VALID_ENRS.iter().map(|s| s.to_string())); + v + }, + threshold: 3, network: DEFAULT_NETWORK.to_string(), + ..default_args() + }, + "invalid ENR: The format of the record is invalid: Record does not start with 'enr:'" ; + "enr_no_colon" + )] + #[test_case( + CreateDkgArgs { + operator_enrs: { + let mut v = vec!["JG4QDKNYm_JK-w6NuRcUFKvJAlq2L4CwkECelzyCVrMWji4YnVRn8AqQEL5fTQotPL2MKxiKNmn2k6XEINtq-6O3Z2GAYGvzr_LgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKlO7fSaBa3h48CdM-qb_Xb2_hSrJOy6nNjR0mapAqMboN0Y3CCDhqDdWRwgg4u".to_string()]; + v.extend(VALID_ENRS.iter().map(|s| s.to_string())); + v + }, + threshold: 3, network: DEFAULT_NETWORK.to_string(), + ..default_args() + }, + "invalid ENR: The format of the record is invalid: Record does not start with 'enr:'" ; + "no_enr_prefix" + )] + #[test_case( + CreateDkgArgs { operator_enrs: vec!["".to_string()], ..default_args() }, + "number of operators is below minimum" ; + "single_empty_enr" + )] + #[test_case( + CreateDkgArgs { ..default_args() }, + "number of operators is below minimum" ; + "no_operators" + )] + #[test_case( + CreateDkgArgs { + operator_enrs: VALID_ENRS[..3].iter().map(|s| s.to_string()).collect(), + threshold: 3, network: DEFAULT_NETWORK.to_string(), + consensus_protocol: "unreal".to_string(), + ..default_args() + }, + "unsupported consensus protocol" ; + "unsupported_consensus" + )] + #[tokio::test] + async fn test_create_dkg_invalid(args: CreateDkgArgs, expected_err: &str) { + let err = run_create_dkg(args).await.unwrap_err(); + assert_eq!(err.to_string(), expected_err); + } + + #[test] + fn test_require_operator_enr_flag_no_enrs() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let err = rt + .block_on(run(CreateDkgArgs { + operator_enrs: vec![], + operator_addresses: vec![], + publish: false, + ..default_args() + })) + .unwrap_err(); + assert_eq!( + err.to_string(), + r#"required flag(s) "operator-enrs" not set"# + ); + } + + #[tokio::test] + async fn test_require_operator_enr_flag_below_minimum() { + let err = run(CreateDkgArgs { + operator_enrs: vec!["enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u".to_string()], + fee_recipient_addresses: vec!["0xa6430105220d0b29688b734b8ea0f3ca9936e846".to_string()], + withdrawal_addresses: vec!["0xa6430105220d0b29688b734b8ea0f3ca9936e846".to_string()], + ..default_args() + }).await.unwrap_err(); + assert_eq!(err.to_string(), "number of operators is below minimum"); + } + + #[tokio::test] + async fn test_existing_cluster_definition() { + let dir = temp_dir(); + tokio::fs::write( + dir.path().join("cluster-definition.json"), + b"sample definition", + ) + .await + .unwrap(); + + let enr = "enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u"; + let enrs: Vec = (0..MIN_NODES).map(|_| enr.to_string()).collect(); + + let err = run_create_dkg(CreateDkgArgs { + output_dir: dir.path().to_path_buf(), + operator_enrs: enrs, + fee_recipient_addresses: vec![VALID_ETH_ADDR.to_string()], + withdrawal_addresses: vec![VALID_ETH_ADDR.to_string()], + threshold: 2, + ..default_args() + }) + .await + .unwrap_err(); + + assert_eq!( + err.to_string(), + "existing cluster-definition.json found. Try again after deleting it" + ); + } + + #[test] + fn test_validate_withdrawal_addr_ok() { + validate_withdrawal_addrs(&[VALID_ETH_ADDR.to_string()], "goerli").unwrap(); + } + + #[test] + fn test_validate_withdrawal_addr_invalid_network() { + let err = validate_withdrawal_addrs(&[ZERO_ADDRESS.to_string()], "mainnet").unwrap_err(); + assert!( + err.to_string() + .contains("zero address forbidden on this network") + ); + } + + #[test] + fn test_validate_withdrawal_addr_invalid_address() { + let err = + validate_withdrawal_addrs(&["0xBAD000BAD000BAD".to_string()], "gnosis").unwrap_err(); + assert!(err.to_string().contains("invalid withdrawal address")); + } + + #[test] + fn test_validate_withdrawal_addr_invalid_checksum() { + let err = validate_withdrawal_addrs( + &["0x000BAD0000000BAD0000000BAD0000000BAD0000".to_string()], + "gnosis", + ) + .unwrap_err(); + assert!(err.to_string().contains("invalid checksummed address")); + } + + #[test] + fn test_validate_dkg_config_insufficient_operators() { + let err = validate_dkg_config(2, "", &[], "", false).unwrap_err(); + assert!( + err.to_string() + .contains("number of operators is below minimum") + ); + } + + #[test] + fn test_validate_dkg_config_invalid_network() { + let err = validate_dkg_config(4, "cosmos", &[], "", false).unwrap_err(); + assert!(err.to_string().contains("unsupported network")); + } + + #[test] + fn test_validate_dkg_config_wrong_deposit_amounts() { + let err = validate_dkg_config(4, "goerli", &[8, 16], "", false).unwrap_err(); + assert!(err.to_string().contains( + "Sum of partial deposit amounts must be at least 32ETH, repetition is allowed" + )); + } + + #[test] + fn test_validate_dkg_config_unsupported_consensus() { + let err = validate_dkg_config(4, "goerli", &[], "unreal", false).unwrap_err(); + assert!(err.to_string().contains("unsupported consensus protocol")); + } + + #[tokio::test] + async fn test_dkg_cli_threshold_below_minimum() { + let enr = "enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u"; + let enrs: Vec = (0..MIN_NODES).map(|_| enr.to_string()).collect(); + + let err = run(CreateDkgArgs { + operator_enrs: enrs, + fee_recipient_addresses: vec![VALID_ETH_ADDR.to_string()], + withdrawal_addresses: vec![VALID_ETH_ADDR.to_string()], + threshold: 1, + ..default_args() + }) + .await + .unwrap_err(); + + assert!(err.to_string().contains("threshold must be greater than 1")); + } + + #[tokio::test] + async fn test_dkg_cli_threshold_above_maximum() { + let enr = "enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u"; + let enrs: Vec = (0..MIN_NODES).map(|_| enr.to_string()).collect(); + + let err = run(CreateDkgArgs { + operator_enrs: enrs, + fee_recipient_addresses: vec![VALID_ETH_ADDR.to_string()], + withdrawal_addresses: vec![VALID_ETH_ADDR.to_string()], + threshold: 4, + ..default_args() + }) + .await + .unwrap_err(); + + assert!( + err.to_string() + .contains("threshold cannot be greater than number of operators") + ); + } + + #[tokio::test] + async fn test_dkg_cli_no_threshold() { + let dir = temp_dir(); + let enr = "enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u"; + let enrs: Vec = (0..MIN_NODES).map(|_| enr.to_string()).collect(); + + run(CreateDkgArgs { + output_dir: dir.path().to_path_buf(), + operator_enrs: enrs, + fee_recipient_addresses: vec![VALID_ETH_ADDR.to_string()], + withdrawal_addresses: vec![VALID_ETH_ADDR.to_string()], + num_validators: 1, + threshold: 0, + ..default_args() + }) + .await + .unwrap(); + + assert!(dir.path().join("cluster-definition.json").exists()); + } + + #[test_case("mainnet", b"123abc", "https://launchpad.obol.org/dv#0x313233616263" ; "mainnet")] + #[test_case("holesky", b"123abc", "https://holesky.launchpad.obol.org/dv#0x313233616263" ; "holesky")] + #[test_case("hoodi", b"123abc", "https://hoodi.launchpad.obol.org/dv#0x313233616263" ; "hoodi")] + #[test_case("sepolia", b"123abc", "https://sepolia.launchpad.obol.org/dv#0x313233616263" ; "sepolia")] + #[test_case("testnet-1", b"123abc", "https://launchpad.obol.org/dv#0x313233616263" ; "unknown_network")] + fn test_launchpad_link(network: &str, config_hash: &[u8], expected: &str) { + assert_eq!(generate_launchpad_link(config_hash, network), expected); + } + + fn default_args() -> CreateDkgArgs { + CreateDkgArgs { + output_dir: PathBuf::from(".charon"), + name: String::new(), + num_validators: 0, + threshold: 0, + fee_recipient_addresses: vec![], + withdrawal_addresses: vec![], + network: DEFAULT_NETWORK.to_string(), + dkg_algo: "default".to_string(), + deposit_amounts: vec![], + operator_enrs: vec![], + consensus_protocol: String::new(), + target_gas_limit: 60_000_000, + compounding: false, + execution_engine_addr: String::new(), + publish: false, + publish_address: "https://api.obol.tech/v1".to_string(), + operator_addresses: vec![], + } + } +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index e18c3e1f..c9fa1d85 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +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..22563b29 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -94,4 +94,24 @@ pub enum CliError { /// Relay P2P error. #[error("Relay P2P error: {0}")] RelayP2PError(#[from] pluto_relay_server::error::RelayP2PError), + + /// Deposit validation error. + #[error("{0}")] + Deposit(#[from] pluto_eth2util::deposit::DepositError), + + /// Network error. + #[error("{0}")] + Network(#[from] pluto_eth2util::network::NetworkError), + + /// Cluster definition error. + #[error("{0}")] + Definition(#[from] pluto_cluster::definition::DefinitionError), + + /// EIP-712 error. + #[error("{0}")] + Eip712(#[from] pluto_cluster::eip712sigs::EIP712Error), + + /// Ethereum L1 client error. + #[error("{0}")] + Eth1Client(#[from] pluto_eth1wrap::EthClientError), } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index cd3b9e7d..26da204a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -38,6 +38,7 @@ async fn main() -> ExitResult { let result = match cli.command { Commands::Create(args) => match args.command { + CreateCommands::Dkg(args) => commands::create_dkg::run(*args).await, CreateCommands::Enr(args) => commands::create_enr::run(args), }, Commands::Enr(args) => commands::enr::run(args), From 2aa66f5a519bded06c81f35667d4929b0918f8c0 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 1 Apr 2026 17:55:25 +0200 Subject: [PATCH 02/14] Small improvements, better logging. --- crates/cli/src/commands/create_dkg.rs | 37 +++++++++++++++++---------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index 52bdfa94..4a212744 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -17,7 +17,7 @@ use pluto_eth2util::{ deposit::{eths_to_gweis, verify_deposit_amounts}, enr::Record, helpers::{checksum_address, public_key_to_address}, - network::{PRATER, network_to_fork_version, valid_network}, + network::{GOERLI, PRATER, network_to_fork_version, valid_network}, }; use tracing::{info, warn}; @@ -118,6 +118,10 @@ pub struct CreateDkgArgs { /// Runs the create dkg command. pub async fn run(args: CreateDkgArgs) -> Result<()> { + run_create_dkg(parse_args(args)?).await +} + +fn parse_args(args: CreateDkgArgs) -> Result { if args.threshold != 0 { if args.threshold < MIN_THRESHOLD { return Err(CliError::Other( @@ -151,20 +155,20 @@ pub async fn run(args: CreateDkgArgs) -> Result<()> { )); } - run_create_dkg(args).await + Ok(args) } async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<()> { // Map prater to goerli to ensure backwards compatibility with older cluster // definitions. if args.network == PRATER { - args.network = "goerli".to_string(); + args.network = GOERLI.name.to_string(); } - let operators_len = if !args.operator_enrs.is_empty() { - args.operator_enrs.len() - } else { + let operators_len = if args.operator_enrs.is_empty() { args.operator_addresses.len() + } else { + args.operator_enrs.len() }; validate_dkg_config( @@ -183,7 +187,7 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<()> { validate_withdrawal_addrs(&withdrawal_addrs, &args.network)?; - info!("Charon create DKG starting"); + info!("Pluto create DKG starting"); let def_path = args.output_dir.join("cluster-definition.json"); if def_path.exists() { @@ -231,6 +235,7 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<()> { let fork_version_hex = network_to_fork_version(&args.network)?; let (priv_key, creator) = if args.publish { + // Temporary creator address let key = SecretKey::random(&mut OsRng); let addr = public_key_to_address(&key.public_key()); ( @@ -262,7 +267,6 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<()> { vec![], )?; - // Apply DKG algorithm override (mirrors Go's cluster.WithDKGAlgorithm). def.dkg_algorithm = args.dkg_algo.clone(); def.set_definition_hashes()?; @@ -293,6 +297,8 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<()> { perms.set_readonly(true); let _ = tokio::fs::set_permissions(&def_path, perms).await; + info!("Cluster definition created: {}", def_path.display()); + Ok(()) } @@ -304,9 +310,9 @@ fn validate_dkg_config( compounding: bool, ) -> Result<()> { if num_operators < MIN_NODES { - return Err(CliError::Other( - "number of operators is below minimum".to_string(), - )); + return Err(CliError::Other(format!( + "number of operators is below minimum: got {num_operators}, need at least {MIN_NODES} via --operator-enrs or --operator-addresses", + ))); } if !valid_network(network) { @@ -567,12 +573,12 @@ mod tests { )] #[test_case( CreateDkgArgs { operator_enrs: vec!["".to_string()], ..default_args() }, - "number of operators is below minimum" ; + "number of operators is below minimum: got 1, need at least 3 via --operator-enrs or --operator-addresses" ; "single_empty_enr" )] #[test_case( CreateDkgArgs { ..default_args() }, - "number of operators is below minimum" ; + "number of operators is below minimum: got 0, need at least 3 via --operator-enrs or --operator-addresses" ; "no_operators" )] #[test_case( @@ -616,7 +622,10 @@ mod tests { withdrawal_addresses: vec!["0xa6430105220d0b29688b734b8ea0f3ca9936e846".to_string()], ..default_args() }).await.unwrap_err(); - assert_eq!(err.to_string(), "number of operators is below minimum"); + assert_eq!( + err.to_string(), + "number of operators is below minimum: got 1, need at least 3 via --operator-enrs or --operator-addresses" + ); } #[tokio::test] From b3d01260b24b05f10967f1592ff49b2640712cc2 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 2 Apr 2026 12:30:27 +0200 Subject: [PATCH 03/14] improvements --- crates/cli/src/commands/create_dkg.rs | 57 +++++++++++++-------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index 4a212744..6d5640fb 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -17,7 +17,10 @@ use pluto_eth2util::{ deposit::{eths_to_gweis, verify_deposit_amounts}, enr::Record, helpers::{checksum_address, public_key_to_address}, - network::{GOERLI, PRATER, network_to_fork_version, valid_network}, + network::{ + GNOSIS, GOERLI, HOLESKY, HOODI, MAINNET, PRATER, SEPOLIA, network_to_fork_version, + valid_network, + }, }; use tracing::{info, warn}; @@ -198,9 +201,9 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<()> { let mut operators: Vec = Vec::new(); - for enr_str in &args.operator_enrs { + for (i, enr_str) in args.operator_enrs.iter().enumerate() { Record::try_from(enr_str.as_str()) - .map_err(|e| CliError::Other(format!("invalid ENR: {e}")))?; + .map_err(|e| CliError::Other(format!("invalid ENR (operator {i}): {e}")))?; operators.push(Operator { enr: enr_str.clone(), @@ -335,8 +338,8 @@ fn validate_dkg_config( fn validate_addresses( num_validators: u64, - mut fee_recipient_addrs: Vec, - mut withdrawal_addrs: Vec, + fee_recipient_addrs: Vec, + withdrawal_addrs: Vec, ) -> Result<(Vec, Vec)> { let num_vals = num_validators; let num_fee = u64::try_from(fee_recipient_addrs.len()) @@ -356,21 +359,17 @@ fn validate_addresses( )); } - if fee_recipient_addrs.len() == 1 { - let addr = fee_recipient_addrs[0].clone(); - for _ in 1..num_validators { - fee_recipient_addrs.push(addr.clone()); + let num_validators = usize::try_from(num_validators) + .map_err(|_| CliError::Other("num_validators is greater than usize::MAX".to_string()))?; + let expand = |addrs: Vec| -> Vec { + if addrs.len() == 1 { + vec![addrs[0].clone(); num_validators] + } else { + addrs } - } - - if withdrawal_addrs.len() == 1 { - let addr = withdrawal_addrs[0].clone(); - for _ in 1..num_validators { - withdrawal_addrs.push(addr.clone()); - } - } + }; - Ok((fee_recipient_addrs, withdrawal_addrs)) + Ok((expand(fee_recipient_addrs), expand(withdrawal_addrs))) } fn validate_withdrawal_addrs(addrs: &[String], network: &str) -> Result<()> { @@ -395,7 +394,7 @@ fn validate_withdrawal_addrs(addrs: &[String], network: &str) -> Result<()> { } fn is_main_or_gnosis(network: &str) -> bool { - network == "mainnet" || network == "gnosis" + network == MAINNET.name || network == GNOSIS.name } fn safe_threshold(num_operators: u64) -> Result { @@ -409,12 +408,12 @@ fn safe_threshold(num_operators: u64) -> Result { } fn generate_launchpad_link(config_hash: &[u8], network: &str) -> String { - let network_prefix = match network { - "holesky" => "holesky.", - "hoodi" => "hoodi.", - "sepolia" => "sepolia.", - _ => "", - }; + let network_prefix = + if network == HOLESKY.name || network == HOODI.name || network == SEPOLIA.name { + format!("{network}.") + } else { + String::new() + }; format!( "https://{}launchpad.obol.org/dv#0x{}", network_prefix, @@ -529,7 +528,7 @@ mod tests { threshold: 3, network: DEFAULT_NETWORK.to_string(), ..default_args() }, - "invalid ENR: The format of the record is invalid: Record does not start with 'enr:'" ; + "invalid ENR (operator 0): The format of the record is invalid: Record does not start with 'enr:'" ; "missing_enr_prefix_dash" )] #[test_case( @@ -542,7 +541,7 @@ mod tests { threshold: 3, network: DEFAULT_NETWORK.to_string(), ..default_args() }, - "invalid ENR: Failed to decode the base64 encoded data: Invalid last symbol 117, offset 194." ; + "invalid ENR (operator 0): Failed to decode the base64 encoded data: Invalid last symbol 117, offset 194." ; "enr_colon_no_dash" )] #[test_case( @@ -555,7 +554,7 @@ mod tests { threshold: 3, network: DEFAULT_NETWORK.to_string(), ..default_args() }, - "invalid ENR: The format of the record is invalid: Record does not start with 'enr:'" ; + "invalid ENR (operator 0): The format of the record is invalid: Record does not start with 'enr:'" ; "enr_no_colon" )] #[test_case( @@ -568,7 +567,7 @@ mod tests { threshold: 3, network: DEFAULT_NETWORK.to_string(), ..default_args() }, - "invalid ENR: The format of the record is invalid: Record does not start with 'enr:'" ; + "invalid ENR (operator 0): The format of the record is invalid: Record does not start with 'enr:'" ; "no_enr_prefix" )] #[test_case( From 1ccaa5732f80cb46f226623116df88781a192657 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 2 Apr 2026 13:18:25 +0200 Subject: [PATCH 04/14] Improved tests, joined some using test-case crate. --- crates/cli/src/commands/create_dkg.rs | 219 +++++++++----------------- 1 file changed, 75 insertions(+), 144 deletions(-) diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index 6d5640fb..d35a06e9 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -575,11 +575,6 @@ mod tests { "number of operators is below minimum: got 1, need at least 3 via --operator-enrs or --operator-addresses" ; "single_empty_enr" )] - #[test_case( - CreateDkgArgs { ..default_args() }, - "number of operators is below minimum: got 0, need at least 3 via --operator-enrs or --operator-addresses" ; - "no_operators" - )] #[test_case( CreateDkgArgs { operator_enrs: VALID_ENRS[..3].iter().map(|s| s.to_string()).collect(), @@ -590,41 +585,62 @@ mod tests { "unsupported consensus protocol" ; "unsupported_consensus" )] + #[test_case( + CreateDkgArgs { ..default_args() }, + "number of operators is below minimum: got 0, need at least 3 via --operator-enrs or --operator-addresses" ; + "no_operators" + )] + #[test_case( + CreateDkgArgs { operator_enrs: vec!["enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u".to_string()], ..default_args() }, + "number of operators is below minimum: got 1, need at least 3 via --operator-enrs or --operator-addresses" ; + "below_minimum" + )] #[tokio::test] async fn test_create_dkg_invalid(args: CreateDkgArgs, expected_err: &str) { let err = run_create_dkg(args).await.unwrap_err(); assert_eq!(err.to_string(), expected_err); } - #[test] - fn test_require_operator_enr_flag_no_enrs() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let err = rt - .block_on(run(CreateDkgArgs { - operator_enrs: vec![], - operator_addresses: vec![], - publish: false, - ..default_args() - })) - .unwrap_err(); - assert_eq!( - err.to_string(), - r#"required flag(s) "operator-enrs" not set"# - ); + #[test_case( + CreateDkgArgs { operator_enrs: vec![], operator_addresses: vec![], publish: false, ..default_args() }, + r#"required flag(s) "operator-enrs" not set"# ; + "no_enrs" + )] + #[test_case( + CreateDkgArgs { threshold: 1, ..default_args() }, + "threshold must be greater than 1" ; + "threshold_below_minimum" + )] + #[test_case( + CreateDkgArgs { operator_enrs: VALID_ENRS[..3].iter().map(|s| s.to_string()).collect(), threshold: 4, ..default_args() }, + "threshold cannot be greater than number of operators" ; + "threshold_above_maximum" + )] + #[tokio::test] + async fn test_run_invalid(args: CreateDkgArgs, expected_err: &str) { + let err = run(args).await.unwrap_err(); + assert_eq!(err.to_string(), expected_err); } #[tokio::test] - async fn test_require_operator_enr_flag_below_minimum() { - let err = run(CreateDkgArgs { - operator_enrs: vec!["enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u".to_string()], - fee_recipient_addresses: vec!["0xa6430105220d0b29688b734b8ea0f3ca9936e846".to_string()], - withdrawal_addresses: vec!["0xa6430105220d0b29688b734b8ea0f3ca9936e846".to_string()], + async fn test_dkg_cli_no_threshold() { + let dir = temp_dir(); + let enr = "enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u"; + let enrs: Vec = (0..MIN_NODES).map(|_| enr.to_string()).collect(); + + run(CreateDkgArgs { + output_dir: dir.path().to_path_buf(), + operator_enrs: enrs, + fee_recipient_addresses: vec![VALID_ETH_ADDR.to_string()], + withdrawal_addresses: vec![VALID_ETH_ADDR.to_string()], + num_validators: 1, + threshold: 0, ..default_args() - }).await.unwrap_err(); - assert_eq!( - err.to_string(), - "number of operators is below minimum: got 1, need at least 3 via --operator-enrs or --operator-addresses" - ); + }) + .await + .unwrap(); + + assert!(dir.path().join("cluster-definition.json").exists()); } #[tokio::test] @@ -657,124 +673,39 @@ mod tests { ); } - #[test] - fn test_validate_withdrawal_addr_ok() { - validate_withdrawal_addrs(&[VALID_ETH_ADDR.to_string()], "goerli").unwrap(); - } - - #[test] - fn test_validate_withdrawal_addr_invalid_network() { - let err = validate_withdrawal_addrs(&[ZERO_ADDRESS.to_string()], "mainnet").unwrap_err(); - assert!( - err.to_string() - .contains("zero address forbidden on this network") - ); - } - - #[test] - fn test_validate_withdrawal_addr_invalid_address() { - let err = - validate_withdrawal_addrs(&["0xBAD000BAD000BAD".to_string()], "gnosis").unwrap_err(); - assert!(err.to_string().contains("invalid withdrawal address")); + #[test_case(VALID_ETH_ADDR, "goerli", None; "ok")] + #[test_case(ZERO_ADDRESS, "mainnet", Some("zero address forbidden on this network"); "invalid_network")] + #[test_case("0xBAD000BAD000BAD", "gnosis", Some("invalid withdrawal address"); "invalid_address")] + #[test_case("0x000BAD0000000BAD0000000BAD0000000BAD0000", "gnosis", Some("invalid checksummed address"); "invalid_checksum")] + fn test_validate_withdrawal_addr(addr: &str, network: &str, expected_err: Option<&str>) { + let result = validate_withdrawal_addrs(&[addr.to_string()], network); + match expected_err { + None => result.unwrap(), + Some(msg) => assert!(result.unwrap_err().to_string().contains(msg)), + } } - #[test] - fn test_validate_withdrawal_addr_invalid_checksum() { - let err = validate_withdrawal_addrs( - &["0x000BAD0000000BAD0000000BAD0000000BAD0000".to_string()], - "gnosis", + #[test_case(2, "", &[], "", false, "number of operators is below minimum"; "insufficient_operators")] + #[test_case(4, "cosmos", &[], "", false, "unsupported network"; "invalid_network")] + #[test_case(4, "goerli", &[8, 16], "", false, "Sum of partial deposit amounts must be at least 32ETH, repetition is allowed"; "wrong_deposit_amounts")] + #[test_case(4, "goerli", &[], "unreal", false, "unsupported consensus protocol"; "unsupported_consensus")] + fn test_validate_dkg_config( + num_operators: usize, + network: &str, + deposit_amounts: &[u64], + consensus_protocol: &str, + compounding: bool, + expected_err: &str, + ) { + let err = validate_dkg_config( + num_operators, + network, + deposit_amounts, + consensus_protocol, + compounding, ) .unwrap_err(); - assert!(err.to_string().contains("invalid checksummed address")); - } - - #[test] - fn test_validate_dkg_config_insufficient_operators() { - let err = validate_dkg_config(2, "", &[], "", false).unwrap_err(); - assert!( - err.to_string() - .contains("number of operators is below minimum") - ); - } - - #[test] - fn test_validate_dkg_config_invalid_network() { - let err = validate_dkg_config(4, "cosmos", &[], "", false).unwrap_err(); - assert!(err.to_string().contains("unsupported network")); - } - - #[test] - fn test_validate_dkg_config_wrong_deposit_amounts() { - let err = validate_dkg_config(4, "goerli", &[8, 16], "", false).unwrap_err(); - assert!(err.to_string().contains( - "Sum of partial deposit amounts must be at least 32ETH, repetition is allowed" - )); - } - - #[test] - fn test_validate_dkg_config_unsupported_consensus() { - let err = validate_dkg_config(4, "goerli", &[], "unreal", false).unwrap_err(); - assert!(err.to_string().contains("unsupported consensus protocol")); - } - - #[tokio::test] - async fn test_dkg_cli_threshold_below_minimum() { - let enr = "enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u"; - let enrs: Vec = (0..MIN_NODES).map(|_| enr.to_string()).collect(); - - let err = run(CreateDkgArgs { - operator_enrs: enrs, - fee_recipient_addresses: vec![VALID_ETH_ADDR.to_string()], - withdrawal_addresses: vec![VALID_ETH_ADDR.to_string()], - threshold: 1, - ..default_args() - }) - .await - .unwrap_err(); - - assert!(err.to_string().contains("threshold must be greater than 1")); - } - - #[tokio::test] - async fn test_dkg_cli_threshold_above_maximum() { - let enr = "enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u"; - let enrs: Vec = (0..MIN_NODES).map(|_| enr.to_string()).collect(); - - let err = run(CreateDkgArgs { - operator_enrs: enrs, - fee_recipient_addresses: vec![VALID_ETH_ADDR.to_string()], - withdrawal_addresses: vec![VALID_ETH_ADDR.to_string()], - threshold: 4, - ..default_args() - }) - .await - .unwrap_err(); - - assert!( - err.to_string() - .contains("threshold cannot be greater than number of operators") - ); - } - - #[tokio::test] - async fn test_dkg_cli_no_threshold() { - let dir = temp_dir(); - let enr = "enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u"; - let enrs: Vec = (0..MIN_NODES).map(|_| enr.to_string()).collect(); - - run(CreateDkgArgs { - output_dir: dir.path().to_path_buf(), - operator_enrs: enrs, - fee_recipient_addresses: vec![VALID_ETH_ADDR.to_string()], - withdrawal_addresses: vec![VALID_ETH_ADDR.to_string()], - num_validators: 1, - threshold: 0, - ..default_args() - }) - .await - .unwrap(); - - assert!(dir.path().join("cluster-definition.json").exists()); + assert!(err.to_string().contains(expected_err)); } #[test_case("mainnet", b"123abc", "https://launchpad.obol.org/dv#0x313233616263" ; "mainnet")] From a48d2809636ad6ceed0b3dc0050b8258ab667184 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 8 Apr 2026 14:02:35 +0200 Subject: [PATCH 05/14] CreateDkgError enum for creating DKG config --- crates/cli/src/commands/create_dkg.rs | 247 +++++++++++++++++++------- crates/cli/src/error.rs | 4 + 2 files changed, 183 insertions(+), 68 deletions(-) diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index d35a06e9..5fb679fb 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -22,10 +22,9 @@ use pluto_eth2util::{ valid_network, }, }; +use thiserror::Error; use tracing::{info, warn}; -use crate::error::{CliError, Result}; - const DEFAULT_NETWORK: &str = "mainnet"; const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; const MIN_NODES: usize = 3; @@ -119,49 +118,130 @@ pub struct CreateDkgArgs { pub operator_addresses: Vec, } +#[derive(Error, Debug)] +pub enum CreateDkgError { + #[error("existing cluster-definition.json found. Try again after deleting it")] + DefinitionAlreadyExists, + + #[error("invalid ENR (operator {index}): {source}")] + InvalidEnr { + index: usize, + #[source] + source: pluto_eth2util::enr::RecordError, + }, + + #[error("invalid operator address: {source} (operator {index})")] + InvalidOperatorAddress { + index: usize, + #[source] + source: pluto_eth2util::helpers::HelperError, + }, + + #[error("operator count overflow")] + OperatorCountOverflow, + + #[error( + "number of operators is below minimum: got {num_operators}, need at least {MIN_NODES} via --operator-enrs or --operator-addresses" + )] + TooFewOperators { num_operators: usize }, + + #[error("unsupported network")] + UnsupportedNetwork, + + #[error("unsupported consensus protocol")] + UnsupportedConsensusProtocol, + + #[error("address count overflow")] + AddressCountOverflow, + + #[error("mismatching --num-validators and --fee-recipient-addresses")] + MismatchingFeeRecipientAddresses, + + #[error("mismatching --num-validators and --withdrawal-addresses")] + MismatchingWithdrawalAddresses, + + #[error("num_validators is greater than usize::MAX")] + NumValidatorsOverflow, + + #[error("threshold overflow")] + ThresholdOverflow, + + #[error("threshold must be greater than 1")] + ThresholdTooLow, + + #[error("threshold cannot be greater than number of operators")] + ThresholdTooHigh, + + #[error("cannot provide both --operator-enrs and --operator-addresses")] + MutuallyExclusiveOperatorFlags, + + #[error(r#"required flag(s) "operator-enrs" or "operator-addresses" not set"#)] + MissingOperatorEnrsOrAddresses, + + #[error(r#"required flag(s) "operator-enrs" not set"#)] + MissingOperatorEnrs, + + #[error(transparent)] + WithdrawalValidation(#[from] WithdrawalValidationError), + + #[error(transparent)] + Network(#[from] pluto_eth2util::network::NetworkError), + + #[error(transparent)] + Definition(#[from] pluto_cluster::definition::DefinitionError), + + #[error(transparent)] + Eip712(#[from] pluto_cluster::eip712sigs::EIP712Error), + + #[error(transparent)] + Eth1wrap(#[from] pluto_eth1wrap::EthClientError), + + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Deposit(#[from] pluto_eth2util::deposit::DepositError), + + #[error(transparent)] + ObolApi(#[from] pluto_app::obolapi::ObolApiError), +} + /// Runs the create dkg command. -pub async fn run(args: CreateDkgArgs) -> Result<()> { - run_create_dkg(parse_args(args)?).await +pub async fn run(args: CreateDkgArgs) -> crate::error::Result<()> { + Ok(run_create_dkg(parse_args(args)?).await?) } -fn parse_args(args: CreateDkgArgs) -> Result { +fn parse_args(args: CreateDkgArgs) -> Result { if args.threshold != 0 { if args.threshold < MIN_THRESHOLD { - return Err(CliError::Other( - "threshold must be greater than 1".to_string(), - )); + return Err(CreateDkgError::ThresholdTooLow); } let num_enrs = u64::try_from(args.operator_enrs.len()) - .map_err(|_| CliError::Other("operator count overflow".to_string()))?; + .map_err(|_| CreateDkgError::OperatorCountOverflow)?; if args.threshold > num_enrs { - return Err(CliError::Other( - "threshold cannot be greater than number of operators".to_string(), - )); + return Err(CreateDkgError::ThresholdTooHigh); } } if !args.operator_enrs.is_empty() && !args.operator_addresses.is_empty() { - return Err(CliError::Other( - "cannot provide both --operator-enrs and --operator-addresses".to_string(), - )); + return Err(CreateDkgError::MutuallyExclusiveOperatorFlags); } if args.publish { if args.operator_enrs.is_empty() && args.operator_addresses.is_empty() { - return Err(CliError::Other( - r#"required flag(s) "operator-enrs" or "operator-addresses" not set"#.to_string(), - )); + return Err(CreateDkgError::MissingOperatorEnrsOrAddresses); } } else if args.operator_enrs.is_empty() { - return Err(CliError::Other( - r#"required flag(s) "operator-enrs" not set"#.to_string(), - )); + return Err(CreateDkgError::MissingOperatorEnrs); } Ok(args) } -async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<()> { +async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<(), CreateDkgError> { // Map prater to goerli to ensure backwards compatibility with older cluster // definitions. if args.network == PRATER { @@ -194,16 +274,14 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<()> { let def_path = args.output_dir.join("cluster-definition.json"); if def_path.exists() { - return Err(CliError::Other( - "existing cluster-definition.json found. Try again after deleting it".to_string(), - )); + return Err(CreateDkgError::DefinitionAlreadyExists); } let mut operators: Vec = Vec::new(); for (i, enr_str) in args.operator_enrs.iter().enumerate() { Record::try_from(enr_str.as_str()) - .map_err(|e| CliError::Other(format!("invalid ENR (operator {i}): {e}")))?; + .map_err(|source| CreateDkgError::InvalidEnr { index: i, source })?; operators.push(Operator { enr: enr_str.clone(), @@ -212,17 +290,16 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<()> { } for (i, addr) in args.operator_addresses.iter().enumerate() { - let checksum_addr = checksum_address(addr).map_err(|e| { - CliError::Other(format!("invalid operator address: {e} (operator {i})")) - })?; + let checksum_addr = checksum_address(addr) + .map_err(|source| CreateDkgError::InvalidOperatorAddress { index: i, source })?; operators.push(Operator { address: checksum_addr, ..Default::default() }); } - let num_operators = u64::try_from(operators.len()) - .map_err(|_| CliError::Other("operator count overflow".to_string()))?; + let num_operators = + u64::try_from(operators.len()).map_err(|_| CreateDkgError::OperatorCountOverflow)?; let safe_thresh = safe_threshold(num_operators)?; let threshold = if args.threshold == 0 { safe_thresh @@ -311,15 +388,13 @@ fn validate_dkg_config( deposit_amounts: &[u64], consensus_protocol: &str, compounding: bool, -) -> Result<()> { +) -> Result<(), CreateDkgError> { if num_operators < MIN_NODES { - return Err(CliError::Other(format!( - "number of operators is below minimum: got {num_operators}, need at least {MIN_NODES} via --operator-enrs or --operator-addresses", - ))); + return Err(CreateDkgError::TooFewOperators { num_operators }); } if !valid_network(network) { - return Err(CliError::Other("unsupported network".to_string())); + return Err(CreateDkgError::UnsupportedNetwork); } if !deposit_amounts.is_empty() { @@ -328,9 +403,7 @@ fn validate_dkg_config( } if !consensus_protocol.is_empty() && !is_supported_protocol_name(consensus_protocol) { - return Err(CliError::Other( - "unsupported consensus protocol".to_string(), - )); + return Err(CreateDkgError::UnsupportedConsensusProtocol); } Ok(()) @@ -340,27 +413,23 @@ fn validate_addresses( num_validators: u64, fee_recipient_addrs: Vec, withdrawal_addrs: Vec, -) -> Result<(Vec, Vec)> { +) -> Result<(Vec, Vec), CreateDkgError> { let num_vals = num_validators; let num_fee = u64::try_from(fee_recipient_addrs.len()) - .map_err(|_| CliError::Other("address count overflow".to_string()))?; - let num_wa = u64::try_from(withdrawal_addrs.len()) - .map_err(|_| CliError::Other("address count overflow".to_string()))?; + .map_err(|_| CreateDkgError::AddressCountOverflow)?; + let num_wa = + u64::try_from(withdrawal_addrs.len()).map_err(|_| CreateDkgError::AddressCountOverflow)?; if num_fee != num_vals && num_fee != 1 { - return Err(CliError::Other( - "mismatching --num-validators and --fee-recipient-addresses".to_string(), - )); + return Err(CreateDkgError::MismatchingFeeRecipientAddresses); } if num_wa != num_vals && num_wa != 1 { - return Err(CliError::Other( - "mismatching --num-validators and --withdrawal-addresses".to_string(), - )); + return Err(CreateDkgError::MismatchingWithdrawalAddresses); } - let num_validators = usize::try_from(num_validators) - .map_err(|_| CliError::Other("num_validators is greater than usize::MAX".to_string()))?; + let num_validators = + usize::try_from(num_validators).map_err(|_| CreateDkgError::NumValidatorsOverflow)?; let expand = |addrs: Vec| -> Vec { if addrs.len() == 1 { vec![addrs[0].clone(); num_validators] @@ -372,21 +441,63 @@ fn validate_addresses( Ok((expand(fee_recipient_addrs), expand(withdrawal_addrs))) } -fn validate_withdrawal_addrs(addrs: &[String], network: &str) -> Result<()> { +/// Errors that can occur during withdrawal address validation. +#[derive(Error, Debug)] +pub enum WithdrawalValidationError { + /// Invalid withdrawal address. + #[error("invalid withdrawal address: {address}: {reason}")] + InvalidWithdrawalAddress { + /// The invalid address. + address: String, + /// The reason for the invalid address. + reason: 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] pluto_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, +) -> Result<(), WithdrawalValidationError> { for addr in addrs { - let checksum = checksum_address(addr) - .map_err(|e| CliError::Other(format!("invalid withdrawal address: {e}")))?; + let checksum_addr = checksum_address(addr).map_err(|e| { + WithdrawalValidationError::InvalidWithdrawalAddress { + address: addr.clone(), + reason: e.to_string(), + } + })?; - if checksum != *addr { - return Err(CliError::Other(format!( - "invalid checksummed address: {addr}" - ))); + 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(CliError::Other(format!( - "zero address forbidden on this network: {network}" - ))); + return Err(WithdrawalValidationError::ZeroAddressForbiddenOnNetwork { + network: network.to_string(), + }); } } @@ -397,13 +508,13 @@ fn is_main_or_gnosis(network: &str) -> bool { network == MAINNET.name || network == GNOSIS.name } -fn safe_threshold(num_operators: u64) -> Result { +fn safe_threshold(num_operators: u64) -> Result { let two_n = num_operators .checked_mul(2) - .ok_or_else(|| CliError::Other("threshold overflow".to_string()))?; + .ok_or(CreateDkgError::ThresholdOverflow)?; Ok(two_n .checked_add(2) - .ok_or_else(|| CliError::Other("threshold overflow".to_string()))? + .ok_or(CreateDkgError::ThresholdOverflow)? / 3) } @@ -432,7 +543,7 @@ async fn publish_partial_definition( args: CreateDkgArgs, priv_key: SecretKey, def: Definition, -) -> Result<()> { +) -> Result<(), CreateDkgError> { let api_client = Client::new( &args.publish_address, ClientOptions::builder() @@ -603,17 +714,17 @@ mod tests { #[test_case( CreateDkgArgs { operator_enrs: vec![], operator_addresses: vec![], publish: false, ..default_args() }, - r#"required flag(s) "operator-enrs" not set"# ; + r#"Create DKG error: required flag(s) "operator-enrs" not set"# ; "no_enrs" )] #[test_case( CreateDkgArgs { threshold: 1, ..default_args() }, - "threshold must be greater than 1" ; + "Create DKG error: threshold must be greater than 1" ; "threshold_below_minimum" )] #[test_case( CreateDkgArgs { operator_enrs: VALID_ENRS[..3].iter().map(|s| s.to_string()).collect(), threshold: 4, ..default_args() }, - "threshold cannot be greater than number of operators" ; + "Create DKG error: threshold cannot be greater than number of operators" ; "threshold_above_maximum" )] #[tokio::test] diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index f2ae55a4..71292de1 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -76,6 +76,10 @@ pub enum CliError { #[error("Command parsing error: {0}")] CommandParsingError(#[from] clap::Error), + /// Create DKG error. + #[error("Create DKG error: {0}")] + CreateDKGError(#[from] crate::commands::create_dkg::CreateDkgError), + /// Generic error with message. #[error("{0}")] Other(String), From 5a7441cc70ec55fbb479a79e0aec5c28d628546c Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 15 Apr 2026 16:03:32 +0200 Subject: [PATCH 06/14] cargo deny fixed --- Cargo.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e16f158..14674368 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1855,15 +1855,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -4750,11 +4741,11 @@ dependencies = [ [[package]] name = "multihash" -version = "0.19.3" +version = "0.19.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" +checksum = "89ace881e3f514092ce9efbcb8f413d0ad9763860b828981c2de51ddc666936c" dependencies = [ - "core2", + "no_std_io2", "serde", "unsigned-varint 0.8.0", ] @@ -4856,6 +4847,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no_std_io2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" +dependencies = [ + "memchr", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -6722,9 +6722,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", From 457555ab12ba2994ae4c757a82ea68688af1c035 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 16 Apr 2026 10:24:14 +0200 Subject: [PATCH 07/14] review corrections part 1 --- crates/cli/src/commands/create_dkg.rs | 157 ++++++++++++++++---------- 1 file changed, 95 insertions(+), 62 deletions(-) diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index 5fb679fb..70f5880f 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -9,7 +9,6 @@ use k256::{SecretKey, elliptic_curve::rand_core::OsRng}; use pluto_app::obolapi::{Client, ClientOptions}; use pluto_cluster::{ definition::{Creator, Definition}, - eip712sigs::{sign_cluster_definition_hash, sign_terms_and_conditions}, operator::Operator, }; use pluto_core::consensus::protocols::is_supported_protocol_name; @@ -37,84 +36,116 @@ const MIN_THRESHOLD: u64 = 2; long_about = "Create a cluster definition file that will be used by all participants of a DKG." )] pub struct CreateDkgArgs { - /// The folder to write the output cluster-definition.json file to. - #[arg(long, default_value = ".charon")] + #[arg( + long, + default_value = ".charon", + help = "The folder to write the output cluster-definition.json file to." + )] pub output_dir: PathBuf, - /// Optional cosmetic cluster name. - #[arg(long, default_value = "")] + #[arg(long, default_value = "", help = "Optional cosmetic cluster name")] pub name: String, - /// The number of distributed validators the cluster will manage (32ETH+ - /// staked for each). - #[arg(long, default_value_t = 1)] + #[arg( + long, + default_value_t = 1, + help = "The number of distributed validators the cluster will manage (32ETH+ staked for each)." + )] pub num_validators: u64, - /// Optional override of threshold required for signature reconstruction. - /// Defaults to ceil(n*2/3) if zero. Warning, non-default values - /// decrease security. - #[arg(long, short = 't', default_value_t = 0)] + #[arg( + long, + short = 't', + default_value_t = 0, + 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: u64, - /// Comma separated list of Ethereum addresses of the fee recipient for each - /// validator. Either provide a single fee recipient address or one per - /// validator. - #[arg(long, value_delimiter = ',')] + #[arg( + long, + 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_addresses: Vec, - /// Comma separated list of Ethereum addresses to receive the returned stake - /// and accrued rewards for each validator. Either provide a single - /// withdrawal address or one per validator. - #[arg(long, value_delimiter = ',')] + #[arg( + long, + 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_addresses: Vec, - /// Ethereum network to create validators for. - /// Options: mainnet, goerli, sepolia, hoodi, holesky, gnosis, chiado. - #[arg(long, default_value = DEFAULT_NETWORK)] + #[arg(long, default_value = DEFAULT_NETWORK, help = "Ethereum network to create validators for. Options: mainnet, goerli, sepolia, hoodi, holesky, gnosis, chiado.")] pub network: String, - /// DKG algorithm to use; default, frost. - #[arg(long = "dkg-algorithm", default_value = "default")] + #[arg( + long = "dkg-algorithm", + default_value = "default", + help = "DKG algorithm to use; default, frost" + )] pub dkg_algo: String, - /// List of partial deposit amounts (integers) in ETH. Values must sum up to - /// at least 32ETH. - #[arg(long, value_delimiter = ',')] + #[arg( + long, + value_delimiter = ',', + help = "List of partial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH." + )] pub deposit_amounts: Vec, - /// Comma-separated list of each operator's Charon ENR address. - #[arg(long, value_delimiter = ',')] + #[arg( + long, + value_delimiter = ',', + help = "Comma-separated list of each operator's Charon ENR address." + )] pub operator_enrs: Vec, - /// Preferred consensus protocol name for the cluster. Selected - /// automatically when not specified. - #[arg(long, default_value = "")] + #[arg( + long, + default_value = "", + help = "Preferred consensus protocol name for the cluster. Selected automatically when not specified." + )] pub consensus_protocol: String, - /// Preferred target gas limit for transactions. - #[arg(long, default_value_t = 60_000_000)] + #[arg( + long, + default_value_t = 60_000_000, + help = "Preferred target gas limit for transactions." + )] pub target_gas_limit: u64, - /// Enable compounding rewards for validators by using 0x02 withdrawal - /// credentials. - #[arg(long, default_value_t = false)] + #[arg( + long, + default_value_t = false, + help = "Enable compounding rewards for validators by using 0x02 withdrawal credentials." + )] pub compounding: bool, - /// The address of the execution engine JSON-RPC API. - #[arg(long = "execution-client-rpc-endpoint", default_value = "")] + #[arg( + long = "execution-client-rpc-endpoint", + default_value = "", + help = "The address of the execution engine JSON-RPC API." + )] pub execution_engine_addr: String, - /// Creates an invitation to the DKG ceremony on the DV Launchpad. - /// Terms and conditions apply. - #[arg(long, default_value_t = false)] + #[arg( + long, + default_value_t = false, + help = "Creates an invitation to the DKG ceremony on the DV Launchpad. Terms and conditions apply." + )] pub publish: bool, - /// The URL to publish the cluster to. - #[arg(long, default_value = "https://api.obol.tech/v1")] + #[arg( + long, + default_value = "https://api.obol.tech/v1", + help = "The URL to publish the cluster to." + )] pub publish_address: String, - /// Comma-separated list of each operator's Ethereum address. - #[arg(long, value_delimiter = ',')] + #[arg( + long, + value_delimiter = ',', + help = "Comma-separated list of each operator's Ethereum address." + )] pub operator_addresses: Vec, } @@ -137,9 +168,6 @@ pub enum CreateDkgError { source: pluto_eth2util::helpers::HelperError, }, - #[error("operator count overflow")] - OperatorCountOverflow, - #[error( "number of operators is below minimum: got {num_operators}, need at least {MIN_NODES} via --operator-enrs or --operator-addresses" )] @@ -166,11 +194,11 @@ pub enum CreateDkgError { #[error("threshold overflow")] ThresholdOverflow, - #[error("threshold must be greater than 1")] - ThresholdTooLow, + #[error("threshold must be greater than 1 (threshold={threshold}, min={min})")] + ThresholdTooLow { threshold: u64, min: u64 }, - #[error("threshold cannot be greater than number of operators")] - ThresholdTooHigh, + #[error("threshold ({threshold}) cannot be greater than number of operators ({num_enrs})")] + ThresholdTooHigh { threshold: u64, num_enrs: u64 }, #[error("cannot provide both --operator-enrs and --operator-addresses")] MutuallyExclusiveOperatorFlags, @@ -217,12 +245,17 @@ pub async fn run(args: CreateDkgArgs) -> crate::error::Result<()> { fn parse_args(args: CreateDkgArgs) -> Result { if args.threshold != 0 { if args.threshold < MIN_THRESHOLD { - return Err(CreateDkgError::ThresholdTooLow); + return Err(CreateDkgError::ThresholdTooLow { + threshold: args.threshold, + min: MIN_THRESHOLD, + }); } - let num_enrs = u64::try_from(args.operator_enrs.len()) - .map_err(|_| CreateDkgError::OperatorCountOverflow)?; + let num_enrs = args.operator_enrs.len() as u64; if args.threshold > num_enrs { - return Err(CreateDkgError::ThresholdTooHigh); + return Err(CreateDkgError::ThresholdTooHigh { + threshold: args.threshold, + num_enrs, + }); } } @@ -298,8 +331,7 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<(), CreateDkgError> { }); } - let num_operators = - u64::try_from(operators.len()).map_err(|_| CreateDkgError::OperatorCountOverflow)?; + let num_operators = operators.len() as u64; let safe_thresh = safe_threshold(num_operators)?; let threshold = if args.threshold == 0 { safe_thresh @@ -351,7 +383,8 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<(), CreateDkgError> { def.set_definition_hashes()?; if let Some(key) = &priv_key { - def.creator.config_signature = sign_cluster_definition_hash(key, &def)?; + def.creator.config_signature = + pluto_cluster::eip712sigs::sign_cluster_definition_hash(key, &def)?; } // Verify signatures when an ETH1 endpoint is available. Skipped when the @@ -551,7 +584,7 @@ async fn publish_partial_definition( .build(), )?; - let sig = sign_terms_and_conditions(&priv_key, &def)?; + let sig = pluto_cluster::eip712sigs::sign_terms_and_conditions(&priv_key, &def)?; api_client .sign_terms_and_conditions(&def.creator.address, &def.fork_version, &sig) From 1580d1040852638138bdd3bcbd0ab46eb67ae6e8 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 16 Apr 2026 10:46:17 +0200 Subject: [PATCH 08/14] loginfo version --- crates/cli/src/commands/create_dkg.rs | 2 +- crates/core/src/version.rs | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index 70f5880f..edfb73ca 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -303,7 +303,7 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<(), CreateDkgError> { validate_withdrawal_addrs(&withdrawal_addrs, &args.network)?; - info!("Pluto create DKG starting"); + pluto_core::version::log_info("Pluto create DKG starting"); let def_path = args.output_dir.join("cluster-definition.json"); if def_path.exists() { diff --git a/crates/core/src/version.rs b/crates/core/src/version.rs index 67891bca..fd4f439a 100644 --- a/crates/core/src/version.rs +++ b/crates/core/src/version.rs @@ -61,6 +61,17 @@ pub fn git_commit() -> (String, String) { (hash, timestamp) } +/// Logs pluto version information along with the provided message. +pub fn log_info(msg: &str) { + let (git_hash, git_timestamp) = git_commit(); + tracing::info!( + version = %*VERSION, + git_commit_hash = git_hash, + git_commit_time = git_timestamp, + "{msg}" + ); +} + /// Dependency list from build info in `name v{version}` format. pub fn dependencies() -> Vec { let mut deps: Vec = built_info::DEPENDENCIES From 84476db96de183d900eebf51cf13a9215b81e11a Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 16 Apr 2026 14:05:07 +0200 Subject: [PATCH 09/14] Added EthClient::Noop to match Charon's implementation --- crates/cli/src/commands/create_dkg.rs | 15 ++++++----- crates/dkg/src/disk.rs | 4 +-- crates/eth1wrap/src/lib.rs | 39 ++++++++++++++++++--------- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index edfb73ca..a45d8715 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -331,6 +331,8 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<(), CreateDkgError> { }); } + // Taking total number of operators, operator_enrs and operator_addresses are + // mutually exclusive so no if statement is needed. let num_operators = operators.len() as u64; let safe_thresh = safe_threshold(num_operators)?; let threshold = if args.threshold == 0 { @@ -387,10 +389,7 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<(), CreateDkgError> { pluto_cluster::eip712sigs::sign_cluster_definition_hash(key, &def)?; } - // Verify signatures when an ETH1 endpoint is available. Skipped when the - // endpoint is empty because the client cannot connect — safe for DKG create - // since operators have no signatures at this stage. - if !args.publish && !args.execution_engine_addr.is_empty() { + if !args.publish { let eth1 = pluto_eth1wrap::EthClient::new(&args.execution_engine_addr).await?; def.verify_signatures(ð1).await?; } @@ -541,6 +540,10 @@ fn is_main_or_gnosis(network: &str) -> bool { network == MAINNET.name || network == GNOSIS.name } +// Ports cluster.Threshold from charon (cluster/helpers.go), which computes +// ceil(2n/3) using math.Ceil(float64(2*n) / 3). The integer identity +// ceil(a/b) == (a + b - 1) / b gives ceil(2n/3) == (2n + 2) / 3, producing +// identical results for all n. fn safe_threshold(num_operators: u64) -> Result { let two_n = num_operators .checked_mul(2) @@ -752,12 +755,12 @@ mod tests { )] #[test_case( CreateDkgArgs { threshold: 1, ..default_args() }, - "Create DKG error: threshold must be greater than 1" ; + "Create DKG error: threshold must be greater than 1 (threshold=1, min=2)" ; "threshold_below_minimum" )] #[test_case( CreateDkgArgs { operator_enrs: VALID_ENRS[..3].iter().map(|s| s.to_string()).collect(), threshold: 4, ..default_args() }, - "Create DKG error: threshold cannot be greater than number of operators" ; + "Create DKG error: threshold (4) cannot be greater than number of operators (3)" ; "threshold_above_maximum" )] #[tokio::test] diff --git a/crates/dkg/src/disk.rs b/crates/dkg/src/disk.rs index ab5505ec..d1f9ef8f 100644 --- a/crates/dkg/src/disk.rs +++ b/crates/dkg/src/disk.rs @@ -551,8 +551,6 @@ mod tests { } async fn noop_eth1_client() -> pluto_eth1wrap::EthClient { - pluto_eth1wrap::EthClient::new("http://0.0.0.0:0") - .await - .unwrap() + pluto_eth1wrap::EthClient::new("").await.unwrap() } } diff --git a/crates/eth1wrap/src/lib.rs b/crates/eth1wrap/src/lib.rs index b09db412..2b179585 100644 --- a/crates/eth1wrap/src/lib.rs +++ b/crates/eth1wrap/src/lib.rs @@ -33,23 +33,32 @@ pub enum EthClientError { /// The Ethereum Address was invalid. #[error("Invalid address: {0}")] InvalidAddress(#[from] alloy::primitives::AddressError), + + /// No execution engine endpoint was configured. + #[error("execution engine endpoint is not set")] + NoExecutionEngineAddr, } /// Defines the interface for the Ethereum EL RPC client. -pub struct EthClient(DynProvider); - -impl std::ops::Deref for EthClient { - type Target = DynProvider; - - fn deref(&self) -> &DynProvider { - &self.0 - } +pub enum EthClient { + /// Connected client backed by a live provider. + Connected(DynProvider), + /// Noop client returned when no address is provided. Mirrors Go's + /// noopClient. + Noop, } impl EthClient { - /// Create a new `EthClient` connected to the given address using defaults - /// for retry. + /// Create a new `EthClient`. When `address` is empty a noop client is + /// returned that errors with [`EthClientError::NoExecutionEngineAddr`] + /// if `verify_smart_contract_based_signature` is ever called, matching + /// Go's `NewDefaultEthClientRunner("")` behaviour. pub async fn new(address: impl AsRef) -> Result { + let address = address.as_ref(); + if address.is_empty() { + return Ok(EthClient::Noop); + } + // The maximum number of retries for rate limit errors. const MAX_RETRY: u32 = 10; // The initial backoff in milliseconds. @@ -61,12 +70,12 @@ impl EthClient { let client = ClientBuilder::default() .layer(retry_layer) - .connect(address.as_ref()) + .connect(address) .await?; let provider = ProviderBuilder::new().connect_client(client); - Ok(EthClient(provider.erased())) + Ok(EthClient::Connected(provider.erased())) } /// Check if `sig` is a valid signature of `hash` according to ERC-1271. @@ -76,12 +85,16 @@ impl EthClient { hash: [u8; 32], sig: &[u8], ) -> Result { + let EthClient::Connected(provider) = self else { + return Err(EthClientError::NoExecutionEngineAddr); + }; + // Magic value defined in [ERC-1271](https://eips.ethereum.org/EIPS/eip-1271). const MAGIC_VALUE: [u8; 4] = [0x16, 0x26, 0xba, 0x7e]; let address = alloy::primitives::Address::parse_checksummed(contract_address, None)?; - let instance = IERC1271::new(address, &self.0); + let instance = IERC1271::new(address, provider); let call = instance .isValidSignature(hash.into(), sig.to_vec().into()) From 8ee19dfdcbba792495d84b80ef05cf4b44e4198a Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 16 Apr 2026 14:41:13 +0200 Subject: [PATCH 10/14] Validation address module --- crates/cli/src/commands/address_validation.rs | 78 +++++++++++++++++++ crates/cli/src/commands/create_cluster.rs | 48 +----------- crates/cli/src/commands/create_dkg.rs | 51 ++---------- crates/cli/src/commands/mod.rs | 1 + crates/cli/src/error.rs | 24 +----- 5 files changed, 88 insertions(+), 114 deletions(-) create mode 100644 crates/cli/src/commands/address_validation.rs diff --git a/crates/cli/src/commands/address_validation.rs b/crates/cli/src/commands/address_validation.rs new file mode 100644 index 00000000..e5ffea4a --- /dev/null +++ b/crates/cli/src/commands/address_validation.rs @@ -0,0 +1,78 @@ +#[derive(thiserror::Error, Debug)] +pub enum AddressValidationError { + /// Value exceeds usize::MAX. + #[error("Value {value} exceeds usize::MAX")] + ValueExceedsUsize { + /// The value that exceeds usize::MAX. + value: u64, + }, + + /// 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, + }, +} + +type Result = std::result::Result; + +/// 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. +pub(crate) 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(|_| AddressValidationError::ValueExceedsUsize { + value: num_validators, + })?; + + if fee_recipient_addrs.len() != num_validators_usize && fee_recipient_addrs.len() != 1 { + return Err(AddressValidationError::MismatchingFeeRecipientAddresses { + num_validators, + addresses: fee_recipient_addrs.len(), + }); + } + + if withdrawal_addrs.len() != num_validators_usize && withdrawal_addrs.len() != 1 { + return Err(AddressValidationError::MismatchingWithdrawalAddresses { + num_validators, + addresses: withdrawal_addrs.len(), + }); + } + + let mut fee_addrs = fee_recipient_addrs.to_vec(); + let mut withdraw_addrs = withdrawal_addrs.to_vec(); + + 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)) +} diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index f8ce0b81..1ea1e0ce 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -46,7 +46,7 @@ use rand::rngs::OsRng; use tracing::{debug, info, warn}; use crate::{ - commands::create_dkg, + commands::{address_validation::validate_addresses, create_dkg}, error::{ CliError, CreateClusterError, InvalidNetworkConfigError, Result as CliResult, ThresholdError, @@ -1208,52 +1208,6 @@ async fn load_definition( 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::ValueExceedsUsize { - 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(), - }); - } - - if withdrawal_addrs.len() != num_validators_usize && withdrawal_addrs.len() != 1 { - return Err(CreateClusterError::MismatchingWithdrawalAddresses { - num_validators, - addresses: withdrawal_addrs.len(), - }); - } - - 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 { diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index a45d8715..d874ff50 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -179,17 +179,8 @@ pub enum CreateDkgError { #[error("unsupported consensus protocol")] UnsupportedConsensusProtocol, - #[error("address count overflow")] - AddressCountOverflow, - - #[error("mismatching --num-validators and --fee-recipient-addresses")] - MismatchingFeeRecipientAddresses, - - #[error("mismatching --num-validators and --withdrawal-addresses")] - MismatchingWithdrawalAddresses, - - #[error("num_validators is greater than usize::MAX")] - NumValidatorsOverflow, + #[error(transparent)] + AddressValidation(#[from] super::address_validation::AddressValidationError), #[error("threshold overflow")] ThresholdOverflow, @@ -295,10 +286,10 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<(), CreateDkgError> { args.compounding, )?; - let (fee_recipient_addrs, withdrawal_addrs) = validate_addresses( + let (fee_recipient_addrs, withdrawal_addrs) = super::address_validation::validate_addresses( args.num_validators, - args.fee_recipient_addresses.clone(), - args.withdrawal_addresses.clone(), + &args.fee_recipient_addresses, + &args.withdrawal_addresses, )?; validate_withdrawal_addrs(&withdrawal_addrs, &args.network)?; @@ -441,38 +432,6 @@ fn validate_dkg_config( Ok(()) } -fn validate_addresses( - num_validators: u64, - fee_recipient_addrs: Vec, - withdrawal_addrs: Vec, -) -> Result<(Vec, Vec), CreateDkgError> { - let num_vals = num_validators; - let num_fee = u64::try_from(fee_recipient_addrs.len()) - .map_err(|_| CreateDkgError::AddressCountOverflow)?; - let num_wa = - u64::try_from(withdrawal_addrs.len()).map_err(|_| CreateDkgError::AddressCountOverflow)?; - - if num_fee != num_vals && num_fee != 1 { - return Err(CreateDkgError::MismatchingFeeRecipientAddresses); - } - - if num_wa != num_vals && num_wa != 1 { - return Err(CreateDkgError::MismatchingWithdrawalAddresses); - } - - let num_validators = - usize::try_from(num_validators).map_err(|_| CreateDkgError::NumValidatorsOverflow)?; - let expand = |addrs: Vec| -> Vec { - if addrs.len() == 1 { - vec![addrs[0].clone(); num_validators] - } else { - addrs - } - }; - - Ok((expand(fee_recipient_addrs), expand(withdrawal_addrs))) -} - /// Errors that can occur during withdrawal address validation. #[derive(Error, Debug)] pub enum WithdrawalValidationError { diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index a04b320d..9168cde0 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod address_validation; pub mod create_cluster; pub mod create_dkg; pub mod create_enr; diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index d4c219a7..3125d3d9 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -264,27 +264,9 @@ pub enum CreateClusterError { #[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, - }, + /// Address validation error. + #[error("Address validation error: {0}")] + AddressValidationError(#[from] crate::commands::address_validation::AddressValidationError), /// K1 error. #[error("K1 error: {0}")] From ebd9b79d8244ba0070c28f8e69fca5712deca1a4 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 16 Apr 2026 15:54:27 +0200 Subject: [PATCH 11/14] constants file --- crates/cli/src/commands/address_validation.rs | 2 +- crates/cli/src/commands/constants.rs | 4 ++++ crates/cli/src/commands/create_cluster.rs | 10 +++++----- crates/cli/src/commands/create_dkg.rs | 7 ++----- crates/cli/src/commands/mod.rs | 1 + crates/cli/src/error.rs | 2 +- 6 files changed, 14 insertions(+), 12 deletions(-) create mode 100644 crates/cli/src/commands/constants.rs diff --git a/crates/cli/src/commands/address_validation.rs b/crates/cli/src/commands/address_validation.rs index e5ffea4a..1aeb0b29 100644 --- a/crates/cli/src/commands/address_validation.rs +++ b/crates/cli/src/commands/address_validation.rs @@ -37,7 +37,7 @@ type Result = std::result::Result; /// /// Returns an error if the number of addresses doesn't match and isn't exactly /// 1. -pub(crate) fn validate_addresses( +pub(super) fn validate_addresses( num_validators: u64, fee_recipient_addrs: &[String], withdrawal_addrs: &[String], diff --git a/crates/cli/src/commands/constants.rs b/crates/cli/src/commands/constants.rs new file mode 100644 index 00000000..39059e45 --- /dev/null +++ b/crates/cli/src/commands/constants.rs @@ -0,0 +1,4 @@ +pub(super) const DEFAULT_NETWORK: &str = "mainnet"; +pub(super) const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; +pub(crate) const MIN_NODES: u64 = 3; +pub(crate) const MIN_THRESHOLD: u64 = 2; diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index 1ea1e0ce..77e104f5 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -46,17 +46,17 @@ use rand::rngs::OsRng; use tracing::{debug, info, warn}; use crate::{ - commands::{address_validation::validate_addresses, create_dkg}, + commands::{ + address_validation::validate_addresses, + constants::{MIN_NODES, MIN_THRESHOLD}, + create_dkg, + }, error::{ CliError, 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; /// HTTP scheme. const HTTP_SCHEME: &str = "http"; /// HTTPS scheme. diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index d874ff50..11a4ab26 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -24,10 +24,7 @@ use pluto_eth2util::{ use thiserror::Error; use tracing::{info, warn}; -const DEFAULT_NETWORK: &str = "mainnet"; -const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; -const MIN_NODES: usize = 3; -const MIN_THRESHOLD: u64 = 2; +use super::constants::{DEFAULT_NETWORK, MIN_NODES, MIN_THRESHOLD, ZERO_ADDRESS}; /// Arguments for the `pluto create dkg` command. #[derive(clap::Args)] @@ -412,7 +409,7 @@ fn validate_dkg_config( consensus_protocol: &str, compounding: bool, ) -> Result<(), CreateDkgError> { - if num_operators < MIN_NODES { + if (num_operators as u64) < MIN_NODES { return Err(CreateDkgError::TooFewOperators { num_operators }); } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 9168cde0..2a15037c 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod address_validation; +pub(crate) mod constants; pub mod create_cluster; pub mod create_dkg; pub mod create_enr; diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 3125d3d9..98657140 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; -use crate::commands::create_cluster::{MIN_NODES, MIN_THRESHOLD}; +use crate::commands::constants::{MIN_NODES, MIN_THRESHOLD}; /// Result type for CLI operations. pub type Result = std::result::Result; From ce8dfdf8d06123694366cc0d663d50fbb0a842ba Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 16 Apr 2026 16:05:33 +0200 Subject: [PATCH 12/14] threshold from helpers --- crates/cli/src/commands/create_dkg.rs | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index 11a4ab26..a79b06ef 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -179,9 +179,6 @@ pub enum CreateDkgError { #[error(transparent)] AddressValidation(#[from] super::address_validation::AddressValidationError), - #[error("threshold overflow")] - ThresholdOverflow, - #[error("threshold must be greater than 1 (threshold={threshold}, min={min})")] ThresholdTooLow { threshold: u64, min: u64 }, @@ -322,13 +319,13 @@ async fn run_create_dkg(mut args: CreateDkgArgs) -> Result<(), CreateDkgError> { // Taking total number of operators, operator_enrs and operator_addresses are // mutually exclusive so no if statement is needed. let num_operators = operators.len() as u64; - let safe_thresh = safe_threshold(num_operators)?; + let safe_threshold = pluto_cluster::helpers::threshold(num_operators); let threshold = if args.threshold == 0 { - safe_thresh + safe_threshold } else { warn!( threshold = args.threshold, - safe_threshold = safe_thresh, + safe_threshold = safe_threshold, "Non standard `--threshold` flag provided, this will affect cluster safety" ); args.threshold @@ -496,20 +493,6 @@ fn is_main_or_gnosis(network: &str) -> bool { network == MAINNET.name || network == GNOSIS.name } -// Ports cluster.Threshold from charon (cluster/helpers.go), which computes -// ceil(2n/3) using math.Ceil(float64(2*n) / 3). The integer identity -// ceil(a/b) == (a + b - 1) / b gives ceil(2n/3) == (2n + 2) / 3, producing -// identical results for all n. -fn safe_threshold(num_operators: u64) -> Result { - let two_n = num_operators - .checked_mul(2) - .ok_or(CreateDkgError::ThresholdOverflow)?; - Ok(two_n - .checked_add(2) - .ok_or(CreateDkgError::ThresholdOverflow)? - / 3) -} - fn generate_launchpad_link(config_hash: &[u8], network: &str) -> String { let network_prefix = if network == HOLESKY.name || network == HOODI.name || network == SEPOLIA.name { From 874eb532149b7d6ffda369812048a331f94491ce Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 16 Apr 2026 16:08:16 +0200 Subject: [PATCH 13/14] removed holesky from create_dkg.rs --- crates/cli/src/commands/create_dkg.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index a79b06ef..9a757a78 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -17,7 +17,7 @@ use pluto_eth2util::{ enr::Record, helpers::{checksum_address, public_key_to_address}, network::{ - GNOSIS, GOERLI, HOLESKY, HOODI, MAINNET, PRATER, SEPOLIA, network_to_fork_version, + GNOSIS, GOERLI, HOODI, MAINNET, PRATER, SEPOLIA, network_to_fork_version, valid_network, }, }; @@ -72,7 +72,7 @@ pub struct CreateDkgArgs { )] pub withdrawal_addresses: Vec, - #[arg(long, default_value = DEFAULT_NETWORK, help = "Ethereum network to create validators for. Options: mainnet, goerli, sepolia, hoodi, holesky, gnosis, chiado.")] + #[arg(long, default_value = DEFAULT_NETWORK, help = "Ethereum network to create validators for. Options: mainnet, goerli, sepolia, hoodi, gnosis, chiado.")] pub network: String, #[arg( @@ -495,7 +495,7 @@ fn is_main_or_gnosis(network: &str) -> bool { fn generate_launchpad_link(config_hash: &[u8], network: &str) -> String { let network_prefix = - if network == HOLESKY.name || network == HOODI.name || network == SEPOLIA.name { + if network == HOODI.name || network == SEPOLIA.name { format!("{network}.") } else { String::new() @@ -795,7 +795,6 @@ mod tests { } #[test_case("mainnet", b"123abc", "https://launchpad.obol.org/dv#0x313233616263" ; "mainnet")] - #[test_case("holesky", b"123abc", "https://holesky.launchpad.obol.org/dv#0x313233616263" ; "holesky")] #[test_case("hoodi", b"123abc", "https://hoodi.launchpad.obol.org/dv#0x313233616263" ; "hoodi")] #[test_case("sepolia", b"123abc", "https://sepolia.launchpad.obol.org/dv#0x313233616263" ; "sepolia")] #[test_case("testnet-1", b"123abc", "https://launchpad.obol.org/dv#0x313233616263" ; "unknown_network")] From 075af28f62293a14b468387431e65b4f30b61cd7 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Thu, 16 Apr 2026 16:09:59 +0200 Subject: [PATCH 14/14] cont. --- crates/cli/src/commands/create_dkg.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs index 9a757a78..01246d26 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -17,8 +17,7 @@ use pluto_eth2util::{ enr::Record, helpers::{checksum_address, public_key_to_address}, network::{ - GNOSIS, GOERLI, HOODI, MAINNET, PRATER, SEPOLIA, network_to_fork_version, - valid_network, + GNOSIS, GOERLI, HOODI, MAINNET, PRATER, SEPOLIA, network_to_fork_version, valid_network, }, }; use thiserror::Error; @@ -494,12 +493,11 @@ fn is_main_or_gnosis(network: &str) -> bool { } fn generate_launchpad_link(config_hash: &[u8], network: &str) -> String { - let network_prefix = - if network == HOODI.name || network == SEPOLIA.name { - format!("{network}.") - } else { - String::new() - }; + let network_prefix = if network == HOODI.name || network == SEPOLIA.name { + format!("{network}.") + } else { + String::new() + }; format!( "https://{}launchpad.obol.org/dv#0x{}", network_prefix,