diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 0c715593..9309bad8 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -18,13 +18,13 @@ hex.workspace = true humantime.workspace = true tokio.workspace = true pluto-app.workspace = true +pluto-eth1wrap.workspace = true pluto-cluster.workspace = true pluto-crypto.workspace = true pluto-relay-server.workspace = true pluto-tracing.workspace = true pluto-core.workspace = true pluto-p2p.workspace = true -pluto-eth1wrap.workspace = true pluto-eth2api.workspace = true pluto-eth2util.workspace = true pluto-k1util.workspace = true diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index fd7694f7..4084823b 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -4,6 +4,7 @@ use clap::{Parser, Subcommand}; use crate::commands::{ create_cluster::CreateClusterArgs, + create_dkg::CreateDkgArgs, create_enr::CreateEnrArgs, enr::EnrArgs, relay::RelayArgs, @@ -133,6 +134,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/address_validation.rs b/crates/cli/src/commands/address_validation.rs new file mode 100644 index 00000000..1aeb0b29 --- /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(super) 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/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 94adbe91..77e104f5 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -46,19 +46,17 @@ use rand::rngs::OsRng; use tracing::{debug, info, warn}; use crate::{ - commands::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; -/// Zero ethereum address (not allowed on mainnet/gnosis). -pub const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; /// HTTP scheme. const HTTP_SCHEME: &str = "http"; /// HTTPS scheme. @@ -1210,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 d8419e17..01246d26 100644 --- a/crates/cli/src/commands/create_dkg.rs +++ b/crates/cli/src/commands/create_dkg.rs @@ -1,32 +1,451 @@ -//! Create DKG command utilities. +//! Create DKG command implementation. //! -//! This module provides utilities for the `pluto create dkg` command, -//! including validation functions for withdrawal addresses. +//! This module implements the `pluto create dkg` command, which creates the +//! configuration for a new Distributed Key Generation ceremony. -use pluto_eth2util::{self as eth2util}; +use std::path::PathBuf; + +use k256::{SecretKey, elliptic_curve::rand_core::OsRng}; +use pluto_app::obolapi::{Client, ClientOptions}; +use pluto_cluster::{ + definition::{Creator, Definition}, + 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::{ + GNOSIS, GOERLI, HOODI, MAINNET, PRATER, SEPOLIA, network_to_fork_version, valid_network, + }, +}; use thiserror::Error; +use tracing::{info, warn}; + +use super::constants::{DEFAULT_NETWORK, MIN_NODES, MIN_THRESHOLD, ZERO_ADDRESS}; + +/// 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 { + #[arg( + long, + default_value = ".charon", + help = "The folder to write the output cluster-definition.json file to." + )] + pub output_dir: PathBuf, + + #[arg(long, default_value = "", help = "Optional cosmetic cluster name")] + pub name: String, + + #[arg( + long, + default_value_t = 1, + help = "The number of distributed validators the cluster will manage (32ETH+ staked for each)." + )] + pub num_validators: u64, + + #[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, + + #[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, + + #[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, + + #[arg(long, default_value = DEFAULT_NETWORK, help = "Ethereum network to create validators for. Options: mainnet, goerli, sepolia, hoodi, gnosis, chiado.")] + pub network: String, + + #[arg( + long = "dkg-algorithm", + default_value = "default", + help = "DKG algorithm to use; default, frost" + )] + pub dkg_algo: String, + + #[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, + + #[arg( + long, + value_delimiter = ',', + help = "Comma-separated list of each operator's Charon ENR address." + )] + pub operator_enrs: Vec, + + #[arg( + long, + default_value = "", + help = "Preferred consensus protocol name for the cluster. Selected automatically when not specified." + )] + pub consensus_protocol: String, + + #[arg( + long, + default_value_t = 60_000_000, + help = "Preferred target gas limit for transactions." + )] + pub target_gas_limit: u64, + + #[arg( + long, + default_value_t = false, + help = "Enable compounding rewards for validators by using 0x02 withdrawal credentials." + )] + pub compounding: bool, + + #[arg( + long = "execution-client-rpc-endpoint", + default_value = "", + help = "The address of the execution engine JSON-RPC API." + )] + pub execution_engine_addr: String, + + #[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, + + #[arg( + long, + default_value = "https://api.obol.tech/v1", + help = "The URL to publish the cluster to." + )] + pub publish_address: String, + + #[arg( + long, + value_delimiter = ',', + help = "Comma-separated list of each operator's Ethereum address." + )] + 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( + "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(transparent)] + AddressValidation(#[from] super::address_validation::AddressValidationError), + + #[error("threshold must be greater than 1 (threshold={threshold}, min={min})")] + ThresholdTooLow { threshold: u64, min: u64 }, + + #[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, + + #[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) -> crate::error::Result<()> { + Ok(run_create_dkg(parse_args(args)?).await?) +} + +fn parse_args(args: CreateDkgArgs) -> Result { + if args.threshold != 0 { + if args.threshold < MIN_THRESHOLD { + return Err(CreateDkgError::ThresholdTooLow { + threshold: args.threshold, + min: MIN_THRESHOLD, + }); + } + let num_enrs = args.operator_enrs.len() as u64; + if args.threshold > num_enrs { + return Err(CreateDkgError::ThresholdTooHigh { + threshold: args.threshold, + num_enrs, + }); + } + } + + if !args.operator_enrs.is_empty() && !args.operator_addresses.is_empty() { + return Err(CreateDkgError::MutuallyExclusiveOperatorFlags); + } + + if args.publish { + if args.operator_enrs.is_empty() && args.operator_addresses.is_empty() { + return Err(CreateDkgError::MissingOperatorEnrsOrAddresses); + } + } else if args.operator_enrs.is_empty() { + return Err(CreateDkgError::MissingOperatorEnrs); + } + + Ok(args) +} + +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 { + args.network = GOERLI.name.to_string(); + } + + let operators_len = if args.operator_enrs.is_empty() { + args.operator_addresses.len() + } else { + args.operator_enrs.len() + }; + + validate_dkg_config( + operators_len, + &args.network, + &args.deposit_amounts, + &args.consensus_protocol, + args.compounding, + )?; + + let (fee_recipient_addrs, withdrawal_addrs) = super::address_validation::validate_addresses( + args.num_validators, + &args.fee_recipient_addresses, + &args.withdrawal_addresses, + )?; + + validate_withdrawal_addrs(&withdrawal_addrs, &args.network)?; + + pluto_core::version::log_info("Pluto create DKG starting"); + + let def_path = args.output_dir.join("cluster-definition.json"); + if def_path.exists() { + 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(|source| CreateDkgError::InvalidEnr { index: i, source })?; + + 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(|source| CreateDkgError::InvalidOperatorAddress { index: i, source })?; + operators.push(Operator { + address: checksum_addr, + ..Default::default() + }); + } + + // 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_threshold = pluto_cluster::helpers::threshold(num_operators); + let threshold = if args.threshold == 0 { + safe_threshold + } else { + warn!( + threshold = args.threshold, + safe_threshold = safe_threshold, + "Non standard `--threshold` flag provided, this will affect cluster safety" + ); + args.threshold + }; + + let fork_version_hex = network_to_fork_version(&args.network)?; -use crate::commands::create_cluster::{ZERO_ADDRESS, is_main_or_gnosis}; + 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()); + ( + 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![], + )?; + + def.dkg_algorithm = args.dkg_algo.clone(); + def.set_definition_hashes()?; + + if let Some(key) = &priv_key { + def.creator.config_signature = + pluto_cluster::eip712sigs::sign_cluster_definition_hash(key, &def)?; + } + + if !args.publish { + 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; + + info!("Cluster definition created: {}", def_path.display()); + + Ok(()) +} + +fn validate_dkg_config( + num_operators: usize, + network: &str, + deposit_amounts: &[u64], + consensus_protocol: &str, + compounding: bool, +) -> Result<(), CreateDkgError> { + if (num_operators as u64) < MIN_NODES { + return Err(CreateDkgError::TooFewOperators { num_operators }); + } + + if !valid_network(network) { + return Err(CreateDkgError::UnsupportedNetwork); + } + + 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(CreateDkgError::UnsupportedConsensusProtocol); + } + + Ok(()) +} /// Errors that can occur during withdrawal address validation. #[derive(Error, Debug)] pub enum WithdrawalValidationError { /// Invalid withdrawal address. - #[error("Invalid withdrawal address: {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}")] + #[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}")] + #[error("zero address forbidden on this network: {network}")] ZeroAddressForbiddenOnNetwork { /// The network name. network: String, @@ -34,7 +453,7 @@ pub enum WithdrawalValidationError { /// Eth2util helpers error. #[error("Eth2util helpers error: {0}")] - Eth2utilHelperError(#[from] eth2util::helpers::HelperError), + Eth2utilHelperError(#[from] pluto_eth2util::helpers::HelperError), } /// Validates withdrawal addresses for the given network. @@ -43,11 +462,12 @@ pub enum WithdrawalValidationError { pub fn validate_withdrawal_addrs( addrs: &[String], network: &str, -) -> std::result::Result<(), WithdrawalValidationError> { +) -> Result<(), WithdrawalValidationError> { for addr in addrs { - let checksum_addr = eth2util::helpers::checksum_address(addr).map_err(|_| { + let checksum_addr = checksum_address(addr).map_err(|e| { WithdrawalValidationError::InvalidWithdrawalAddress { address: addr.clone(), + reason: e.to_string(), } })?; @@ -67,3 +487,338 @@ pub fn validate_withdrawal_addrs( Ok(()) } + +fn is_main_or_gnosis(network: &str) -> bool { + network == MAINNET.name || network == GNOSIS.name +} + +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() + }; + 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<(), CreateDkgError> { + let api_client = Client::new( + &args.publish_address, + ClientOptions::builder() + .timeout(std::time::Duration::from_secs(10)) + .build(), + )?; + + 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) + .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 (operator 0): 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 (operator 0): 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 (operator 0): 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 (operator 0): 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: got 1, need at least 3 via --operator-enrs or --operator-addresses" ; + "single_empty_enr" + )] + #[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" + )] + #[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_case( + CreateDkgArgs { operator_enrs: vec![], operator_addresses: vec![], publish: false, ..default_args() }, + r#"Create DKG error: required flag(s) "operator-enrs" not set"# ; + "no_enrs" + )] + #[test_case( + CreateDkgArgs { threshold: 1, ..default_args() }, + "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 (4) cannot be greater than number of operators (3)" ; + "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_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()); + } + + #[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_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_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(expected_err)); + } + + #[test_case("mainnet", b"123abc", "https://launchpad.obol.org/dv#0x313233616263" ; "mainnet")] + #[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 a04b320d..2a15037c 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,3 +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 d59ac1b9..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; @@ -98,6 +98,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), @@ -260,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}")] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 65551bc0..18e9e3eb 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -44,6 +44,7 @@ async fn run() -> std::result::Result<(), CliError> { 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), CreateCommands::Cluster(args) => { let mut stdout = std::io::stdout(); 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 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())