diff --git a/AGENTS.md b/AGENTS.md index 2d92ba75..d0bfffd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,43 @@ Rules: - Prefer copying doc comments from Go and adapting to Rust conventions (avoid “Type is a …”). - Avoid leaving TODOs in merged code. If a short-lived internal note is necessary, use `// TODO:` and remove before PR merge. +## Generalized Parameter Types + +Prefer generic parameters over concrete types when a function only needs the behavior of a trait. This mirrors the standard library's own conventions and makes functions callable with a wider range of inputs without extra allocations. + +| Instead of | Prefer | Accepts | +| --- | --- | --- | +| `&str` | `impl AsRef` | `&str`, `String`, `&String`, … | +| `&Path` | `impl AsRef` | `&str`, `String`, `PathBuf`, `&Path`, … | +| `&[u8]` | `impl AsRef<[u8]>` | `&[u8]`, `Vec`, arrays, … | +| `&Vec` | `impl AsRef<[T]>` | `Vec`, slices, arrays, … | +| `String` (owned, read-only) | `impl Into` | `&str`, `String`, … | + +Examples: + +```rust +// accepts &str, String, PathBuf, &Path, … +fn read_file(path: impl AsRef) -> std::io::Result { + std::fs::read_to_string(path.as_ref()) +} + +// accepts &str, String, &String, … +fn print_message(msg: impl AsRef) { + println!(“{}”, msg.as_ref()); +} + +// accepts &[u8], Vec, arrays, … +fn hash_bytes(data: impl AsRef<[u8]>) -> [u8; 32] { + sha256(data.as_ref()) +} +``` + +Rules: + +- Call `.as_ref()` once at the top of the function and bind it to a local variable when the value is used in multiple places. +- Do not use `impl AsRef` if the function immediately converts to an owned type anyway — use `impl Into` (or just accept the owned type) in that case. +- Applies to public and private functions alike; the gain is ergonomics, not just API surface. + ## Testing - Translate Go tests to Rust where applicable; keep similar test names for cross-reference. diff --git a/Cargo.lock b/Cargo.lock index 58bce4f4..ddbf3a20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -729,9 +729,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -1749,9 +1749,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -5495,7 +5495,6 @@ dependencies = [ "prost-build", "prost-types 0.14.3", "rand 0.8.5", - "rand_core 0.6.4", "reqwest 0.13.2", "serde", "serde_json", @@ -5548,9 +5547,22 @@ dependencies = [ name = "pluto-dkg" version = "1.7.1" dependencies = [ + "hex", "pluto-build-proto", + "pluto-cluster", + "pluto-crypto", + "pluto-eth1wrap", + "pluto-eth2util", "prost 0.14.3", "prost-types 0.14.3", + "rand 0.8.5", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", ] [[package]] diff --git a/crates/cluster/Cargo.toml b/crates/cluster/Cargo.toml index a881a110..c3a4be5c 100644 --- a/crates/cluster/Cargo.toml +++ b/crates/cluster/Cargo.toml @@ -19,7 +19,7 @@ pluto-core.workspace = true pluto-crypto.workspace = true pluto-eth2api.workspace = true thiserror.workspace = true -rand_core.workspace = true +rand.workspace = true libp2p.workspace = true pluto-p2p.workspace = true pluto-eth2util.workspace = true @@ -28,6 +28,9 @@ pluto-k1util.workspace = true k256.workspace = true tokio.workspace = true reqwest = { workspace = true, features = ["json"] } +# Workaround to use test code from different crate. +# See: https://github.com/NethermindEth/pluto/pull/285 +pluto-testutil = { workspace = true, optional = true } [build-dependencies] prost-build.workspace = true @@ -35,9 +38,11 @@ prost-build.workspace = true [dev-dependencies] test-case.workspace = true pluto-testutil.workspace = true -rand.workspace = true tempfile.workspace = true wiremock.workspace = true [lints] workspace = true + +[features] +test-cluster = [] diff --git a/crates/cluster/src/definition.rs b/crates/cluster/src/definition.rs index 13d6bba2..d3f60692 100644 --- a/crates/cluster/src/definition.rs +++ b/crates/cluster/src/definition.rs @@ -933,10 +933,12 @@ pub struct DefinitionV1x2or3 { /// Config hash uniquely identifies a cluster definition excluding operator /// ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub config_hash: Vec, /// Definition hash uniquely identifies a cluster definition including /// operator ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub definition_hash: Vec, } @@ -1047,10 +1049,12 @@ pub struct DefinitionV1x4 { /// Config hash uniquely identifies a cluster definition excluding operator /// ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub config_hash: Vec, /// Definition hash uniquely identifies a cluster definition including /// operator ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub definition_hash: Vec, } @@ -1159,10 +1163,12 @@ pub struct DefinitionV1x5to7 { /// Config hash uniquely identifies a cluster definition excluding operator /// ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub config_hash: Vec, /// Definition hash uniquely identifies a cluster definition including /// operator ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub definition_hash: Vec, } @@ -1260,10 +1266,12 @@ pub struct DefinitionV1x8 { /// ConfigHash uniquely identifies a cluster definition excluding operator /// ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub config_hash: Vec, /// DefinitionHash uniquely identifies a cluster definition including /// operator ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub definition_hash: Vec, } @@ -1365,10 +1373,12 @@ pub struct DefinitionV1x9 { /// ConfigHash uniquely identifies a cluster definition excluding operator /// ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub config_hash: Vec, /// DefinitionHash uniquely identifies a cluster definition including /// operator ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub definition_hash: Vec, } @@ -1477,10 +1487,12 @@ pub struct DefinitionV1x10 { /// Config hash uniquely identifies a cluster definition excluding operator /// ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub config_hash: Vec, /// Definition hash uniquely identifies a cluster definition including /// operator ENRs and signatures. #[serde_as(as = "EthHex")] + #[serde(default)] pub definition_hash: Vec, } diff --git a/crates/cluster/src/helpers.rs b/crates/cluster/src/helpers.rs index b5be6f86..fc7d51a4 100644 --- a/crates/cluster/src/helpers.rs +++ b/crates/cluster/src/helpers.rs @@ -65,8 +65,10 @@ pub async fn fetch_definition( /// Creates a new directory for validator keys. /// If the directory "validator_keys" exists, it checks if the directory is /// empty. -pub async fn create_validator_keys_dir(parent_dir: &std::path::Path) -> std::io::Result { - let vk_dir = parent_dir.join("validator_keys"); +pub async fn create_validator_keys_dir( + parent_dir: impl AsRef, +) -> std::io::Result { + let vk_dir = parent_dir.as_ref().join("validator_keys"); if let Err(e) = tokio::fs::create_dir(&vk_dir).await { if e.kind() != std::io::ErrorKind::AlreadyExists { diff --git a/crates/cluster/src/lib.rs b/crates/cluster/src/lib.rs index fc85adc6..52af8adb 100644 --- a/crates/cluster/src/lib.rs +++ b/crates/cluster/src/lib.rs @@ -4,32 +4,39 @@ //! This crate handles the formation, management, and coordination of validator //! clusters in the Charon network. -/// Cluster definition management and coordination. +/// `Definition` type representing the intended cluster configuration +/// (operators, validators, fork version) with EIP-712 hashing and verification. pub mod definition; -/// Cluster deposit management and coordination. +/// `DepositData` type for activating validators. pub mod deposit; -/// Cluster distributed validator management and coordination. +/// `DistValidator` type representing a distributed validator with its group +/// public key, per-node public shares, and deposit data. pub mod distvalidator; -/// Cluster EIP-712 signatures management and coordination. +/// EIP-712 typed data construction and signing for cluster definition config +/// hashes and operator ENR signatures. pub mod eip712sigs; -/// Cluster helpers management and coordination. +/// General helper utilities. pub mod helpers; -/// Cluster lock management and coordination. +/// `Lock` type representing the finalized cluster configuration, including +/// distributed validators and node signatures. pub mod lock; -/// Manifest +/// Cluster manifest types, loading, mutation, and materialization. pub mod manifest; -/// Manifest protocol buffers. +/// Generated protobuf types for the cluster manifest (v1). pub mod manifestpb; -/// Cluster operator management and coordination. +/// `Operator` type representing a charon node operator with Ethereum address, +/// ENR, and config/ENR signatures. pub mod operator; -/// Cluster registration management and coordination. +/// `BuilderRegistration` and `Registration` types for pre-generated signed +/// validator registrations sent to the builder network. pub mod registration; -/// Cluster SSZ management and coordination. +/// SSZ serialization for various cluster types. pub mod ssz; -/// Cluster SSZ hashing management and coordination. +/// Core SSZ utilities. pub mod ssz_hasher; -/// Cluster test cluster management and coordination. -#[cfg(test)] +/// Factory for constructing deterministic or random cluster locks for use in +/// tests. +#[cfg(any(test, feature = "test-cluster"))] pub mod test_cluster; -/// Cluster version management and coordination. +/// Supported cluster definition version constants and feature-flag helpers. pub mod version; diff --git a/crates/cluster/src/test_cluster.rs b/crates/cluster/src/test_cluster.rs index 7b7e6dc3..94d733c6 100644 --- a/crates/cluster/src/test_cluster.rs +++ b/crates/cluster/src/test_cluster.rs @@ -1,3 +1,5 @@ +#![allow(clippy::unwrap_used, reason = "test code")] + use crate::{definition, distvalidator, helpers, lock, operator, registration, version}; use chrono::{TimeZone, Utc}; use pluto_crypto::tbls::Tbls; diff --git a/crates/dkg/Cargo.toml b/crates/dkg/Cargo.toml index ef9f1053..75cb7bc0 100644 --- a/crates/dkg/Cargo.toml +++ b/crates/dkg/Cargo.toml @@ -9,9 +9,25 @@ publish.workspace = true [dependencies] prost.workspace = true prost-types.workspace = true +pluto-cluster.workspace = true +pluto-crypto.workspace = true +pluto-eth1wrap.workspace = true +pluto-eth2util.workspace = true +hex.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +url.workspace = true [build-dependencies] pluto-build-proto.workspace = true +[dev-dependencies] +pluto-cluster = { workspace = true, features = ["test-cluster"] } +tempfile.workspace = true + [lints] workspace = true diff --git a/crates/dkg/src/disk.rs b/crates/dkg/src/disk.rs new file mode 100644 index 00000000..d47af091 --- /dev/null +++ b/crates/dkg/src/disk.rs @@ -0,0 +1,561 @@ +use crate::{dkg, share}; +use rand::RngCore; +use std::{ + collections::{HashMap, HashSet}, + path::{self, PathBuf}, +}; +use tracing::{info, warn}; + +/// Error type for DKG disk operations. +#[derive(Debug, thiserror::Error)] +pub enum DiskError { + /// Invalid URL. + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + + /// Cluster definition fetch error. + #[error("Cluster definition fetch error: {0}")] + FetchError(#[from] pluto_cluster::helpers::FetchError), + + /// I/O error. + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + /// JSON parsing error. + #[error("JSON parsing error: {0}")] + JsonError(#[from] serde_json::Error), + + /// Cluster definition error. + #[error("Cluster definition error: {0}")] + ClusterDefinitionError(#[from] pluto_cluster::definition::DefinitionError), + + /// Deposit amounts verification error. + #[error("Deposit amounts verification failed: {0}")] + DepositAmountsVerificationError(#[from] pluto_eth2util::deposit::DepositError), + + /// Keystore operation error. + #[error("Keystore error: {0}")] + KeystoreError(#[from] pluto_eth2util::keystore::KeystoreError), + + /// Keymanager client error. + #[error("Keymanager error: {0}")] + KeymanagerClientError(#[from] pluto_eth2util::keymanager::KeymanagerError), + + /// Data directory does not exist. + #[error("data directory doesn't exist, cannot continue")] + DataDirNotFound(PathBuf), + + /// Data directory path points to a file, not a directory. + #[error("data directory already exists and is a file, cannot continue")] + DataDirIsFile(PathBuf), + + /// Data directory contains disallowed entries. + #[error("data directory not clean, cannot continue")] + DataDirNotClean { + /// Name of the disallowed file or directory. + disallowed_entity: String, + /// Path where the disallowed entity was found. + data_dir: PathBuf, + }, + + /// Data directory is missing required files. + #[error("missing required files, cannot continue")] + MissingRequiredFiles { + /// Name of the missing required file. + file_name: String, + /// Path where required file was expected. + data_dir: PathBuf, + }, +} + +type Result = std::result::Result; + +/// Returns the [`pluto_cluster::definition::Definition`] from disk or an HTTP +/// URL. It returns the test definition if configured. +pub async fn load_definition( + conf: &dkg::Config, + eth1cl: &pluto_eth1wrap::EthClient, +) -> Result { + if let Some(definition) = &conf.test_config.def { + return Ok(definition.clone()); + } + + // Fetch definition from URI or disk + + let parsed_url = url::Url::parse(&conf.def_file); + let mut def = if let Ok(url) = parsed_url + && url.has_host() + { + if url.scheme() != "https" { + warn!( + addr = conf.def_file, + "Definition file URL does not use https protocol" + ); + } + + let def = pluto_cluster::helpers::fetch_definition(url).await?; + let definition_hash = pluto_cluster::helpers::to_0x_hex(&def.definition_hash); + + info!( + url = conf.def_file, + definition_hash, "Cluster definition downloaded from URL" + ); + + def + } else { + let buf = tokio::fs::read_to_string(&conf.def_file).await?; + + let def: pluto_cluster::definition::Definition = serde_json::from_str(&buf)?; + let definition_hash = pluto_cluster::helpers::to_0x_hex(&def.definition_hash); + + info!( + path = conf.def_file, + definition_hash, "Cluster definition loaded from disk" + ); + + def + }; + + // Verify + if let Err(error) = def.verify_hashes() { + if conf.no_verify { + warn!( + error = %error, + "Ignoring failed cluster definition hashes verification due to --no-verify flag" + ); + } else { + return Err(DiskError::ClusterDefinitionError(error)); + } + } + if let Err(error) = def.verify_signatures(eth1cl).await { + if conf.no_verify { + warn!( + error = %error, + "Ignoring failed cluster definition signatures verification due to --no-verify flag" + ); + } else { + return Err(DiskError::ClusterDefinitionError(error)); + } + } + + // Ensure we have a definition hash in case of no-verify. + if def.definition_hash.is_empty() { + def.set_definition_hashes()?; + } + + pluto_eth2util::deposit::verify_deposit_amounts(&def.deposit_amounts, def.compounding)?; + + Ok(def) +} + +/// Writes validator private keyshares for the node to the provided keymanager +/// address. +pub async fn write_to_keymanager( + keymanager_url: impl AsRef, + auth_token: impl AsRef, + shares: &[share::Share], +) -> Result<()> { + let mut keystores = Vec::new(); + let mut passwords = Vec::new(); + + for share in shares { + let password = random_hex64(); + let store = pluto_eth2util::keystore::encrypt( + &share.secret_share, + &password, + None, // TODO: What to use here as argument? + &mut rand::rngs::OsRng, + )?; + + passwords.push(password); + keystores.push(store); + } + + let cl = pluto_eth2util::keymanager::Client::new(keymanager_url, auth_token)?; + cl.import_keystores(&keystores, &passwords).await?; + + Ok(()) +} + +/// Writes validator private keyshares for the node to disk. +pub async fn write_keys_to_disk( + conf: &dkg::Config, + shares: &[share::Share], + insecure: bool, +) -> Result<()> { + let secret_shares = shares.iter().map(|s| s.secret_share).collect::>(); + + let keys_dir = pluto_cluster::helpers::create_validator_keys_dir(&conf.data_dir).await?; + // TODO: All paths should be handled using `std::path::*` instead of strings. + let keys_dir = keys_dir.to_string_lossy().into_owned(); + + if insecure { + pluto_eth2util::keystore::store_keys_insecure( + &secret_shares, + keys_dir, + &pluto_eth2util::keystore::CONFIRM_INSECURE_KEYS, + ) + .await?; + } else { + pluto_eth2util::keystore::store_keys(&secret_shares, keys_dir).await?; + } + + Ok(()) +} + +/// Writes a [`pluto_cluster::lock::Lock`] to disk. +pub async fn write_lock( + data_dir: impl AsRef, + lock: &pluto_cluster::lock::Lock, +) -> Result<()> { + use serde::Serialize; + + let b = { + let mut buf = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + + lock.serialize(&mut ser)?; + buf + }; + + let path = data_dir.as_ref().join("cluster-lock.json"); + + tokio::fs::write(&path, &b).await?; + + let mut permissions = tokio::fs::metadata(&path).await?.permissions(); + permissions.set_readonly(true); + tokio::fs::set_permissions(&path, permissions).await?; + + Ok(()) +} + +/// Ensures `data_dir` exists, is a directory, and does not contain any +/// disallowed entries, while checking for the presence of necessary files. +pub async fn check_clear_data_dir(data_dir: impl AsRef) -> Result<()> { + let path = path::PathBuf::from(data_dir.as_ref()); + + match tokio::fs::metadata(&path).await { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(DiskError::DataDirNotFound(path)); + } + Err(e) => { + return Err(DiskError::IoError(e)); + } + Ok(meta) if !meta.is_dir() => { + return Err(DiskError::DataDirIsFile(path)); + } + Ok(_) => {} + } + + let disallowed = HashSet::from(["validator_keys", "cluster-lock.json"]); + let mut necessary = HashMap::from([("charon-enr-private-key", false)]); + + let mut read_dir = tokio::fs::read_dir(&path).await?; + while let Some(entry) = read_dir.next_entry().await? { + let os_string = entry.file_name(); + let name = os_string.to_string_lossy(); + + let is_deposit_data = name.starts_with("deposit-data"); + + if disallowed.contains(name.as_ref()) || is_deposit_data { + return Err(DiskError::DataDirNotClean { + disallowed_entity: name.into(), + data_dir: path, + }); + } + + if let Some(found) = necessary.get_mut(name.as_ref()) { + *found = true; + } + } + + for (file_name, found) in &necessary { + if !found { + return Err(DiskError::MissingRequiredFiles { + file_name: file_name.to_string(), + data_dir: path, + }); + } + } + + Ok(()) +} + +/// Writes sample files to check disk writes and removes sample files after +/// verification. +pub async fn check_writes(data_dir: impl AsRef) -> Result<()> { + const CHECK_BODY: &str = "delete me: dummy file used to check write permissions"; + + let base = data_dir.as_ref(); + + for file in [ + "cluster-lock.json", + "deposit-data.json", + "validator_keys/keystore-0.json", + ] { + let file_path = path::Path::new(file); + let subdir = file_path.parent().filter(|p| !p.as_os_str().is_empty()); + + if let Some(subdir) = subdir { + tokio::fs::create_dir_all(base.join(subdir)).await?; + } + + let full_path = base.join(file_path); + tokio::fs::write(&full_path, CHECK_BODY).await?; + + let mut perms = tokio::fs::metadata(&full_path).await?.permissions(); + perms.set_readonly(true); + tokio::fs::set_permissions(&full_path, perms).await?; + + tokio::fs::remove_file(&full_path).await?; + + if let Some(subdir) = subdir { + tokio::fs::remove_dir_all(base.join(subdir)).await?; + } + } + + Ok(()) +} + +/// Generate a random 32-byte value and return it as a hex string. +fn random_hex64() -> String { + let mut rng = rand::rngs::OsRng; + + let mut bytes = [0u8; 32]; + rng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +#[cfg(test)] +mod tests { + use crate::dkg; + + #[tokio::test] + async fn load_definition_valid() { + let tempdir = tempfile::tempdir().unwrap(); + let definition_path = tempdir.path().join("definition.json"); + + let (lock, ..) = pluto_cluster::test_cluster::new_for_test(1, 2, 3, 0); + let definition = &lock.definition; + let json = serde_json::to_string(definition).unwrap(); + tokio::fs::write(&definition_path, json).await.unwrap(); + + let cfg = dkg::Config { + def_file: definition_path.to_string_lossy().into(), + no_verify: false, + ..Default::default() + }; + + let client = noop_eth1_client().await; + let actual = super::load_definition(&cfg, &client).await.unwrap(); + + assert_eq!(actual, *definition); + } + + #[tokio::test] + async fn load_definition_file_does_not_exist() { + let cfg = dkg::Config { + def_file: "".into(), + no_verify: false, + ..Default::default() + }; + + let client = noop_eth1_client().await; + let result = super::load_definition(&cfg, &client).await; + + assert!(matches!(result, Err(super::DiskError::IoError(_)))); + } + + #[tokio::test] + async fn load_definition_invalid_file() { + let tempfile = tempfile::NamedTempFile::new().unwrap(); + tokio::fs::write(tempfile.path(), r#"{}"#).await.unwrap(); + + let cfg = dkg::Config { + def_file: tempfile.path().to_string_lossy().into(), + no_verify: false, + ..Default::default() + }; + + let client = noop_eth1_client().await; + let result = super::load_definition(&cfg, &client).await; + + assert!(matches!(result, Err(super::DiskError::JsonError(_)))); + } + + #[tokio::test] + async fn load_definition_invalid_definition_no_verify() { + let tempdir = tempfile::tempdir().unwrap(); + let definition_path = tempdir.path().join("definition.json"); + + let (lock, ..) = pluto_cluster::test_cluster::new_for_test(1, 2, 3, 0); + let definition = lock.definition; + + let json = { + let mut json = serde_json::to_value(&definition).unwrap(); + let as_object = json.as_object_mut().unwrap(); + // Intentionally remove the hashes to make the definition invalid + as_object.remove("config_hash"); + as_object.remove("definition_hash"); + + serde_json::to_string(&json).unwrap() + }; + tokio::fs::write(&definition_path, json).await.unwrap(); + + let cfg = dkg::Config { + def_file: definition_path.to_string_lossy().into(), + no_verify: true, // Intentionally set to `true` to bypass verification + ..Default::default() + }; + + let client = noop_eth1_client().await; + let actual = super::load_definition(&cfg, &client).await.unwrap(); + + assert_eq!(actual, definition); + } + + #[tokio::test] + async fn load_definition_invalid_definition_verify() { + let tempdir = tempfile::tempdir().unwrap(); + let definition_path = tempdir.path().join("definition.json"); + + let (lock, ..) = pluto_cluster::test_cluster::new_for_test(1, 2, 3, 0); + let definition = lock.definition; + + let json = { + let mut json = serde_json::to_value(&definition).unwrap(); + let as_object = json.as_object_mut().unwrap(); + // Intentionally remove the hashes to make the definition invalid + as_object.remove("config_hash"); + as_object.remove("definition_hash"); + + serde_json::to_string(&json).unwrap() + }; + tokio::fs::write(&definition_path, json).await.unwrap(); + + let cfg = dkg::Config { + def_file: definition_path.to_string_lossy().into(), + no_verify: false, // Verify the definition + ..Default::default() + }; + + let client = noop_eth1_client().await; + let result = super::load_definition(&cfg, &client).await; + + assert!(matches!( + result, + Err(super::DiskError::ClusterDefinitionError { .. }) + )); + } + + #[tokio::test] + async fn clear_data_dir_does_not_exist() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path().join("nonexistent"); + + let result = super::check_clear_data_dir(&data_dir).await; + assert!(matches!(result, Err(super::DiskError::DataDirNotFound(_)))); + } + + #[tokio::test] + async fn clear_data_dir_is_file() { + let temp_file = tempfile::NamedTempFile::new().unwrap(); + tokio::fs::write(temp_file.path(), [0x0, 0x1, 0x2]) + .await + .unwrap(); + + let result = super::check_clear_data_dir(temp_file.path()).await; + assert!(matches!(result, Err(super::DiskError::DataDirIsFile(_)))); + } + + #[tokio::test] + async fn clear_data_dir_contains_validator_keys_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path(); + tokio::fs::write(data_dir.join("validator_keys"), [0x0, 0x1, 0x2]) + .await + .unwrap(); + + let result = super::check_clear_data_dir(data_dir).await; + assert!(matches!( + result, + Err(super::DiskError::DataDirNotClean { .. }) + )); + } + + #[tokio::test] + async fn clear_data_dir_contains_validator_keys_dir() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path(); + tokio::fs::create_dir_all(data_dir.join("validator_keys")) + .await + .unwrap(); + + let result = super::check_clear_data_dir(data_dir).await; + assert!(matches!( + result, + Err(super::DiskError::DataDirNotClean { .. }) + )); + } + + #[tokio::test] + async fn clear_data_dir_contains_cluster_lock() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path(); + tokio::fs::write(data_dir.join("cluster-lock.json"), [0x0, 0x1, 0x2]) + .await + .unwrap(); + + let result = super::check_clear_data_dir(data_dir).await; + assert!(matches!( + result, + Err(super::DiskError::DataDirNotClean { .. }) + )); + } + + #[tokio::test] + async fn clear_data_dir_contains_deposit_data() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path(); + tokio::fs::write(data_dir.join("deposit-data-32eth.json"), [0x0, 0x1, 0x2]) + .await + .unwrap(); + + let result = super::check_clear_data_dir(data_dir).await; + assert!(matches!( + result, + Err(super::DiskError::DataDirNotClean { .. }) + )); + } + + #[tokio::test] + async fn clear_data_dir_missing_private_key() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path(); + + let result = super::check_clear_data_dir(data_dir).await; + assert!(matches!( + result, + Err(super::DiskError::MissingRequiredFiles { .. }) + )); + } + + #[tokio::test] + async fn clear_data_dir_contains_private_key() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path(); + tokio::fs::write(data_dir.join("charon-enr-private-key"), [0x0, 0x1, 0x2]) + .await + .unwrap(); + + let result = super::check_clear_data_dir(data_dir).await; + assert!(result.is_ok()); + } + + async fn noop_eth1_client() -> pluto_eth1wrap::EthClient { + pluto_eth1wrap::EthClient::new("http://0.0.0.0:0") + .await + .unwrap() + } +} diff --git a/crates/dkg/src/dkg.rs b/crates/dkg/src/dkg.rs new file mode 100644 index 00000000..b3c751fa --- /dev/null +++ b/crates/dkg/src/dkg.rs @@ -0,0 +1,23 @@ +use std::path; + +/// DKG configuration +#[derive(Debug, Clone, Default)] +pub struct Config { + /// Path to the definition file. Can be an URL or an absolute path on disk. + pub def_file: String, + /// Skip cluster definition verification. + pub no_verify: bool, + + /// Data directory to store generated keys and other DKG artifacts. + pub data_dir: path::PathBuf, + + /// Test configuration, used for testing purposes. + pub test_config: TestConfig, +} + +/// Additional test-only config for DKG. +#[derive(Debug, Clone, Default)] +pub struct TestConfig { + /// Provides the cluster definition explicitly, skips loading from disk. + pub def: Option, +} diff --git a/crates/dkg/src/lib.rs b/crates/dkg/src/lib.rs index 097bec20..34739a8f 100644 --- a/crates/dkg/src/lib.rs +++ b/crates/dkg/src/lib.rs @@ -7,3 +7,12 @@ /// Protobuf definitions. pub mod dkgpb; + +/// General DKG IO operations. +pub mod disk; + +/// Main DKG protocol implementation. +pub mod dkg; + +/// Shares distributed to each node in the cluster. +pub mod share; diff --git a/crates/dkg/src/share.rs b/crates/dkg/src/share.rs new file mode 100644 index 00000000..5cb4370a --- /dev/null +++ b/crates/dkg/src/share.rs @@ -0,0 +1,44 @@ +use std::collections::HashMap; + +/// The co-validator public key, tbls public shares, and private key share. +/// Each node in the cluster will receive one for each distributed validator. +#[derive(Debug, Clone)] +pub struct Share { + /// Public key + pub pub_key: pluto_crypto::types::PublicKey, + /// Private key share + pub secret_share: pluto_crypto::types::PrivateKey, + /// TBLS public shares, indexed by share id. + pub public_shares: HashMap, // u64 == Share index +} + +/// The [`Share`] message wire format sent by the dealer. +#[derive(Debug, Clone)] +pub struct ShareMsg { + /// Public key + pub pub_key: Vec, + /// TBLS public shares, sorted in ascending order by share id. + pub pub_shares: Vec>, + /// Private key share + pub secret_share: Vec, +} + +impl From<&Share> for ShareMsg { + fn from(share: &Share) -> Self { + let pub_key = share.pub_key.to_vec(); + let secret_share = share.secret_share.to_vec(); + + // Sort pub shares by id. + let pub_shares = { + let mut entries: Vec<_> = share.public_shares.iter().collect(); + entries.sort_by_key(|(id, _)| *id); + entries.into_iter().map(|(_, pk)| pk.to_vec()).collect() + }; + + Self { + pub_key, + pub_shares, + secret_share, + } + } +}