From b7e0ecb654fea73e7b510834c181e6b4e7e4475a Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 21 Apr 2026 16:58:27 +0700 Subject: [PATCH 01/10] feat(dkg): implement aggregate functions --- crates/dkg/src/aggregate.rs | 520 ++++++++++++++++++++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 crates/dkg/src/aggregate.rs diff --git a/crates/dkg/src/aggregate.rs b/crates/dkg/src/aggregate.rs new file mode 100644 index 00000000..18224615 --- /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, Signature as CoreSignature}, +}; +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, + CoreSignature::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(CoreSignature::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" + } + )); + } +} From c0ac2dc5d811ad18af6a0960e71bf5dc88c3c2af Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 21 Apr 2026 17:00:14 +0700 Subject: [PATCH 02/10] feat(dkg): implement publish --- crates/dkg/src/publish.rs | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 crates/dkg/src/publish.rs 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()); + } +} From 75f79e31990b4b07023b48ef08c1c745d77f9f78 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 21 Apr 2026 17:10:57 +0700 Subject: [PATCH 03/10] feat(dkg): implement signing --- crates/dkg/src/signing.rs | 287 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 crates/dkg/src/signing.rs diff --git a/crates/dkg/src/signing.rs b/crates/dkg/src/signing.rs new file mode 100644 index 00000000..8d9ccf68 --- /dev/null +++ b/crates/dkg/src/signing.rs @@ -0,0 +1,287 @@ +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 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 + ); + } +} From 0dba0ded783b77d5c27870d731d114556c11d923 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 21 Apr 2026 17:28:00 +0700 Subject: [PATCH 04/10] feat(dkg): implement validators --- Cargo.lock | 3 + crates/dkg/Cargo.toml | 5 + crates/dkg/src/aggregate.rs | 6 +- crates/dkg/src/dkg.rs | 56 +++++- crates/dkg/src/lib.rs | 12 ++ crates/dkg/src/validators.rs | 338 +++++++++++++++++++++++++++++++++++ 6 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 crates/dkg/src/validators.rs diff --git a/Cargo.lock b/Cargo.lock index 178ccf34..0b2b08a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5622,6 +5622,7 @@ version = "1.7.1" dependencies = [ "anyhow", "bon", + "chrono", "clap", "either", "futures", @@ -5633,6 +5634,7 @@ dependencies = [ "pluto-core", "pluto-crypto", "pluto-eth1wrap", + "pluto-eth2api", "pluto-eth2util", "pluto-k1util", "pluto-p2p", @@ -5651,6 +5653,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 index 18224615..d0ef3b1e 100644 --- a/crates/dkg/src/aggregate.rs +++ b/crates/dkg/src/aggregate.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use pluto_core::{ signeddata::{SignedDataError, VersionedSignedValidatorRegistration}, - types::{ParSignedData, PubKey, Signature as CoreSignature}, + types::{ParSignedData, PubKey}, }; use pluto_crypto::{ blst_impl::BlstImpl, @@ -213,7 +213,7 @@ pub fn agg_validator_registrations( res.push(set_registration_signature( msg, - CoreSignature::new(agg_sig), + pluto_core::types::Signature::new(agg_sig), )?); } @@ -309,7 +309,7 @@ mod tests { } fn partial_signature(sig: Signature, share_idx: u64) -> ParSignedData { - ParSignedData::new(CoreSignature::new(sig), share_idx) + ParSignedData::new(pluto_core::types::Signature::new(sig), share_idx) } #[test] diff --git a/crates/dkg/src/dkg.rs b/crates/dkg/src/dkg.rs index 358b09fa..8e7599fa 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"; @@ -212,6 +223,49 @@ 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"); + } + } +} + async fn verify_keymanager_connection(conf: &Config) -> Result<(), DkgError> { let addr = conf.keymanager.address.as_str(); 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/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(_))); + } +} From eb84b3e7a0476985ef676b52e274a5cad49acbc5 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 21 Apr 2026 17:34:37 +0700 Subject: [PATCH 05/10] fix: missing error --- crates/dkg/src/signing.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/dkg/src/signing.rs b/crates/dkg/src/signing.rs index 8d9ccf68..b152a527 100644 --- a/crates/dkg/src/signing.rs +++ b/crates/dkg/src/signing.rs @@ -27,6 +27,10 @@ pub enum SigningError { #[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), From 8699f99721712e867b7a68482bba5ace902a33be Mon Sep 17 00:00:00 2001 From: Quang Le Date: Thu, 23 Apr 2026 16:39:24 +0700 Subject: [PATCH 06/10] fix: resolve conflict --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 0b2b08a5..31d4c10e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5629,6 +5629,7 @@ dependencies = [ "hex", "k256", "libp2p", + "pluto-app", "pluto-build-proto", "pluto-cluster", "pluto-core", From 922cada59ae3856ea76de9b83f2363c684235b51 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Thu, 23 Apr 2026 17:10:22 +0700 Subject: [PATCH 07/10] feat(dkg): implement get_existing_shares --- crates/dkg/src/dkg.rs | 95 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/crates/dkg/src/dkg.rs b/crates/dkg/src/dkg.rs index 8e7599fa..62f34c16 100644 --- a/crates/dkg/src/dkg.rs +++ b/crates/dkg/src/dkg.rs @@ -61,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. @@ -266,6 +281,44 @@ pub fn log_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(); @@ -306,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); From 902bb17a90d1586358e85d048e5c09a3a0247d95 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 28 Apr 2026 16:52:24 +0700 Subject: [PATCH 08/10] fix: lint --- crates/dkg/src/aggregate.rs | 15 +++++++-------- crates/dkg/src/signing.rs | 5 ++++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/dkg/src/aggregate.rs b/crates/dkg/src/aggregate.rs index f554ad64..0544fc97 100644 --- a/crates/dkg/src/aggregate.rs +++ b/crates/dkg/src/aggregate.rs @@ -350,7 +350,10 @@ mod tests { .map(|idx| { partial_signature( BlstImpl - .sign(secret_shares.get(&idx).expect("share should exist"), &sig_root) + .sign( + secret_shares.get(&idx).expect("share should exist"), + &sig_root, + ) .expect("partial signing should succeed"), u64::from(idx), ) @@ -363,13 +366,9 @@ mod tests { let mut msgs = HashMap::new(); msgs.insert(core_pub_key, reg.clone()); - let res = agg_validator_registrations( - &data, - std::slice::from_ref(&share), - &msgs, - &fork_version, - ) - .expect("aggregation should succeed"); + let res = + agg_validator_registrations(&data, std::slice::from_ref(&share), &msgs, &fork_version) + .expect("aggregation should succeed"); assert_eq!(res.len(), 1); let agg = res[0].0.v1.as_ref().expect("v1 registration should exist"); diff --git a/crates/dkg/src/signing.rs b/crates/dkg/src/signing.rs index fe2d806b..68c27377 100644 --- a/crates/dkg/src/signing.rs +++ b/crates/dkg/src/signing.rs @@ -271,7 +271,10 @@ mod tests { let partial = set.get(&pub_key).expect("partial signature should exist"); assert_eq!(partial.share_idx, 2); - let sig = partial.signed_data.signature().expect("signature should exist"); + let sig = partial + .signed_data + .signature() + .expect("signature should exist"); BlstImpl .verify(&share.public_shares[&2], &hash, sig.as_ref()) .expect("partial signature should verify against share public key"); From 59d09acebdd49fb1410b8bd734bdb8f7994d19ea Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 28 Apr 2026 17:10:03 +0700 Subject: [PATCH 09/10] fix: address comments --- crates/dkg/src/aggregate.rs | 83 ++++++++++++++++++++++++++---------- crates/dkg/src/publish.rs | 4 +- crates/dkg/src/validators.rs | 3 +- 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/crates/dkg/src/aggregate.rs b/crates/dkg/src/aggregate.rs index 0544fc97..6a3d4ec7 100644 --- a/crates/dkg/src/aggregate.rs +++ b/crates/dkg/src/aggregate.rs @@ -57,21 +57,46 @@ pub enum AggregateError { context: &'static str, }, + /// Local share data contained an invalid validator pubkey. + #[error("invalid pubkey in local share")] + InvalidLocalSharePubKey, + /// 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, + #[error("invalid deposit data partial signature from peer {share_idx} for pubkey {pub_key}")] + InvalidDepositPartialSignature { + /// Peer share index. + share_idx: u64, + /// Validator pubkey. + pub_key: String, + }, /// Partial signature verification failed for validator registrations. - #[error("invalid validator registration partial signature from peer")] - InvalidValidatorRegistrationPartialSignature, + #[error( + "invalid validator registration partial signature from peer {share_idx} for pubkey {pub_key}" + )] + InvalidValidatorRegistrationPartialSignature { + /// Peer share index. + share_idx: u64, + /// Validator pubkey. + pub_key: String, + }, /// Partial signature verification failed for lock hash. - #[error("invalid lock hash partial signature from peer: {0}")] - InvalidLockHashPartialSignature(pluto_crypto::types::Error), + #[error( + "invalid lock hash partial signature from peer {share_idx} for pubkey {pub_key}: {source}" + )] + InvalidLockHashPartialSignature { + /// Peer share index. + share_idx: u64, + /// Validator pubkey. + pub_key: String, + /// Verification error. + source: pluto_crypto::types::Error, + }, /// Aggregate signature verification failed for deposit data. #[error("invalid deposit data aggregated signature: {0}")] @@ -108,6 +133,7 @@ pub fn agg_lock_hash_sig( let mut pubkeys = Vec::new(); for (pub_key, partials) in data { + let pub_key_hex = hex_pubkey(pub_key); let share = shares .get(pub_key) .ok_or(AggregateError::InvalidPubKeyFromPeer { @@ -121,9 +147,13 @@ pub fn agg_lock_hash_sig( .get(&partial.share_idx) .ok_or(AggregateError::InvalidPubshare)?; - BlstImpl - .verify(pubshare, hash, &sig) - .map_err(AggregateError::InvalidLockHashPartialSignature)?; + BlstImpl.verify(pubshare, hash, &sig).map_err(|source| { + AggregateError::InvalidLockHashPartialSignature { + share_idx: partial.share_idx, + pub_key: pub_key_hex.clone(), + source, + } + })?; sigs.push(sig); pubkeys.push(*pubshare); @@ -144,6 +174,7 @@ pub fn agg_deposit_data( let mut res = Vec::with_capacity(data.len()); for (pub_key, partials) in data { + let pub_key_hex = hex_pubkey(pub_key); let msg = msgs .get(pub_key) .ok_or(AggregateError::DepositMessageNotFound)?; @@ -154,8 +185,11 @@ pub fn agg_deposit_data( context: "deposit data", })?; let partial_sigs = - verify_threshold_partials(partials, &share.public_shares, &sig_root, || { - AggregateError::InvalidDepositPartialSignature + verify_threshold_partials(partials, &share.public_shares, &sig_root, |share_idx| { + AggregateError::InvalidDepositPartialSignature { + share_idx, + pub_key: pub_key_hex.clone(), + } })?; let agg_sig = BlstImpl.threshold_aggregate(&partial_sigs)?; @@ -188,6 +222,7 @@ pub fn agg_validator_registrations( let mut res = Vec::with_capacity(data.len()); for (pub_key, partials) in data { + let pub_key_hex = hex_pubkey(pub_key); let msg = msgs .get(pub_key) .ok_or(AggregateError::ValidatorRegistrationNotFound)?; @@ -199,8 +234,11 @@ pub fn agg_validator_registrations( context: "validator registrations", })?; let partial_sigs = - verify_threshold_partials(partials, &share.public_shares, &sig_root, || { - AggregateError::InvalidValidatorRegistrationPartialSignature + verify_threshold_partials(partials, &share.public_shares, &sig_root, |share_idx| { + AggregateError::InvalidValidatorRegistrationPartialSignature { + share_idx, + pub_key: pub_key_hex.clone(), + } })?; let agg_sig = BlstImpl.threshold_aggregate(&partial_sigs)?; @@ -231,16 +269,17 @@ 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", - } - })?; + let pub_key = PubKey::try_from(share.pub_key.as_slice()) + .map_err(|_| AggregateError::InvalidLocalSharePubKey)?; Ok((pub_key, share)) }) .collect() } +fn hex_pubkey(pub_key: &PubKey) -> String { + hex::encode(pub_key.as_ref()) +} + fn extract_partial_signature(partial: &ParSignedData) -> Result { let sig = partial.signed_data.signature()?; Ok(signature_from_bytes(sig.as_ref())?) @@ -250,7 +289,7 @@ fn verify_threshold_partials( partials: &[ParSignedData], public_shares: &HashMap, message: &[u8], - invalid_signature_error: fn() -> AggregateError, + invalid_signature_error: impl Fn(u64) -> AggregateError, ) -> Result> { let mut res = HashMap::with_capacity(partials.len()); @@ -262,7 +301,7 @@ fn verify_threshold_partials( BlstImpl .verify(pubshare, message, &sig) - .map_err(|_| invalid_signature_error())?; + .map_err(|_| invalid_signature_error(partial.share_idx))?; res.insert(u8::try_from(partial.share_idx)?, sig); } @@ -418,7 +457,7 @@ mod tests { assert!(matches!( err, - AggregateError::InvalidDepositPartialSignature + AggregateError::InvalidDepositPartialSignature { share_idx: 3, .. } )); } @@ -453,7 +492,7 @@ mod tests { assert!(matches!( err, - AggregateError::InvalidLockHashPartialSignature(_) + AggregateError::InvalidLockHashPartialSignature { share_idx: 3, .. } )); } diff --git a/crates/dkg/src/publish.rs b/crates/dkg/src/publish.rs index a173e94b..c4093e2b 100644 --- a/crates/dkg/src/publish.rs +++ b/crates/dkg/src/publish.rs @@ -2,7 +2,7 @@ use std::time::Duration; use pluto_app::obolapi; use pluto_cluster::lock::Lock; -use tracing::info; +use tracing::debug; /// Result type for DKG publish helpers. pub type Result = std::result::Result; @@ -27,7 +27,7 @@ pub async fn write_lock_to_api( )?; client.publish_lock(lock.clone()).await?; - info!(addr = publish_addr, "Published lock file"); + debug!(addr = publish_addr, "Published lock file"); Ok(client.launchpad_url_for_lock(lock)?) } diff --git a/crates/dkg/src/validators.rs b/crates/dkg/src/validators.rs index 38dd836d..4695f4c9 100644 --- a/crates/dkg/src/validators.rs +++ b/crates/dkg/src/validators.rs @@ -113,7 +113,8 @@ pub fn create_dist_validators( .ok_or(ValidatorsError::ValidatorRegistrationNotFound)?; let partial_deposit_data = deposit_datas_map - .remove(&share.pub_key) + .get(&share.pub_key) + .cloned() .ok_or_else(|| ValidatorsError::DepositDataNotFound(hex::encode(share.pub_key)))?; dvs.push(DistValidator { From 0e58de068aac5c1ee20d720ce9f614f3d300a0a8 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Wed, 29 Apr 2026 18:13:22 +0700 Subject: [PATCH 10/10] fix: address comments --- crates/dkg/src/aggregate.rs | 7 ++----- crates/dkg/src/dkg.rs | 37 +++++++++---------------------------- crates/dkg/src/signing.rs | 19 +++++++++++-------- 3 files changed, 22 insertions(+), 41 deletions(-) diff --git a/crates/dkg/src/aggregate.rs b/crates/dkg/src/aggregate.rs index 6a3d4ec7..92823f44 100644 --- a/crates/dkg/src/aggregate.rs +++ b/crates/dkg/src/aggregate.rs @@ -399,11 +399,8 @@ mod tests { }) .collect::>(); - let mut data = HashMap::new(); - data.insert(core_pub_key, partials); - - let mut msgs = HashMap::new(); - msgs.insert(core_pub_key, reg.clone()); + let data = HashMap::from([(core_pub_key, partials)]); + let msgs = HashMap::from([(core_pub_key, reg.clone())]); let res = agg_validator_registrations(&data, std::slice::from_ref(&share), &msgs, &fork_version) diff --git a/crates/dkg/src/dkg.rs b/crates/dkg/src/dkg.rs index 62f34c16..c9711e79 100644 --- a/crates/dkg/src/dkg.rs +++ b/crates/dkg/src/dkg.rs @@ -250,34 +250,15 @@ pub fn log_peer_summary( .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"); - } + let you = is_current_peer.then_some("⭐"); + + info!( + peer = peer.name, + index = peer.index, + address, + you, + "Peer summary" + ); } } diff --git a/crates/dkg/src/signing.rs b/crates/dkg/src/signing.rs index 68c27377..64e151bc 100644 --- a/crates/dkg/src/signing.rs +++ b/crates/dkg/src/signing.rs @@ -16,8 +16,11 @@ pub type Result = std::result::Result; #[derive(Debug, thiserror::Error)] pub enum SigningError { /// Failed to build a core public key from bytes. - #[error("invalid public key length")] - InvalidPublicKeyLength, + #[error("invalid public key length while {error_context}")] + InvalidPublicKeyLength { + /// Signing helper that encountered the invalid key. + error_context: &'static str, + }, /// Failed to sign or verify with threshold BLS. #[error(transparent)] @@ -65,8 +68,7 @@ pub fn sign_lock_hash(share_idx: u64, shares: &[Share], hash: &[u8]) -> Result

Result { - PubKey::try_from(share.pub_key.as_slice()).map_err(|_| SigningError::InvalidPublicKeyLength) +fn share_pubkey(share: &Share, error_context: &'static str) -> Result { + PubKey::try_from(share.pub_key.as_slice()) + .map_err(|_| SigningError::InvalidPublicKeyLength { error_context }) } #[cfg(test)]