diff --git a/.claude/skills/rust-style/SKILL.md b/.claude/skills/rust-style/SKILL.md index 8f14ad36..165eccab 100644 --- a/.claude/skills/rust-style/SKILL.md +++ b/.claude/skills/rust-style/SKILL.md @@ -61,16 +61,25 @@ let x = a.checked_add(b).ok_or(Error::Overflow)?; ## Casts -No lossy or unchecked casts — use fallible conversions: +**Never use `as` for numeric type conversions** — use fallible conversions with `try_from`: ```rust -// Bad +// Bad - will cause clippy errors let x = value as u32; +let y = some_usize as u64; -// Good +// Good - use try_from with proper error handling let x = u32::try_from(value)?; +let y = u64::try_from(some_usize).expect("message explaining why this is safe"); ``` +Rules: + +- Always use `TryFrom`/`try_from` for numeric conversions between different types +- Handle conversion failures explicitly (either with `?` or `expect` with justification) +- The only acceptable use of `expect` is when the conversion is guaranteed to succeed (e.g., `usize` to `u64` on 64-bit platforms) +- Clippy will error on unchecked `as` casts: `cast_possible_truncation`, `cast_possible_wrap`, `cast_sign_loss` + --- ## Async / Tokio diff --git a/Cargo.lock b/Cargo.lock index a450ceff..bddb3262 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5447,7 +5447,9 @@ name = "pluto-cli" version = "1.7.1" dependencies = [ "backon", + "chrono", "clap", + "flate2", "hex", "humantime", "k256", @@ -5455,6 +5457,9 @@ dependencies = [ "pluto-app", "pluto-cluster", "pluto-core", + "pluto-crypto", + "pluto-eth1wrap", + "pluto-eth2api", "pluto-eth2util", "pluto-k1util", "pluto-p2p", @@ -5466,12 +5471,15 @@ dependencies = [ "serde", "serde_json", "serde_with", + "tar", "tempfile", "test-case", "thiserror 2.0.18", "tokio", "tokio-util", "tracing", + "url", + "uuid", ] [[package]] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 13c7c012..6b611b89 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -19,10 +19,13 @@ humantime.workspace = true tokio.workspace = true pluto-app.workspace = true pluto-cluster.workspace = true +pluto-crypto.workspace = true pluto-relay-server.workspace = true pluto-tracing.workspace = true pluto-core.workspace = true pluto-p2p.workspace = true +pluto-eth1wrap.workspace = true +pluto-eth2api.workspace = true pluto-eth2util.workspace = true pluto-k1util.workspace = true pluto-ssz.workspace = true @@ -35,6 +38,11 @@ serde_with = { workspace = true, features = ["base64"] } rand.workspace = true tempfile.workspace = true reqwest.workspace = true +url.workspace = true +chrono.workspace = true +uuid.workspace = true +flate2.workspace = true +tar.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 5c4956f0..fd7694f7 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -3,6 +3,7 @@ use clap::{Parser, Subcommand}; use crate::commands::{ + create_cluster::CreateClusterArgs, create_enr::CreateEnrArgs, enr::EnrArgs, relay::RelayArgs, @@ -40,7 +41,7 @@ pub enum Commands { about = "Create artifacts for a distributed validator cluster", long_about = "Create artifacts for a distributed validator cluster. These commands can be used to facilitate the creation of a distributed validator cluster between a group of operators by performing a distributed key generation ceremony, or they can be used to create a local cluster for single operator use cases." )] - Create(CreateArgs), + Create(Box), #[command(about = "Print version and exit", long_about = "Output version info")] Version(VersionArgs), @@ -135,4 +136,10 @@ pub enum CreateCommands { /// Create an Ethereum Node Record (ENR) private key to identify this charon /// client Enr(CreateEnrArgs), + + #[command( + about = "Create private keys and configuration files needed to run a distributed validator cluster locally", + long_about = "Creates a local charon cluster configuration including validator keys, charon p2p keys, cluster-lock.json and deposit-data.json file(s). See flags for supported features." + )] + Cluster(Box), } diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs new file mode 100644 index 00000000..90b221bb --- /dev/null +++ b/crates/cli/src/commands/create_cluster.rs @@ -0,0 +1,1481 @@ +//! Create cluster command implementation. +//! +//! This module implements the `pluto create cluster` command, which creates a +//! local distributed validator cluster configuration including validator keys, +//! threshold BLS key shares, p2p private keys, cluster-lock files, and deposit +//! data files. + +use std::{ + collections::HashMap, + io::Write, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, +}; + +use chrono::Utc; +use k256::SecretKey; +use pluto_cluster::{ + definition::Definition, + deposit::DepositData, + distvalidator::DistValidator, + helpers::{create_validator_keys_dir, fetch_definition}, + lock::Lock, + operator::Operator, + registration::{BuilderRegistration, Registration}, +}; +use pluto_core::consensus::protocols; +use pluto_crypto::{ + blst_impl::BlstImpl, + tbls::Tbls, + types::{PrivateKey, PublicKey}, +}; +use pluto_eth1wrap as eth1wrap; + +use pluto_app::{obolapi, utils as app_utils}; +use pluto_eth2util::{ + self as eth2util, + deposit::{self, Gwei}, + enr::Record, + keymanager, + keystore::{ + CONFIRM_INSECURE_KEYS, Keystore, encrypt as keystore_encrypt, load_files_recursively, + load_files_unordered, store_keys, store_keys_insecure, + }, + network, registration as eth2util_registration, +}; +use pluto_p2p::k1::new_saved_priv_key; +use pluto_ssz::to_0x_hex; +use rand::rngs::OsRng; +use tracing::{debug, info, warn}; + +use crate::{ + commands::create_dkg::validate_withdrawal_addrs, + error::{CreateClusterError, InvalidNetworkConfigError, Result as CliResult, ThresholdError}, +}; + +/// Minimum number of nodes required in a cluster. +pub const MIN_NODES: u64 = 3; +/// Minimum threshold value. +pub const MIN_THRESHOLD: u64 = 2; +/// Zero ethereum address (not allowed on mainnet/gnosis). +pub const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; +/// HTTP scheme. +const HTTP_SCHEME: &str = "http"; +/// HTTPS scheme. +const HTTPS_SCHEME: &str = "https"; + +type Result = std::result::Result; + +/// Ethereum network options. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +#[value(rename_all = "lowercase")] +pub enum Network { + /// Ethereum mainnet + #[default] + Mainnet, + /// Prater testnet (alias for Goerli) + Prater, + /// Goerli testnet + Goerli, + /// Sepolia testnet + Sepolia, + /// Hoodi testnet + Hoodi, + /// Holesky testnet + Holesky, + /// Gnosis chain + Gnosis, + /// Chiado testnet + Chiado, +} + +impl Network { + /// Returns the canonical network name. + pub fn as_str(&self) -> &'static str { + match self { + Network::Mainnet => "mainnet", + Network::Goerli | Network::Prater => "goerli", + Network::Sepolia => "sepolia", + Network::Hoodi => "hoodi", + Network::Holesky => "holesky", + Network::Gnosis => "gnosis", + Network::Chiado => "chiado", + } + } +} + +impl TryFrom<&str> for Network { + type Error = InvalidNetworkConfigError; + + fn try_from(value: &str) -> std::result::Result { + match value { + "mainnet" => Ok(Network::Mainnet), + "prater" => Ok(Network::Prater), + "goerli" => Ok(Network::Goerli), + "sepolia" => Ok(Network::Sepolia), + "hoodi" => Ok(Network::Hoodi), + "holesky" => Ok(Network::Holesky), + "gnosis" => Ok(Network::Gnosis), + "chiado" => Ok(Network::Chiado), + _ => Err(InvalidNetworkConfigError::InvalidNetworkSpecified { + network: value.to_string(), + }), + } + } +} + +impl std::fmt::Display for Network { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Custom testnet configuration. +#[derive(Debug, Clone, Default, clap::Args)] +pub struct TestnetConfig { + /// Chain ID of the custom test network + #[arg( + long = "testnet-chain-id", + help = "Chain ID of the custom test network." + )] + pub chain_id: Option, + + /// Genesis fork version of the custom test network (in hex) + #[arg( + long = "testnet-fork-version", + help = "Genesis fork version of the custom test network (in hex)." + )] + pub fork_version: Option, + + /// Genesis timestamp of the custom test network + #[arg( + long = "testnet-genesis-timestamp", + help = "Genesis timestamp of the custom test network." + )] + pub genesis_timestamp: Option, + + /// Name of the custom test network + #[arg(long = "testnet-name", help = "Name of the custom test network.")] + pub testnet_name: Option, +} + +impl TestnetConfig { + pub fn is_empty(&self) -> bool { + self.testnet_name.is_none() + && self.fork_version.is_none() + && self.chain_id.is_none() + && self.genesis_timestamp.is_none() + } +} + +/// Arguments for the create cluster command +#[derive(clap::Args)] +pub struct CreateClusterArgs { + /// The target folder to create the cluster in. + #[arg( + long = "cluster-dir", + default_value = "./", + help = "The target folder to create the cluster in." + )] + pub cluster_dir: PathBuf, + + /// Enable compounding rewards for validators + #[arg( + long = "compounding", + help = "Enable compounding rewards for validators by using 0x02 withdrawal credentials." + )] + pub compounding: bool, + + /// Preferred consensus protocol name for the cluster + #[arg( + long = "consensus-protocol", + help = "Preferred consensus protocol name for the cluster. Selected automatically when not specified." + )] + pub consensus_protocol: Option, + + /// Path to a cluster definition file or HTTP URL + #[arg( + long = "definition-file", + help = "Optional path to a cluster definition file or an HTTP URL. This overrides all other configuration flags." + )] + pub definition_file: Option, + + /// List of partial deposit amounts (integers) in ETH + #[arg( + long = "deposit-amounts", + value_delimiter = ',', + help = "List of partial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH." + )] + pub deposit_amounts: Vec, + + /// The address of the execution engine JSON-RPC API + #[arg( + long = "execution-client-rpc-endpoint", + help = "The address of the execution engine JSON-RPC API." + )] + pub execution_engine_addr: Option, + + /// Comma separated list of fee recipient addresses + #[arg( + long = "fee-recipient-addresses", + value_delimiter = ',', + help = "Comma separated list of Ethereum addresses of the fee recipient for each validator. Either provide a single fee recipient address or fee recipient addresses for each validator." + )] + pub fee_recipient_addrs: Vec, + + /// Generates insecure keystore files (testing only) + #[arg( + long = "insecure-keys", + help = "Generates insecure keystore files. This should never be used. It is not supported on mainnet." + )] + pub insecure_keys: bool, + + /// Comma separated list of keymanager URLs + #[arg( + long = "keymanager-addresses", + value_delimiter = ',', + help = "Comma separated list of keymanager URLs to import validator key shares to. Note that multiple addresses are required, one for each node in the cluster." + )] + pub keymanager_addrs: Vec, + + /// Authentication bearer tokens for keymanager URLs + #[arg( + long = "keymanager-auth-tokens", + value_delimiter = ',', + help = "Authentication bearer tokens to interact with the keymanager URLs. Don't include the \"Bearer\" symbol, only include the api-token." + )] + pub keymanager_auth_tokens: Vec, + + /// The cluster name + #[arg(long = "name")] + pub name: Option, + + /// Ethereum network to create validators for + #[arg(long = "network", help = "Ethereum network to create validators for.")] + pub network: Option, + + /// The number of charon nodes in the cluster + #[arg( + long = "nodes", + help = "The number of charon nodes in the cluster. Minimum is 3." + )] + pub nodes: Option, + + /// The number of distributed validators needed in the cluster + #[arg( + long = "num-validators", + help = "The number of distributed validators needed in the cluster." + )] + pub num_validators: Option, + + /// Publish lock file to obol-api + #[arg(long = "publish", help = "Publish lock file to obol-api.")] + pub publish: bool, + + /// The URL to publish the lock file to + #[arg( + long = "publish-address", + default_value = "https://api.obol.tech/v1", + help = "The URL to publish the lock file to." + )] + pub publish_address: String, + + /// Split an existing validator's private key + #[arg( + long = "split-existing-keys", + help = "Split an existing validator's private key into a set of distributed validator private key shares. Does not re-create deposit data for this key." + )] + pub split_keys: bool, + + /// Directory containing keys to split + #[arg( + long = "split-keys-dir", + help = "Directory containing keys to split. Expects keys in keystore-*.json and passwords in keystore-*.txt. Requires --split-existing-keys." + )] + pub split_keys_dir: Option, + + /// Preferred target gas limit for transactions + #[arg( + long = "target-gas-limit", + default_value = "60000000", + help = "Preferred target gas limit for transactions." + )] + pub target_gas_limit: u64, + + /// Custom testnet configuration + #[command(flatten)] + pub testnet_config: TestnetConfig, + + /// Optional override of threshold + #[arg( + long = "threshold", + help = "Optional override of threshold required for signature reconstruction. Defaults to ceil(n*2/3) if zero. Warning, non-default values decrease security." + )] + pub threshold: Option, + + /// Comma separated list of withdrawal addresses + #[arg( + long = "withdrawal-addresses", + value_delimiter = ',', + help = "Comma separated list of Ethereum addresses to receive the returned stake and accrued rewards for each validator. Either provide a single withdrawal address or withdrawal addresses for each validator." + )] + pub withdrawal_addrs: Vec, + + /// Create a tar archive compressed with gzip + #[arg( + long = "zipped", + help = "Create a tar archive compressed with gzip of the cluster directory after creation." + )] + pub zipped: bool, +} + +impl From for network::Network { + fn from(config: TestnetConfig) -> Self { + network::Network { + chain_id: config.chain_id.unwrap_or(0), + name: Box::leak( + config + .testnet_name + .as_ref() + .unwrap_or(&String::new()) + .clone() + .into_boxed_str(), + ), + genesis_fork_version_hex: Box::leak( + config + .fork_version + .as_ref() + .unwrap_or(&String::new()) + .clone() + .into_boxed_str(), + ), + genesis_timestamp: config.genesis_timestamp.unwrap_or(0), + capella_hard_fork: "", + } + } +} + +fn validate_threshold(args: &CreateClusterArgs) -> Result<()> { + let Some(threshold) = args.threshold else { + return Ok(()); + }; + + if threshold < MIN_THRESHOLD { + return Err(ThresholdError::ThresholdTooLow { threshold }.into()); + } + + let number_of_nodes = args.nodes.unwrap_or(0); + if threshold > number_of_nodes { + return Err(ThresholdError::ThresholdTooHigh { + threshold, + number_of_nodes, + } + .into()); + } + + Ok(()) +} + +/// Runs the create cluster command +pub async fn run(w: &mut dyn Write, mut args: CreateClusterArgs) -> CliResult<()> { + validate_threshold(&args)?; + + validate_create_config(&args)?; + + let mut secrets: Vec = Vec::new(); + + // If we're splitting keys, read them from `split_keys_dir` and set + // args.num_validators to the amount of secrets we read. + // If `split_keys` wasn't set, we wouldn't have reached this part of code + // because `validate_create_config()` would've already errored. + if args.split_keys { + let use_sequence_keys = args.withdrawal_addrs.len() > 1; + + let Some(split_keys_dir) = &args.split_keys_dir else { + return Err(CreateClusterError::MissingSplitKeysDir.into()); + }; + + secrets = get_keys(&split_keys_dir, use_sequence_keys).await?; + + debug!( + "Read {} secrets from {}", + secrets.len(), + split_keys_dir.display() + ); + + // Needed if --split-existing-keys is called without a definition file. + // It's safe to unwrap here because we know the length is less than u64::MAX. + args.num_validators = + Some(u64::try_from(secrets.len()).expect("secrets length is too large")); + } + + // Get a cluster definition, either from a definition file or from the config. + let definition_file = args.definition_file.clone(); + let (mut def, mut deposit_amounts) = if let Some(definition_file) = definition_file { + let Some(addr) = args.execution_engine_addr.clone() else { + return Err(CreateClusterError::MissingExecutionEngineAddress.into()); + }; + + let eth1cl = eth1wrap::EthClient::new(addr).await?; + + let def = load_definition(&definition_file, ð1cl).await?; + + // Should not happen, if it does - it won't affect the runtime, because the + // validation will fail. + args.nodes = + Some(u64::try_from(def.operators.len()).expect("operators length is too large")); + args.threshold = Some(def.threshold); + + validate_definition(&def, args.insecure_keys, &args.keymanager_addrs, ð1cl).await?; + + let network = eth2util::network::fork_version_to_network(&def.fork_version)?; + + args.network = Some( + Network::try_from(network.as_str()) + .map_err(CreateClusterError::InvalidNetworkConfig)?, + ); + + let deposit_amounts = def.deposit_amounts.clone(); + + (def, deposit_amounts) + } else { + // Create new definition from cluster config + let def = new_def_from_config(&args).await?; + + let deposit_amounts = deposit::eths_to_gweis(&args.deposit_amounts); + + (def, deposit_amounts) + }; + + if deposit_amounts.is_empty() { + deposit_amounts = deposit::default_deposit_amounts(args.compounding); + } + + if secrets.is_empty() { + // This is the case in which split-keys is undefined and user passed validator + // amount on CLI + secrets = generate_keys(def.num_validators)?; + } + + let num_validators_usize = + usize::try_from(def.num_validators).map_err(|_| CreateClusterError::ValueExceedsU8 { + value: def.num_validators, + })?; + + if secrets.len() != num_validators_usize { + return Err(CreateClusterError::KeyCountMismatch { + disk_keys: secrets.len(), + definition_keys: def.num_validators, + } + .into()); + } + + let num_nodes = u64::try_from(def.operators.len()).expect("operators length is too large"); + + // Generate threshold bls key shares + + let (pub_keys, share_sets) = get_tss_shares(&secrets, def.threshold, num_nodes)?; + + // Create cluster directory at the given location + tokio::fs::create_dir_all(&args.cluster_dir).await?; + + // Set directory permissions to 0o755 + let permissions = std::fs::Permissions::from_mode(0o755); + tokio::fs::set_permissions(&args.cluster_dir, permissions).await?; + + // Create operators and their enr node keys + let (ops, node_keys) = get_operators(num_nodes, &args.cluster_dir)?; + + def.operators = ops; + + let keys_to_disk = args.keymanager_addrs.is_empty(); + + // Pre-compute public shares and clone private shares before share_sets is + // consumed by the write step. The private share clone is needed for the + // aggregate BLS signature; the public shares are needed for DistValidator. + // Each entry is [share_node0, share_node1, ...] indexed by validator. + let tbls = BlstImpl; + let pub_shares_sets: Vec> = share_sets + .clone() + .iter() + .map(|shares| { + shares + .iter() + .map(|s| tbls.secret_to_public_key(s)) + .collect::, _>>() + }) + .collect::, _>>() + .map_err(CreateClusterError::CryptoError)?; + + if keys_to_disk { + write_keys_to_disk( + num_nodes, + &args.cluster_dir, + args.insecure_keys, + &share_sets, + ) + .await?; + } else { + write_keys_to_keymanager(&args, num_nodes, &share_sets).await?; + } + + let network = eth2util::network::fork_version_to_network(&def.fork_version)?; + + let deposit_datas = create_deposit_datas( + &def.withdrawal_addresses(), + &network, + &secrets, + &deposit_amounts, + def.compounding, + )?; + + let eth2util_deposit_datas = deposit_datas + .iter() + .map(|dd| cluster_deposit_data_to_eth2util_deposit_data(dd)) + .collect::>(); + + // Write deposit-data files + eth2util::deposit::write_cluster_deposit_data_files( + ð2util_deposit_datas, + network, + &args.cluster_dir, + usize::try_from(num_nodes).expect("num_nodes should fit in usize"), + ) + .await?; + + let val_regs = create_validator_registrations( + &def.fee_recipient_addresses(), + &secrets, + &def.fork_version, + args.split_keys, + args.target_gas_limit, + )?; + + let vals = get_validators(&pub_keys, &pub_shares_sets, &deposit_datas, val_regs)?; + + let mut lock = Lock { + definition: def, + distributed_validators: vals, + ..Default::default() + }; + + lock.set_lock_hash().map_err(CreateClusterError::from)?; + + lock.signature_aggregate = agg_sign(&share_sets, &lock.lock_hash)?; + + for op_key in &node_keys { + let node_sig = + pluto_k1util::sign(op_key, &lock.lock_hash).map_err(CreateClusterError::K1UtilError)?; + lock.node_signatures.push(node_sig.to_vec()); + } + + let mut dashboard_url = String::new(); + if args.publish { + match write_lock_to_api(&args.publish_address, &lock).await { + Ok(url) => dashboard_url = url, + Err(err) => { + warn!(error = %err, "Failed to publish lock file to Obol API"); + } + } + } + + write_lock(&lock, &args.cluster_dir, num_nodes).await?; + + if args.zipped { + app_utils::bundle_output(&args.cluster_dir, "cluster.tar.gz") + .map_err(CreateClusterError::BundleOutputError)?; + } + + if args.split_keys { + write_split_keys_warning(w)?; + } + + write_output( + w, + args.split_keys, + &args.cluster_dir, + num_nodes, + keys_to_disk, + args.zipped, + )?; + + if !dashboard_url.is_empty() { + info!( + "You can find your newly-created cluster dashboard here: {}", + dashboard_url + ); + } + + Ok(()) +} + +async fn write_lock_to_api(publish_addr: &str, lock: &Lock) -> Result { + let client = obolapi::Client::new(publish_addr, obolapi::ClientOptions::default()) + .map_err(CreateClusterError::ObolApiError)?; + match client.publish_lock(lock.clone()).await { + Ok(()) => { + info!(addr = publish_addr, "Published lock file"); + match client.launchpad_url_for_lock(lock) { + Ok(url) => Ok(url), + Err(err) => Err(CreateClusterError::ObolApiError(err)), + } + } + Err(err) => Err(CreateClusterError::ObolApiError(err)), + } +} + +fn create_validator_registrations( + fee_recipient_addresses: &[String], + secrets: &[PrivateKey], + fork_version: &[u8], + split_keys: bool, + target_gas_limit: u64, +) -> Result> { + if fee_recipient_addresses.len() != secrets.len() { + return Err(CreateClusterError::InsufficientFeeAddresses { + expected: secrets.len(), + got: fee_recipient_addresses.len(), + }); + } + + let effective_gas_limit = if target_gas_limit == 0 { + warn!( + default_gas_limit = eth2util_registration::DEFAULT_GAS_LIMIT, + "Custom target gas limit not supported, setting to default" + ); + eth2util_registration::DEFAULT_GAS_LIMIT + } else { + target_gas_limit + }; + + let fork_version_arr: [u8; 4] = fork_version + .try_into() + .map_err(|_| CreateClusterError::InvalidForkVersionLength)?; + + let tbls = BlstImpl; + let mut registrations = Vec::with_capacity(secrets.len()); + + for (secret, fee_address) in secrets.iter().zip(fee_recipient_addresses.iter()) { + let timestamp = if split_keys { + Utc::now() + } else { + eth2util::network::fork_version_to_genesis_time(fork_version)? + }; + + let pk = tbls.secret_to_public_key(secret)?; + + let unsigned_reg = eth2util_registration::new_message( + pk, + fee_address, + effective_gas_limit, + u64::try_from(timestamp.timestamp()).unwrap_or(0), + )?; + + let sig_root = + eth2util_registration::get_message_signing_root(&unsigned_reg, fork_version_arr); + + let sig = tbls.sign(secret, &sig_root)?; + + registrations.push(BuilderRegistration { + message: Registration { + fee_recipient: unsigned_reg.fee_recipient, + gas_limit: unsigned_reg.gas_limit, + timestamp, + pub_key: unsigned_reg.pubkey, + }, + signature: sig, + }); + } + + Ok(registrations) +} + +fn cluster_deposit_data_to_eth2util_deposit_data( + deposit_datas: &[DepositData], +) -> Vec { + deposit_datas + .iter() + .map(|dd| eth2util::deposit::DepositData { + pubkey: dd.pub_key, + withdrawal_credentials: dd.withdrawal_credentials, + amount: dd.amount, + signature: dd.signature, + }) + .collect() +} + +async fn write_keys_to_disk( + num_nodes: u64, + cluster_dir: impl AsRef, + insecure_keys: bool, + share_sets: &[Vec], +) -> Result<()> { + for i in 0..num_nodes { + let i_usize = usize::try_from(i).expect("node index should fit in usize on all platforms"); + + let mut secrets: Vec = Vec::new(); + for shares in share_sets { + secrets.push(shares[i_usize]); + } + + let keys_dir = create_validator_keys_dir(node_dir(cluster_dir.as_ref(), i)) + .await + .map_err(CreateClusterError::IoError)?; + + if insecure_keys { + store_keys_insecure(&secrets, &keys_dir, &CONFIRM_INSECURE_KEYS).await?; + } else { + store_keys(&secrets, &keys_dir).await?; + } + } + + Ok(()) +} + +fn random_hex64() -> Result { + let mut bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut OsRng, &mut bytes); + Ok(hex::encode(bytes)) +} + +async fn write_keys_to_keymanager( + args: &CreateClusterArgs, + num_nodes: u64, + share_sets: &[Vec], +) -> Result<()> { + // Create and verify all keymanager clients first. + let mut clients: Vec = Vec::new(); + for i in 0..num_nodes { + let i_usize = usize::try_from(i).expect("node index should fit in usize on all platforms"); + let cl = keymanager::Client::new( + &args.keymanager_addrs[i_usize], + &args.keymanager_auth_tokens[i_usize], + )?; + cl.verify_connection().await?; + clients.push(cl); + } + + // For each node, build keystores from this node's share of each validator, + // then import them into that node's keymanager. + for i in 0..num_nodes { + let i_usize = usize::try_from(i).expect("node index should fit in usize on all platforms"); + + let mut keystores: Vec = Vec::new(); + let mut passwords: Vec = Vec::new(); + + // share_sets[validator_idx][node_idx] + for shares in share_sets { + let password = random_hex64()?; + let pbkdf2_c = if args.insecure_keys { + Some(16u32) + } else { + None + }; + let store = keystore_encrypt(&shares[i_usize], &password, pbkdf2_c, &mut OsRng)?; + passwords.push(password); + keystores.push(store); + } + + clients[i_usize] + .import_keystores(&keystores, &passwords) + .await + .inspect_err(|_| { + tracing::error!( + addr = %args.keymanager_addrs[i_usize], + "Failed to import keys", + ); + })?; + + info!( + node = format!("node{}", i), + addr = %args.keymanager_addrs[i_usize], + "Imported key shares to keymanager", + ); + } + + info!("Imported all validator keys to respective keymanagers"); + + Ok(()) +} + +fn create_deposit_datas( + withdrawal_addresses: &[String], + network: impl AsRef, + secrets: &[PrivateKey], + deposit_amounts: &[Gwei], + compounding: bool, +) -> Result>> { + if secrets.len() != withdrawal_addresses.len() { + return Err(CreateClusterError::InsufficientWithdrawalAddresses); + } + if deposit_amounts.is_empty() { + return Err(CreateClusterError::EmptyDepositAmounts); + } + let deduped = deposit::dedup_amounts(deposit_amounts); + sign_deposit_datas( + secrets, + withdrawal_addresses, + network.as_ref(), + &deduped, + compounding, + ) +} + +fn sign_deposit_datas( + secrets: &[PrivateKey], + withdrawal_addresses: &[String], + network: &str, + deposit_amounts: &[Gwei], + compounding: bool, +) -> Result>> { + if secrets.len() != withdrawal_addresses.len() { + return Err(CreateClusterError::InsufficientWithdrawalAddresses); + } + if deposit_amounts.is_empty() { + return Err(CreateClusterError::EmptyDepositAmounts); + } + let tbls = BlstImpl; + let mut dd = Vec::new(); + for &deposit_amount in deposit_amounts { + let mut datas = Vec::new(); + for (secret, withdrawal_addr) in secrets.iter().zip(withdrawal_addresses.iter()) { + let pk = tbls.secret_to_public_key(secret)?; + let msg = deposit::new_message(pk, withdrawal_addr, deposit_amount, compounding)?; + let sig_root = deposit::get_message_signing_root(&msg, network)?; + let sig = tbls.sign(secret, &sig_root)?; + datas.push(DepositData { + pub_key: msg.pubkey, + withdrawal_credentials: msg.withdrawal_credentials, + amount: msg.amount, + signature: sig, + }); + } + dd.push(datas); + } + Ok(dd) +} + +fn generate_keys(num_validators: u64) -> Result> { + let tbls = BlstImpl; + let mut secrets = Vec::new(); + + for _ in 0..num_validators { + let secret = tbls.generate_secret_key(OsRng)?; + secrets.push(secret); + } + + Ok(secrets) +} + +fn get_operators( + num_nodes: u64, + cluster_dir: impl AsRef, +) -> Result<(Vec, Vec)> { + let mut ops = Vec::new(); + let mut node_keys = Vec::new(); + + for i in 0..num_nodes { + let (record, identity_key) = new_peer(&cluster_dir, i)?; + + ops.push(Operator { + enr: record.to_string(), + ..Default::default() + }); + node_keys.push(identity_key); + } + + Ok((ops, node_keys)) +} + +fn new_peer(cluster_dir: impl AsRef, peer_idx: u64) -> Result<(Record, SecretKey)> { + let dir = node_dir(cluster_dir.as_ref(), peer_idx); + + let p2p_key = new_saved_priv_key(&dir)?; + + let record = Record::new(&p2p_key, Vec::new())?; + + Ok((record, p2p_key)) +} + +async fn get_keys( + split_keys_dir: impl AsRef, + use_sequence_keys: bool, +) -> Result> { + if use_sequence_keys { + let files = load_files_unordered(&split_keys_dir).await?; + Ok(files.sequenced_keys()?) + } else { + let files = load_files_recursively(&split_keys_dir).await?; + Ok(files.keys()) + } +} + +/// Creates a new cluster definition from the provided configuration. +async fn new_def_from_config(args: &CreateClusterArgs) -> Result { + let num_validators = args + .num_validators + .ok_or(CreateClusterError::MissingNumValidatorsOrDefinitionFile)?; + + let (fee_recipient_addrs, withdrawal_addrs) = validate_addresses( + num_validators, + &args.fee_recipient_addrs, + &args.withdrawal_addrs, + )?; + + let fork_version = if let Some(network) = args.network { + eth2util::network::network_to_fork_version(network.as_str())? + } else if let Some(ref fork_version_hex) = args.testnet_config.fork_version { + fork_version_hex.clone() + } else { + return Err(CreateClusterError::InvalidNetworkConfig( + InvalidNetworkConfigError::MissingNetworkFlagAndNoTestnetConfigFlag, + )); + }; + + let num_nodes = args + .nodes + .ok_or(CreateClusterError::MissingNodesOrDefinitionFile)?; + + let operators = vec![ + pluto_cluster::operator::Operator::default(); + usize::try_from(num_nodes).expect("num_nodes should fit in usize") + ]; + let threshold = safe_threshold(num_nodes, args.threshold); + + let name = args.name.clone().unwrap_or_default(); + + let consensus_protocol = args.consensus_protocol.clone().unwrap_or_default(); + + let def = pluto_cluster::definition::Definition::new( + name, + num_validators, + threshold, + fee_recipient_addrs, + withdrawal_addrs, + fork_version, + pluto_cluster::definition::Creator::default(), + operators, + args.deposit_amounts.clone(), + consensus_protocol, + args.target_gas_limit, + args.compounding, + vec![], + )?; + + Ok(def) +} + +fn get_tss_shares( + secrets: &[PrivateKey], + threshold: u64, + num_nodes: u64, +) -> Result<(Vec, Vec>)> { + let tbls = BlstImpl; + let mut dvs = Vec::new(); + let mut splits = Vec::new(); + + let num_nodes = u8::try_from(num_nodes) + .map_err(|_| CreateClusterError::ValueExceedsU8 { value: num_nodes })?; + let threshold = u8::try_from(threshold) + .map_err(|_| CreateClusterError::ValueExceedsU8 { value: threshold })?; + + for secret in secrets { + let shares = tbls.threshold_split(secret, num_nodes, threshold)?; + + // Preserve order when transforming from map of private shares to array of + // private keys + let mut secret_set = vec![PrivateKey::default(); shares.len()]; + for i in 1..=shares.len() { + let i_u64 = u64::try_from(i).expect("shares length should fit in u64 on all platforms"); + let idx = + u8::try_from(i).map_err(|_| CreateClusterError::ValueExceedsU8 { value: i_u64 })?; + secret_set[i.saturating_sub(1)] = shares[&idx]; + } + + splits.push(secret_set); + + let pubkey = tbls.secret_to_public_key(secret)?; + dvs.push(pubkey); + } + + Ok((dvs, splits)) +} + +async fn validate_definition( + def: &Definition, + insecure_keys: bool, + keymanager_addrs: &[String], + eth1cl: ð1wrap::EthClient, +) -> Result<()> { + if def.num_validators == 0 { + return Err(CreateClusterError::ZeroValidators); + } + + let num_operators = + u64::try_from(def.operators.len()).expect("operators length should fit in u64"); + if num_operators < MIN_NODES { + return Err(CreateClusterError::TooFewNodes { + num_nodes: num_operators, + }); + } + + if !keymanager_addrs.is_empty() && (keymanager_addrs.len() != def.operators.len()) { + return Err(CreateClusterError::InsufficientKeymanagerAddresses { + expected: def.operators.len(), + got: keymanager_addrs.len(), + }); + } + + if !def.deposit_amounts.is_empty() { + deposit::verify_deposit_amounts(&def.deposit_amounts, def.compounding)?; + } + + let network_name = network::fork_version_to_network(&def.fork_version)?; + + if insecure_keys && is_main_or_gnosis(&network_name) { + return Err(CreateClusterError::InsecureKeysOnMainnetOrGnosis); + } else if insecure_keys { + tracing::warn!("Insecure keystores configured. ONLY DO THIS DURING TESTING"); + } + + if def.name.is_empty() { + return Err(CreateClusterError::DefinitionNameNotProvided); + } + + def.verify_hashes()?; + + def.verify_signatures(eth1cl).await?; + + if !network::valid_network(&network_name) { + return Err(CreateClusterError::UnsupportedNetwork { + network: network_name.to_string(), + }); + } + + if !def.consensus_protocol.is_empty() + && !protocols::is_supported_protocol_name(&def.consensus_protocol) + { + return Err(CreateClusterError::UnsupportedConsensusProtocol { + consensus_protocol: def.consensus_protocol.clone(), + }); + } + + validate_withdrawal_addrs(&def.withdrawal_addresses(), &network_name)?; + + Ok(()) +} + +pub fn is_main_or_gnosis(network: &str) -> bool { + network == network::MAINNET.name || network == network::GNOSIS.name +} + +fn validate_create_config(args: &CreateClusterArgs) -> Result<()> { + if args.nodes.is_none() && args.definition_file.is_none() { + return Err(CreateClusterError::MissingNodesOrDefinitionFile); + } + + // Check for valid network configuration. + validate_network_config(args)?; + + detect_node_dirs(&args.cluster_dir, args.nodes.unwrap_or(0))?; + + // Ensure sufficient auth tokens are provided for the keymanager addresses + if args.keymanager_addrs.len() != args.keymanager_auth_tokens.len() { + return Err(CreateClusterError::InvalidKeymanagerConfig { + keymanager_addrs: args.keymanager_addrs.len(), + keymanager_auth_tokens: args.keymanager_auth_tokens.len(), + }); + } + + if !args.deposit_amounts.is_empty() { + let amount = eth2util::deposit::eths_to_gweis(&args.deposit_amounts); + + eth2util::deposit::verify_deposit_amounts(&amount, args.compounding)?; + } + + for addr in &args.keymanager_addrs { + let keymanager_url = + url::Url::parse(addr).map_err(CreateClusterError::InvalidKeymanagerUrl)?; + + if keymanager_url.scheme() != HTTP_SCHEME { + return Err(CreateClusterError::InvalidKeymanagerUrlScheme { addr: addr.clone() }); + } + } + + if args.split_keys && args.num_validators.is_some() { + return Err(CreateClusterError::CannotSpecifyNumValidatorsWithSplitKeys); + } else if !args.split_keys && args.num_validators.is_none() && args.definition_file.is_none() { + return Err(CreateClusterError::MissingNumValidatorsOrDefinitionFile); + } + + // Don't allow cluster size to be less than `MIN_NODES`. + let num_nodes = args.nodes.unwrap_or(0); + if num_nodes < MIN_NODES { + return Err(CreateClusterError::TooFewNodes { num_nodes }); + } + + if let Some(consensus_protocol) = &args.consensus_protocol + && !protocols::is_supported_protocol_name(consensus_protocol) + { + return Err(CreateClusterError::UnsupportedConsensusProtocol { + consensus_protocol: consensus_protocol.clone(), + }); + } + + Ok(()) +} + +fn detect_node_dirs(cluster_dir: impl AsRef, node_amount: u64) -> Result<()> { + for i in 0..node_amount { + let abs_path = std::path::absolute(node_dir(cluster_dir.as_ref(), i)) + .map_err(CreateClusterError::AbsolutePathError)?; + + if std::fs::exists(abs_path.join("cluster-lock.json")) + .map_err(CreateClusterError::IoError)? + { + return Err(CreateClusterError::NodeDirectoryAlreadyExists { node_dir: abs_path }); + } + } + + Ok(()) +} + +fn node_dir(cluster_dir: impl AsRef, node_index: u64) -> PathBuf { + cluster_dir.as_ref().join(format!("node{}", node_index)) +} + +/// Validates the network configuration. +fn validate_network_config(args: &CreateClusterArgs) -> Result<()> { + if let Some(network) = args.network { + if eth2util::network::valid_network(network.as_str()) { + return Ok(()); + } + + return Err(InvalidNetworkConfigError::InvalidNetworkSpecified { + network: network.to_string(), + } + .into()); + } + + // Check if custom testnet configuration is provided. + if !args.testnet_config.is_empty() { + // Add testnet config to supported networks. + eth2util::network::add_test_network(args.testnet_config.clone().into())?; + + return Ok(()); + } + + Err(InvalidNetworkConfigError::MissingNetworkFlagAndNoTestnetConfigFlag.into()) +} + +/// Returns true if the input string is a valid HTTP/HTTPS URI. +fn is_valid_uri(s: impl AsRef) -> bool { + if let Ok(url) = url::Url::parse(s.as_ref()) { + (url.scheme() == HTTP_SCHEME || url.scheme() == HTTPS_SCHEME) + && !url.host_str().unwrap_or("").is_empty() + } else { + false + } +} + +/// Loads and validates the cluster definition from disk or an HTTP URL. +/// +/// It fetches the definition, verifies signatures and hashes, and checks +/// that at least one validator is specified before returning. +async fn load_definition( + definition_file: impl AsRef, + eth1cl: ð1wrap::EthClient, +) -> Result { + let def_file = definition_file.as_ref(); + + // Fetch definition from network if URI is provided + let def = if is_valid_uri(def_file) { + let def = fetch_definition(def_file).await?; + + info!( + url = def_file, + definition_hash = to_0x_hex(&def.definition_hash), + "Cluster definition downloaded from URL" + ); + + def + } else { + // Fetch definition from disk + let buf = tokio::fs::read(def_file).await?; + let def: Definition = serde_json::from_slice(&buf)?; + + info!( + path = def_file, + definition_hash = to_0x_hex(&def.definition_hash), + "Cluster definition loaded from disk", + ); + + def + }; + + def.verify_signatures(eth1cl).await?; + def.verify_hashes()?; + + if def.num_validators == 0 { + return Err(CreateClusterError::NoValidatorsInDefinition); + } + + Ok(def) +} + +/// Validates that addresses match the number of validators. +/// If only one address is provided, it fills the slice to match num_validators. +/// +/// Returns an error if the number of addresses doesn't match and isn't exactly +/// 1. +fn validate_addresses( + num_validators: u64, + fee_recipient_addrs: &[String], + withdrawal_addrs: &[String], +) -> Result<(Vec, Vec)> { + let num_validators_usize = + usize::try_from(num_validators).map_err(|_| CreateClusterError::ValueExceedsU8 { + value: num_validators, + })?; + + if fee_recipient_addrs.len() != num_validators_usize && fee_recipient_addrs.len() != 1 { + return Err(CreateClusterError::MismatchingFeeRecipientAddresses { + num_validators, + addresses: fee_recipient_addrs.len(), + }); + } + + 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 { + let safe = pluto_cluster::helpers::threshold(num_nodes); + + match threshold { + Some(0) | None => safe, + Some(t) => { + if t != safe { + warn!( + num_nodes = num_nodes, + threshold = t, + safe_threshold = safe, + "Non standard threshold provided, this will affect cluster safety" + ); + } + t + } + } +} + +/// Builds the list of `DistValidator`s from the DV public keys, precomputed +/// public shares, deposit data and validator registrations. +fn get_validators( + dv_pubkeys: &[PublicKey], + pub_shares_sets: &[Vec], + deposit_datas: &[Vec], + val_regs: Vec, +) -> Result> { + let mut deposit_datas_map: HashMap> = HashMap::new(); + for amount_level in deposit_datas { + for dd in amount_level { + deposit_datas_map + .entry(dd.pub_key) + .or_default() + .push(dd.clone()); + } + } + + let mut vals = Vec::with_capacity(dv_pubkeys.len()); + + for (idx, dv_pubkey) in dv_pubkeys.iter().enumerate() { + // Public shares for this validator's nodes. + let pub_shares: Vec> = pub_shares_sets + .get(idx) + .map(|shares| shares.iter().map(|pk| pk.to_vec()).collect()) + .unwrap_or_default(); + + // Builder registration — same index as the validator. + let builder_registration = val_regs + .get(idx) + .cloned() + .ok_or(CreateClusterError::ValidatorRegistrationNotFound { index: idx })?; + + // Partial deposit data for this DV pubkey. + let partial_deposit_data = deposit_datas_map.remove(dv_pubkey).ok_or_else(|| { + CreateClusterError::DepositDataNotFound { + dv: hex::encode(dv_pubkey), + } + })?; + + vals.push(DistValidator { + pub_key: dv_pubkey.to_vec(), + pub_shares, + partial_deposit_data, + builder_registration, + }); + } + + Ok(vals) +} + +/// Aggregates BLS signatures over `message` produced by every private share +/// across all validators, mirroring Go's `aggSign`. +/// +/// `share_sets` — outer dimension is per-validator, inner is per-node private +/// key share. +fn agg_sign(share_sets: &[Vec], message: &[u8]) -> Result> { + use pluto_crypto::types::Signature; + + let tbls = BlstImpl; + let mut sigs: Vec = Vec::new(); + + for shares in share_sets { + for share in shares { + let sig = tbls + .sign(share, message) + .map_err(CreateClusterError::CryptoError)?; + sigs.push(sig); + } + } + + if sigs.is_empty() { + return Ok(Vec::new()); + } + + let agg = tbls + .aggregate(&sigs) + .map_err(CreateClusterError::CryptoError)?; + Ok(agg.to_vec()) +} + +/// Writes `cluster-lock.json` to every node directory under `cluster_dir`. +/// The file is created with 0o400 (owner read-only) permissions. +async fn write_lock(lock: &Lock, cluster_dir: impl AsRef, num_nodes: u64) -> Result<()> { + let json = serde_json::to_string_pretty(lock)?; + let bytes = json.into_bytes(); + + for i in 0..num_nodes { + let lock_path = node_dir(cluster_dir.as_ref(), i).join("cluster-lock.json"); + + tokio::fs::write(&lock_path, &bytes) + .await + .map_err(CreateClusterError::IoError)?; + + let perms = std::fs::Permissions::from_mode(0o400); + tokio::fs::set_permissions(&lock_path, perms) + .await + .map_err(CreateClusterError::IoError)?; + } + + Ok(()) +} + +fn write_output( + w: &mut dyn Write, + split_keys: bool, + cluster_dir: impl AsRef, + num_nodes: u64, + keys_to_disk: bool, + zipped: bool, +) -> Result<()> { + let abs_cluster_dir = + std::path::absolute(cluster_dir.as_ref()).map_err(CreateClusterError::AbsolutePathError)?; + let abs_str = abs_cluster_dir.display().to_string(); + let abs_str = abs_str.trim_end_matches('/'); + + writeln!(w, "Created charon cluster:").map_err(CreateClusterError::IoError)?; + writeln!(w, " --split-existing-keys={}", split_keys).map_err(CreateClusterError::IoError)?; + writeln!(w).map_err(CreateClusterError::IoError)?; + writeln!(w, "{}/", abs_str).map_err(CreateClusterError::IoError)?; + writeln!( + w, + "├─ node[0-{}]/\t\t\tDirectory for each node", + num_nodes.saturating_sub(1) + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + "│ ├─ charon-enr-private-key\tCharon networking private key for node authentication" + ) + .map_err(CreateClusterError::IoError)?; + writeln!(w, "│ ├─ cluster-lock.json\t\tCluster lock defines the cluster lock file which is signed by all nodes").map_err(CreateClusterError::IoError)?; + writeln!(w, "│ ├─ deposit-data-*.json\tDeposit data files are used to activate a Distributed Validator on the DV Launchpad").map_err(CreateClusterError::IoError)?; + if keys_to_disk { + writeln!( + w, + "│ ├─ validator_keys\t\tValidator keystores and password" + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + "│ │ ├─ keystore-*.json\tValidator private share key for duty signing" + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + "│ │ ├─ keystore-*.txt\t\tKeystore password files for keystore-*.json" + ) + .map_err(CreateClusterError::IoError)?; + } + if zipped { + writeln!(w).map_err(CreateClusterError::IoError)?; + writeln!(w, "Files compressed and archived to:").map_err(CreateClusterError::IoError)?; + writeln!(w, "{}/cluster.tar.gz", abs_str).map_err(CreateClusterError::IoError)?; + } + + Ok(()) +} + +fn write_split_keys_warning(w: &mut dyn Write) -> Result<()> { + writeln!(w).map_err(CreateClusterError::IoError)?; + writeln!( + w, + "***************** WARNING: Splitting keys **********************" + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + " Please make sure any existing validator has been shut down for" + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + " at least 2 finalised epochs before starting the charon cluster," + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + " otherwise slashing could occur. " + ) + .map_err(CreateClusterError::IoError)?; + writeln!( + w, + "****************************************************************" + ) + .map_err(CreateClusterError::IoError)?; + writeln!(w).map_err(CreateClusterError::IoError)?; + Ok(()) +} diff --git a/crates/cli/src/commands/create_dkg.rs b/crates/cli/src/commands/create_dkg.rs new file mode 100644 index 00000000..d8419e17 --- /dev/null +++ b/crates/cli/src/commands/create_dkg.rs @@ -0,0 +1,69 @@ +//! Create DKG command utilities. +//! +//! This module provides utilities for the `pluto create dkg` command, +//! including validation functions for withdrawal addresses. + +use pluto_eth2util::{self as eth2util}; +use thiserror::Error; + +use crate::commands::create_cluster::{ZERO_ADDRESS, is_main_or_gnosis}; + +/// Errors that can occur during withdrawal address validation. +#[derive(Error, Debug)] +pub enum WithdrawalValidationError { + /// Invalid withdrawal address. + #[error("Invalid withdrawal address: {address}")] + InvalidWithdrawalAddress { + /// The invalid address. + address: String, + }, + + /// Invalid checksummed address. + #[error("Invalid checksummed address: {address}")] + InvalidChecksummedAddress { + /// The address with invalid checksum. + address: String, + }, + + /// Zero address forbidden on mainnet/gnosis. + #[error("Zero address forbidden on this network: {network}")] + ZeroAddressForbiddenOnNetwork { + /// The network name. + network: String, + }, + + /// Eth2util helpers error. + #[error("Eth2util helpers error: {0}")] + Eth2utilHelperError(#[from] eth2util::helpers::HelperError), +} + +/// Validates withdrawal addresses for the given network. +/// +/// Returns an error if any of the provided withdrawal addresses is invalid. +pub fn validate_withdrawal_addrs( + addrs: &[String], + network: &str, +) -> std::result::Result<(), WithdrawalValidationError> { + for addr in addrs { + let checksum_addr = eth2util::helpers::checksum_address(addr).map_err(|_| { + WithdrawalValidationError::InvalidWithdrawalAddress { + address: addr.clone(), + } + })?; + + if checksum_addr != *addr { + return Err(WithdrawalValidationError::InvalidChecksummedAddress { + address: addr.clone(), + }); + } + + // We cannot allow a zero withdrawal address on mainnet or gnosis. + if is_main_or_gnosis(network) && addr == ZERO_ADDRESS { + return Err(WithdrawalValidationError::ZeroAddressForbiddenOnNetwork { + network: network.to_string(), + }); + } + } + + Ok(()) +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index e18c3e1f..a04b320d 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,3 +1,5 @@ +pub mod create_cluster; +pub mod create_dkg; pub mod create_enr; pub mod enr; pub mod relay; diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index e049863e..a37590e5 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -5,8 +5,11 @@ use std::{ process::{ExitCode, Termination}, }; +use pluto_eth2util as eth2util; use thiserror::Error; +use crate::commands::create_cluster::{MIN_NODES, MIN_THRESHOLD}; + /// Result type for CLI operations. pub type Result = std::result::Result; @@ -26,6 +29,7 @@ impl Termination for ExitResult { /// Errors that can occur in the Pluto CLI. #[derive(Error, Debug)] +#[allow(dead_code)] pub enum CliError { /// Private key file not found. #[error( @@ -94,4 +98,330 @@ pub enum CliError { /// Relay P2P error. #[error("Relay P2P error: {0}")] RelayP2PError(#[from] pluto_relay_server::error::RelayP2PError), + + /// Create cluster error. + #[error("Create cluster error: {0}")] + CreateClusterError(#[from] CreateClusterError), + + /// Eth1wrap error. + #[error("Eth1wrap error: {0}")] + Eth1wrapError(#[from] pluto_eth1wrap::EthClientError), + + /// Eth2util network error. + #[error("Eth2util network error: {0}")] + Eth2utilNetworkError(#[from] eth2util::network::NetworkError), + + /// Eth2util deposit error. + #[error("Eth2util deposit error: {0}")] + Eth2utilDepositError(#[from] eth2util::deposit::DepositError), +} + +#[derive(Error, Debug)] +pub enum CreateClusterError { + /// Invalid threshold. + #[error("Invalid threshold: {0}")] + InvalidThreshold(#[from] ThresholdError), + + /// Missing nodes or definition file. + #[error("Missing --nodes or --definition-file flag")] + MissingNodesOrDefinitionFile, + + /// Invalid network configuration. + #[error("Invalid network configuration: {0}")] + InvalidNetworkConfig(InvalidNetworkConfigError), + + /// Absolute path error. + #[error("Absolute path retrieval error: {0}")] + AbsolutePathError(std::io::Error), + + /// IO error. + #[error("IO error: {0}")] + IoError(std::io::Error), + + /// Node directory already exists. + #[error( + "Existing node directory found, please delete it before running this command: node_dir={node_dir}" + )] + NodeDirectoryAlreadyExists { + /// Node directory. + node_dir: PathBuf, + }, + + /// Invalid keymanager configuration. + #[error( + "number of --keymanager-addresses={keymanager_addrs} do not match --keymanager-auth-tokens={keymanager_auth_tokens}. Please fix configuration flags" + )] + InvalidKeymanagerConfig { + /// Number of keymanager addresses. + keymanager_addrs: usize, + /// Number of keymanager auth tokens. + keymanager_auth_tokens: usize, + }, + + /// Invalid deposit amounts. + #[error("Invalid deposit amounts: {0}")] + InvalidDepositAmounts(#[from] eth2util::deposit::DepositError), + + /// Invalid keymanager URL. + #[error("Invalid keymanager URL: {0}")] + InvalidKeymanagerUrl(#[from] url::ParseError), + + // todo(varex83): 1-to-1 replication of go impl, possible bug here. consider changing https to + // http. + /// Invalid keymanager URL scheme. + #[error("Keymanager URL does not use https protocol: {addr}")] + InvalidKeymanagerUrlScheme { + /// Keymanager URL. + addr: String, + }, + + /// Cannot specify --num-validators with --split-existing-keys. + #[error("Cannot specify --num-validators with --split-existing-keys")] + CannotSpecifyNumValidatorsWithSplitKeys, + + /// Missing --num-validators or --definition-file flag. + #[error("Missing --num-validators or --definition-file flag")] + MissingNumValidatorsOrDefinitionFile, + + /// Too few nodes. + #[error("Too few nodes: {num_nodes}. Minimum is {MIN_NODES}")] + TooFewNodes { + /// Number of nodes. + num_nodes: u64, + }, + + /// Unsupported consensus protocol. + #[error("Unsupported consensus protocol: {consensus_protocol}")] + UnsupportedConsensusProtocol { + /// Consensus protocol. + consensus_protocol: String, + }, + + /// Missing --split-keys-dir flag. + #[error("--split-keys-dir is required when splitting keys")] + MissingSplitKeysDir, + + /// Missing --execution-client-rpc-endpoint flag. + #[error("--execution-client-rpc-endpoint is required when creating a new cluster")] + MissingExecutionEngineAddress, + + /// Amount of keys read from disk differs from cluster definition. + #[error( + "Amount of keys read from disk differs from cluster definition: disk={disk_keys}, definition={definition_keys}" + )] + KeyCountMismatch { + /// Number of keys read from disk. + disk_keys: usize, + /// Number of validators in the definition. + definition_keys: u64, + }, + + /// Crypto error. + #[error("Crypto error: {0}")] + CryptoError(#[from] pluto_crypto::types::Error), + + /// Value exceeds u8::MAX. + #[error("Value {value} exceeds u8::MAX (255)")] + ValueExceedsU8 { + /// The value that exceeds u8::MAX. + value: u64, + }, + + /// Keystore error. + #[error("Keystore error: {0}")] + KeystoreError(#[from] eth2util::keystore::KeystoreError), + + /// Cannot create cluster with zero validators. + #[error("Cannot create cluster with zero validators, specify at least one")] + ZeroValidators, + + /// Insufficient keymanager addresses. + #[error("Insufficient number of keymanager addresses: expected={expected}, got={got}")] + InsufficientKeymanagerAddresses { + /// Expected number of keymanager addresses. + expected: usize, + /// Actual number of keymanager addresses. + got: usize, + }, + + /// Insecure keys not supported on mainnet/gnosis. + #[error("Insecure keys not supported on mainnet or gnosis")] + InsecureKeysOnMainnetOrGnosis, + + /// Definition name not provided. + #[error("Name not provided in cluster definition")] + DefinitionNameNotProvided, + + /// Definition error. + #[error("Definition error: {0}")] + DefinitionError(#[from] pluto_cluster::definition::DefinitionError), + + /// Unsupported network. + #[error("Unsupported network: {network}")] + UnsupportedNetwork { + /// Network name. + network: String, + }, + + /// Withdrawal validation error. + #[error("Withdrawal validation error: {0}")] + WithdrawalValidationError(#[from] crate::commands::create_dkg::WithdrawalValidationError), + + /// Failed to read definition file. + #[error("Failed to read definition file: {0}")] + ReadDefinitionFile(#[from] std::io::Error), + + /// Failed to parse definition JSON. + #[error("Failed to parse definition JSON: {0}")] + ParseDefinitionJson(#[from] serde_json::Error), + + /// Cluster fetch error. + #[error("Failed to fetch cluster definition: {0}")] + FetchDefinition(#[from] pluto_cluster::helpers::FetchError), + + /// No validators specified in definition. + #[error("No validators specified in the given definition")] + NoValidatorsInDefinition, + + /// Mismatching number of fee recipient addresses. + #[error( + "mismatching --num-validators and --fee-recipient-addresses: num_validators={num_validators}, addresses={addresses}" + )] + MismatchingFeeRecipientAddresses { + /// Number of validators. + num_validators: u64, + /// Number of addresses. + addresses: usize, + }, + + /// Mismatching number of withdrawal addresses. + #[error( + "mismatching --num-validators and --withdrawal-addresses: num_validators={num_validators}, addresses={addresses}" + )] + MismatchingWithdrawalAddresses { + /// Number of validators. + num_validators: u64, + /// Number of addresses. + addresses: usize, + }, + + /// K1 error. + #[error("K1 error: {0}")] + K1Error(#[from] pluto_p2p::k1::K1Error), + + /// Record error. + #[error("Record error: {0}")] + RecordError(#[from] eth2util::enr::RecordError), + + /// Insufficient withdrawal addresses. + #[error("Insufficient withdrawal addresses")] + InsufficientWithdrawalAddresses, + + /// Empty deposit amounts. + #[error("Empty deposit amounts")] + EmptyDepositAmounts, + + /// Keymanager error. + #[error("Keymanager error: {0}")] + KeymanagerError(#[from] eth2util::keymanager::KeymanagerError), + + /// Insufficient fee addresses. + #[error("Insufficient fee addresses: expected {expected}, got {got}")] + InsufficientFeeAddresses { + /// Expected number of fee addresses. + expected: usize, + /// Actual number of fee addresses. + got: usize, + }, + + /// Invalid fork version length. + #[error("Invalid fork version length: expected 4 bytes")] + InvalidForkVersionLength, + + /// Registration error. + #[error("Registration error: {0}")] + RegistrationError(#[from] eth2util::registration::RegistrationError), + + /// Validator registration not found at the given index. + #[error("Validator registration not found at index {index}")] + ValidatorRegistrationNotFound { + /// Index that was out of bounds. + index: usize, + }, + + /// Deposit data not found for the given distributed validator pubkey. + #[error("Deposit data not found for distributed validator pubkey: {dv}")] + DepositDataNotFound { + /// Hex-encoded distributed validator pubkey. + dv: String, + }, + + /// Lock error (e.g. set_lock_hash failed). + #[error("Lock error: {0}")] + LockError(#[from] pluto_cluster::lock::LockError), + + /// K1 utility signing error. + #[error("K1 util signing error: {0}")] + K1UtilError(#[from] pluto_k1util::K1UtilError), + + /// Obol API error (publish_lock / launchpad URL). + #[error("Obol API error: {0}")] + ObolApiError(#[from] pluto_app::obolapi::ObolApiError), + + /// Bundle output (tar.gz archival) error. + #[error("Bundle output error: {0}")] + BundleOutputError(#[from] pluto_app::utils::UtilsError), +} + +#[derive(Error, Debug)] +pub enum ThresholdError { + /// Threshold must be greater than {MIN_THRESHOLD}. + #[error("Threshold must be greater than {MIN_THRESHOLD}, got {threshold}")] + ThresholdTooLow { + /// Threshold value. + threshold: u64, + }, + + /// Threshold must be less than the number of nodes. + #[error( + "Threshold cannot be greater than number of operators (nodes): Threshold={threshold}, Number of nodes={number_of_nodes}" + )] + ThresholdTooHigh { + /// Threshold value. + threshold: u64, + /// Number of operators (nodes). + number_of_nodes: u64, + }, +} + +#[derive(Error, Debug)] +pub enum InvalidNetworkConfigError { + /// Invalid network name. + #[error("Invalid network name: {0}")] + InvalidNetworkName(#[from] eth2util::network::NetworkError), + + /// Invalid network specified. + #[error("Invalid network specified: network={network}")] + InvalidNetworkSpecified { + /// Network name. + network: String, + }, + + /// Missing --network flag or testnet config flags. + #[error("Missing --network flag and no testnet config flag")] + MissingNetworkFlagAndNoTestnetConfigFlag, +} + +impl From for CreateClusterError { + fn from(error: InvalidNetworkConfigError) -> Self { + CreateClusterError::InvalidNetworkConfig(error) + } +} + +impl From for CreateClusterError { + fn from(error: eth2util::network::NetworkError) -> Self { + CreateClusterError::InvalidNetworkConfig(InvalidNetworkConfigError::InvalidNetworkName( + error, + )) + } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index cd3b9e7d..f5554d95 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -39,6 +39,10 @@ async fn main() -> ExitResult { let result = match cli.command { Commands::Create(args) => match args.command { CreateCommands::Enr(args) => commands::create_enr::run(args), + CreateCommands::Cluster(args) => { + let mut stdout = std::io::stdout(); + commands::create_cluster::run(&mut stdout, *args).await + } }, Commands::Enr(args) => commands::enr::run(args), Commands::Version(args) => commands::version::run(args), diff --git a/crates/cluster/src/definition.rs b/crates/cluster/src/definition.rs index 2aa54b66..fdb00cc5 100644 --- a/crates/cluster/src/definition.rs +++ b/crates/cluster/src/definition.rs @@ -45,7 +45,7 @@ pub struct NodeIdx { /// Definition defines an intended charon cluster configuration excluding /// validators. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Definition { /// Human-readable random unique identifier. Max 64 chars. pub uuid: String, diff --git a/crates/cluster/src/helpers.rs b/crates/cluster/src/helpers.rs index c9a06778..ec8fa41d 100644 --- a/crates/cluster/src/helpers.rs +++ b/crates/cluster/src/helpers.rs @@ -153,6 +153,22 @@ pub fn sign_operator( Ok(()) } +/// Returns minimum threshold required for a cluster with given nodes. +/// This formula has been taken from: +/// +/// Computes ceil(2*nodes / 3) using integer arithmetic to avoid floating point +/// conversions. +pub fn threshold(nodes: u64) -> u64 { + // Integer ceiling division: ceil(a/b) = (a + b - 1) / b + // Here we compute: ceil(2*nodes / 3) = (2*nodes + 3 - 1) / 3 = (2*nodes + 2) / + // 3 + let numerator = nodes.checked_mul(2).expect("threshold: nodes * 2 overflow"); + let adjusted = numerator + .checked_add(2) + .expect("threshold: numerator + 2 overflow"); + adjusted / 3 +} + /// Returns a BLS aggregate signature of the message signed by all the shares. pub fn agg_sign( secrets: &[Vec], diff --git a/crates/cluster/src/lock.rs b/crates/cluster/src/lock.rs index a370a738..8ac6d91f 100644 --- a/crates/cluster/src/lock.rs +++ b/crates/cluster/src/lock.rs @@ -145,7 +145,7 @@ type Result = std::result::Result; /// Lock extends the cluster config Definition with bls threshold public keys /// and checksums. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Lock { /// Definition is embedded and extended by Lock. pub definition: Definition,