diff --git a/Cargo.lock b/Cargo.lock index 178ccf34..31d4c10e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5622,17 +5622,20 @@ version = "1.7.1" dependencies = [ "anyhow", "bon", + "chrono", "clap", "either", "futures", "hex", "k256", "libp2p", + "pluto-app", "pluto-build-proto", "pluto-cluster", "pluto-core", "pluto-crypto", "pluto-eth1wrap", + "pluto-eth2api", "pluto-eth2util", "pluto-k1util", "pluto-p2p", @@ -5651,6 +5654,7 @@ dependencies = [ "tokio-util", "tracing", "url", + "wiremock", "zeroize", ] diff --git a/crates/dkg/Cargo.toml b/crates/dkg/Cargo.toml index 287b01d1..3368a064 100644 --- a/crates/dkg/Cargo.toml +++ b/crates/dkg/Cargo.toml @@ -15,6 +15,7 @@ libp2p.workspace = true futures.workspace = true tokio.workspace = true tokio-util.workspace = true +chrono.workspace = true sha2.workspace = true tracing.workspace = true either.workspace = true @@ -22,7 +23,10 @@ k256.workspace = true pluto-k1util.workspace = true pluto-p2p.workspace = true pluto-cluster.workspace = true +pluto-core.workspace = true +pluto-app.workspace = true pluto-crypto.workspace = true +pluto-eth2api.workspace = true pluto-eth1wrap.workspace = true pluto-eth2util.workspace = true pluto-tracing.workspace = true @@ -48,6 +52,7 @@ pluto-tracing.workspace = true serde_json.workspace = true tokio-util.workspace = true tempfile.workspace = true +wiremock.workspace = true [lints] workspace = true diff --git a/crates/dkg/src/aggregate.rs b/crates/dkg/src/aggregate.rs new file mode 100644 index 00000000..d0ef3b1e --- /dev/null +++ b/crates/dkg/src/aggregate.rs @@ -0,0 +1,520 @@ +use std::collections::HashMap; + +use pluto_core::{ + signeddata::{SignedDataError, VersionedSignedValidatorRegistration}, + types::{ParSignedData, PubKey}, +}; +use pluto_crypto::{ + blst_impl::BlstImpl, + tbls::Tbls, + tblsconv::signature_from_bytes, + types::{PublicKey, Signature}, +}; +use pluto_eth2api::spec::phase0; +use pluto_eth2util::{deposit, registration}; + +use crate::{ + share::Share, + validators::{ValidatorsError, set_registration_signature}, +}; + +/// Result type for DKG aggregation helpers. +pub type Result = std::result::Result; + +/// Error type for DKG aggregation helpers. +#[derive(Debug, thiserror::Error)] +pub enum AggregateError { + /// Failed to convert raw bytes into a threshold signature. + #[error(transparent)] + SignatureBytes(#[from] pluto_crypto::tblsconv::ConvError), + + /// Failed to verify or aggregate threshold signatures. + #[error(transparent)] + Crypto(#[from] pluto_crypto::types::Error), + + /// Failed to derive the deposit signing root. + #[error(transparent)] + Deposit(#[from] deposit::DepositError), + + /// Failed to derive the validator-registration signing root. + #[error(transparent)] + Registration(#[from] registration::RegistrationError), + + /// Failed to update the aggregated validator registration. + #[error(transparent)] + Validators(#[from] ValidatorsError), + + /// Failed to extract a signature from partially signed data. + #[error(transparent)] + SignedData(#[from] SignedDataError), + + /// Partial signatures referenced a pubkey that is not in the local share + /// set. + #[error("invalid pubkey in {context} partial signature from peer")] + InvalidPubKeyFromPeer { + /// Context string for the error. + context: &'static str, + }, + + /// Partial signatures referenced a missing public share. + #[error("invalid pubshare")] + InvalidPubshare, + + /// Partial signature verification failed for deposit data. + #[error("invalid deposit data partial signature from peer")] + InvalidDepositPartialSignature, + + /// Partial signature verification failed for validator registrations. + #[error("invalid validator registration partial signature from peer")] + InvalidValidatorRegistrationPartialSignature, + + /// Partial signature verification failed for lock hash. + #[error("invalid lock hash partial signature from peer: {0}")] + InvalidLockHashPartialSignature(pluto_crypto::types::Error), + + /// Aggregate signature verification failed for deposit data. + #[error("invalid deposit data aggregated signature: {0}")] + InvalidDepositAggregatedSignature(pluto_crypto::types::Error), + + /// Aggregate signature verification failed for validator registrations. + #[error("invalid validator registration aggregated signature: {0}")] + InvalidValidatorRegistrationAggregatedSignature(pluto_crypto::types::Error), + + /// Deposit message was missing for a validator. + #[error("deposit message not found")] + DepositMessageNotFound, + + /// Validator registration was missing for a validator. + #[error("validator registration not found")] + ValidatorRegistrationNotFound, + + /// Failed to convert a share index to the threshold-signature index type. + #[error(transparent)] + ShareIndex(#[from] std::num::TryFromIntError), + + /// Fork version is not 4 bytes. + #[error("invalid fork version length")] + InvalidForkVersionLength, +} + +/// Aggregates all lock-hash partial signatures across validators. +pub fn agg_lock_hash_sig( + data: &HashMap>, + shares: &HashMap, + hash: &[u8], +) -> Result<(Signature, Vec)> { + let mut sigs = Vec::new(); + let mut pubkeys = Vec::new(); + + for (pub_key, partials) in data { + let share = shares + .get(pub_key) + .ok_or(AggregateError::InvalidPubKeyFromPeer { + context: "lock hash", + })?; + + for partial in partials { + let sig = extract_partial_signature(partial)?; + let pubshare = share + .public_shares + .get(&partial.share_idx) + .ok_or(AggregateError::InvalidPubshare)?; + + BlstImpl + .verify(pubshare, hash, &sig) + .map_err(AggregateError::InvalidLockHashPartialSignature)?; + + sigs.push(sig); + pubkeys.push(*pubshare); + } + } + + Ok((BlstImpl.aggregate(&sigs)?, pubkeys)) +} + +/// Aggregates threshold deposit-data signatures per validator. +pub fn agg_deposit_data( + data: &HashMap>, + shares: &[Share], + msgs: &HashMap, + network_name: &str, +) -> Result> { + let shares_by_pubkey = shares_by_pubkey(shares)?; + let mut res = Vec::with_capacity(data.len()); + + for (pub_key, partials) in data { + let msg = msgs + .get(pub_key) + .ok_or(AggregateError::DepositMessageNotFound)?; + let sig_root = deposit::get_message_signing_root(msg, network_name)?; + let share = shares_by_pubkey + .get(pub_key) + .ok_or(AggregateError::InvalidPubKeyFromPeer { + context: "deposit data", + })?; + let partial_sigs = + verify_threshold_partials(partials, &share.public_shares, &sig_root, || { + AggregateError::InvalidDepositPartialSignature + })?; + + let agg_sig = BlstImpl.threshold_aggregate(&partial_sigs)?; + BlstImpl + .verify(&share.pub_key, &sig_root, &agg_sig) + .map_err(AggregateError::InvalidDepositAggregatedSignature)?; + + res.push(phase0::DepositData { + pubkey: msg.pubkey, + withdrawal_credentials: msg.withdrawal_credentials, + amount: msg.amount, + signature: agg_sig, + }); + } + + Ok(res) +} + +/// Aggregates threshold validator-registration signatures per validator. +pub fn agg_validator_registrations( + data: &HashMap>, + shares: &[Share], + msgs: &HashMap, + fork_version: &[u8], +) -> Result> { + let shares_by_pubkey = shares_by_pubkey(shares)?; + let fork_version: phase0::Version = fork_version + .try_into() + .map_err(|_| AggregateError::InvalidForkVersionLength)?; + let mut res = Vec::with_capacity(data.len()); + + for (pub_key, partials) in data { + let msg = msgs + .get(pub_key) + .ok_or(AggregateError::ValidatorRegistrationNotFound)?; + let v1 = msg + .0 + .v1 + .as_ref() + .ok_or(ValidatorsError::MissingV1Registration)?; + let sig_root = registration::get_message_signing_root(&v1.message, fork_version); + let share = shares_by_pubkey + .get(pub_key) + .ok_or(AggregateError::InvalidPubKeyFromPeer { + context: "validator registrations", + })?; + let partial_sigs = + verify_threshold_partials(partials, &share.public_shares, &sig_root, || { + AggregateError::InvalidValidatorRegistrationPartialSignature + })?; + + let agg_sig = BlstImpl.threshold_aggregate(&partial_sigs)?; + BlstImpl + .verify(&share.pub_key, &sig_root, &agg_sig) + .map_err(AggregateError::InvalidValidatorRegistrationAggregatedSignature)?; + + res.push(set_registration_signature( + msg, + pluto_core::types::Signature::new(agg_sig), + )?); + } + + Ok(res) +} + +fn shares_by_pubkey(shares: &[Share]) -> Result> { + shares + .iter() + .map(|share| { + let pub_key = PubKey::try_from(share.pub_key.as_slice()).map_err(|_| { + AggregateError::InvalidPubKeyFromPeer { + context: "local share", + } + })?; + Ok((pub_key, share)) + }) + .collect() +} + +fn extract_partial_signature(partial: &ParSignedData) -> Result { + let sig = partial.signed_data.signature()?; + Ok(signature_from_bytes(sig.as_ref())?) +} + +fn verify_threshold_partials( + partials: &[ParSignedData], + public_shares: &HashMap, + message: &[u8], + invalid_signature_error: fn() -> AggregateError, +) -> Result> { + let mut res = HashMap::with_capacity(partials.len()); + + for partial in partials { + let sig = extract_partial_signature(partial)?; + let pubshare = public_shares + .get(&partial.share_idx) + .ok_or(AggregateError::InvalidPubshare)?; + + BlstImpl + .verify(pubshare, message, &sig) + .map_err(|_| invalid_signature_error())?; + + res.insert(u8::try_from(partial.share_idx)?, sig); + } + + Ok(res) +} + +#[cfg(test)] +mod tests { + use super::*; + + use pluto_core::signeddata::VersionedSignedValidatorRegistration as CoreRegistration; + use pluto_crypto::tblsconv::pubkey_to_eth2; + use pluto_eth2api::{ + v1, + versioned::{BuilderVersion, VersionedSignedValidatorRegistration}, + }; + use pluto_eth2util::network; + use rand::SeedableRng; + + fn build_share_fixture() -> (Share, HashMap) { + let tbls = BlstImpl; + let secret = tbls + .generate_insecure_secret(rand::rngs::StdRng::seed_from_u64(7)) + .expect("secret generation should succeed"); + let pub_key = tbls + .secret_to_public_key(&secret) + .expect("public key derivation should succeed"); + let secret_shares = tbls + .threshold_split(&secret, 4, 3) + .expect("threshold split should succeed"); + let public_shares = secret_shares + .iter() + .map(|(idx, share)| { + ( + u64::from(*idx), + tbls.secret_to_public_key(share) + .expect("public share derivation should succeed"), + ) + }) + .collect(); + + ( + Share { + pub_key, + secret_share: *secret_shares.get(&1).expect("share 1 should exist"), + public_shares, + }, + secret_shares, + ) + } + + fn partial_signature(sig: Signature, share_idx: u64) -> ParSignedData { + ParSignedData::new(pluto_core::types::Signature::new(sig), share_idx) + } + + #[test] + fn agg_deposit_data_rejects_invalid_partial_signature() { + let (share, secret_shares) = build_share_fixture(); + let core_pub_key = PubKey::try_from(share.pub_key.as_slice()).expect("pubkey should fit"); + let msg = deposit::new_message( + pubkey_to_eth2(share.pub_key), + "0x000000000000000000000000000000000000dEaD", + deposit::DEFAULT_DEPOSIT_AMOUNT, + true, + ) + .expect("message should build"); + let sig_root = + deposit::get_message_signing_root(&msg, "goerli").expect("root should build"); + let mut partials = Vec::new(); + + for idx in [1_u8, 2, 3] { + let message = if idx == 3 { + b"invalid msg".as_slice() + } else { + &sig_root + }; + let sig = BlstImpl + .sign( + secret_shares.get(&idx).expect("share should exist"), + message, + ) + .expect("signing should succeed"); + partials.push(partial_signature(sig, u64::from(idx))); + } + + let err = agg_deposit_data( + &HashMap::from([(core_pub_key, partials)]), + &[share], + &HashMap::from([(core_pub_key, msg)]), + "goerli", + ) + .expect_err("aggregation should fail"); + + assert!(matches!( + err, + AggregateError::InvalidDepositPartialSignature + )); + } + + #[test] + fn agg_lock_hash_sig_rejects_invalid_partial_signature() { + let (share, secret_shares) = build_share_fixture(); + let core_pub_key = PubKey::try_from(share.pub_key.as_slice()).expect("pubkey should fit"); + let hash = b"cluster lock hash"; + let mut partials = Vec::new(); + + for idx in [1_u8, 2, 3] { + let message = if idx == 3 { + b"invalid msg".as_slice() + } else { + hash + }; + let sig = BlstImpl + .sign( + secret_shares.get(&idx).expect("share should exist"), + message, + ) + .expect("signing should succeed"); + partials.push(partial_signature(sig, u64::from(idx))); + } + + let err = agg_lock_hash_sig( + &HashMap::from([(core_pub_key, partials)]), + &HashMap::from([(core_pub_key, share)]), + hash, + ) + .expect_err("aggregation should fail"); + + assert!(matches!( + err, + AggregateError::InvalidLockHashPartialSignature(_) + )); + } + + #[test] + fn agg_deposit_data_accepts_valid_signatures() { + let (share, secret_shares) = build_share_fixture(); + let core_pub_key = PubKey::try_from(share.pub_key.as_slice()).expect("pubkey should fit"); + let msg = deposit::new_message( + pubkey_to_eth2(share.pub_key), + "0x000000000000000000000000000000000000dEaD", + deposit::DEFAULT_DEPOSIT_AMOUNT, + true, + ) + .expect("message should build"); + let sig_root = + deposit::get_message_signing_root(&msg, "goerli").expect("root should build"); + let partials = [1_u8, 2, 3] + .into_iter() + .map(|idx| { + partial_signature( + BlstImpl + .sign( + secret_shares.get(&idx).expect("share should exist"), + &sig_root, + ) + .expect("signing should succeed"), + u64::from(idx), + ) + }) + .collect::>(); + + let deposit_datas = agg_deposit_data( + &HashMap::from([(core_pub_key, partials)]), + &[share], + &HashMap::from([(core_pub_key, msg)]), + "goerli", + ) + .expect("aggregation should succeed"); + + assert_eq!(deposit_datas.len(), 1); + } + + #[test] + fn agg_lock_hash_sig_accepts_valid_signatures() { + let (share, secret_shares) = build_share_fixture(); + let core_pub_key = PubKey::try_from(share.pub_key.as_slice()).expect("pubkey should fit"); + let hash = b"cluster lock hash"; + let partials = [1_u8, 2, 3] + .into_iter() + .map(|idx| { + partial_signature( + BlstImpl + .sign(secret_shares.get(&idx).expect("share should exist"), hash) + .expect("signing should succeed"), + u64::from(idx), + ) + }) + .collect::>(); + + let (sig, pubkeys) = agg_lock_hash_sig( + &HashMap::from([(core_pub_key, partials)]), + &HashMap::from([(core_pub_key, share)]), + hash, + ) + .expect("aggregation should succeed"); + + assert_ne!(sig, [0; 96]); + assert_eq!(pubkeys.len(), 3); + } + + #[test] + fn agg_validator_registrations_rejects_unknown_pubkeys() { + let (share, secret_shares) = build_share_fixture(); + let pub_key = pubkey_to_eth2(share.pub_key); + let reg_msg = registration::new_message( + pub_key, + "0x000000000000000000000000000000000000dEaD", + registration::DEFAULT_GAS_LIMIT, + 1_616_508_000, + ) + .expect("message should build"); + let sig_root = registration::get_message_signing_root( + ®_msg, + network::network_to_fork_version_bytes("goerli") + .expect("network should exist") + .as_slice() + .try_into() + .expect("fork version should fit"), + ); + let partials = [1_u8, 2, 3] + .into_iter() + .map(|idx| { + partial_signature( + BlstImpl + .sign( + secret_shares.get(&idx).expect("share should exist"), + &sig_root, + ) + .expect("signing should succeed"), + u64::from(idx), + ) + }) + .collect::>(); + + let reg = CoreRegistration::new(VersionedSignedValidatorRegistration { + version: BuilderVersion::V1, + v1: Some(v1::SignedValidatorRegistration { + message: reg_msg, + signature: [0; 96], + }), + }) + .expect("registration wrapper should be valid"); + let unknown_pubkey = PubKey::new([0x42; 48]); + + let err = agg_validator_registrations( + &HashMap::from([(unknown_pubkey, partials)]), + &[share], + &HashMap::from([(unknown_pubkey, reg)]), + &network::network_to_fork_version_bytes("goerli").expect("network should exist"), + ) + .expect_err("aggregation should fail"); + + assert!(matches!( + err, + AggregateError::InvalidPubKeyFromPeer { + context: "validator registrations" + } + )); + } +} diff --git a/crates/dkg/src/dkg.rs b/crates/dkg/src/dkg.rs index 358b09fa..62f34c16 100644 --- a/crates/dkg/src/dkg.rs +++ b/crates/dkg/src/dkg.rs @@ -1,8 +1,19 @@ use std::{path, time::Duration}; use bon::Builder; +use libp2p::PeerId; use tokio_util::sync::CancellationToken; -use tracing::warn; +use tracing::{info, warn}; + +pub use crate::{ + aggregate::{AggregateError, agg_deposit_data, agg_lock_hash_sig, agg_validator_registrations}, + publish::{PublishError, write_lock_to_api}, + signing::{SigningError, sign_deposit_msgs, sign_lock_hash, sign_validator_registrations}, + validators::{ + ValidatorsError, builder_registration_from_eth2, create_dist_validators, + set_registration_signature, + }, +}; const DEFAULT_DATA_DIR: &str = ".charon"; const DEFAULT_DEFINITION_FILE: &str = ".charon/cluster-definition.json"; @@ -50,6 +61,21 @@ pub enum DkgError { /// Failed to verify keymanager connectivity. #[error("verify keymanager address: {0}")] Keymanager(#[from] pluto_eth2util::keymanager::KeymanagerError), + + /// Failed to decode distributed validator data from the existing lock. + #[error("existing shares lock decode failed: {0}")] + DistValidator(#[from] pluto_cluster::distvalidator::DistValidatorError), + + /// There are more secret shares than distributed validators in the lock. + #[error( + "existing shares input invalid: got {secret_shares} secret shares for {validators} distributed validators" + )] + ExistingSharesCountMismatch { + /// Number of secret shares provided. + secret_shares: usize, + /// Number of distributed validators present in the lock. + validators: usize, + }, } /// Keymanager configuration accepted by the entrypoint. @@ -212,6 +238,87 @@ fn validate_keymanager_flags(conf: &Config) -> Result<(), DkgError> { Ok(()) } +/// Logs peer summary with peer names and operator addresses. +pub fn log_peer_summary( + current_peer: PeerId, + peers: &[pluto_p2p::peer::Peer], + operators: &[pluto_cluster::operator::Operator], +) { + for (idx, peer) in peers.iter().enumerate() { + let address = operators + .get(idx) + .filter(|operator| !operator.address.is_empty()) + .map(|operator| operator.address.as_str()); + let is_current_peer = peer.id == current_peer; + + if let Some(address) = address { + if is_current_peer { + info!( + peer = peer.name, + index = peer.index, + address, + you = "⭐️", + "Peer summary" + ); + } else { + info!( + peer = peer.name, + index = peer.index, + address, + "Peer summary" + ); + } + } else if is_current_peer { + info!( + peer = peer.name, + index = peer.index, + you = "⭐️", + "Peer summary" + ); + } else { + info!(peer = peer.name, index = peer.index, "Peer summary"); + } + } +} + +/// Rebuilds existing shares from a cluster lock plus the local secret shares. +pub fn get_existing_shares( + lock: &pluto_cluster::lock::Lock, + secret_shares: &[pluto_crypto::types::PrivateKey], +) -> Result, DkgError> { + if secret_shares.len() > lock.distributed_validators.len() { + return Err(DkgError::ExistingSharesCountMismatch { + secret_shares: secret_shares.len(), + validators: lock.distributed_validators.len(), + }); + } + + let mut shares = Vec::with_capacity(secret_shares.len()); + + for (idx, secret_share) in secret_shares.iter().enumerate() { + let validator = &lock.distributed_validators[idx]; + let pub_key = validator.public_key()?; + + let mut public_shares = + std::collections::HashMap::with_capacity(validator.pub_shares.len()); + for share_idx in 0..validator.pub_shares.len() { + let share_id = u64::try_from(share_idx) + .expect("share index should fit in u64") + .checked_add(1) + .expect("share index should not overflow"); + public_shares.insert(share_id, validator.public_share(share_idx)?); + } + + shares.push(crate::share::Share { + pub_key, + secret_share: *secret_share, + public_shares, + }); + } + + Ok(shares) +} + async fn verify_keymanager_connection(conf: &Config) -> Result<(), DkgError> { let addr = conf.keymanager.address.as_str(); @@ -252,6 +359,48 @@ mod tests { assert!(config.test_config.def.is_none()); } + #[test] + fn get_existing_shares_rebuilds_share_shape_from_lock() { + let (lock, _, dv_shares) = pluto_cluster::test_cluster::new_for_test(2, 3, 4, 1); + let secret_shares = dv_shares.iter().map(|shares| shares[0]).collect::>(); + + let shares = get_existing_shares(&lock, &secret_shares).unwrap(); + + assert_eq!(shares.len(), secret_shares.len()); + + for (idx, share) in shares.iter().enumerate() { + let validator = &lock.distributed_validators[idx]; + + assert_eq!(share.secret_share, secret_shares[idx]); + assert_eq!(share.pub_key, validator.public_key().unwrap()); + assert_eq!(share.public_shares.len(), validator.pub_shares.len()); + + for share_idx in 0..validator.pub_shares.len() { + assert_eq!( + share.public_shares.get(&((share_idx + 1) as u64)), + Some(&validator.public_share(share_idx).unwrap()) + ); + } + } + } + + #[test] + fn get_existing_shares_rejects_more_secret_shares_than_validators() { + let (lock, _, dv_shares) = pluto_cluster::test_cluster::new_for_test(2, 3, 4, 1); + let mut secret_shares = dv_shares.iter().map(|shares| shares[0]).collect::>(); + secret_shares.push([0x55; 32]); + + let err = get_existing_shares(&lock, &secret_shares).unwrap_err(); + + assert!(matches!( + err, + DkgError::ExistingSharesCountMismatch { + secret_shares: 3, + validators: 2 + } + )); + } + #[tokio::test] async fn run_rejects_mismatched_keymanager_flags() { let (lock, ..) = pluto_cluster::test_cluster::new_for_test(1, 3, 4, 0); diff --git a/crates/dkg/src/lib.rs b/crates/dkg/src/lib.rs index 79e95f2c..8d67e945 100644 --- a/crates/dkg/src/lib.rs +++ b/crates/dkg/src/lib.rs @@ -11,6 +11,9 @@ pub mod dkgpb; /// Reliable broadcast protocol for DKG messages. pub mod bcast; +/// Partial-signature verification and aggregation helpers. +mod aggregate; + /// General DKG IO operations. pub mod disk; @@ -20,5 +23,14 @@ pub mod dkg; /// Node signature exchange over the lock hash. pub mod nodesigs; +/// Lock publishing helpers. +mod publish; + /// Shares distributed to each node in the cluster. pub mod share; + +/// Local DKG signing helpers. +mod signing; + +/// Registration conversion and distributed-validator assembly helpers. +mod validators; diff --git a/crates/dkg/src/publish.rs b/crates/dkg/src/publish.rs new file mode 100644 index 00000000..a173e94b --- /dev/null +++ b/crates/dkg/src/publish.rs @@ -0,0 +1,58 @@ +use std::time::Duration; + +use pluto_app::obolapi; +use pluto_cluster::lock::Lock; +use tracing::info; + +/// Result type for DKG publish helpers. +pub type Result = std::result::Result; + +/// Error type for DKG publish helpers. +#[derive(Debug, thiserror::Error)] +pub enum PublishError { + /// Failed to create or use the Obol API client. + #[error(transparent)] + ObolApi(#[from] obolapi::ObolApiError), +} + +/// Publishes the lock file and returns the launchpad dashboard URL. +pub async fn write_lock_to_api( + publish_addr: &str, + lock: &Lock, + timeout: Duration, +) -> Result { + let client = obolapi::Client::new( + publish_addr, + obolapi::ClientOptions::builder().timeout(timeout).build(), + )?; + + client.publish_lock(lock.clone()).await?; + info!(addr = publish_addr, "Published lock file"); + + Ok(client.launchpad_url_for_lock(lock)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn write_lock_to_api_publishes_and_returns_launchpad_url() { + let server = wiremock::MockServer::start().await; + let (lock, ..) = pluto_cluster::test_cluster::new_for_test(1, 3, 4, 0); + + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/lock")) + .respond_with(wiremock::ResponseTemplate::new(200)) + .mount(&server) + .await; + + let url = write_lock_to_api(&server.uri(), &lock, Duration::from_secs(3)) + .await + .expect("publish should succeed"); + let client = obolapi::Client::new(&server.uri(), obolapi::ClientOptions::default()) + .expect("client should build"); + + assert_eq!(url, client.launchpad_url_for_lock(&lock).unwrap()); + } +} diff --git a/crates/dkg/src/signing.rs b/crates/dkg/src/signing.rs new file mode 100644 index 00000000..b152a527 --- /dev/null +++ b/crates/dkg/src/signing.rs @@ -0,0 +1,291 @@ +use std::collections::HashMap; + +use crate::share::Share; +use pluto_core::{ + signeddata::VersionedSignedValidatorRegistration, + types::{ParSignedData, ParSignedDataSet, PubKey}, +}; +use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls, tblsconv::pubkey_to_eth2}; +use pluto_eth2api::{spec::phase0, v1, versioned}; +use pluto_eth2util::{deposit, network, registration}; + +/// Result type for DKG signing helpers. +pub type Result = std::result::Result; + +/// Error type for DKG signing helpers. +#[derive(Debug, thiserror::Error)] +pub enum SigningError { + /// Failed to build a core public key from bytes. + #[error("invalid public key length")] + InvalidPublicKeyLength, + + /// Failed to sign or verify with threshold BLS. + #[error(transparent)] + Crypto(#[from] pluto_crypto::types::Error), + + /// Failed to build or hash deposit data. + #[error(transparent)] + Deposit(#[from] deposit::DepositError), + + /// Failed to normalize the withdrawal address. + #[error(transparent)] + Helper(#[from] pluto_eth2util::helpers::HelperError), + + /// Failed to build or hash validator registrations. + #[error(transparent)] + Registration(#[from] registration::RegistrationError), + + /// Failed to resolve network metadata from the fork version. + #[error(transparent)] + Network(#[from] network::NetworkError), + + /// Fork version is not 4 bytes. + #[error("invalid fork version length")] + InvalidForkVersionLength, + + /// Failed to build a versioned validator registration wrapper. + #[error(transparent)] + SignedData(#[from] pluto_core::signeddata::SignedDataError), + + /// Failed to convert a timestamp to seconds. + #[error(transparent)] + Timestamp(#[from] std::num::TryFromIntError), + + /// Withdrawal addresses do not cover all shares. + #[error("insufficient withdrawal addresses")] + InsufficientWithdrawalAddresses, + + /// Fee recipients do not cover all shares. + #[error("insufficient fee recipients")] + InsufficientFeeRecipients, +} + +/// Returns partially signed signatures of the lock hash. +pub fn sign_lock_hash(share_idx: u64, shares: &[Share], hash: &[u8]) -> Result { + let mut set = ParSignedDataSet::new(); + + for share in shares { + let pub_key = PubKey::try_from(share.pub_key.as_slice()) + .map_err(|_| SigningError::InvalidPublicKeyLength)?; + let sig = BlstImpl.sign(&share.secret_share, hash)?; + + set.insert( + pub_key, + ParSignedData::new(pluto_core::types::Signature::new(sig), share_idx), + ); + } + + Ok(set) +} + +/// Returns partially signed deposit-message signatures keyed by validator +/// pubkey. +pub fn sign_deposit_msgs( + shares: &[Share], + share_idx: u64, + withdrawal_addresses: &[String], + network_name: &str, + amount: phase0::Gwei, + compounding: bool, +) -> Result<(ParSignedDataSet, HashMap)> { + if shares.len() != withdrawal_addresses.len() { + return Err(SigningError::InsufficientWithdrawalAddresses); + } + + let mut msgs = HashMap::with_capacity(shares.len()); + let mut set = ParSignedDataSet::new(); + + for (share, withdrawal_address) in shares.iter().zip(withdrawal_addresses.iter()) { + let eth2_pubkey = pubkey_to_eth2(share.pub_key); + let pub_key = share_pubkey(share)?; + let withdrawal_address = pluto_eth2util::helpers::checksum_address(withdrawal_address)?; + + let msg = deposit::new_message(eth2_pubkey, &withdrawal_address, amount, compounding)?; + let sig_root = deposit::get_message_signing_root(&msg, network_name)?; + let sig = BlstImpl.sign(&share.secret_share, &sig_root)?; + + set.insert( + pub_key, + ParSignedData::new(pluto_core::types::Signature::new(sig), share_idx), + ); + msgs.insert(pub_key, msg); + } + + Ok((set, msgs)) +} + +/// Returns partially signed validator registrations keyed by validator pubkey. +pub fn sign_validator_registrations( + shares: &[Share], + share_idx: u64, + fee_recipients: &[String], + gas_limit: u64, + fork_version: &[u8], +) -> Result<( + ParSignedDataSet, + HashMap, +)> { + if shares.len() != fee_recipients.len() { + return Err(SigningError::InsufficientFeeRecipients); + } + + let timestamp = network::fork_version_to_genesis_time(fork_version)?; + let fork_version: phase0::Version = fork_version + .try_into() + .map_err(|_| SigningError::InvalidForkVersionLength)?; + + let mut msgs = HashMap::with_capacity(shares.len()); + let mut set = ParSignedDataSet::new(); + + for (share, fee_recipient) in shares.iter().zip(fee_recipients.iter()) { + let eth2_pubkey = pubkey_to_eth2(share.pub_key); + let pub_key = share_pubkey(share)?; + + let reg_msg = registration::new_message( + eth2_pubkey, + fee_recipient, + gas_limit, + u64::try_from(timestamp.timestamp())?, + )?; + let sig_root = registration::get_message_signing_root(®_msg, fork_version); + let sig = BlstImpl.sign(&share.secret_share, &sig_root)?; + + let signed_reg = VersionedSignedValidatorRegistration::new( + versioned::VersionedSignedValidatorRegistration { + version: versioned::BuilderVersion::V1, + v1: Some(v1::SignedValidatorRegistration { + message: reg_msg, + signature: sig, + }), + }, + )?; + + set.insert( + pub_key, + ParSignedData::new(pluto_core::types::Signature::new(sig), share_idx), + ); + msgs.insert(pub_key, signed_reg); + } + + Ok((set, msgs)) +} + +fn share_pubkey(share: &Share) -> Result { + PubKey::try_from(share.pub_key.as_slice()).map_err(|_| SigningError::InvalidPublicKeyLength) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + + fn build_shares(num_validators: usize, total: u8, threshold: u8, share_idx: u8) -> Vec { + let mut res = Vec::with_capacity(num_validators); + + for seed in 0..num_validators { + let secret = BlstImpl + .generate_insecure_secret(rand::rngs::StdRng::seed_from_u64( + u64::try_from(seed) + .expect("seed should fit") + .checked_add(1) + .expect("seed increment should not overflow"), + )) + .expect("secret generation should succeed"); + let pub_key = BlstImpl + .secret_to_public_key(&secret) + .expect("public key derivation should succeed"); + let shares = BlstImpl + .threshold_split(&secret, total, threshold) + .expect("threshold split should succeed"); + + res.push(Share { + pub_key, + secret_share: *shares + .get(&share_idx) + .expect("requested share index should exist"), + public_shares: shares + .into_iter() + .map(|(idx, secret_share)| { + ( + u64::from(idx), + BlstImpl + .secret_to_public_key(&secret_share) + .expect("public share derivation should succeed"), + ) + }) + .collect(), + }); + } + + res + } + + #[test] + fn sign_deposit_msgs_returns_one_message_per_share() { + let shares = build_shares(2, 4, 3, 1); + let withdrawal_addresses = vec![ + "0x000000000000000000000000000000000000dEaD".to_string(), + "0x000000000000000000000000000000000000bEEF".to_string(), + ]; + + let (set, msgs) = sign_deposit_msgs( + &shares, + 1, + &withdrawal_addresses, + "goerli", + deposit::DEFAULT_DEPOSIT_AMOUNT, + true, + ) + .expect("deposit signing should succeed"); + + assert_eq!(set.inner().len(), 2); + assert_eq!(msgs.len(), 2); + for (share, withdrawal_address) in shares.iter().zip(withdrawal_addresses.iter()) { + let pub_key = PubKey::try_from(share.pub_key.as_slice()).expect("pubkey should fit"); + let msg = msgs.get(&pub_key).expect("message should exist"); + let expected = deposit::new_message( + share.pub_key, + withdrawal_address, + deposit::DEFAULT_DEPOSIT_AMOUNT, + true, + ) + .expect("message should build"); + assert_eq!(*msg, expected); + assert_eq!( + set.get(&pub_key).expect("signature should exist").share_idx, + 1 + ); + } + } + + #[test] + fn sign_validator_registrations_uses_fork_version_timestamp() { + let shares = build_shares(1, 4, 3, 1); + let fork_version = + network::network_to_fork_version_bytes("goerli").expect("network should exist"); + let (set, msgs) = sign_validator_registrations( + &shares, + 1, + &["0x000000000000000000000000000000000000dEaD".to_string()], + registration::DEFAULT_GAS_LIMIT, + &fork_version, + ) + .expect("registration signing should succeed"); + + let pub_key = PubKey::try_from(shares[0].pub_key.as_slice()).expect("pubkey should fit"); + let msg = msgs.get(&pub_key).expect("message should exist"); + let expected_timestamp = network::fork_version_to_genesis_time(&fork_version) + .expect("fork version should be valid") + .timestamp(); + + let v1 = msg.0.v1.as_ref().expect("v1 payload should exist"); + assert_eq!( + i64::try_from(v1.message.timestamp).expect("timestamp should fit"), + expected_timestamp + ); + assert_eq!( + set.get(&pub_key).expect("signature should exist").share_idx, + 1 + ); + } +} diff --git a/crates/dkg/src/validators.rs b/crates/dkg/src/validators.rs new file mode 100644 index 00000000..38dd836d --- /dev/null +++ b/crates/dkg/src/validators.rs @@ -0,0 +1,338 @@ +use std::collections::HashMap; + +use pluto_cluster::{ + deposit::DepositData, + distvalidator::DistValidator, + registration::{BuilderRegistration, Registration}, +}; +use pluto_core::{ + signeddata::{SignedDataError, VersionedSignedValidatorRegistration}, + types::SignedData, +}; +use pluto_eth2api::{spec::phase0, v1, versioned}; + +use crate::share::{Share, ShareMsg}; + +/// Result type for DKG validator helpers. +pub type Result = std::result::Result; + +/// Error type for DKG validator helpers. +#[derive(Debug, thiserror::Error)] +pub enum ValidatorsError { + /// Builder registration payload is missing. + #[error("no V1 registration")] + MissingV1Registration, + + /// Builder registration version is unsupported. + #[error("unknown version")] + UnknownVersion, + + /// Failed to update the registration signature. + #[error(transparent)] + SignedData(#[from] SignedDataError), + + /// Registration timestamp is outside the supported range. + #[error("invalid registration timestamp: {0}")] + InvalidRegistrationTimestamp(u64), + + /// Validator registration for a distributed validator was not found. + #[error("validator registration not found")] + ValidatorRegistrationNotFound, + + /// Deposit data for the given distributed validator public key was not + /// found. + #[error("deposit data not found for pubkey: {0}")] + DepositDataNotFound(String), +} + +/// Converts a versioned validator registration into cluster lock format. +pub fn builder_registration_from_eth2( + reg: &VersionedSignedValidatorRegistration, +) -> Result { + let v1 = registration_v1(reg)?; + + Ok(BuilderRegistration { + message: Registration { + fee_recipient: v1.message.fee_recipient, + gas_limit: v1.message.gas_limit, + timestamp: chrono::DateTime::from_timestamp( + i64::try_from(v1.message.timestamp).map_err(|_| { + ValidatorsError::InvalidRegistrationTimestamp(v1.message.timestamp) + })?, + 0, + ) + .ok_or(ValidatorsError::InvalidRegistrationTimestamp( + v1.message.timestamp, + ))?, + pub_key: v1.message.pubkey, + }, + signature: v1.signature, + }) +} + +/// Returns a copy of the registration with the signature replaced. +pub fn set_registration_signature( + reg: &VersionedSignedValidatorRegistration, + sig: pluto_core::types::Signature, +) -> Result { + Ok(reg.set_signature(sig)?) +} + +/// Builds distributed validators from shares, deposit data, and registrations. +pub fn create_dist_validators( + shares: &[Share], + deposit_datas: &[Vec], + val_regs: &[VersionedSignedValidatorRegistration], +) -> 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.pubkey) + .or_default() + .push(deposit_data_from_phase0(dd)); + } + } + + let registrations_by_pubkey: HashMap = val_regs + .iter() + .map(|reg| { + Ok(( + registration_pubkey(reg)?, + builder_registration_from_eth2(reg)?, + )) + }) + .collect::>()?; + + let mut dvs = Vec::with_capacity(shares.len()); + for share in shares { + let msg = ShareMsg::from(share); + let builder_registration = registrations_by_pubkey + .get(&share.pub_key) + .cloned() + .ok_or(ValidatorsError::ValidatorRegistrationNotFound)?; + + let partial_deposit_data = deposit_datas_map + .remove(&share.pub_key) + .ok_or_else(|| ValidatorsError::DepositDataNotFound(hex::encode(share.pub_key)))?; + + dvs.push(DistValidator { + pub_key: msg.pub_key, + pub_shares: msg.pub_shares, + partial_deposit_data, + builder_registration, + }); + } + + Ok(dvs) +} + +fn registration_pubkey(reg: &VersionedSignedValidatorRegistration) -> Result { + Ok(registration_v1(reg)?.message.pubkey) +} + +fn registration_v1( + reg: &VersionedSignedValidatorRegistration, +) -> Result<&v1::SignedValidatorRegistration> { + match reg.0.version { + versioned::BuilderVersion::V1 => reg + .0 + .v1 + .as_ref() + .ok_or(ValidatorsError::MissingV1Registration), + versioned::BuilderVersion::Unknown => Err(ValidatorsError::UnknownVersion), + } +} + +fn deposit_data_from_phase0(dd: &phase0::DepositData) -> DepositData { + DepositData { + pub_key: dd.pubkey, + withdrawal_credentials: dd.withdrawal_credentials, + amount: dd.amount, + signature: dd.signature, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::HashMap; + + use pluto_core::signeddata::VersionedSignedValidatorRegistration as CoreRegistration; + use pluto_eth2api::{ + spec::phase0::BLSPubKey, v1, versioned::VersionedSignedValidatorRegistration, + }; + + fn make_core_registration( + pub_key: BLSPubKey, + fee_recipient: [u8; 20], + gas_limit: u64, + timestamp: u64, + signature: [u8; 96], + ) -> CoreRegistration { + CoreRegistration::new(VersionedSignedValidatorRegistration { + version: versioned::BuilderVersion::V1, + v1: Some(v1::SignedValidatorRegistration { + message: v1::ValidatorRegistration { + fee_recipient, + gas_limit, + timestamp, + pubkey: pub_key, + }, + signature, + }), + }) + .expect("registration should be valid") + } + + #[test] + fn builder_registration_from_eth2_preserves_fields() { + let pub_key = [0x11; 48]; + let fee_recipient = [0x22; 20]; + let gas_limit = 30_000_000; + let timestamp = 1_746_843_400; + let signature = [0x33; 96]; + let reg = make_core_registration(pub_key, fee_recipient, gas_limit, timestamp, signature); + + let builder_registration = + builder_registration_from_eth2(®).expect("conversion should succeed"); + + assert_eq!(builder_registration.message.pub_key, pub_key); + assert_eq!(builder_registration.message.fee_recipient, fee_recipient); + assert_eq!(builder_registration.message.gas_limit, gas_limit); + assert_eq!( + u64::try_from(builder_registration.message.timestamp.timestamp()) + .expect("timestamp should fit"), + timestamp + ); + assert_eq!(builder_registration.signature, signature); + } + + #[test] + fn set_registration_signature_updates_v1_signature() { + let reg = + make_core_registration([0x11; 48], [0x22; 20], 30_000_000, 1_746_843_400, [0; 96]); + let updated = + set_registration_signature(®, pluto_core::types::Signature::new([0x44; 96])) + .expect("should work"); + + let builder_registration = + builder_registration_from_eth2(&updated).expect("conversion should succeed"); + assert_eq!(builder_registration.signature, [0x44; 96]); + } + + #[test] + fn create_dist_validators_builds_expected_shape() { + let (lock, ..) = pluto_cluster::test_cluster::new_for_test(1, 3, 4, 0); + let dv = &lock.distributed_validators[0]; + let deposit_data = phase0::DepositData { + pubkey: dv + .pub_key + .as_slice() + .try_into() + .expect("pubkey should be 48 bytes"), + withdrawal_credentials: [0x11; 32], + amount: 32_000_000_000, + signature: [0x22; 96], + }; + + let public_shares = dv + .pub_shares + .iter() + .enumerate() + .map(|(idx, share)| { + ( + u64::try_from(idx + 1).expect("share index should fit"), + share + .as_slice() + .try_into() + .expect("public share should be 48 bytes"), + ) + }) + .collect::>(); + let shares = vec![Share { + pub_key: dv + .pub_key + .as_slice() + .try_into() + .expect("pubkey should be 48 bytes"), + secret_share: [0x55; 32], + public_shares, + }]; + + let deposit_datas = vec![vec![deposit_data]]; + + let reg = CoreRegistration::new(dv.eth2_registration().expect("registration should exist")) + .expect("registration wrapper should be valid"); + + let validators = + create_dist_validators(&shares, &deposit_datas, &[reg]).expect("should succeed"); + + assert_eq!(validators.len(), 1); + assert_eq!(validators[0].pub_key, dv.pub_key); + assert_eq!(validators[0].pub_shares, dv.pub_shares); + assert_eq!( + validators[0].partial_deposit_data, + vec![DepositData { + pub_key: deposit_datas[0][0].pubkey, + withdrawal_credentials: deposit_datas[0][0].withdrawal_credentials, + amount: deposit_datas[0][0].amount, + signature: deposit_datas[0][0].signature, + }] + ); + assert_eq!(validators[0].builder_registration, dv.builder_registration); + } + + #[test] + fn create_dist_validators_fails_when_registration_missing() { + let (lock, ..) = pluto_cluster::test_cluster::new_for_test(1, 3, 4, 0); + let dv = &lock.distributed_validators[0]; + let deposit_data = phase0::DepositData { + pubkey: dv + .pub_key + .as_slice() + .try_into() + .expect("pubkey should be 48 bytes"), + withdrawal_credentials: [0x11; 32], + amount: 32_000_000_000, + signature: [0x22; 96], + }; + let shares = vec![Share { + pub_key: dv + .pub_key + .as_slice() + .try_into() + .expect("pubkey should be 48 bytes"), + secret_share: [0x55; 32], + public_shares: HashMap::new(), + }]; + let deposit_datas = vec![vec![deposit_data]]; + + let err = create_dist_validators(&shares, &deposit_datas, &[]).expect_err("should fail"); + assert!(matches!( + err, + ValidatorsError::ValidatorRegistrationNotFound + )); + } + + #[test] + fn create_dist_validators_fails_when_deposit_data_missing() { + let (lock, ..) = pluto_cluster::test_cluster::new_for_test(1, 3, 4, 0); + let dv = &lock.distributed_validators[0]; + let shares = vec![Share { + pub_key: dv + .pub_key + .as_slice() + .try_into() + .expect("pubkey should be 48 bytes"), + secret_share: [0x55; 32], + public_shares: HashMap::new(), + }]; + let reg = CoreRegistration::new(dv.eth2_registration().expect("registration should exist")) + .expect("registration wrapper should be valid"); + + let err = create_dist_validators(&shares, &[], &[reg]).expect_err("should fail"); + assert!(matches!(err, ValidatorsError::DepositDataNotFound(_))); + } +}