From 02dd79849ec51e0b25d3de785d69dd5dd731de0d Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:12:47 +0200 Subject: [PATCH 001/121] make the two threshold DTO conversions fallible & validating --- crates/contract/src/dto_mapping.rs | 65 ++++++++++++++------ crates/contract/src/lib.rs | 6 +- crates/contract/src/primitives/thresholds.rs | 4 +- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 7f74659068..dd96b42cf7 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -212,27 +212,24 @@ impl IntoContractType for dtos::Participants { } } -impl IntoContractType for dtos::ThresholdParameters { - fn into_contract_type(self) -> ThresholdParameters { - // This conversion is intentionally infallible: `new_unvalidated` skips the - // absolute/relative (>= 60%) threshold checks. Validation is not the job of the - // DTO mapping — every contract entry point that accepts these parameters - // (`init`, `init_running`, `vote_new_parameters`) calls `validate()` / - // `validate_incoming_proposal` downstream, so production proposals are still - // rejected if they violate the threshold bounds. Deferring validation here also - // lets tests construct parameters with sub-production thresholds. - ThresholdParameters::new_unvalidated(self.participants.into_contract_type(), self.threshold) - } -} - -impl IntoContractType for dtos::ProposedThresholdParameters { - fn into_contract_type(self) -> ProposedThresholdParameters { - // Infallible for the same reason as `ThresholdParameters` above: the - // proposal is validated downstream in `process_new_parameters_proposal`. - ProposedThresholdParameters::new( - self.parameters.into_contract_type(), +impl TryIntoContractType for dtos::ThresholdParameters { + type Error = Error; + + fn try_into_contract_type(self) -> Result { + // Validate eagerly at the DTO boundary so invalid proposal parameters are rejected here. + ThresholdParameters::new(self.participants.into_contract_type(), self.threshold) + } +} + +impl TryIntoContractType for dtos::ProposedThresholdParameters { + type Error = Error; + + fn try_into_contract_type(self) -> Result { + // Validates the inner threshold parameters; see the conversion above. + Ok(ProposedThresholdParameters::new( + self.parameters.try_into_contract_type()?, self.per_domain_thresholds, - ) + )) } } @@ -1006,4 +1003,32 @@ mod tests { assert_eq!(internal_json, dto_json); } + + /// A threshold below the relative (>= 60%) requirement must be rejected at the + /// DTO boundary rather than deferred to a later validation step. + #[test] + fn try_into_contract_type__should_reject_threshold_below_relative_requirement() { + // Given a DTO with 5 participants and a threshold of 2 (below the 60% bound of 3). + use crate::errors::InvalidThreshold; + use crate::primitives::test_utils::gen_participants; + + let dto = dtos::ThresholdParameters { + participants: (&gen_participants(5)).into_dto_type(), + threshold: Threshold::new(2), + }; + + // When converting the DTO into the contract type. + let result: Result = dto.try_into_contract_type(); + + // Then conversion fails with the relative-threshold error. + assert!(matches!( + result, + Err(Error::InvalidThreshold( + InvalidThreshold::MinRelRequirementFailed { + required: 3, + found: 2 + } + )) + )); + } } diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 1b0266df07..406c87e714 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -879,7 +879,7 @@ impl MpcContract { proposal: dtos::ProposedThresholdParameters, ) -> Result<(), Error> { Self::assert_caller_is_signer(); - let proposal: ProposedThresholdParameters = proposal.into_contract_type(); + let proposal: ProposedThresholdParameters = proposal.try_into_contract_type()?; log!( "vote_new_parameters: signer={}, proposal={:?}", env::signer_account_id(), @@ -1805,7 +1805,7 @@ impl MpcContract { parameters: dtos::ThresholdParameters, init_config: Option, ) -> Result { - let parameters: ThresholdParameters = parameters.into_contract_type(); + let parameters: ThresholdParameters = parameters.try_into_contract_type()?; // Log participant count and hash - full parameters exceed NEAR's 16KB log limit at ~100 participants let params_hash = env::sha256_array(borsh::to_vec(¶meters).unwrap()); log!( @@ -1856,7 +1856,7 @@ impl MpcContract { parameters: dtos::ThresholdParameters, init_config: Option, ) -> Result { - let parameters: ThresholdParameters = parameters.into_contract_type(); + let parameters: ThresholdParameters = parameters.try_into_contract_type()?; // Log participant count and hash - full parameters exceed NEAR's 16KB log limit at ~100 participants let params_hash = env::sha256_array(borsh::to_vec(¶meters).unwrap()); log!( diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index afd278d562..30c50f5303 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -143,7 +143,9 @@ impl ThresholdParameters { &self.participants } - /// For integration testing. + /// Test-only: builds parameters without [`Self::validate_threshold`], so tests can use + /// sub-production thresholds. Production proposal DTOs are validated during conversion + /// (see `TryIntoContractType` in `dto_mapping`) and use [`Self::new`] instead. pub fn new_unvalidated(participants: Participants, threshold: Threshold) -> Self { ThresholdParameters { participants, From 44e9e76003c23b49c890f8c0d13f9836e1ca854e Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:57:24 +0200 Subject: [PATCH 002/121] Adding validation (upper cap and wrt the ReconstructionThreshold) --- crates/contract/src/dto_mapping.rs | 26 ++++ crates/contract/src/errors.rs | 18 +++ crates/contract/src/lib.rs | 145 +++++++++++++------ crates/contract/src/primitives/test_utils.rs | 9 +- crates/contract/src/primitives/thresholds.rs | 144 +++++++++++++++--- crates/contract/src/state/resharing.rs | 8 +- crates/contract/src/state/running.rs | 72 ++++++++- crates/contract/src/v3_11_2_state.rs | 41 +++++- 8 files changed, 390 insertions(+), 73 deletions(-) diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index dd96b42cf7..c931604135 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -918,6 +918,7 @@ pub fn args_into_verify_foreign_tx_request( } #[cfg(test)] +#[expect(non_snake_case)] mod tests { use super::*; use crate::primitives::thresholds::Threshold; @@ -1031,4 +1032,29 @@ mod tests { )) )); } + + /// A threshold above the relative upper cap (> 80%) must be rejected at the + /// DTO boundary rather than deferred to a later validation step. + #[test] + fn try_into_contract_type__should_reject_threshold_above_upper_cap() { + // Given a DTO with 5 participants and a threshold of 5 (above the cap of floor(0.8*5)=4). + use crate::errors::InvalidThreshold; + use crate::primitives::test_utils::gen_participants; + + let dto = dtos::ThresholdParameters { + participants: (&gen_participants(5)).into_dto_type(), + threshold: Threshold::new(5), + }; + + // When converting the DTO into the contract type. + let result: Result = dto.try_into_contract_type(); + + // Then conversion fails with the upper-cap error. + assert!(matches!( + result, + Err(Error::InvalidThreshold( + InvalidThreshold::MaxRelRequirementFailed { max: 4, found: 5 } + )) + )); + } } diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index 4a58fd9a67..7bbe642385 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -183,6 +183,17 @@ pub enum InvalidThreshold { MinRelRequirementFailed { required: u64, found: u64 }, #[error("Threshold must not exceed number of participants: max {max}, found {found}")] MaxRequirementFailed { max: u64, found: u64 }, + #[error( + "GovernanceThreshold does not meet the maximum relative requirement: max {max}, found {found}" + )] + MaxRelRequirementFailed { max: u64, found: u64 }, + #[error( + "GovernanceThreshold {governance_threshold} is below the largest ReconstructionThreshold {reconstruction_threshold}" + )] + BelowReconstructionThreshold { + reconstruction_threshold: u64, + governance_threshold: u64, + }, } #[derive(Debug, PartialEq, Eq, Clone, thiserror::Error)] @@ -265,6 +276,13 @@ pub enum DomainError { "Resharing proposal references domain ID {domain_id}, which is not in the current registry." )] UnknownDomainInProposal { domain_id: DomainId }, + #[error( + "ReconstructionThreshold {reconstruction_threshold} exceeds the GovernanceThreshold {governance_threshold}." + )] + ReconstructionThresholdExceedsGovernance { + reconstruction_threshold: u64, + governance_threshold: u64, + }, } /// A list specifying general categories of MPC Contract errors. diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 406c87e714..493b74ea06 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -57,8 +57,7 @@ use crypto_shared::{ types::{PublicKeyExtended, PublicKeyExtendedConversionError}, }; use errors::{ - DomainError, InvalidParameters, InvalidState, InvalidThreshold, PublicKeyError, RespondError, - TeeError, + DomainError, InvalidParameters, InvalidState, PublicKeyError, RespondError, TeeError, }; use k256::elliptic_curve::PrimeField; use near_mpc_contract_interface::types::kdf::derive_tweak; @@ -945,17 +944,9 @@ impl MpcContract { let num_participants = u64::try_from(proposal.participants().len()) .expect("participant list should be wayyyy smaller than u64::MAX"); - let threshold = proposal.threshold().value(); - if threshold > num_participants { - return Err(InvalidThreshold::MaxRequirementFailed { - max: num_participants, - found: threshold, - } - .into()); - } - let domains = self.protocol_state.domain_registry()?; let updates = proposal.per_domain_thresholds(); + let mut max_reconstruction_threshold = 0u64; for domain in domains.domains() { let effective = updates .get(&domain.id) @@ -969,8 +960,16 @@ impl MpcContract { } .into()); } + max_reconstruction_threshold = max_reconstruction_threshold.max(effective); } - Ok(()) + + // Enforce the GovernanceThreshold bounds together with the relation + // `GovernanceThreshold >= max(ReconstructionThreshold)`. + ThresholdParameters::validate_governance_against_reconstruction( + num_participants, + proposal.threshold(), + max_reconstruction_threshold, + ) } /// Propose adding a new set of domains for the MPC network. @@ -1628,24 +1627,28 @@ impl MpcContract { } => { let threshold = current_params.threshold().value() as usize; let remaining = participants_with_valid_attestation.len(); - // Defense in depth: the surviving participant set must cover every - // threshold the network is bound to — the governance threshold and - // each domain's reconstruction threshold (the kickout keeps the - // existing per-domain thresholds). Resharing into a smaller set - // would leave a key un-signable, so we refuse and wait for manual - // intervention. + // Defense in depth: the surviving participant set must keep the full + // threshold relation intact — the GovernanceThreshold must still sit + // within its bounds for the smaller set (in particular it must not + // exceed the remaining participant count or the upper cap) and must + // remain at least every domain's ReconstructionThreshold (the kickout + // keeps the existing per-domain thresholds). Otherwise we refuse and + // wait for manual intervention. let max_reconstruction_threshold = running_state .domains .domains() .iter() - .map(|domain| domain.reconstruction_threshold.inner() as usize) + .map(|domain| domain.reconstruction_threshold.inner()) .max() .unwrap_or(0); - let required = threshold.max(max_reconstruction_threshold); - if required > remaining { + if let Err(err) = ThresholdParameters::validate_governance_against_reconstruction( + remaining as u64, + current_params.threshold(), + max_reconstruction_threshold, + ) { log!( - "Fewer than the required number of participants ({}) are left with a valid TEE status ({}). This requires manual intervention. We will not accept new signature requests as a safety precaution.", - required, + "Kicking out participants with an invalid TEE status would break the threshold relation ({:?}); {} participants remain with a valid TEE status. This requires manual intervention. We will not accept new signature requests as a safety precaution.", + err, remaining, ); self.accept_requests = false; @@ -1872,9 +1875,18 @@ impl MpcContract { parameters.validate()?; let domains = DomainRegistry::from_raw_validated(domains, next_domain_id)?; let num_participants = parameters.participants().len() as u64; + let mut max_reconstruction_threshold = 0u64; for domain in domains.domains() { crate::primitives::domain::validate_domain_threshold(domain, num_participants)?; + max_reconstruction_threshold = + max_reconstruction_threshold.max(domain.reconstruction_threshold.inner()); } + // Keep the GovernanceThreshold at least as large as the largest ReconstructionThreshold. + ThresholdParameters::validate_governance_against_reconstruction( + num_participants, + parameters.threshold(), + max_reconstruction_threshold, + )?; // Check that the domains match exactly those in the keyset. let domain_ids_from_domains = domains.domains().iter().map(|d| d.id).collect::>(); @@ -2504,7 +2516,7 @@ mod tests { }; use super::*; - use crate::errors::NodeMigrationError; + use crate::errors::{InvalidThreshold, NodeMigrationError}; use crate::pending_requests::MAX_PENDING_REQUEST_FAN_OUT; use crate::primitives::participants::{ParticipantId, ParticipantInfo, Participants}; use crate::primitives::test_utils::{ @@ -3941,10 +3953,10 @@ mod tests { #[test] fn vote_new_parameters__should_reject_when_shrinking_below_unchanged_domain_threshold() { - // Given: a Running contract with 3 participants and a domain whose - // reconstruction threshold is 3. + // Given: a Running contract with 4 participants (GovernanceThreshold 3) and a + // domain whose reconstruction threshold is 3. let (mut contract, participants, signer, _domain_id) = - setup_running_contract_with_domain(3, 3, 3); + setup_running_contract_with_domain(4, 3, 3); // ...and a proposal that shrinks the participant set to 2 without touching // the per-domain thresholds. let proposal = ProposedThresholdParameters::new( @@ -3989,15 +4001,15 @@ mod tests { #[test] fn vote_new_parameters__should_accept_per_domain_threshold_within_participant_count() { - // Given: a Running contract with 3 participants and one domain. + // Given: a Running contract with 5 participants (GovernanceThreshold 4) and one domain. let (mut contract, participants, signer, domain_id) = - setup_running_contract_with_domain(3, 2, 2); - // ...and a proposal raising the domain's reconstruction threshold to 3, - // which still fits the 3 participants. + setup_running_contract_with_domain(5, 4, 2); + // ...and a proposal raising the domain's reconstruction threshold to 4, + // which fits the 5 participants and does not exceed the GovernanceThreshold. let mut per_domain = BTreeMap::new(); - per_domain.insert(domain_id, ReconstructionThreshold::new(3)); + per_domain.insert(domain_id, ReconstructionThreshold::new(4)); let proposal = ProposedThresholdParameters::new( - ThresholdParameters::new(participants, Threshold::new(2)).unwrap(), + ThresholdParameters::new(participants, Threshold::new(4)).unwrap(), per_domain, ); @@ -4008,6 +4020,55 @@ mod tests { assert_matches!(result, Ok(())); } + #[test] + fn vote_new_parameters__should_reject_governance_below_max_reconstruction() { + // Given: a Running contract with 5 participants, GovernanceThreshold 4, and a + // domain whose reconstruction threshold is 4. + let (mut contract, participants, signer, _domain_id) = + setup_running_contract_with_domain(5, 4, 4); + // ...and a proposal lowering the GovernanceThreshold to 3 (valid on its own) + // while the domain keeps its reconstruction threshold of 4. + let proposal = ProposedThresholdParameters::new( + ThresholdParameters::new(participants, Threshold::new(3)).unwrap(), + BTreeMap::new(), + ); + + // When + let result = vote_params(&mut contract, &signer, &proposal); + + // Then: the GovernanceThreshold (3) would fall below the domain's + // reconstruction threshold (4), so the proposal is rejected. + assert_matches!( + result.unwrap_err(), + Error::InvalidThreshold(InvalidThreshold::BelowReconstructionThreshold { + reconstruction_threshold: 4, + governance_threshold: 3, + }) + ); + } + + #[test] + fn vote_new_parameters__should_reject_governance_above_upper_cap() { + // Given: a Running contract with 5 participants and one domain. + let (mut contract, participants, signer, _domain_id) = + setup_running_contract_with_domain(5, 4, 2); + // ...and a proposal raising the GovernanceThreshold to 5 (== n), which exceeds + // the upper cap of floor(0.8 * 5) = 4. + let proposal = ProposedThresholdParameters::new( + ThresholdParameters::new_unvalidated(participants, Threshold::new(5)), + BTreeMap::new(), + ); + + // When + let result = vote_params(&mut contract, &signer, &proposal); + + // Then + assert_matches!( + result.unwrap_err(), + Error::InvalidThreshold(InvalidThreshold::MaxRelRequirementFailed { max: 4, found: 5 }) + ); + } + #[test] #[should_panic(expected = "Caller must be the signer account")] fn vote_new_parameters__should_panic_when_predecessor_differs_from_signer() { @@ -5465,20 +5526,22 @@ mod tests { /// threshold, even though the remaining set still meets the governance /// threshold. The contract stays Running and stops accepting requests. #[test] - fn verify_tee__should_refuse_kickout_below_domain_reconstruction_threshold() { + fn verify_tee__should_refuse_kickout_when_remaining_breaks_threshold_relation() { const PARTICIPANT_COUNT: usize = 5; const ATTESTATION_EXPIRY_SECONDS: u64 = 5; const TEE_UPGRADE_DURATION: Duration = Duration::MAX; - // Given: 5 participants, governance threshold 3, and one domain whose - // reconstruction threshold is 5 (every participant is needed to sign). + // Given: 5 participants, GovernanceThreshold 4, and one domain whose + // reconstruction threshold is 4. Dropping to 4 participants would push the + // GovernanceThreshold above its upper cap (floor(0.8 * 4) = 3), breaking the + // threshold relation. let participants = gen_participants(PARTICIPANT_COUNT); - let parameters = ThresholdParameters::new(participants.clone(), Threshold::new(3)).unwrap(); + let parameters = ThresholdParameters::new(participants.clone(), Threshold::new(4)).unwrap(); let domain_id = DomainId::default(); let domains = vec![DomainConfig { id: domain_id, protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(5), + reconstruction_threshold: ReconstructionThreshold::new(4), purpose: DomainPurpose::Sign, }]; let (pk, _) = make_public_key_for_curve(Curve::Secp256k1, &mut OsRng); @@ -5526,9 +5589,9 @@ mod tests { // When let result = contract.verify_tee(); - // Then: the 4 surviving participants meet the governance threshold (3) but - // not the domain's reconstruction threshold (5), so verify_tee refuses to - // reshare, stays Running, and stops accepting requests. + // Then: with only 4 surviving participants the GovernanceThreshold of 4 + // would exceed its upper cap, breaking the threshold relation, so verify_tee + // refuses to reshare, stays Running, and stops accepting requests. assert_matches!(result, Ok(false)); assert_matches!(contract.protocol_state, ProtocolContractState::Running(_)); assert!(!contract.accept_requests); diff --git a/crates/contract/src/primitives/test_utils.rs b/crates/contract/src/primitives/test_utils.rs index 7cba2f2038..3aae3a6cc7 100644 --- a/crates/contract/src/primitives/test_utils.rs +++ b/crates/contract/src/primitives/test_utils.rs @@ -133,6 +133,12 @@ pub fn min_thrershold(n: usize) -> usize { ((n as f64) * 0.6).ceil() as usize } +/// Mirrors the contract's GovernanceThreshold upper cap: `floor(0.8 * n)`, clamped +/// up to the 60% lower bound so the feasible window is never empty for small `n`. +pub fn max_threshold(n: usize) -> usize { + (n * 4 / 5).max(min_thrershold(n)) +} + pub fn gen_accounts_and_info(n: usize) -> BTreeMap { (0..n).map(gen_participant).collect() } @@ -159,7 +165,8 @@ pub fn gen_threshold_params(max_n: usize) -> ThresholdParameters { // `n >= 3` even at the minimum `t = 2`. let n: usize = rand::thread_rng().gen_range(3..max_n + 1); let k_min = min_thrershold(n); - let k = rand::thread_rng().gen_range(k_min..n + 1); + let k_max = max_threshold(n); + let k = rand::thread_rng().gen_range(k_min..k_max + 1); ThresholdParameters::new(gen_participants(n), Threshold::new(k as u64)).unwrap() } diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 30c50f5303..e44a8dcaa6 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -10,6 +10,14 @@ pub use near_mpc_contract_interface::types::Threshold; /// Minimum absolute threshold required. const MIN_THRESHOLD_ABSOLUTE: u64 = 2; +/// Maximum fraction of participants the GovernanceThreshold may reach, expressed +/// as `MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR` (currently 80%). A +/// GovernanceThreshold set too high would let a minority that stops serving lock +/// the contract (it could no longer reshare, add/kick participants, or sign). +/// Kept as an explicit fraction so the percentage and rounding are easy to tune. +const MAX_THRESHOLD_NUMERATOR: u64 = 4; +const MAX_THRESHOLD_DENOMINATOR: u64 = 5; + /// Stores the threshold key parameters: the owners of key shares /// (`participants`) and the cryptographic `threshold`. This is the stored, /// always-current shape. @@ -33,11 +41,15 @@ impl ThresholdParameters { } } - /// Ensures that the threshold `k` is sensible and meets the absolute and minimum requirements. + /// Ensures that the threshold `k` is sensible and meets the absolute and relative requirements. /// That is: /// - threshold must be at least `MIN_THRESHOLD_ABSOLUTE` /// - threshold can not exceed the number of shares `n_shares`. /// - threshold must be at least 60% of the number of shares (rounded upwards). + /// - threshold must not exceed `MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR` + /// of the number of shares (rounded downwards), so a minority that stops serving + /// cannot lock the contract. This upper cap is clamped up to the 60% lower bound so + /// the feasible window is never empty for small `n_shares`. pub fn validate_threshold(n_shares: u64, k: Threshold) -> Result<(), Error> { if k.value() > n_shares { return Err(InvalidThreshold::MaxRequirementFailed { @@ -49,10 +61,20 @@ impl ThresholdParameters { if k.value() < MIN_THRESHOLD_ABSOLUTE { return Err(InvalidThreshold::MinAbsRequirementFailed.into()); } - let percentage_bound = (3 * n_shares).div_ceil(5); // minimum 60% - if k.value() < percentage_bound { + let lower_relative_bound = (3 * n_shares).div_ceil(5); // minimum 60% + if k.value() < lower_relative_bound { return Err(InvalidThreshold::MinRelRequirementFailed { - required: percentage_bound, + required: lower_relative_bound, + found: k.value(), + } + .into()); + } + // Clamp the upper cap up to the lower bound so the window stays non-empty for small n. + let upper_relative_bound = + (MAX_THRESHOLD_NUMERATOR * n_shares / MAX_THRESHOLD_DENOMINATOR).max(lower_relative_bound); + if k.value() > upper_relative_bound { + return Err(InvalidThreshold::MaxRelRequirementFailed { + max: upper_relative_bound, found: k.value(), } .into()); @@ -60,6 +82,28 @@ impl ThresholdParameters { Ok(()) } + /// Validates the GovernanceThreshold `k` against both the participant count and the + /// largest ReconstructionThreshold across all domains. Layers the cross-domain rule + /// `GovernanceThreshold >= max(ReconstructionThreshold)` on top of [`Self::validate_threshold`]: + /// the network must never be able to govern with fewer parties than are required to + /// reconstruct any domain's key. Call this at every point where the GovernanceThreshold, + /// a ReconstructionThreshold, or the participant set changes. + pub fn validate_governance_against_reconstruction( + num_participants: u64, + governance: Threshold, + max_reconstruction_threshold: u64, + ) -> Result<(), Error> { + Self::validate_threshold(num_participants, governance)?; + if governance.value() < max_reconstruction_threshold { + return Err(InvalidThreshold::BelowReconstructionThreshold { + reconstruction_threshold: max_reconstruction_threshold, + governance_threshold: governance.value(), + } + .into()); + } + Ok(()) + } + pub fn validate(&self) -> Result<(), Error> { Self::validate_threshold(self.participants.len() as u64, self.threshold())?; self.participants.validate() @@ -228,10 +272,10 @@ impl ProposedThresholdParameters { #[expect(non_snake_case)] mod tests { use crate::{ - errors::{Error, InvalidCandidateSet}, + errors::{Error, InvalidCandidateSet, InvalidThreshold}, primitives::{ participants::{ParticipantId, Participants}, - test_utils::{gen_participant, gen_participants, gen_threshold_params}, + test_utils::{gen_participant, gen_participants, gen_threshold_params, max_threshold}, thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }, state::test_utils::gen_valid_params_proposal, @@ -254,28 +298,38 @@ mod tests { fn test_validate_threshold() { let n = rand::thread_rng().gen_range(2..600) as u64; let min_threshold = ((n as f64) * 0.6).ceil() as u64; + // Upper cap is floor(0.8 * n), clamped up to the 60% lower bound. + let max_threshold = (4 * n / 5).max(min_threshold); for k in 0..min_threshold { let _ = ThresholdParameters::validate_threshold(n, Threshold::new(k)).unwrap_err(); } - for k in min_threshold..(n + 1) { + for k in min_threshold..=max_threshold { ThresholdParameters::validate_threshold(n, Threshold::new(k)).unwrap(); } - let _ = ThresholdParameters::validate_threshold(n, Threshold::new(n + 1)).unwrap_err(); + // Anything above the upper cap (up to and beyond n) must be rejected. + for k in (max_threshold + 1)..=(n + 1) { + let _ = ThresholdParameters::validate_threshold(n, Threshold::new(k)).unwrap_err(); + } } #[test] fn test_threshold_parameters_constructor() { let n: usize = rand::thread_rng().gen_range(2..600); let min_threshold = ((n as f64) * 0.6).ceil() as usize; + // Upper cap is floor(0.8 * n), clamped up to the 60% lower bound. + let max_threshold = (4 * n / 5).max(min_threshold); let participants = gen_participants(n); for k in 1..min_threshold { let invalid_threshold = Threshold::new(k as u64); let _ = ThresholdParameters::new(participants.clone(), invalid_threshold).unwrap_err(); } - let _ = ThresholdParameters::new(participants.clone(), Threshold::new((n + 1) as u64)) - .unwrap_err(); - for k in min_threshold..(n + 1) { + // Thresholds above the upper cap (including up to n and beyond) are rejected. + for k in (max_threshold + 1)..=(n + 1) { + let invalid_threshold = Threshold::new(k as u64); + let _ = ThresholdParameters::new(participants.clone(), invalid_threshold).unwrap_err(); + } + for k in min_threshold..=max_threshold { let threshold = Threshold::new(k as u64); let tp = ThresholdParameters::new(participants.clone(), threshold); let tp = tp.expect("Threshold parameters should be valid for the given threshold"); @@ -296,6 +350,56 @@ mod tests { } } + #[test] + fn validate_threshold__should_reject_governance_above_upper_cap() { + // Given 10 participants, the upper cap is floor(0.8 * 10) = 8. + let n = 10; + // When/Then thresholds within the window are accepted. + ThresholdParameters::validate_threshold(n, Threshold::new(8)).unwrap(); + // ...and the first value above the cap is rejected. + assert_matches!( + ThresholdParameters::validate_threshold(n, Threshold::new(9)), + Err(Error::InvalidThreshold( + InvalidThreshold::MaxRelRequirementFailed { max: 8, found: 9 } + )) + ); + } + + #[test] + fn validate_threshold__should_not_produce_empty_window_for_small_n() { + // For small n the floor(0.8n) cap can dip below the ceil(0.6n) lower bound; + // the clamp must keep at least one valid threshold available. + for n in 2..=12u64 { + let lower = (3 * n).div_ceil(5); + let upper = max_threshold(n as usize) as u64; + assert!(upper >= lower, "empty window at n={n}: [{lower}, {upper}]"); + // The clamped boundary value must validate. + ThresholdParameters::validate_threshold(n, Threshold::new(upper)).unwrap(); + } + } + + #[test] + fn validate_governance_against_reconstruction__should_reject_governance_below_max_reconstruction() + { + // Given 10 participants and a governance threshold of 6 (a valid value on its own). + let n = 10; + let governance = Threshold::new(6); + // When the largest reconstruction threshold is 7 (above governance). + // Then the relation is rejected. + assert_matches!( + ThresholdParameters::validate_governance_against_reconstruction(n, governance, 7), + Err(Error::InvalidThreshold( + InvalidThreshold::BelowReconstructionThreshold { + reconstruction_threshold: 7, + governance_threshold: 6, + } + )) + ); + // ...but is accepted when governance meets or exceeds the max reconstruction threshold. + ThresholdParameters::validate_governance_against_reconstruction(n, governance, 6).unwrap(); + ThresholdParameters::validate_governance_against_reconstruction(n, governance, 5).unwrap(); + } + #[test] fn test_validate_incoming_proposal() { // Valid proposals should validate. @@ -445,8 +549,8 @@ mod tests { #[test] fn test_proposal_non_contiguous_new_ids_fail() { // Test that the lowest new id equals to the `next_id` of the previous set. - // Use a high threshold so adding one participant doesn't violate the 60% rule. - let params = ThresholdParameters::new(gen_participants(5), Threshold::new(5)).unwrap(); + // Use a high (but capped) threshold so adding one participant doesn't violate the 60% rule. + let params = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); let wrong_id = params.participants.next_id().0 + 1; @@ -470,7 +574,7 @@ mod tests { #[test] fn test_proposal_non_unique_ids() { - let params = ThresholdParameters::new(gen_participants(5), Threshold::new(5)).unwrap(); + let params = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); // Create proposal with duplicate participants (doubled list) let tampered_participants = Participants::init( @@ -501,9 +605,9 @@ mod tests { fn test_remove_only() { let params = ThresholdParameters::new(gen_participants(5), Threshold::new(3)).unwrap(); - let new_participants = params - .participants - .subset(0..params.threshold.value() as usize); + // Shrink to 4 participants (not down to the threshold of 3): with the upper + // cap, k=3 requires at least 4 participants (floor(0.8*4) = 3). + let new_participants = params.participants.subset(0..4); let new_params = ThresholdParameters::new(new_participants, params.threshold).unwrap(); @@ -530,7 +634,7 @@ mod tests { fn test_new_participant_id_too_high() { // When proposal's next_id is higher than max_id + 1, it should fail with // NewParticipantIdsTooHigh. - let params = ThresholdParameters::new(gen_participants(5), Threshold::new(5)).unwrap(); + let params = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); let next_id = params.participants.next_id(); // Add one new participant with the correct next_id, but set the proposal's @@ -539,13 +643,13 @@ mod tests { let mut new_participants_vec: Vec<_> = params.participants.participants().to_vec(); new_participants_vec.push((new_account, next_id, new_info)); - // 6 participants with threshold 5: validate_threshold passes (60% of 6 = 4 <= 5 <= 6) + // 6 participants with threshold 4: validate_threshold passes (60% of 6 = 4, upper cap = 4) let proposal = ThresholdParameters::new_unvalidated( Participants::init( ParticipantId(next_id.get() + 2), // too high: should be next_id + 1 new_participants_vec, ), - Threshold::new(5), + Threshold::new(4), ); assert_eq!( params.validate_incoming_proposal(&proposal).unwrap_err(), diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index 0c8a104f9d..623b534b5e 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -416,12 +416,18 @@ pub mod tests { // Reproposing with new_params_1 should succeed, but then reproposing with new_params_2 // should be rejected, since all re-proposals must be valid against the original. let mut new_participants_1 = old_participants.clone(); + // Threshold valid for the grown set (1.5x): old_len sits within + // [ceil(0.6 * 1.5n), floor(0.8 * 1.5n)]. let new_threshold = Threshold::new(old_participants.len() as u64); new_participants_1.add_random_participants_till_n((old_participants.len() * 3).div_ceil(2)); let new_participants_2 = new_participants_1 .subset(new_participants_1.len() - old_participants.len()..new_participants_1.len()); let new_params_1 = ThresholdParameters::new(new_participants_1, new_threshold).unwrap(); - let new_params_2 = ThresholdParameters::new(new_participants_2, new_threshold).unwrap(); + // new_params_2 has the same size as the old set, for which `new_threshold` + // (== old_len) would be k == n and exceed the upper cap; use a valid + // threshold for that size instead. + let new_threshold_2 = Threshold::new((3 * new_participants_2.len() as u64).div_ceil(5)); + let new_params_2 = ThresholdParameters::new(new_participants_2, new_threshold_2).unwrap(); // Proposals carry an empty (no-change) set of per-domain threshold updates. let proposed_1 = ProposedThresholdParameters::new(new_params_1.clone(), BTreeMap::new()); let proposed_2 = ProposedThresholdParameters::new(new_params_2.clone(), BTreeMap::new()); diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index e20eacd3e0..8d254468f7 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -211,9 +211,20 @@ impl RunningContractState { return Err(DomainError::AddDomainsMustAddAtLeastOneDomain.into()); } let num_participants = self.parameters.participants().len() as u64; + let governance_threshold = self.parameters.threshold().value(); for domain in &domains { crate::primitives::domain::validate_domain_purpose(domain)?; crate::primitives::domain::validate_domain_threshold(domain, num_participants)?; + // Keep trust assumptions consistent: a domain must never require more + // shares to reconstruct than the GovernanceThreshold demands to govern. + let reconstruction_threshold = domain.reconstruction_threshold.inner(); + if reconstruction_threshold > governance_threshold { + return Err(DomainError::ReconstructionThresholdExceedsGovernance { + reconstruction_threshold, + governance_threshold, + } + .into()); + } } let participant = AuthenticatedParticipantId::new(self.parameters.participants())?; let n_votes = self.add_domains_votes.vote(domains.clone(), &participant); @@ -248,6 +259,7 @@ pub mod running_tests { use rstest::rstest; use super::RunningContractState; + use crate::errors::{DomainError, Error}; use crate::primitives::domain::AddDomainsVotes; use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_proposed_threshold_params}; use crate::primitives::threshold_votes::ThresholdParametersVotes; @@ -475,17 +487,18 @@ pub mod running_tests { } #[test] - fn vote_add_domains__should_accept_threshold_equal_to_participant_count() { - // Given a Frost proposal where t == n (boundary case). + fn vote_add_domains__should_accept_reconstruction_threshold_equal_to_governance() { + // Given a Frost proposal where the ReconstructionThreshold == the + // GovernanceThreshold (the new upper boundary case). let mut state = gen_running_state(1); let mut env = Environment::new(None, None, None); env.set_signer(&state.parameters.participants().participants()[0].0); - let n = state.parameters.participants().len() as u64; + let governance = state.parameters.threshold().value(); let next_id = state.domains.next_domain_id(); let proposal = vec![DomainConfig { id: DomainId(next_id), protocol: Protocol::Frost, - reconstruction_threshold: ReconstructionThreshold::new(n), + reconstruction_threshold: ReconstructionThreshold::new(governance), purpose: DomainPurpose::Sign, }]; @@ -493,7 +506,42 @@ pub mod running_tests { let res = state.vote_add_domains(proposal); // Then the call succeeds (single voter is below quorum, so no transition) - assert!(res.is_ok(), "Expected success at boundary t == n: {res:?}"); + assert!( + res.is_ok(), + "Expected success at boundary ReconstructionThreshold == GovernanceThreshold: {res:?}" + ); + } + + #[test] + fn vote_add_domains__should_reject_reconstruction_threshold_above_governance() { + // Given a Frost proposal whose ReconstructionThreshold exceeds the + // GovernanceThreshold (but is still <= participant count). + let mut state = gen_running_state(1); + let mut env = Environment::new(None, None, None); + env.set_signer(&state.parameters.participants().participants()[0].0); + let governance = state.parameters.threshold().value(); + let n = state.parameters.participants().len() as u64; + // gen_threshold_params caps governance below n, so governance + 1 <= n here. + assert!(governance < n, "test requires governance below participant count"); + let next_id = state.domains.next_domain_id(); + let proposal = vec![DomainConfig { + id: DomainId(next_id), + protocol: Protocol::Frost, + reconstruction_threshold: ReconstructionThreshold::new(governance + 1), + purpose: DomainPurpose::Sign, + }]; + + // When + let err = state.vote_add_domains(proposal).unwrap_err(); + + // Then the GovernanceThreshold/ReconstructionThreshold relation is enforced. + assert!( + matches!( + err, + Error::DomainError(DomainError::ReconstructionThresholdExceedsGovernance { .. }) + ), + "Expected ReconstructionThresholdExceedsGovernance, got: {err}" + ); } #[test] @@ -605,7 +653,11 @@ pub mod running_tests { fn vote_add_domains__should_accept_caitsith_threshold_differing_from_existing() { // Given a Running state already holding a CaitSith domain at t = 2 // (the fixture default) and a proposal for a second CaitSith at t = 3. + // Require GovernanceThreshold >= 3 so a reconstruction threshold of 3 is allowed. let mut state = gen_running_state(1); + while state.parameters.threshold().value() < 3 { + state = gen_running_state(1); + } let mut env = Environment::new(None, None, None); env.set_signer(&state.parameters.participants().participants()[0].0); let proposal = single_domain_proposal(&state, Protocol::CaitSith, DomainPurpose::Sign, 3); @@ -623,8 +675,10 @@ pub mod running_tests { let mut state = gen_running_state(0); let mut env = Environment::new(None, None, None); env.set_signer(&state.parameters.participants().participants()[0].0); - let n = state.parameters.participants().len() as u64; - let proposal = single_domain_proposal(&state, Protocol::CaitSith, DomainPurpose::Sign, n); + // Use the GovernanceThreshold as the ReconstructionThreshold (the maximum allowed). + let governance = state.parameters.threshold().value(); + let proposal = + single_domain_proposal(&state, Protocol::CaitSith, DomainPurpose::Sign, governance); // When let res = state.vote_add_domains(proposal); @@ -637,7 +691,11 @@ pub mod running_tests { fn vote_add_domains__should_accept_two_new_caitsith_with_differing_thresholds() { // Given a Running state with no existing CaitSith and a proposal // adding two CaitSith domains at different thresholds. + // Require GovernanceThreshold >= 3 so reconstruction thresholds 2 and 3 are both allowed. let mut state = gen_running_state(0); + while state.parameters.threshold().value() < 3 { + state = gen_running_state(0); + } let mut env = Environment::new(None, None, None); env.set_signer(&state.parameters.participants().participants()[0].0); let next_id = state.domains.next_domain_id(); diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index 4ac3973e29..d3bcdfdaa6 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -11,7 +11,7 @@ use std::collections::BTreeMap; use borsh::{BorshDeserialize, BorshSerialize}; use near_mpc_contract_interface::types::{Metrics, VerifyForeignTransactionRequest}; -use near_sdk::{env, store::LookupMap}; +use near_sdk::{env, log, store::LookupMap}; use crate::{ Config, SupportedForeignChainsByNode, @@ -128,8 +128,11 @@ impl From for crate::MpcContract { env::panic_str("Contract must be in running state when migrating."); }; + let running: RunningContractState = running.into(); + validate_threshold_relation_on_migration(&running); + crate::MpcContract { - protocol_state: ProtocolContractState::Running(running.into()), + protocol_state: ProtocolContractState::Running(running), pending_signature_requests: old.pending_signature_requests, pending_ckd_requests: old.pending_ckd_requests, pending_verify_foreign_tx_requests: old.pending_verify_foreign_tx_requests, @@ -145,6 +148,36 @@ impl From for crate::MpcContract { } } +/// One-time pass over migrated state: re-validate the GovernanceThreshold against +/// the participant count and the largest ReconstructionThreshold under the current +/// rules. Pre-existing state may have been written under looser rules (e.g. before +/// the upper cap existed), so a violation is logged loudly rather than panicked — +/// panicking here would brick the upgrade. The next `vote_new_parameters` enforces +/// the rules going forward. +fn validate_threshold_relation_on_migration(running: &RunningContractState) { + let num_participants = running.parameters.participants().len() as u64; + let max_reconstruction_threshold = running + .domains + .domains() + .iter() + .map(|domain| domain.reconstruction_threshold.inner()) + .max() + .unwrap_or(0); + if let Err(err) = ThresholdParameters::validate_governance_against_reconstruction( + num_participants, + running.parameters.threshold(), + max_reconstruction_threshold, + ) { + log!( + "MIGRATION WARNING: existing state violates the GovernanceThreshold/ReconstructionThreshold relation ({:?}). num_participants={}, governance_threshold={}, max_reconstruction_threshold={}. This must be corrected via vote_new_parameters.", + err, + num_participants, + running.parameters.threshold().value(), + max_reconstruction_threshold, + ); + } +} + #[cfg(test)] #[expect(non_snake_case)] mod tests { @@ -177,7 +210,9 @@ mod tests { // and old-layout vote bytes: a single vote whose value is a bare // `ThresholdParameters` (the 3.11.2 vote shape). - let params = ThresholdParameters::new(participants, Threshold::new(n)).unwrap(); + // A valid GovernanceThreshold (60% lower bound) — the exact value is irrelevant here. + let params = + ThresholdParameters::new(participants, Threshold::new((3 * n).div_ceil(5))).unwrap(); let old = OldThresholdParametersVotes { proposal_by_account: BTreeMap::from([(voter, params)]), }; From 7d8685689ca34b39c513ec0300ba78c5b892a909 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:07:12 +0200 Subject: [PATCH 003/121] Updating tests now GovThreshold is capped at 80% no more allowed the same number of participants --- crates/contract/src/primitives/thresholds.rs | 6 ++--- crates/contract/src/state/running.rs | 5 +++- crates/contract/src/v3_11_2_state.rs | 14 +++++----- crates/contract/tests/sandbox/sign.rs | 2 +- .../sandbox/tee_cleanup_after_resharing.rs | 26 ++++++++++--------- .../update_votes_cleanup_after_resharing.rs | 17 +++++++----- docs/design/domain-separation.md | 22 +++++++++++++--- 7 files changed, 57 insertions(+), 35 deletions(-) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index e44a8dcaa6..63a3dbb7b7 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -70,8 +70,8 @@ impl ThresholdParameters { .into()); } // Clamp the upper cap up to the lower bound so the window stays non-empty for small n. - let upper_relative_bound = - (MAX_THRESHOLD_NUMERATOR * n_shares / MAX_THRESHOLD_DENOMINATOR).max(lower_relative_bound); + let upper_relative_bound = (MAX_THRESHOLD_NUMERATOR * n_shares / MAX_THRESHOLD_DENOMINATOR) + .max(lower_relative_bound); if k.value() > upper_relative_bound { return Err(InvalidThreshold::MaxRelRequirementFailed { max: upper_relative_bound, @@ -380,7 +380,7 @@ mod tests { #[test] fn validate_governance_against_reconstruction__should_reject_governance_below_max_reconstruction() - { + { // Given 10 participants and a governance threshold of 6 (a valid value on its own). let n = 10; let governance = Threshold::new(6); diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 8d254468f7..767bf45c26 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -522,7 +522,10 @@ pub mod running_tests { let governance = state.parameters.threshold().value(); let n = state.parameters.participants().len() as u64; // gen_threshold_params caps governance below n, so governance + 1 <= n here. - assert!(governance < n, "test requires governance below participant count"); + assert!( + governance < n, + "test requires governance below participant count" + ); let next_id = state.domains.next_domain_id(); let proposal = vec![DomainConfig { id: DomainId(next_id), diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index d3bcdfdaa6..ae2bbd46c8 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -150,10 +150,9 @@ impl From for crate::MpcContract { /// One-time pass over migrated state: re-validate the GovernanceThreshold against /// the participant count and the largest ReconstructionThreshold under the current -/// rules. Pre-existing state may have been written under looser rules (e.g. before -/// the upper cap existed), so a violation is logged loudly rather than panicked — -/// panicking here would brick the upgrade. The next `vote_new_parameters` enforces -/// the rules going forward. +/// rules. A violation hard-blocks the migration (panic): we refuse to carry state +/// that breaks the threshold relation into the new contract. Such state must be +/// corrected (via `vote_new_parameters`) before upgrading. fn validate_threshold_relation_on_migration(running: &RunningContractState) { let num_participants = running.parameters.participants().len() as u64; let max_reconstruction_threshold = running @@ -168,13 +167,12 @@ fn validate_threshold_relation_on_migration(running: &RunningContractState) { running.parameters.threshold(), max_reconstruction_threshold, ) { - log!( - "MIGRATION WARNING: existing state violates the GovernanceThreshold/ReconstructionThreshold relation ({:?}). num_participants={}, governance_threshold={}, max_reconstruction_threshold={}. This must be corrected via vote_new_parameters.", - err, + env::panic_str(&format!( + "Migration aborted: existing state violates the GovernanceThreshold/ReconstructionThreshold relation ({err:?}). num_participants={}, governance_threshold={}, max_reconstruction_threshold={}. Correct it via vote_new_parameters before upgrading.", num_participants, running.parameters.threshold().value(), max_reconstruction_threshold, - ); + )); } } diff --git a/crates/contract/tests/sandbox/sign.rs b/crates/contract/tests/sandbox/sign.rs index 2d58978645..1c624b29d7 100644 --- a/crates/contract/tests/sandbox/sign.rs +++ b/crates/contract/tests/sandbox/sign.rs @@ -313,7 +313,7 @@ async fn test_contract_initialization() -> anyhow::Result<()> { ); let proposed_parameters = - ThresholdParameters::new(candidates(None), Threshold::new(3)).unwrap(); + ThresholdParameters::new(candidates(None), Threshold::new(2)).unwrap(); let result = contract .call(method_names::INIT) .args_json(serde_json::json!({ diff --git a/crates/contract/tests/sandbox/tee_cleanup_after_resharing.rs b/crates/contract/tests/sandbox/tee_cleanup_after_resharing.rs index 8b0803a3b7..c125907dc4 100644 --- a/crates/contract/tests/sandbox/tee_cleanup_after_resharing.rs +++ b/crates/contract/tests/sandbox/tee_cleanup_after_resharing.rs @@ -101,12 +101,13 @@ async fn reshare__should_leave_valid_non_participant_attestations_in_storage() - let initial_and_non_participants = get_tee_accounts(&contract).await.unwrap(); assert_eq!(initial_and_non_participants, expected_node_ids); - // Now, we do a resharing. We only retain `threshold` of the initial participants + // Now, we do a resharing. We retain the smallest participant set that keeps the + // GovernanceThreshold within its upper cap: n >= ceil(threshold * 5 / 4) so that + // threshold <= floor(0.8 * n). + let retained = ((threshold.0 * 5).div_ceil(4)) as usize; let mut new_participants = Participants::new(); - for (account_id, participant_id, participant_info) in initial_participants - .participants - .iter() - .take(threshold.0 as usize) + for (account_id, participant_id, participant_info) in + initial_participants.participants.iter().take(retained) { new_participants .insert_with_id( @@ -130,7 +131,7 @@ async fn reshare__should_leave_valid_non_participant_attestations_in_storage() - let prospective_epoch_id = dtos::EpochId(6); do_resharing( - &mpc_signer_accounts[..threshold.0 as usize], + &mpc_signer_accounts[..retained], &contract, new_threshold_parameters, prospective_epoch_id, @@ -212,12 +213,13 @@ async fn reshare__should_evict_expired_attestations_via_post_reshare_sweep() -> // sweep sees the outsider entry as invalid. worker.fast_forward(BLOCKS_TO_FAST_FORWARD).await?; - // Reshare to the threshold subset; this triggers the post-reshare cleanup promise. + // Reshare to the smallest valid reduced subset (n >= ceil(threshold * 5 / 4), so + // the GovernanceThreshold stays within its upper cap); this triggers the + // post-reshare cleanup promise. + let retained = ((threshold.0 * 5).div_ceil(4)) as usize; let mut new_participants = Participants::new(); - for (account_id, participant_id, participant_info) in initial_participants - .participants - .iter() - .take(threshold.0 as usize) + for (account_id, participant_id, participant_info) in + initial_participants.participants.iter().take(retained) { new_participants .insert_with_id( @@ -236,7 +238,7 @@ async fn reshare__should_evict_expired_attestations_via_post_reshare_sweep() -> ) .unwrap(); do_resharing( - &mpc_signer_accounts[..threshold.0 as usize], + &mpc_signer_accounts[..retained], &contract, new_threshold_parameters, dtos::EpochId(6), diff --git a/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs b/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs index ab37437b0c..350c8c1466 100644 --- a/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs +++ b/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs @@ -76,13 +76,15 @@ async fn update_votes_from_kicked_out_participants_are_cleared_after_resharing() ); // when: resharing completes with new participants that exclude participant 0 - // Reshare with threshold participants, excluding participant 0 who voted + // Reshare excluding participant 0 who voted, keeping the smallest valid set + // (n >= ceil(threshold * 5 / 4), so the GovernanceThreshold stays within its cap). + let retained = ((threshold.0 * 5).div_ceil(4)) as usize; let mut new_participants = Participants::new(); for (account_id, participant_id, participant_info) in initial_participants .participants .iter() - .skip(1) // Skip participant 0, so participant 1-6 are included - .take(threshold.0 as usize) + .skip(1) // Skip participant 0 + .take(retained) { new_participants .insert_with_id( @@ -105,7 +107,7 @@ async fn update_votes_from_kicked_out_participants_are_cleared_after_resharing() // when: resharing completes with new participants that exclude participant 0 do_resharing( - &mpc_signer_accounts[1..threshold.0 as usize + 1], + &mpc_signer_accounts[1..retained + 1], &contract, new_threshold_parameters, prospective_epoch_id, @@ -191,12 +193,15 @@ async fn add_domain_votes_from_kicked_out_participants_are_cleared_after_reshari assert_eq!(running.add_domains_votes.proposal_by_account.len(), 2); // When + // Retain the smallest valid reduced set (n >= ceil(threshold * 5 / 4), so the + // GovernanceThreshold stays within its upper cap), excluding participant 0. + let retained = ((threshold.0 * 5).div_ceil(4)) as usize; let mut new_participants = Participants::new(); for (account_id, participant_id, participant_info) in initial_participants .participants .iter() .skip(1) - .take(threshold.0 as usize) + .take(retained) { new_participants .insert_with_id( @@ -218,7 +223,7 @@ async fn add_domain_votes_from_kicked_out_participants_are_cleared_after_reshari let prospective_epoch_id = dtos::EpochId(6); do_resharing( - &mpc_signer_accounts[1..threshold.0 as usize + 1], + &mpc_signer_accounts[1..retained + 1], &contract, new_threshold_parameters, prospective_epoch_id, diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index 60adcb5700..f3ebedb1c1 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -343,11 +343,22 @@ pub fn validate_governance(governance: &GovernanceBody) -> Result<(), Error> { let t = governance.governance_threshold.0; if t < 2 { return Err(Error::GovernanceThresholdTooLow); } if t > n { return Err(Error::GovernanceThresholdExceedsParticipants); } - // Governance minimum: >= 60% (same policy as current) + // Governance lower bound: >= 60% (same policy as current) let min_relative = (3 * n).div_ceil(5); if t < min_relative { return Err(Error::GovernanceThresholdBelowMinimumRelative); } + // Governance upper cap: <= 80% (floor), clamped up to the 60% lower bound so the + // window is never empty. Prevents a minority that stops serving from locking the + // contract (reshare / add / kick / sign). See §7.1. + let max_relative = (4 * n / 5).max(min_relative); + if t > max_relative { + return Err(Error::GovernanceThresholdAboveMaximumRelative); + } + // Cross-domain rule: GovernanceThreshold must dominate every ReconstructionThreshold, + // checked wherever governance, a reconstruction threshold, or the participant set + // changes (see §7.1). Implemented as + // `ThresholdParameters::validate_governance_against_reconstruction`. Ok(()) } ``` @@ -919,11 +930,14 @@ let threshold = match dk.protocol { ## 7. Open Questions -### 7.1 Governance Threshold Validation +### 7.1 Governance Threshold Validation — RESOLVED (#3499) + +Resolved: the `GovernanceThreshold` is now constrained relative to the cryptographic `ReconstructionThreshold`. Concretely, for `n` participants and per-domain reconstruction thresholds `t_i`: -Should the governance `GovernanceThreshold` be constrained relative to the cryptographic `ReconstructionThreshold`? For example, should we require `governance_threshold >= max(reconstruction_threshold for all configs)`? +- `max(t_i) <= GovernanceThreshold` — a governance majority can never approve a reshare into a set smaller than what any domain needs to reconstruct its key (keeps trust assumptions consistent). +- `ceil(0.6*n) <= GovernanceThreshold <= max(ceil(0.6*n), floor(0.8*n))` — the existing 60% lower bound plus an 80%-floor upper cap so a minority that stops serving cannot lock the contract. -If not, it is possible for a governance majority to approve a resharing that a cryptographic protocol cannot support. +These are enforced at every mutation point (governance threshold updates, reconstruction threshold updates, participant add, participant kick) and re-validated once on migration (logged, not panicked, to avoid bricking upgrades of pre-existing state). See `ThresholdParameters::validate_threshold` and `validate_governance_against_reconstruction` in `crates/contract/src/primitives/thresholds.rs`. ### 7.2 Backward-Compatible View Methods From b33f455b502ae0b032045824da47632efed952b2 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:08:04 +0200 Subject: [PATCH 004/121] Panic in migration misconfig --- crates/contract/src/v3_11_2_state.rs | 2 +- docs/design/domain-separation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index ae2bbd46c8..ddcd7aecf5 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -11,7 +11,7 @@ use std::collections::BTreeMap; use borsh::{BorshDeserialize, BorshSerialize}; use near_mpc_contract_interface::types::{Metrics, VerifyForeignTransactionRequest}; -use near_sdk::{env, log, store::LookupMap}; +use near_sdk::{env, store::LookupMap}; use crate::{ Config, SupportedForeignChainsByNode, diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index f3ebedb1c1..354399a9da 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -937,7 +937,7 @@ Resolved: the `GovernanceThreshold` is now constrained relative to the cryptogra - `max(t_i) <= GovernanceThreshold` — a governance majority can never approve a reshare into a set smaller than what any domain needs to reconstruct its key (keeps trust assumptions consistent). - `ceil(0.6*n) <= GovernanceThreshold <= max(ceil(0.6*n), floor(0.8*n))` — the existing 60% lower bound plus an 80%-floor upper cap so a minority that stops serving cannot lock the contract. -These are enforced at every mutation point (governance threshold updates, reconstruction threshold updates, participant add, participant kick) and re-validated once on migration (logged, not panicked, to avoid bricking upgrades of pre-existing state). See `ThresholdParameters::validate_threshold` and `validate_governance_against_reconstruction` in `crates/contract/src/primitives/thresholds.rs`. +These are enforced at every mutation point (governance threshold updates, reconstruction threshold updates, participant add, participant kick) and re-validated once on migration, where a violation hard-blocks the upgrade (panic) — state that breaks the relation must be corrected via `vote_new_parameters` before upgrading. See `ThresholdParameters::validate_threshold` and `validate_governance_against_reconstruction` in `crates/contract/src/primitives/thresholds.rs`. ### 7.2 Backward-Compatible View Methods From 02c531309fc38bd7d9fd7283103a90a9a470a9a1 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:10:45 +0200 Subject: [PATCH 005/121] Remove migration function after migration --- crates/contract/src/v3_11_2_state.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index ddcd7aecf5..74b4a53ac7 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -153,6 +153,12 @@ impl From for crate::MpcContract { /// rules. A violation hard-blocks the migration (panic): we refuse to carry state /// that breaks the threshold relation into the new contract. Such state must be /// corrected (via `vote_new_parameters`) before upgrading. +/// +/// TODO(#XXXX): remove together with this module once the 3.11.2 -> current +/// migration is retired. The relation is enforced at every runtime mutation point +/// (`assert_proposal_meets_all_thresholds`, `vote_add_domains`, `init_running`, +/// `verify_tee`), so once no pre-existing violating state can reach this path the +/// gate has no remaining job. fn validate_threshold_relation_on_migration(running: &RunningContractState) { let num_participants = running.parameters.participants().len() as u64; let max_reconstruction_threshold = running From a378c56f365aafdea618ecdf5cb6e8e8f5900477 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:48:37 +0200 Subject: [PATCH 006/121] Raising the number of participants in the test to meet the 80% upper cap --- crates/node/src/tests/resharing.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/node/src/tests/resharing.rs b/crates/node/src/tests/resharing.rs index fbfa3d0fc0..c07916a937 100644 --- a/crates/node/src/tests/resharing.rs +++ b/crates/node/src/tests/resharing.rs @@ -37,7 +37,7 @@ async fn test_key_resharing_simple( #[case] protocol: Protocol, #[case] threshold: usize, ) { - let num_participants: usize = threshold + 1; + let num_participants: usize = threshold + 2; const TXN_DELAY_BLOCKS: u64 = 1; let temp_dir = tempfile::tempdir().unwrap(); let mut setup = IntegrationTestSetup::new( From 3da165205ac5d6c02a764efb028d704a99822bfe Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:03:18 +0200 Subject: [PATCH 007/121] Raising extra tests number of particpants to meet the 80% cap --- crates/e2e-tests/tests/key_resharing.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/e2e-tests/tests/key_resharing.rs b/crates/e2e-tests/tests/key_resharing.rs index 22763320a1..6a97fb74dc 100644 --- a/crates/e2e-tests/tests/key_resharing.rs +++ b/crates/e2e-tests/tests/key_resharing.rs @@ -49,10 +49,10 @@ async fn test_key_resharing() { .await .expect("ckd request failed"); - // Resharing 2: shrink to nodes [1,2,3] (drop node 0), threshold 3. + // Resharing 2: shrink to nodes [1,2,3] (drop node 0), threshold 2. tracing::info!("resharing 2: dropping node 0"); cluster - .start_resharing_and_wait(&[1, 2, 3], 3) + .start_resharing_and_wait(&[1, 2, 3], 2) .await .expect("resharing 2 failed"); let running = running_state(&cluster).await.expect("running_state failed"); @@ -77,14 +77,14 @@ async fn test_key_resharing() { .await .expect("sign request failed"); - // Resharing 4: increase threshold to 4 (all participants required). - tracing::info!("resharing 4: increasing threshold to 4"); + // Resharing 4: re-add node 0 and increase threshold to 4 (n=5, the 80% cap allows 4). + tracing::info!("resharing 4: expanding to 5 nodes, increasing threshold to 4"); cluster - .start_resharing_and_wait(&[1, 2, 3, 4], 4) + .start_resharing_and_wait(&[0, 1, 2, 3, 4], 4) .await .expect("resharing 4 failed"); let running = running_state(&cluster).await.expect("running_state failed"); - assert_eq!(running.parameters.participants.participants.len(), 4); + assert_eq!(running.parameters.participants.participants.len(), 5); // Verify resharing didn't repeatedly fail. Allow a small number of // retries for transient sandbox contention (key_event_timeout_blocks From aeed6e76bc431bdad3bb583cb40e7ea896ca0a78 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:18:50 +0200 Subject: [PATCH 008/121] Adding note documentation about cases where the threshold is a single possible value due to 60%-80% range --- docs/design/domain-separation.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index 354399a9da..8e0686ddd5 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -939,6 +939,8 @@ Resolved: the `GovernanceThreshold` is now constrained relative to the cryptogra These are enforced at every mutation point (governance threshold updates, reconstruction threshold updates, participant add, participant kick) and re-validated once on migration, where a violation hard-blocks the upgrade (panic) — state that breaks the relation must be corrected via `vote_new_parameters` before upgrading. See `ThresholdParameters::validate_threshold` and `validate_governance_against_reconstruction` in `crates/contract/src/primitives/thresholds.rs`. +> **Operator note — the feasible window can be a single value.** When `ceil(0.6*n)` and `floor(0.8*n)` round to the same integer, exactly one `GovernanceThreshold` is legal and there is zero room to tune: this happens at `n = 2, 3, 4, 6, 7` (forced thresholds `2, 2, 3, 4, 5`), widening only from `n >= 8`. The `n = 2` case is degenerate — it forces `GovernanceThreshold = n` (unanimity), defeating the cap; permitted by validation but not a sensible configuration. + ### 7.2 Backward-Compatible View Methods How long should the old `state()` view method be maintained alongside the new `state_v2()`? Should the old format be deprecated immediately or kept for N epochs? Are there external consumers (e.g., block explorers, SDK clients) that depend on the `state()` format? From 7257a161d5a22a339b52eae39d034a13f718e329 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:28:16 +0200 Subject: [PATCH 009/121] Using already existent helpers in vote_add_domains --- crates/contract/src/errors.rs | 7 ----- crates/contract/src/state/running.rs | 42 +++++++++++++++------------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index 7bbe642385..070a1c1192 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -276,13 +276,6 @@ pub enum DomainError { "Resharing proposal references domain ID {domain_id}, which is not in the current registry." )] UnknownDomainInProposal { domain_id: DomainId }, - #[error( - "ReconstructionThreshold {reconstruction_threshold} exceeds the GovernanceThreshold {governance_threshold}." - )] - ReconstructionThresholdExceedsGovernance { - reconstruction_threshold: u64, - governance_threshold: u64, - }, } /// A list specifying general categories of MPC Contract errors. diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 767bf45c26..3c057824db 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -3,7 +3,7 @@ use super::key_event::KeyEvent; use super::resharing::ResharingContractState; use crate::errors::{ConversionError, DomainError, Error, InvalidParameters, VoteError}; use crate::primitives::{ - domain::{AddDomainsVotes, DomainRegistry, validate_domain_threshold}, + domain::{AddDomainsVotes, DomainRegistry, validate_domain_purpose, validate_domain_threshold}, key_state::{AuthenticatedAccountId, AuthenticatedParticipantId, EpochId, Keyset}, threshold_votes::ThresholdParametersVotes, thresholds::{ProposedThresholdParameters, ThresholdParameters}, @@ -210,22 +210,25 @@ impl RunningContractState { if domains.is_empty() { return Err(DomainError::AddDomainsMustAddAtLeastOneDomain.into()); } - let num_participants = self.parameters.participants().len() as u64; - let governance_threshold = self.parameters.threshold().value(); + let num_participants = u64::try_from(self.parameters.participants().len()) + .expect("participant list should be wayyyy smaller than u64::MAX"); for domain in &domains { - crate::primitives::domain::validate_domain_purpose(domain)?; - crate::primitives::domain::validate_domain_threshold(domain, num_participants)?; - // Keep trust assumptions consistent: a domain must never require more - // shares to reconstruct than the GovernanceThreshold demands to govern. - let reconstruction_threshold = domain.reconstruction_threshold.inner(); - if reconstruction_threshold > governance_threshold { - return Err(DomainError::ReconstructionThresholdExceedsGovernance { - reconstruction_threshold, - governance_threshold, - } - .into()); - } + validate_domain_purpose(domain)?; + validate_domain_threshold(domain, num_participants)?; } + // Keep trust assumptions consistent: a domain must never require more shares to + // reconstruct than the GovernanceThreshold demands to govern. Route through the + // canonical helper so the cross-domain invariant has a single source of truth. + let max_reconstruction_threshold = domains + .iter() + .map(|domain| domain.reconstruction_threshold.inner()) + .max() + .expect("domains is non-empty: guarded by AddDomainsMustAddAtLeastOneDomain above"); + ThresholdParameters::validate_governance_against_reconstruction( + num_participants, + self.parameters.threshold(), + max_reconstruction_threshold, + )?; let participant = AuthenticatedParticipantId::new(self.parameters.participants())?; let n_votes = self.add_domains_votes.vote(domains.clone(), &participant); if self.parameters.participants().len() as u64 == n_votes { @@ -259,7 +262,7 @@ pub mod running_tests { use rstest::rstest; use super::RunningContractState; - use crate::errors::{DomainError, Error}; + use crate::errors::{Error, InvalidThreshold}; use crate::primitives::domain::AddDomainsVotes; use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_proposed_threshold_params}; use crate::primitives::threshold_votes::ThresholdParametersVotes; @@ -537,13 +540,14 @@ pub mod running_tests { // When let err = state.vote_add_domains(proposal).unwrap_err(); - // Then the GovernanceThreshold/ReconstructionThreshold relation is enforced. + // Then the GovernanceThreshold/ReconstructionThreshold relation is enforced via the + // canonical validate_governance_against_reconstruction helper. assert!( matches!( err, - Error::DomainError(DomainError::ReconstructionThresholdExceedsGovernance { .. }) + Error::InvalidThreshold(InvalidThreshold::BelowReconstructionThreshold { .. }) ), - "Expected ReconstructionThresholdExceedsGovernance, got: {err}" + "Expected BelowReconstructionThreshold, got: {err}" ); } From a20a7a8de291a175f70466c09aa30ae34207c6a3 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:33:54 +0200 Subject: [PATCH 010/121] Bump the number of participants for parallel_sign_calls to meet 80% upper cap --- crates/e2e-tests/tests/parallel_sign_calls.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/e2e-tests/tests/parallel_sign_calls.rs b/crates/e2e-tests/tests/parallel_sign_calls.rs index 7e6b3ac1b4..88f97960d6 100644 --- a/crates/e2e-tests/tests/parallel_sign_calls.rs +++ b/crates/e2e-tests/tests/parallel_sign_calls.rs @@ -8,7 +8,7 @@ use near_mpc_contract_interface::types::{ use serde_json::json; /// 9 parallel calls (3 robust ECDSA + 2 ECDSA + 2 EdDSA + 2 CKD) via the test parallel -/// contract, against a 6-node / threshold-5 cluster that carries all four signing-scheme +/// contract, against a 7-node / threshold-5 cluster that carries all four signing-scheme /// domains. Verifies all calls succeed and both the signature and CKD queues drain. #[tokio::test] async fn mpc_cluster_should_successfully_process_parallel_requests() { @@ -21,8 +21,8 @@ async fn mpc_cluster_should_successfully_process_parallel_requests() { // given let (cluster, _running) = common::must_setup_cluster(common::PARALLEL_SIGN_CALLS_PORT_SEED, |c| { - c.num_nodes = 6; - c.initial_participant_indices = (0..6).collect(); + c.num_nodes = 7; + c.initial_participant_indices = (0..7).collect(); c.threshold = 5; c.domains = vec![ DomainConfig { From 1afd958fc1426c98abdc6ad1e56ec65377f669ea Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:48:07 +0200 Subject: [PATCH 011/121] make process_new_parameters_proposal self-contained --- crates/contract/src/state/running.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 3c057824db..af67df11d0 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -180,6 +180,19 @@ impl RunningContractState { validate_domain_threshold(domain, new_num_participants)?; } + // The GovernanceThreshold must dominate every domain's effective ReconstructionThreshold; + // enforced here so the state transition is self-contained (single source of truth). + let max_reconstruction_threshold = effective_domains + .iter() + .map(|domain| domain.reconstruction_threshold.inner()) + .max() + .unwrap_or(0); + ThresholdParameters::validate_governance_against_reconstruction( + new_num_participants, + proposal.threshold(), + max_reconstruction_threshold, + )?; + // ensure the signer is a proposed participant let candidate = AuthenticatedAccountId::new(proposal.participants())?; From db47b857627e4237547f5f301ea4fc4790503ef6 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:10:09 +0200 Subject: [PATCH 012/121] Removing redundant function --- crates/contract/src/lib.rs | 45 ---------------------------- crates/contract/src/state/running.rs | 13 ++++++-- crates/contract/src/v3_11_2_state.rs | 2 +- 3 files changed, 11 insertions(+), 49 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 493b74ea06..be172d8b63 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -885,10 +885,6 @@ impl MpcContract { proposal, ); - // Defense in depth: never reshare into a participant set smaller than any - // threshold (see `assert_proposal_meets_all_thresholds`). - self.assert_proposal_meets_all_thresholds(&proposal)?; - let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); @@ -931,47 +927,6 @@ impl MpcContract { } } - /// Defense-in-depth guard for [`Self::vote_new_parameters`]: rejects a - /// proposal whose participant set is smaller than the proposed signing - /// threshold or any domain's effective reconstruction threshold (proposed - /// override if present, else the domain's current value). Such a set would - /// leave a key un-signable or un-reconstructible. Redundant with - /// `RunningContractState::process_new_parameters_proposal`. - fn assert_proposal_meets_all_thresholds( - &self, - proposal: &ProposedThresholdParameters, - ) -> Result<(), Error> { - let num_participants = u64::try_from(proposal.participants().len()) - .expect("participant list should be wayyyy smaller than u64::MAX"); - - let domains = self.protocol_state.domain_registry()?; - let updates = proposal.per_domain_thresholds(); - let mut max_reconstruction_threshold = 0u64; - for domain in domains.domains() { - let effective = updates - .get(&domain.id) - .copied() - .unwrap_or(domain.reconstruction_threshold) - .inner(); - if effective > num_participants { - return Err(DomainError::ReconstructionThresholdExceedsParticipants { - threshold: effective, - participants: num_participants, - } - .into()); - } - max_reconstruction_threshold = max_reconstruction_threshold.max(effective); - } - - // Enforce the GovernanceThreshold bounds together with the relation - // `GovernanceThreshold >= max(ReconstructionThreshold)`. - ThresholdParameters::validate_governance_against_reconstruction( - num_participants, - proposal.threshold(), - max_reconstruction_threshold, - ) - } - /// Propose adding a new set of domains for the MPC network. /// If a threshold number of votes are reached on the exact same proposal, this will transition /// the contract into the Initializing state to generate keys for the new domains. diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index af67df11d0..3b8a13e7a3 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -277,8 +277,9 @@ pub mod running_tests { use super::RunningContractState; use crate::errors::{Error, InvalidThreshold}; use crate::primitives::domain::AddDomainsVotes; - use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_proposed_threshold_params}; + use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_participants, gen_proposed_threshold_params}; use crate::primitives::threshold_votes::ThresholdParametersVotes; + use crate::primitives::thresholds::{Threshold, ThresholdParameters}; use crate::state::key_event::tests::Environment; use crate::state::test_utils::{gen_running_state, gen_valid_params_proposal}; use near_mpc_contract_interface::types::{ @@ -773,6 +774,9 @@ pub mod running_tests { // Given a running state with one CaitSith domain at the fixture default // t = 2. let mut state = gen_running_state(1); + // Pin a participant set so the generated proposal's GovernanceThreshold is >= 3; + // ReconstructionThreshold (3) must not exceed it. + state.parameters = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); let mut env = Environment::new(None, None, None); let proposal = gen_valid_params_proposal(&state.parameters); // Sign as a participant present in BOTH the current and proposed sets @@ -791,8 +795,8 @@ pub mod running_tests { .expect("proposal must retain at least one current participant"); env.set_signer(&signer); - // When voting with an update raising t to 3. The proposed participant - // count is always >= 3, so the raised threshold stays within bounds. + // When voting with an update raising t to 3, which stays within both the proposed + // participant count and the GovernanceThreshold (>= 3 by the pinned params above). let domain_id = state.domains.domains()[0].id; let mut threshold_updates = BTreeMap::new(); threshold_updates.insert(domain_id, ReconstructionThreshold::new(3)); @@ -841,6 +845,9 @@ pub mod running_tests { // Given a running state with two CaitSith domains, both at the fixture // default t = 2 (the protocols cycle, so 5 domains yields two CaitSith). let mut state = gen_running_state(5); + // Pin a participant set so the generated proposal's GovernanceThreshold is >= 3; + // ReconstructionThreshold (3) must not exceed it. + state.parameters = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); let mut env = Environment::new(None, None, None); assert!( state diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index 74b4a53ac7..55b1c81f45 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -156,7 +156,7 @@ impl From for crate::MpcContract { /// /// TODO(#XXXX): remove together with this module once the 3.11.2 -> current /// migration is retired. The relation is enforced at every runtime mutation point -/// (`assert_proposal_meets_all_thresholds`, `vote_add_domains`, `init_running`, +/// (`process_new_parameters_proposal`, `vote_add_domains`, `init_running`, /// `verify_tee`), so once no pre-existing violating state can reach this path the /// gate has no remaining job. fn validate_threshold_relation_on_migration(running: &RunningContractState) { From 0cf1b539dba4247f49aff2888ea08ae5ef78bdda Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:15:15 +0200 Subject: [PATCH 013/121] Obsolete test as the check happens in the underlying function --- crates/contract/src/lib.rs | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index be172d8b63..c7fa9d0b60 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -3906,33 +3906,6 @@ mod tests { ); } - #[test] - fn vote_new_parameters__should_reject_when_shrinking_below_unchanged_domain_threshold() { - // Given: a Running contract with 4 participants (GovernanceThreshold 3) and a - // domain whose reconstruction threshold is 3. - let (mut contract, participants, signer, _domain_id) = - setup_running_contract_with_domain(4, 3, 3); - // ...and a proposal that shrinks the participant set to 2 without touching - // the per-domain thresholds. - let proposal = ProposedThresholdParameters::new( - ThresholdParameters::new(participants.subset(0..2), Threshold::new(2)).unwrap(), - BTreeMap::new(), - ); - - // When - let result = vote_params(&mut contract, &signer, &proposal); - - // Then: the domain's unchanged threshold of 3 exceeds the 2 proposed - // participants, so the guard rejects it. - assert_matches!( - result.unwrap_err(), - Error::DomainError(DomainError::ReconstructionThresholdExceedsParticipants { - threshold: 3, - participants: 2, - }) - ); - } - #[test] fn vote_new_parameters__should_reject_when_signing_threshold_exceeds_participants() { // Given: a Running contract with 3 participants and one domain. From 27e40bf29727358233c1229a63f5ad06d3073186 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:17:55 +0200 Subject: [PATCH 014/121] make function private --- crates/contract/src/primitives/thresholds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 63a3dbb7b7..602c5059b2 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -50,7 +50,7 @@ impl ThresholdParameters { /// of the number of shares (rounded downwards), so a minority that stops serving /// cannot lock the contract. This upper cap is clamped up to the 60% lower bound so /// the feasible window is never empty for small `n_shares`. - pub fn validate_threshold(n_shares: u64, k: Threshold) -> Result<(), Error> { + fn validate_threshold(n_shares: u64, k: Threshold) -> Result<(), Error> { if k.value() > n_shares { return Err(InvalidThreshold::MaxRequirementFailed { max: n_shares, From 7878ac8bb999fe1ee33bb286d2e929d827d6ec3f Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:58:20 +0200 Subject: [PATCH 015/121] Fixing tests with govthreshold = number of participants --- crates/contract/src/state/resharing.rs | 48 +++++++++----------------- 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index 623b534b5e..a5117ad68d 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -207,7 +207,7 @@ impl ResharingContractState { } #[cfg(test)] pub mod tests { - use crate::primitives::test_utils::NUM_PROTOCOLS; + use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_participants}; use crate::state::{ key_event::tests::{Environment, find_leader}, running::RunningContractState, @@ -498,32 +498,21 @@ pub mod tests { /// resharing state, so the stored `RunningContractState.parameters` /// (a plain `ThresholdParameters`) cannot carry them at all. /// - /// The fixture is deterministic on purpose: it keeps the participant - /// set unchanged (a key-refresh resharing) so the proposed participant - /// count equals the running count (`n >= 3`, guaranteed by - /// `gen_running_state`). That guarantees `n` is itself a valid - /// reconstruction threshold and always differs from the default `2` — - /// avoiding the flakiness of deriving the new value from the random - /// cluster threshold, which lands on `2` whenever the proposal has 2 or - /// 3 participants. + /// Pins a participant set with GovernanceThreshold >= 3 (unchanged across the + /// resharing) and moves the domain to that threshold — the max reconstruction the + /// cross-domain invariant allows, deterministically distinct from the default 2. #[expect(non_snake_case)] #[test] fn vote_reshared__final_transition__should_apply_threshold_updates_to_registry() { - // Given a running state with a single CaitSith domain at the default - // reconstruction threshold (2), and a resharing proposal over the - // same participant set carrying a threshold update that moves that - // domain to `n` — a value valid for `n` participants and distinct from 2. + // Given a CaitSith domain at the default threshold 2 and a key-refresh proposal + // moving it to the GovernanceThreshold. let mut env = Environment::new(Some(100), None, None); let mut running = gen_running_state(1); + running.parameters = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); let current_params = running.parameters.clone(); - let n = current_params.participants().len() as u64; - assert!( - n >= 3, - "gen_running_state guarantees at least 3 participants" - ); let domain_id = running.domains.domains()[0].id; let original_threshold = running.domains.domains()[0].reconstruction_threshold; - let new_threshold = ReconstructionThreshold::new(n); + let new_threshold = ReconstructionThreshold::new(current_params.threshold().value()); assert_ne!(new_threshold, original_threshold); let mut threshold_updates = BTreeMap::new(); threshold_updates.insert(domain_id, new_threshold); @@ -575,27 +564,22 @@ pub mod tests { ); } - /// End-to-end companion to the single-domain test above: two domains end the - /// resharing with *different* thresholds. Only the Frost domain is updated, - /// leaving CaitSith at the default `2`. Key-refresh resharing (unchanged - /// participants) keeps `n >= 3`, so `n` is a valid threshold distinct from `2`. + /// Companion to the test above: two domains end the resharing with different + /// thresholds. Only Frost is moved (to the GovernanceThreshold); CaitSith keeps 2. + /// The pinned set keeps GovernanceThreshold >= 3 so the two values differ. #[expect(non_snake_case)] #[test] fn vote_reshared__final_transition__should_apply_distinct_thresholds_per_domain() { - // Given CaitSith (index 0) and Frost (index 1) domains, both at default - // threshold 2, and a proposal moving only the Frost domain to `n`. + // Given CaitSith (index 0) and Frost (index 1) at default threshold 2, and a + // proposal moving only Frost to the GovernanceThreshold. let mut env = Environment::new(Some(100), None, None); let mut running = gen_running_state(2); + running.parameters = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); let current_params = running.parameters.clone(); - let n = current_params.participants().len() as u64; - assert!( - n >= 3, - "gen_running_state guarantees at least 3 participants" - ); let caitsith_id = running.domains.domains()[0].id; let frost_id = running.domains.domains()[1].id; let default_threshold = running.domains.domains()[0].reconstruction_threshold; - let frost_new_threshold = ReconstructionThreshold::new(n); + let frost_new_threshold = ReconstructionThreshold::new(current_params.threshold().value()); assert_ne!(frost_new_threshold, default_threshold); let mut threshold_updates = BTreeMap::new(); threshold_updates.insert(frost_id, frost_new_threshold); @@ -644,7 +628,7 @@ pub mod tests { } } - // Then each domain carries its own threshold: CaitSith keeps `2`, Frost holds `n`. + // Then each domain carries its own threshold: CaitSith keeps 2, Frost holds the update. let new_running = new_running.expect("resharing should have transitioned to Running"); let threshold_for = |id| { new_running From 1f28966c7ece00f9c9fd2a7d71be9157df209517 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:03:09 +0200 Subject: [PATCH 016/121] More participants during resharing --- .../tests/request_during_resharing.rs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/e2e-tests/tests/request_during_resharing.rs b/crates/e2e-tests/tests/request_during_resharing.rs index da3a146e0f..bcb0bc52ec 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -9,20 +9,20 @@ use rand::SeedableRng; /// Tests that signature and CKD requests are processed using the previous /// running state's threshold while resharing is in progress. /// -/// Setup: 6 nodes, 5 initial participants (threshold 5). Domains cover +/// Setup: 8 nodes, 7 initial participants (threshold 5). Domains cover /// classic ECDSA (CaitSith), robust ECDSA (DamgardEtAl), EdDSA (Frost) and /// CKD (ConfidentialKeyDerivation). Threshold is 5 because robust ECDSA -/// requires ≥ 5 signers (see `robust_ecdsa::translate_threshold`). Begin -/// resharing to all 6 with threshold 6, then kill node 5 so resharing can't -/// complete. Requests should still succeed using the old threshold of 5 -/// across all signing schemes. +/// requires ≥ 5 signers (see `robust_ecdsa::translate_threshold`); 7 +/// participants keep it within the 80% governance cap. Begin resharing to all +/// 8 with threshold 6, then kill node 7 so resharing can't complete. Requests +/// should still succeed using the old threshold of 5 across all signing schemes. #[tokio::test] async fn test_request_during_resharing() { // given let (mut cluster, contract_state) = common::must_setup_cluster(common::REQUEST_DURING_RESHARING_PORT_SEED, |c| { - c.num_nodes = 6; - c.initial_participant_indices = (0..5).collect(); + c.num_nodes = 8; + c.initial_participant_indices = (0..7).collect(); c.threshold = 5; c.triples_to_buffer = 2; c.presignatures_to_buffer = 2; @@ -36,14 +36,14 @@ async fn test_request_during_resharing() { .await; // when - tracing::info!("beginning resharing to 6 nodes, threshold 6"); + tracing::info!("beginning resharing to 8 nodes, threshold 6"); cluster - .start_resharing(&[0, 1, 2, 3, 4, 5], 6) + .start_resharing(&[0, 1, 2, 3, 4, 5, 6, 7], 6) .await .expect("start_resharing failed"); - tracing::info!("killing node 5 to block resharing"); - cluster.kill_nodes(&[5]).expect("failed to kill node 5"); + tracing::info!("killing node 7 to block resharing"); + cluster.kill_nodes(&[7]).expect("failed to kill node 7"); // then let ecdsa_domain = contract_state From 7bca4d8e338daaaae422789c5355c65edb1117d5 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:04:26 +0200 Subject: [PATCH 017/121] Raising the number of participants to 7 --- crates/e2e-tests/tests/request_lifecycle.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/e2e-tests/tests/request_lifecycle.rs b/crates/e2e-tests/tests/request_lifecycle.rs index bc8d4e3001..3ef344c321 100644 --- a/crates/e2e-tests/tests/request_lifecycle.rs +++ b/crates/e2e-tests/tests/request_lifecycle.rs @@ -87,8 +87,8 @@ async fn mpc_cluster__should_sign_with_scheme_matching_domain() { async fn mpc_cluster__should_successfully_process_robust_ecdsa_requests() { // given let (cluster, running) = common::must_setup_cluster(common::ROBUST_ECDSA_PORT_SEED, |c| { - c.num_nodes = 6; - c.initial_participant_indices = (0..6).collect(); + c.num_nodes = 7; + c.initial_participant_indices = (0..7).collect(); c.threshold = 5; c.domains = vec![DomainConfig { id: DomainId(0), From 263bc8d46711b1a7f7d7b70a4267e8c202b8a617 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:16:16 +0200 Subject: [PATCH 018/121] Using governance_threshold_lower_bound and governance_threshold_upper_bound as helpers --- crates/contract/src/primitives/test_utils.rs | 19 +++----- crates/contract/src/primitives/thresholds.rs | 47 ++++++++++++++------ crates/contract/src/state/test_utils.rs | 7 ++- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/crates/contract/src/primitives/test_utils.rs b/crates/contract/src/primitives/test_utils.rs index 3aae3a6cc7..6a39808b05 100644 --- a/crates/contract/src/primitives/test_utils.rs +++ b/crates/contract/src/primitives/test_utils.rs @@ -3,7 +3,10 @@ use crate::{ crypto_shared::types::{PublicKeyExtended, serializable::SerializableEdwardsPoint}, primitives::{ participants::{ParticipantInfo, Participants}, - thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, + thresholds::{ + ProposedThresholdParameters, Threshold, ThresholdParameters, + governance_threshold_lower_bound, governance_threshold_upper_bound, + }, }, }; use curve25519_dalek::edwards::CompressedEdwardsY; @@ -129,16 +132,6 @@ pub fn gen_participant(i: usize) -> (AccountId, ParticipantInfo) { ) } -pub fn min_thrershold(n: usize) -> usize { - ((n as f64) * 0.6).ceil() as usize -} - -/// Mirrors the contract's GovernanceThreshold upper cap: `floor(0.8 * n)`, clamped -/// up to the 60% lower bound so the feasible window is never empty for small `n`. -pub fn max_threshold(n: usize) -> usize { - (n * 4 / 5).max(min_thrershold(n)) -} - pub fn gen_accounts_and_info(n: usize) -> BTreeMap { (0..n).map(gen_participant).collect() } @@ -164,8 +157,8 @@ pub fn gen_threshold_params(max_n: usize) -> ThresholdParameters { // every protocol — `DamgardEtAl` requires `n >= 2t - 1`, which forces // `n >= 3` even at the minimum `t = 2`. let n: usize = rand::thread_rng().gen_range(3..max_n + 1); - let k_min = min_thrershold(n); - let k_max = max_threshold(n); + let k_min = governance_threshold_lower_bound(n as u64) as usize; + let k_max = governance_threshold_upper_bound(n as u64) as usize; let k = rand::thread_rng().gen_range(k_min..k_max + 1); ThresholdParameters::new(gen_participants(n), Threshold::new(k as u64)).unwrap() } diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 602c5059b2..943e6fd396 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -10,6 +10,12 @@ pub use near_mpc_contract_interface::types::Threshold; /// Minimum absolute threshold required. const MIN_THRESHOLD_ABSOLUTE: u64 = 2; +/// Minimum fraction of participants the GovernanceThreshold must reach, expressed +/// as `MIN_THRESHOLD_NUMERATOR / MIN_THRESHOLD_DENOMINATOR` (currently 60%, rounded +/// up) so a key stays reconstructible/signable by a robust majority. +const MIN_THRESHOLD_NUMERATOR: u64 = 3; +const MIN_THRESHOLD_DENOMINATOR: u64 = 5; + /// Maximum fraction of participants the GovernanceThreshold may reach, expressed /// as `MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR` (currently 80%). A /// GovernanceThreshold set too high would let a minority that stops serving lock @@ -18,6 +24,20 @@ const MIN_THRESHOLD_ABSOLUTE: u64 = 2; const MAX_THRESHOLD_NUMERATOR: u64 = 4; const MAX_THRESHOLD_DENOMINATOR: u64 = 5; +/// Lower bound on the GovernanceThreshold for `n` participants: 60% rounded up. +/// Single source of truth shared by validation and test fixtures. +pub(crate) fn governance_threshold_lower_bound(n: u64) -> u64 { + (MIN_THRESHOLD_NUMERATOR * n).div_ceil(MIN_THRESHOLD_DENOMINATOR) +} + +/// Upper bound on the GovernanceThreshold for `n` participants: 80% floored, +/// clamped up to the lower bound so the feasible window is never empty for small +/// `n`. Single source of truth shared by validation and test fixtures. +pub(crate) fn governance_threshold_upper_bound(n: u64) -> u64 { + (MAX_THRESHOLD_NUMERATOR * n / MAX_THRESHOLD_DENOMINATOR) + .max(governance_threshold_lower_bound(n)) +} + /// Stores the threshold key parameters: the owners of key shares /// (`participants`) and the cryptographic `threshold`. This is the stored, /// always-current shape. @@ -61,7 +81,7 @@ impl ThresholdParameters { if k.value() < MIN_THRESHOLD_ABSOLUTE { return Err(InvalidThreshold::MinAbsRequirementFailed.into()); } - let lower_relative_bound = (3 * n_shares).div_ceil(5); // minimum 60% + let lower_relative_bound = governance_threshold_lower_bound(n_shares); if k.value() < lower_relative_bound { return Err(InvalidThreshold::MinRelRequirementFailed { required: lower_relative_bound, @@ -69,9 +89,7 @@ impl ThresholdParameters { } .into()); } - // Clamp the upper cap up to the lower bound so the window stays non-empty for small n. - let upper_relative_bound = (MAX_THRESHOLD_NUMERATOR * n_shares / MAX_THRESHOLD_DENOMINATOR) - .max(lower_relative_bound); + let upper_relative_bound = governance_threshold_upper_bound(n_shares); if k.value() > upper_relative_bound { return Err(InvalidThreshold::MaxRelRequirementFailed { max: upper_relative_bound, @@ -275,8 +293,11 @@ mod tests { errors::{Error, InvalidCandidateSet, InvalidThreshold}, primitives::{ participants::{ParticipantId, Participants}, - test_utils::{gen_participant, gen_participants, gen_threshold_params, max_threshold}, - thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, + test_utils::{gen_participant, gen_participants, gen_threshold_params}, + thresholds::{ + ProposedThresholdParameters, Threshold, ThresholdParameters, + governance_threshold_lower_bound, governance_threshold_upper_bound, + }, }, state::test_utils::gen_valid_params_proposal, }; @@ -297,9 +318,8 @@ mod tests { #[test] fn test_validate_threshold() { let n = rand::thread_rng().gen_range(2..600) as u64; - let min_threshold = ((n as f64) * 0.6).ceil() as u64; - // Upper cap is floor(0.8 * n), clamped up to the 60% lower bound. - let max_threshold = (4 * n / 5).max(min_threshold); + let min_threshold = governance_threshold_lower_bound(n); + let max_threshold = governance_threshold_upper_bound(n); for k in 0..min_threshold { let _ = ThresholdParameters::validate_threshold(n, Threshold::new(k)).unwrap_err(); } @@ -315,9 +335,8 @@ mod tests { #[test] fn test_threshold_parameters_constructor() { let n: usize = rand::thread_rng().gen_range(2..600); - let min_threshold = ((n as f64) * 0.6).ceil() as usize; - // Upper cap is floor(0.8 * n), clamped up to the 60% lower bound. - let max_threshold = (4 * n / 5).max(min_threshold); + let min_threshold = governance_threshold_lower_bound(n as u64) as usize; + let max_threshold = governance_threshold_upper_bound(n as u64) as usize; let participants = gen_participants(n); for k in 1..min_threshold { @@ -370,8 +389,8 @@ mod tests { // For small n the floor(0.8n) cap can dip below the ceil(0.6n) lower bound; // the clamp must keep at least one valid threshold available. for n in 2..=12u64 { - let lower = (3 * n).div_ceil(5); - let upper = max_threshold(n as usize) as u64; + let lower = governance_threshold_lower_bound(n); + let upper = governance_threshold_upper_bound(n); assert!(upper >= lower, "empty window at n={n}: [{lower}, {upper}]"); // The clamped boundary value must validate. ThresholdParameters::validate_threshold(n, Threshold::new(upper)).unwrap(); diff --git a/crates/contract/src/state/test_utils.rs b/crates/contract/src/state/test_utils.rs index 1500d102cf..39869f0935 100644 --- a/crates/contract/src/state/test_utils.rs +++ b/crates/contract/src/state/test_utils.rs @@ -11,7 +11,10 @@ use crate::primitives::{ key_state::{EpochId, KeyForDomain, Keyset}, participants::{ParticipantId, Participants}, test_utils::{gen_participant, gen_threshold_params}, - thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, + thresholds::{ + ProposedThresholdParameters, Threshold, ThresholdParameters, + governance_threshold_lower_bound, + }, }; use rand::Rng; use std::collections::BTreeMap; @@ -58,7 +61,7 @@ pub fn gen_valid_params_proposal(params: &ThresholdParameters) -> ProposedThresh next_id = next_id.next(); } - let threshold = ((new_participants.len() as f64) * 0.6).ceil() as u64; + let threshold = governance_threshold_lower_bound(new_participants.len() as u64); let parameters = ThresholdParameters::new(new_participants, Threshold::new(threshold)).unwrap(); // Empty per-domain threshold updates (no change); see the doc comment. ProposedThresholdParameters::new(parameters, BTreeMap::new()) From f45ebf5d3ff17872fae57da70a67d712d9587b73 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:16:39 +0200 Subject: [PATCH 019/121] cargo fmt --- crates/contract/src/state/resharing.rs | 6 ++++-- crates/contract/src/state/running.rs | 10 +++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index a5117ad68d..c4ef53d544 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -508,7 +508,8 @@ pub mod tests { // moving it to the GovernanceThreshold. let mut env = Environment::new(Some(100), None, None); let mut running = gen_running_state(1); - running.parameters = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); + running.parameters = + ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); let current_params = running.parameters.clone(); let domain_id = running.domains.domains()[0].id; let original_threshold = running.domains.domains()[0].reconstruction_threshold; @@ -574,7 +575,8 @@ pub mod tests { // proposal moving only Frost to the GovernanceThreshold. let mut env = Environment::new(Some(100), None, None); let mut running = gen_running_state(2); - running.parameters = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); + running.parameters = + ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); let current_params = running.parameters.clone(); let caitsith_id = running.domains.domains()[0].id; let frost_id = running.domains.domains()[1].id; diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 3b8a13e7a3..50683b4762 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -277,7 +277,9 @@ pub mod running_tests { use super::RunningContractState; use crate::errors::{Error, InvalidThreshold}; use crate::primitives::domain::AddDomainsVotes; - use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_participants, gen_proposed_threshold_params}; + use crate::primitives::test_utils::{ + NUM_PROTOCOLS, gen_participants, gen_proposed_threshold_params, + }; use crate::primitives::threshold_votes::ThresholdParametersVotes; use crate::primitives::thresholds::{Threshold, ThresholdParameters}; use crate::state::key_event::tests::Environment; @@ -776,7 +778,8 @@ pub mod running_tests { let mut state = gen_running_state(1); // Pin a participant set so the generated proposal's GovernanceThreshold is >= 3; // ReconstructionThreshold (3) must not exceed it. - state.parameters = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); + state.parameters = + ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); let mut env = Environment::new(None, None, None); let proposal = gen_valid_params_proposal(&state.parameters); // Sign as a participant present in BOTH the current and proposed sets @@ -847,7 +850,8 @@ pub mod running_tests { let mut state = gen_running_state(5); // Pin a participant set so the generated proposal's GovernanceThreshold is >= 3; // ReconstructionThreshold (3) must not exceed it. - state.parameters = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); + state.parameters = + ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); let mut env = Environment::new(None, None, None); assert!( state From c8fec49b09f209364b51273502d898ef1646dfa4 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:37:38 +0200 Subject: [PATCH 020/121] reducing code redundancy --- crates/contract/src/primitives/domain.rs | 13 +++++++++++++ crates/contract/src/primitives/thresholds.rs | 5 ++--- crates/contract/src/state/resharing.rs | 3 --- crates/contract/src/state/running.rs | 19 ++++++------------- crates/contract/src/v3_11_2_state.rs | 10 ++-------- 5 files changed, 23 insertions(+), 27 deletions(-) diff --git a/crates/contract/src/primitives/domain.rs b/crates/contract/src/primitives/domain.rs index 003845e865..ea910ceeba 100644 --- a/crates/contract/src/primitives/domain.rs +++ b/crates/contract/src/primitives/domain.rs @@ -71,6 +71,19 @@ pub fn validate_domain_threshold( Ok(()) } +/// The largest `ReconstructionThreshold` across `domains`, or 0 if there are none +/// (an empty set imposes no cross-domain lower bound on the GovernanceThreshold). +/// Feeds [`ThresholdParameters::validate_governance_against_reconstruction`](crate::primitives::thresholds::ThresholdParameters::validate_governance_against_reconstruction). +pub fn max_reconstruction_threshold<'a>( + domains: impl IntoIterator, +) -> u64 { + domains + .into_iter() + .map(|domain| domain.reconstruction_threshold.inner()) + .max() + .unwrap_or(0) +} + /// All the domains present in the contract, as well as the next domain ID which is kept to ensure /// that we never reuse domain IDs. (Domains may be deleted in only one case: when we decided to /// add domains but ultimately canceled that process.) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 943e6fd396..3159d02a1b 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -624,8 +624,7 @@ mod tests { fn test_remove_only() { let params = ThresholdParameters::new(gen_participants(5), Threshold::new(3)).unwrap(); - // Shrink to 4 participants (not down to the threshold of 3): with the upper - // cap, k=3 requires at least 4 participants (floor(0.8*4) = 3). + // Shrink to 4 participants with the upper cap k=3 let new_participants = params.participants.subset(0..4); let new_params = ThresholdParameters::new(new_participants, params.threshold).unwrap(); @@ -662,7 +661,7 @@ mod tests { let mut new_participants_vec: Vec<_> = params.participants.participants().to_vec(); new_participants_vec.push((new_account, next_id, new_info)); - // 6 participants with threshold 4: validate_threshold passes (60% of 6 = 4, upper cap = 4) + // 6 participants with threshold 4 let proposal = ThresholdParameters::new_unvalidated( Participants::init( ParticipantId(next_id.get() + 2), // too high: should be next_id + 1 diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index c4ef53d544..db4bfed648 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -423,9 +423,6 @@ pub mod tests { let new_participants_2 = new_participants_1 .subset(new_participants_1.len() - old_participants.len()..new_participants_1.len()); let new_params_1 = ThresholdParameters::new(new_participants_1, new_threshold).unwrap(); - // new_params_2 has the same size as the old set, for which `new_threshold` - // (== old_len) would be k == n and exceed the upper cap; use a valid - // threshold for that size instead. let new_threshold_2 = Threshold::new((3 * new_participants_2.len() as u64).div_ceil(5)); let new_params_2 = ThresholdParameters::new(new_participants_2, new_threshold_2).unwrap(); // Proposals carry an empty (no-change) set of per-domain threshold updates. diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 50683b4762..bfbb2360dc 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -3,7 +3,10 @@ use super::key_event::KeyEvent; use super::resharing::ResharingContractState; use crate::errors::{ConversionError, DomainError, Error, InvalidParameters, VoteError}; use crate::primitives::{ - domain::{AddDomainsVotes, DomainRegistry, validate_domain_purpose, validate_domain_threshold}, + domain::{ + AddDomainsVotes, DomainRegistry, max_reconstruction_threshold, validate_domain_purpose, + validate_domain_threshold, + }, key_state::{AuthenticatedAccountId, AuthenticatedParticipantId, EpochId, Keyset}, threshold_votes::ThresholdParametersVotes, thresholds::{ProposedThresholdParameters, ThresholdParameters}, @@ -182,15 +185,10 @@ impl RunningContractState { // The GovernanceThreshold must dominate every domain's effective ReconstructionThreshold; // enforced here so the state transition is self-contained (single source of truth). - let max_reconstruction_threshold = effective_domains - .iter() - .map(|domain| domain.reconstruction_threshold.inner()) - .max() - .unwrap_or(0); ThresholdParameters::validate_governance_against_reconstruction( new_num_participants, proposal.threshold(), - max_reconstruction_threshold, + max_reconstruction_threshold(&effective_domains), )?; // ensure the signer is a proposed participant @@ -232,15 +230,10 @@ impl RunningContractState { // Keep trust assumptions consistent: a domain must never require more shares to // reconstruct than the GovernanceThreshold demands to govern. Route through the // canonical helper so the cross-domain invariant has a single source of truth. - let max_reconstruction_threshold = domains - .iter() - .map(|domain| domain.reconstruction_threshold.inner()) - .max() - .expect("domains is non-empty: guarded by AddDomainsMustAddAtLeastOneDomain above"); ThresholdParameters::validate_governance_against_reconstruction( num_participants, self.parameters.threshold(), - max_reconstruction_threshold, + max_reconstruction_threshold(&domains), )?; let participant = AuthenticatedParticipantId::new(self.parameters.participants())?; let n_votes = self.add_domains_votes.vote(domains.clone(), &participant); diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index 55b1c81f45..86665358fa 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -19,7 +19,7 @@ use crate::{ node_migrations::NodeMigrations, primitives::{ ckd::CKDRequest, - domain::{AddDomainsVotes, DomainRegistry}, + domain::{AddDomainsVotes, DomainRegistry, max_reconstruction_threshold}, key_state::{AuthenticatedAccountId, EpochId, Keyset}, signature::{SignatureRequest, YieldIndex}, threshold_votes::ThresholdParametersVotes, @@ -161,13 +161,7 @@ impl From for crate::MpcContract { /// gate has no remaining job. fn validate_threshold_relation_on_migration(running: &RunningContractState) { let num_participants = running.parameters.participants().len() as u64; - let max_reconstruction_threshold = running - .domains - .domains() - .iter() - .map(|domain| domain.reconstruction_threshold.inner()) - .max() - .unwrap_or(0); + let max_reconstruction_threshold = max_reconstruction_threshold(running.domains.domains()); if let Err(err) = ThresholdParameters::validate_governance_against_reconstruction( num_participants, running.parameters.threshold(), From 6e1ce47ffd5d83cc7c9eee9adfab828e431e789b Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:39:40 +0200 Subject: [PATCH 021/121] Some more redundancy reduction --- crates/contract/src/lib.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index c7fa9d0b60..b60c614d53 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -1589,13 +1589,10 @@ impl MpcContract { // remain at least every domain's ReconstructionThreshold (the kickout // keeps the existing per-domain thresholds). Otherwise we refuse and // wait for manual intervention. - let max_reconstruction_threshold = running_state - .domains - .domains() - .iter() - .map(|domain| domain.reconstruction_threshold.inner()) - .max() - .unwrap_or(0); + let max_reconstruction_threshold = + crate::primitives::domain::max_reconstruction_threshold( + running_state.domains.domains(), + ); if let Err(err) = ThresholdParameters::validate_governance_against_reconstruction( remaining as u64, current_params.threshold(), @@ -1830,17 +1827,14 @@ impl MpcContract { parameters.validate()?; let domains = DomainRegistry::from_raw_validated(domains, next_domain_id)?; let num_participants = parameters.participants().len() as u64; - let mut max_reconstruction_threshold = 0u64; for domain in domains.domains() { crate::primitives::domain::validate_domain_threshold(domain, num_participants)?; - max_reconstruction_threshold = - max_reconstruction_threshold.max(domain.reconstruction_threshold.inner()); } // Keep the GovernanceThreshold at least as large as the largest ReconstructionThreshold. ThresholdParameters::validate_governance_against_reconstruction( num_participants, parameters.threshold(), - max_reconstruction_threshold, + crate::primitives::domain::max_reconstruction_threshold(domains.domains()), )?; // Check that the domains match exactly those in the keyset. From 5f5af826be1efcee746beb3bb152aaeb7a97772d Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:44:25 +0200 Subject: [PATCH 022/121] Disable 80% governance upper cap (set MAX_THRESHOLD_NUMERATOR = denominator) Colleagues did not agree on an 80% upper bound for the GovernanceThreshold, so set MAX_THRESHOLD_NUMERATOR = MAX_THRESHOLD_DENOMINATOR (5/5 = 100%). The relative upper cap structure is kept but never binds below the absolute `k <= n` check, so the GovernanceThreshold may again go up to the participant count. The cross-domain rule (GovernanceThreshold >= max(ReconstructionThreshold)) is unchanged. Revert the test changes that were only needed to satisfy the 80% cap (dropping thresholds / raising participant counts) and remove the now-meaningless dedicated upper-cap tests: - thresholds.rs: restore 5/5-participant thresholds; drop reject-above-cap test - dto_mapping.rs / lib.rs: drop the upper-cap rejection tests - lib.rs verify_tee: rework the kickout-refusal fixture to break the relation via the participant-count ceiling instead of the cap - running.rs: make the reconstruction>governance test regenerate until gov < n - sandbox + e2e + node resharing tests: restore original participant/threshold values - docs/design/domain-separation.md: describe the cap as disabled (100%) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/contract/src/dto_mapping.rs | 25 -------- crates/contract/src/lib.rs | 43 ++++---------- crates/contract/src/primitives/thresholds.rs | 59 ++++++++----------- crates/contract/src/state/resharing.rs | 2 +- crates/contract/src/state/running.rs | 13 ++-- crates/contract/tests/sandbox/sign.rs | 2 +- .../sandbox/tee_cleanup_after_resharing.rs | 26 ++++---- .../update_votes_cleanup_after_resharing.rs | 17 ++---- crates/e2e-tests/tests/key_resharing.rs | 12 ++-- crates/e2e-tests/tests/parallel_sign_calls.rs | 6 +- .../tests/request_during_resharing.rs | 22 +++---- crates/e2e-tests/tests/request_lifecycle.rs | 4 +- crates/node/src/tests/resharing.rs | 2 +- docs/design/domain-separation.md | 14 ++--- 14 files changed, 91 insertions(+), 156 deletions(-) diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index c931604135..3777691549 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -1032,29 +1032,4 @@ mod tests { )) )); } - - /// A threshold above the relative upper cap (> 80%) must be rejected at the - /// DTO boundary rather than deferred to a later validation step. - #[test] - fn try_into_contract_type__should_reject_threshold_above_upper_cap() { - // Given a DTO with 5 participants and a threshold of 5 (above the cap of floor(0.8*5)=4). - use crate::errors::InvalidThreshold; - use crate::primitives::test_utils::gen_participants; - - let dto = dtos::ThresholdParameters { - participants: (&gen_participants(5)).into_dto_type(), - threshold: Threshold::new(5), - }; - - // When converting the DTO into the contract type. - let result: Result = dto.try_into_contract_type(); - - // Then conversion fails with the upper-cap error. - assert!(matches!( - result, - Err(Error::InvalidThreshold( - InvalidThreshold::MaxRelRequirementFailed { max: 4, found: 5 } - )) - )); - } } diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index b60c614d53..546a4bcca8 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -3969,28 +3969,6 @@ mod tests { ); } - #[test] - fn vote_new_parameters__should_reject_governance_above_upper_cap() { - // Given: a Running contract with 5 participants and one domain. - let (mut contract, participants, signer, _domain_id) = - setup_running_contract_with_domain(5, 4, 2); - // ...and a proposal raising the GovernanceThreshold to 5 (== n), which exceeds - // the upper cap of floor(0.8 * 5) = 4. - let proposal = ProposedThresholdParameters::new( - ThresholdParameters::new_unvalidated(participants, Threshold::new(5)), - BTreeMap::new(), - ); - - // When - let result = vote_params(&mut contract, &signer, &proposal); - - // Then - assert_matches!( - result.unwrap_err(), - Error::InvalidThreshold(InvalidThreshold::MaxRelRequirementFailed { max: 4, found: 5 }) - ); - } - #[test] #[should_panic(expected = "Caller must be the signer account")] fn vote_new_parameters__should_panic_when_predecessor_differs_from_signer() { @@ -5444,26 +5422,25 @@ mod tests { } /// Tests that [`MpcContract::verify_tee`] refuses to reshare when a TEE - /// kickout would leave fewer participants than a domain's reconstruction - /// threshold, even though the remaining set still meets the governance - /// threshold. The contract stays Running and stops accepting requests. + /// kickout would leave fewer participants than the threshold relation requires. + /// The contract stays Running and stops accepting requests. #[test] fn verify_tee__should_refuse_kickout_when_remaining_breaks_threshold_relation() { const PARTICIPANT_COUNT: usize = 5; const ATTESTATION_EXPIRY_SECONDS: u64 = 5; const TEE_UPGRADE_DURATION: Duration = Duration::MAX; - // Given: 5 participants, GovernanceThreshold 4, and one domain whose - // reconstruction threshold is 4. Dropping to 4 participants would push the - // GovernanceThreshold above its upper cap (floor(0.8 * 4) = 3), breaking the - // threshold relation. + // Given: 5 participants, GovernanceThreshold 5, and one domain whose + // reconstruction threshold is 5 (every participant is needed to sign). Dropping + // to 4 participants would leave the GovernanceThreshold above the participant + // count, breaking the threshold relation. let participants = gen_participants(PARTICIPANT_COUNT); - let parameters = ThresholdParameters::new(participants.clone(), Threshold::new(4)).unwrap(); + let parameters = ThresholdParameters::new(participants.clone(), Threshold::new(5)).unwrap(); let domain_id = DomainId::default(); let domains = vec![DomainConfig { id: domain_id, protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(4), + reconstruction_threshold: ReconstructionThreshold::new(5), purpose: DomainPurpose::Sign, }]; let (pk, _) = make_public_key_for_curve(Curve::Secp256k1, &mut OsRng); @@ -5511,8 +5488,8 @@ mod tests { // When let result = contract.verify_tee(); - // Then: with only 4 surviving participants the GovernanceThreshold of 4 - // would exceed its upper cap, breaking the threshold relation, so verify_tee + // Then: with only 4 surviving participants the GovernanceThreshold of 5 would + // exceed the participant count, breaking the threshold relation, so verify_tee // refuses to reshare, stays Running, and stops accepting requests. assert_matches!(result, Ok(false)); assert_matches!(contract.protocol_state, ProtocolContractState::Running(_)); diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 3159d02a1b..5a8018e968 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -17,11 +17,12 @@ const MIN_THRESHOLD_NUMERATOR: u64 = 3; const MIN_THRESHOLD_DENOMINATOR: u64 = 5; /// Maximum fraction of participants the GovernanceThreshold may reach, expressed -/// as `MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR` (currently 80%). A -/// GovernanceThreshold set too high would let a minority that stops serving lock -/// the contract (it could no longer reshare, add/kick participants, or sign). -/// Kept as an explicit fraction so the percentage and rounding are easy to tune. -const MAX_THRESHOLD_NUMERATOR: u64 = 4; +/// as `MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR`. Currently set to 100% +/// (5/5), so the relative upper cap is effectively disabled: the GovernanceThreshold +/// may go all the way up to the participant count (the absolute `k <= n` check still +/// applies). Kept as an explicit fraction so a stricter cap can be re-introduced by +/// lowering the numerator should a future policy require it. +const MAX_THRESHOLD_NUMERATOR: u64 = 5; const MAX_THRESHOLD_DENOMINATOR: u64 = 5; /// Lower bound on the GovernanceThreshold for `n` participants: 60% rounded up. @@ -30,8 +31,9 @@ pub(crate) fn governance_threshold_lower_bound(n: u64) -> u64 { (MIN_THRESHOLD_NUMERATOR * n).div_ceil(MIN_THRESHOLD_DENOMINATOR) } -/// Upper bound on the GovernanceThreshold for `n` participants: 80% floored, -/// clamped up to the lower bound so the feasible window is never empty for small +/// Upper bound on the GovernanceThreshold for `n` participants: +/// `MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR` floored (currently 100%, i.e. +/// `n`), clamped up to the lower bound so the feasible window is never empty for small /// `n`. Single source of truth shared by validation and test fixtures. pub(crate) fn governance_threshold_upper_bound(n: u64) -> u64 { (MAX_THRESHOLD_NUMERATOR * n / MAX_THRESHOLD_DENOMINATOR) @@ -67,9 +69,10 @@ impl ThresholdParameters { /// - threshold can not exceed the number of shares `n_shares`. /// - threshold must be at least 60% of the number of shares (rounded upwards). /// - threshold must not exceed `MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR` - /// of the number of shares (rounded downwards), so a minority that stops serving - /// cannot lock the contract. This upper cap is clamped up to the 60% lower bound so - /// the feasible window is never empty for small `n_shares`. + /// of the number of shares (rounded downwards), clamped up to the 60% lower bound so + /// the feasible window is never empty for small `n_shares`. This relative upper cap is + /// currently set to 100%, so in practice it never binds below the absolute `k <= n` + /// check above. fn validate_threshold(n_shares: u64, k: Threshold) -> Result<(), Error> { if k.value() > n_shares { return Err(InvalidThreshold::MaxRequirementFailed { @@ -369,25 +372,10 @@ mod tests { } } - #[test] - fn validate_threshold__should_reject_governance_above_upper_cap() { - // Given 10 participants, the upper cap is floor(0.8 * 10) = 8. - let n = 10; - // When/Then thresholds within the window are accepted. - ThresholdParameters::validate_threshold(n, Threshold::new(8)).unwrap(); - // ...and the first value above the cap is rejected. - assert_matches!( - ThresholdParameters::validate_threshold(n, Threshold::new(9)), - Err(Error::InvalidThreshold( - InvalidThreshold::MaxRelRequirementFailed { max: 8, found: 9 } - )) - ); - } - #[test] fn validate_threshold__should_not_produce_empty_window_for_small_n() { - // For small n the floor(0.8n) cap can dip below the ceil(0.6n) lower bound; - // the clamp must keep at least one valid threshold available. + // The relative upper cap is clamped up to the ceil(0.6n) lower bound, so the + // feasible window must always hold at least one valid threshold. for n in 2..=12u64 { let lower = governance_threshold_lower_bound(n); let upper = governance_threshold_upper_bound(n); @@ -568,8 +556,8 @@ mod tests { #[test] fn test_proposal_non_contiguous_new_ids_fail() { // Test that the lowest new id equals to the `next_id` of the previous set. - // Use a high (but capped) threshold so adding one participant doesn't violate the 60% rule. - let params = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); + // Use a high threshold so adding one participant doesn't violate the 60% rule. + let params = ThresholdParameters::new(gen_participants(5), Threshold::new(5)).unwrap(); let wrong_id = params.participants.next_id().0 + 1; @@ -593,7 +581,7 @@ mod tests { #[test] fn test_proposal_non_unique_ids() { - let params = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); + let params = ThresholdParameters::new(gen_participants(5), Threshold::new(5)).unwrap(); // Create proposal with duplicate participants (doubled list) let tampered_participants = Participants::init( @@ -624,8 +612,9 @@ mod tests { fn test_remove_only() { let params = ThresholdParameters::new(gen_participants(5), Threshold::new(3)).unwrap(); - // Shrink to 4 participants with the upper cap k=3 - let new_participants = params.participants.subset(0..4); + let new_participants = params + .participants + .subset(0..params.threshold.value() as usize); let new_params = ThresholdParameters::new(new_participants, params.threshold).unwrap(); @@ -652,7 +641,7 @@ mod tests { fn test_new_participant_id_too_high() { // When proposal's next_id is higher than max_id + 1, it should fail with // NewParticipantIdsTooHigh. - let params = ThresholdParameters::new(gen_participants(5), Threshold::new(4)).unwrap(); + let params = ThresholdParameters::new(gen_participants(5), Threshold::new(5)).unwrap(); let next_id = params.participants.next_id(); // Add one new participant with the correct next_id, but set the proposal's @@ -661,13 +650,13 @@ mod tests { let mut new_participants_vec: Vec<_> = params.participants.participants().to_vec(); new_participants_vec.push((new_account, next_id, new_info)); - // 6 participants with threshold 4 + // 6 participants with threshold 5 let proposal = ThresholdParameters::new_unvalidated( Participants::init( ParticipantId(next_id.get() + 2), // too high: should be next_id + 1 new_participants_vec, ), - Threshold::new(4), + Threshold::new(5), ); assert_eq!( params.validate_incoming_proposal(&proposal).unwrap_err(), diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index db4bfed648..7693ea2a5f 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -417,7 +417,7 @@ pub mod tests { // should be rejected, since all re-proposals must be valid against the original. let mut new_participants_1 = old_participants.clone(); // Threshold valid for the grown set (1.5x): old_len sits within - // [ceil(0.6 * 1.5n), floor(0.8 * 1.5n)]. + // [ceil(0.6 * 1.5n), 1.5n]. let new_threshold = Threshold::new(old_participants.len() as u64); new_participants_1.add_random_participants_till_n((old_participants.len() * 3).div_ceil(2)); let new_participants_2 = new_participants_1 diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index bfbb2360dc..2542ff1b94 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -528,16 +528,17 @@ pub mod running_tests { fn vote_add_domains__should_reject_reconstruction_threshold_above_governance() { // Given a Frost proposal whose ReconstructionThreshold exceeds the // GovernanceThreshold (but is still <= participant count). + // Regenerate until the GovernanceThreshold sits strictly below the participant + // count, so `governance + 1 <= n` and the rejection is driven by the + // GovernanceThreshold/ReconstructionThreshold relation rather than the + // participant-count ceiling. let mut state = gen_running_state(1); + while state.parameters.threshold().value() >= state.parameters.participants().len() as u64 { + state = gen_running_state(1); + } let mut env = Environment::new(None, None, None); env.set_signer(&state.parameters.participants().participants()[0].0); let governance = state.parameters.threshold().value(); - let n = state.parameters.participants().len() as u64; - // gen_threshold_params caps governance below n, so governance + 1 <= n here. - assert!( - governance < n, - "test requires governance below participant count" - ); let next_id = state.domains.next_domain_id(); let proposal = vec![DomainConfig { id: DomainId(next_id), diff --git a/crates/contract/tests/sandbox/sign.rs b/crates/contract/tests/sandbox/sign.rs index 1c624b29d7..2d58978645 100644 --- a/crates/contract/tests/sandbox/sign.rs +++ b/crates/contract/tests/sandbox/sign.rs @@ -313,7 +313,7 @@ async fn test_contract_initialization() -> anyhow::Result<()> { ); let proposed_parameters = - ThresholdParameters::new(candidates(None), Threshold::new(2)).unwrap(); + ThresholdParameters::new(candidates(None), Threshold::new(3)).unwrap(); let result = contract .call(method_names::INIT) .args_json(serde_json::json!({ diff --git a/crates/contract/tests/sandbox/tee_cleanup_after_resharing.rs b/crates/contract/tests/sandbox/tee_cleanup_after_resharing.rs index c125907dc4..8b0803a3b7 100644 --- a/crates/contract/tests/sandbox/tee_cleanup_after_resharing.rs +++ b/crates/contract/tests/sandbox/tee_cleanup_after_resharing.rs @@ -101,13 +101,12 @@ async fn reshare__should_leave_valid_non_participant_attestations_in_storage() - let initial_and_non_participants = get_tee_accounts(&contract).await.unwrap(); assert_eq!(initial_and_non_participants, expected_node_ids); - // Now, we do a resharing. We retain the smallest participant set that keeps the - // GovernanceThreshold within its upper cap: n >= ceil(threshold * 5 / 4) so that - // threshold <= floor(0.8 * n). - let retained = ((threshold.0 * 5).div_ceil(4)) as usize; + // Now, we do a resharing. We only retain `threshold` of the initial participants let mut new_participants = Participants::new(); - for (account_id, participant_id, participant_info) in - initial_participants.participants.iter().take(retained) + for (account_id, participant_id, participant_info) in initial_participants + .participants + .iter() + .take(threshold.0 as usize) { new_participants .insert_with_id( @@ -131,7 +130,7 @@ async fn reshare__should_leave_valid_non_participant_attestations_in_storage() - let prospective_epoch_id = dtos::EpochId(6); do_resharing( - &mpc_signer_accounts[..retained], + &mpc_signer_accounts[..threshold.0 as usize], &contract, new_threshold_parameters, prospective_epoch_id, @@ -213,13 +212,12 @@ async fn reshare__should_evict_expired_attestations_via_post_reshare_sweep() -> // sweep sees the outsider entry as invalid. worker.fast_forward(BLOCKS_TO_FAST_FORWARD).await?; - // Reshare to the smallest valid reduced subset (n >= ceil(threshold * 5 / 4), so - // the GovernanceThreshold stays within its upper cap); this triggers the - // post-reshare cleanup promise. - let retained = ((threshold.0 * 5).div_ceil(4)) as usize; + // Reshare to the threshold subset; this triggers the post-reshare cleanup promise. let mut new_participants = Participants::new(); - for (account_id, participant_id, participant_info) in - initial_participants.participants.iter().take(retained) + for (account_id, participant_id, participant_info) in initial_participants + .participants + .iter() + .take(threshold.0 as usize) { new_participants .insert_with_id( @@ -238,7 +236,7 @@ async fn reshare__should_evict_expired_attestations_via_post_reshare_sweep() -> ) .unwrap(); do_resharing( - &mpc_signer_accounts[..retained], + &mpc_signer_accounts[..threshold.0 as usize], &contract, new_threshold_parameters, dtos::EpochId(6), diff --git a/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs b/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs index 350c8c1466..ab37437b0c 100644 --- a/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs +++ b/crates/contract/tests/sandbox/update_votes_cleanup_after_resharing.rs @@ -76,15 +76,13 @@ async fn update_votes_from_kicked_out_participants_are_cleared_after_resharing() ); // when: resharing completes with new participants that exclude participant 0 - // Reshare excluding participant 0 who voted, keeping the smallest valid set - // (n >= ceil(threshold * 5 / 4), so the GovernanceThreshold stays within its cap). - let retained = ((threshold.0 * 5).div_ceil(4)) as usize; + // Reshare with threshold participants, excluding participant 0 who voted let mut new_participants = Participants::new(); for (account_id, participant_id, participant_info) in initial_participants .participants .iter() - .skip(1) // Skip participant 0 - .take(retained) + .skip(1) // Skip participant 0, so participant 1-6 are included + .take(threshold.0 as usize) { new_participants .insert_with_id( @@ -107,7 +105,7 @@ async fn update_votes_from_kicked_out_participants_are_cleared_after_resharing() // when: resharing completes with new participants that exclude participant 0 do_resharing( - &mpc_signer_accounts[1..retained + 1], + &mpc_signer_accounts[1..threshold.0 as usize + 1], &contract, new_threshold_parameters, prospective_epoch_id, @@ -193,15 +191,12 @@ async fn add_domain_votes_from_kicked_out_participants_are_cleared_after_reshari assert_eq!(running.add_domains_votes.proposal_by_account.len(), 2); // When - // Retain the smallest valid reduced set (n >= ceil(threshold * 5 / 4), so the - // GovernanceThreshold stays within its upper cap), excluding participant 0. - let retained = ((threshold.0 * 5).div_ceil(4)) as usize; let mut new_participants = Participants::new(); for (account_id, participant_id, participant_info) in initial_participants .participants .iter() .skip(1) - .take(retained) + .take(threshold.0 as usize) { new_participants .insert_with_id( @@ -223,7 +218,7 @@ async fn add_domain_votes_from_kicked_out_participants_are_cleared_after_reshari let prospective_epoch_id = dtos::EpochId(6); do_resharing( - &mpc_signer_accounts[1..retained + 1], + &mpc_signer_accounts[1..threshold.0 as usize + 1], &contract, new_threshold_parameters, prospective_epoch_id, diff --git a/crates/e2e-tests/tests/key_resharing.rs b/crates/e2e-tests/tests/key_resharing.rs index 6a97fb74dc..22763320a1 100644 --- a/crates/e2e-tests/tests/key_resharing.rs +++ b/crates/e2e-tests/tests/key_resharing.rs @@ -49,10 +49,10 @@ async fn test_key_resharing() { .await .expect("ckd request failed"); - // Resharing 2: shrink to nodes [1,2,3] (drop node 0), threshold 2. + // Resharing 2: shrink to nodes [1,2,3] (drop node 0), threshold 3. tracing::info!("resharing 2: dropping node 0"); cluster - .start_resharing_and_wait(&[1, 2, 3], 2) + .start_resharing_and_wait(&[1, 2, 3], 3) .await .expect("resharing 2 failed"); let running = running_state(&cluster).await.expect("running_state failed"); @@ -77,14 +77,14 @@ async fn test_key_resharing() { .await .expect("sign request failed"); - // Resharing 4: re-add node 0 and increase threshold to 4 (n=5, the 80% cap allows 4). - tracing::info!("resharing 4: expanding to 5 nodes, increasing threshold to 4"); + // Resharing 4: increase threshold to 4 (all participants required). + tracing::info!("resharing 4: increasing threshold to 4"); cluster - .start_resharing_and_wait(&[0, 1, 2, 3, 4], 4) + .start_resharing_and_wait(&[1, 2, 3, 4], 4) .await .expect("resharing 4 failed"); let running = running_state(&cluster).await.expect("running_state failed"); - assert_eq!(running.parameters.participants.participants.len(), 5); + assert_eq!(running.parameters.participants.participants.len(), 4); // Verify resharing didn't repeatedly fail. Allow a small number of // retries for transient sandbox contention (key_event_timeout_blocks diff --git a/crates/e2e-tests/tests/parallel_sign_calls.rs b/crates/e2e-tests/tests/parallel_sign_calls.rs index 88f97960d6..7e6b3ac1b4 100644 --- a/crates/e2e-tests/tests/parallel_sign_calls.rs +++ b/crates/e2e-tests/tests/parallel_sign_calls.rs @@ -8,7 +8,7 @@ use near_mpc_contract_interface::types::{ use serde_json::json; /// 9 parallel calls (3 robust ECDSA + 2 ECDSA + 2 EdDSA + 2 CKD) via the test parallel -/// contract, against a 7-node / threshold-5 cluster that carries all four signing-scheme +/// contract, against a 6-node / threshold-5 cluster that carries all four signing-scheme /// domains. Verifies all calls succeed and both the signature and CKD queues drain. #[tokio::test] async fn mpc_cluster_should_successfully_process_parallel_requests() { @@ -21,8 +21,8 @@ async fn mpc_cluster_should_successfully_process_parallel_requests() { // given let (cluster, _running) = common::must_setup_cluster(common::PARALLEL_SIGN_CALLS_PORT_SEED, |c| { - c.num_nodes = 7; - c.initial_participant_indices = (0..7).collect(); + c.num_nodes = 6; + c.initial_participant_indices = (0..6).collect(); c.threshold = 5; c.domains = vec![ DomainConfig { diff --git a/crates/e2e-tests/tests/request_during_resharing.rs b/crates/e2e-tests/tests/request_during_resharing.rs index bcb0bc52ec..da3a146e0f 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -9,20 +9,20 @@ use rand::SeedableRng; /// Tests that signature and CKD requests are processed using the previous /// running state's threshold while resharing is in progress. /// -/// Setup: 8 nodes, 7 initial participants (threshold 5). Domains cover +/// Setup: 6 nodes, 5 initial participants (threshold 5). Domains cover /// classic ECDSA (CaitSith), robust ECDSA (DamgardEtAl), EdDSA (Frost) and /// CKD (ConfidentialKeyDerivation). Threshold is 5 because robust ECDSA -/// requires ≥ 5 signers (see `robust_ecdsa::translate_threshold`); 7 -/// participants keep it within the 80% governance cap. Begin resharing to all -/// 8 with threshold 6, then kill node 7 so resharing can't complete. Requests -/// should still succeed using the old threshold of 5 across all signing schemes. +/// requires ≥ 5 signers (see `robust_ecdsa::translate_threshold`). Begin +/// resharing to all 6 with threshold 6, then kill node 5 so resharing can't +/// complete. Requests should still succeed using the old threshold of 5 +/// across all signing schemes. #[tokio::test] async fn test_request_during_resharing() { // given let (mut cluster, contract_state) = common::must_setup_cluster(common::REQUEST_DURING_RESHARING_PORT_SEED, |c| { - c.num_nodes = 8; - c.initial_participant_indices = (0..7).collect(); + c.num_nodes = 6; + c.initial_participant_indices = (0..5).collect(); c.threshold = 5; c.triples_to_buffer = 2; c.presignatures_to_buffer = 2; @@ -36,14 +36,14 @@ async fn test_request_during_resharing() { .await; // when - tracing::info!("beginning resharing to 8 nodes, threshold 6"); + tracing::info!("beginning resharing to 6 nodes, threshold 6"); cluster - .start_resharing(&[0, 1, 2, 3, 4, 5, 6, 7], 6) + .start_resharing(&[0, 1, 2, 3, 4, 5], 6) .await .expect("start_resharing failed"); - tracing::info!("killing node 7 to block resharing"); - cluster.kill_nodes(&[7]).expect("failed to kill node 7"); + tracing::info!("killing node 5 to block resharing"); + cluster.kill_nodes(&[5]).expect("failed to kill node 5"); // then let ecdsa_domain = contract_state diff --git a/crates/e2e-tests/tests/request_lifecycle.rs b/crates/e2e-tests/tests/request_lifecycle.rs index 3ef344c321..bc8d4e3001 100644 --- a/crates/e2e-tests/tests/request_lifecycle.rs +++ b/crates/e2e-tests/tests/request_lifecycle.rs @@ -87,8 +87,8 @@ async fn mpc_cluster__should_sign_with_scheme_matching_domain() { async fn mpc_cluster__should_successfully_process_robust_ecdsa_requests() { // given let (cluster, running) = common::must_setup_cluster(common::ROBUST_ECDSA_PORT_SEED, |c| { - c.num_nodes = 7; - c.initial_participant_indices = (0..7).collect(); + c.num_nodes = 6; + c.initial_participant_indices = (0..6).collect(); c.threshold = 5; c.domains = vec![DomainConfig { id: DomainId(0), diff --git a/crates/node/src/tests/resharing.rs b/crates/node/src/tests/resharing.rs index c07916a937..fbfa3d0fc0 100644 --- a/crates/node/src/tests/resharing.rs +++ b/crates/node/src/tests/resharing.rs @@ -37,7 +37,7 @@ async fn test_key_resharing_simple( #[case] protocol: Protocol, #[case] threshold: usize, ) { - let num_participants: usize = threshold + 2; + let num_participants: usize = threshold + 1; const TXN_DELAY_BLOCKS: u64 = 1; let temp_dir = tempfile::tempdir().unwrap(); let mut setup = IntegrationTestSetup::new( diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index 8e0686ddd5..e14b9ff20d 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -348,10 +348,12 @@ pub fn validate_governance(governance: &GovernanceBody) -> Result<(), Error> { if t < min_relative { return Err(Error::GovernanceThresholdBelowMinimumRelative); } - // Governance upper cap: <= 80% (floor), clamped up to the 60% lower bound so the - // window is never empty. Prevents a minority that stops serving from locking the - // contract (reshare / add / kick / sign). See §7.1. - let max_relative = (4 * n / 5).max(min_relative); + // Governance relative upper cap, expressed as a fraction of n. Currently set to + // 100% (5/5 = n) — effectively disabled — so it never binds below the absolute + // `t <= n` check above. Kept as an explicit fraction (clamped up to the 60% lower + // bound so the window is never empty) so a stricter cap can be re-introduced later. + // See §7.1. + let max_relative = (5 * n / 5).max(min_relative); if t > max_relative { return Err(Error::GovernanceThresholdAboveMaximumRelative); } @@ -935,12 +937,10 @@ let threshold = match dk.protocol { Resolved: the `GovernanceThreshold` is now constrained relative to the cryptographic `ReconstructionThreshold`. Concretely, for `n` participants and per-domain reconstruction thresholds `t_i`: - `max(t_i) <= GovernanceThreshold` — a governance majority can never approve a reshare into a set smaller than what any domain needs to reconstruct its key (keeps trust assumptions consistent). -- `ceil(0.6*n) <= GovernanceThreshold <= max(ceil(0.6*n), floor(0.8*n))` — the existing 60% lower bound plus an 80%-floor upper cap so a minority that stops serving cannot lock the contract. +- `ceil(0.6*n) <= GovernanceThreshold <= n` — the existing 60% lower bound. A relative upper cap (`MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR`) is retained in the code but currently set to 100% (5/5 = n), so it is effectively disabled and only the absolute `GovernanceThreshold <= n` check binds. An 80%-floor cap was considered (to stop a minority that stops serving from locking the contract) but not adopted; the fraction is kept explicit so a stricter cap can be re-introduced later. These are enforced at every mutation point (governance threshold updates, reconstruction threshold updates, participant add, participant kick) and re-validated once on migration, where a violation hard-blocks the upgrade (panic) — state that breaks the relation must be corrected via `vote_new_parameters` before upgrading. See `ThresholdParameters::validate_threshold` and `validate_governance_against_reconstruction` in `crates/contract/src/primitives/thresholds.rs`. -> **Operator note — the feasible window can be a single value.** When `ceil(0.6*n)` and `floor(0.8*n)` round to the same integer, exactly one `GovernanceThreshold` is legal and there is zero room to tune: this happens at `n = 2, 3, 4, 6, 7` (forced thresholds `2, 2, 3, 4, 5`), widening only from `n >= 8`. The `n = 2` case is degenerate — it forces `GovernanceThreshold = n` (unanimity), defeating the cap; permitted by validation but not a sensible configuration. - ### 7.2 Backward-Compatible View Methods How long should the old `state()` view method be maintained alongside the new `state_v2()`? Should the old format be deprecated immediately or kept for N epochs? Are there external consumers (e.g., block explorers, SDK clients) that depend on the `state()` format? From b310e84713382494595bf81f67afaaaeeb49a554 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:54:32 +0200 Subject: [PATCH 023/121] delimiter fix --- crates/contract/src/dto_mapping.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 2f9bbb52d6..9e729861ad 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -950,7 +950,7 @@ mod tests { found: 2 } )) - )); + ); } /// A threshold above the relative upper cap (> 80%) must be rejected at the From da2d19023d8827a980aa9fb1938f7f148d03e3ab Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:56:05 +0200 Subject: [PATCH 024/121] Update crates/contract/src/v3_11_2_state.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mårten Blankfors --- crates/contract/src/v3_11_2_state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index 86665358fa..18ad0cffe3 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -154,7 +154,7 @@ impl From for crate::MpcContract { /// that breaks the threshold relation into the new contract. Such state must be /// corrected (via `vote_new_parameters`) before upgrading. /// -/// TODO(#XXXX): remove together with this module once the 3.11.2 -> current +/// TODO(#3598): remove together with this module once the 3.11.2 -> current /// migration is retired. The relation is enforced at every runtime mutation point /// (`process_new_parameters_proposal`, `vote_add_domains`, `init_running`, /// `verify_tee`), so once no pre-existing violating state can reach this path the From 74584321fe521b084f45ae736556fb90a8c070e0 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:08:45 +0200 Subject: [PATCH 025/121] using ReconstructionThreshold as u64 and using checked arithmetic --- crates/contract/src/primitives/domain.rs | 7 ++-- crates/contract/src/primitives/thresholds.rs | 40 ++++++++++++++------ crates/contract/src/v3_11_2_state.rs | 4 +- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/crates/contract/src/primitives/domain.rs b/crates/contract/src/primitives/domain.rs index ea910ceeba..29fbe716e0 100644 --- a/crates/contract/src/primitives/domain.rs +++ b/crates/contract/src/primitives/domain.rs @@ -71,17 +71,16 @@ pub fn validate_domain_threshold( Ok(()) } -/// The largest `ReconstructionThreshold` across `domains`, or 0 if there are none +/// The largest `ReconstructionThreshold` across `domains`, or `None` if there are none /// (an empty set imposes no cross-domain lower bound on the GovernanceThreshold). /// Feeds [`ThresholdParameters::validate_governance_against_reconstruction`](crate::primitives::thresholds::ThresholdParameters::validate_governance_against_reconstruction). pub fn max_reconstruction_threshold<'a>( domains: impl IntoIterator, -) -> u64 { +) -> Option { domains .into_iter() - .map(|domain| domain.reconstruction_threshold.inner()) + .map(|domain| domain.reconstruction_threshold) .max() - .unwrap_or(0) } /// All the domains present in the contract, as well as the next domain ID which is kept to ensure diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 1cca6f304c..502df598e2 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -28,7 +28,9 @@ const MAX_THRESHOLD_DENOMINATOR: u64 = 5; /// Lower bound on the GovernanceThreshold for `n` participants: 60% rounded up. /// Single source of truth shared by validation and test fixtures. pub(crate) fn governance_threshold_lower_bound(n: u64) -> u64 { - (MIN_THRESHOLD_NUMERATOR * n).div_ceil(MIN_THRESHOLD_DENOMINATOR) + MIN_THRESHOLD_NUMERATOR + .saturating_mul(n) + .div_ceil(MIN_THRESHOLD_DENOMINATOR) } /// Upper bound on the GovernanceThreshold for `n` participants: @@ -36,7 +38,7 @@ pub(crate) fn governance_threshold_lower_bound(n: u64) -> u64 { /// `n`), clamped up to the lower bound so the feasible window is never empty for small /// `n`. Single source of truth shared by validation and test fixtures. pub(crate) fn governance_threshold_upper_bound(n: u64) -> u64 { - (MAX_THRESHOLD_NUMERATOR * n / MAX_THRESHOLD_DENOMINATOR) + (MAX_THRESHOLD_NUMERATOR.saturating_mul(n) / MAX_THRESHOLD_DENOMINATOR) .max(governance_threshold_lower_bound(n)) } @@ -112,15 +114,17 @@ impl ThresholdParameters { pub fn validate_governance_against_reconstruction( num_participants: u64, governance: Threshold, - max_reconstruction_threshold: u64, + max_reconstruction_threshold: Option, ) -> Result<(), Error> { Self::validate_threshold(num_participants, governance)?; - if governance.value() < max_reconstruction_threshold { - return Err(InvalidThreshold::BelowReconstructionThreshold { - reconstruction_threshold: max_reconstruction_threshold, - governance_threshold: governance.value(), + if let Some(max_reconstruction_threshold) = max_reconstruction_threshold { + if governance.value() < max_reconstruction_threshold.inner() { + return Err(InvalidThreshold::BelowReconstructionThreshold { + reconstruction_threshold: max_reconstruction_threshold.inner(), + governance_threshold: governance.value(), + } + .into()); } - .into()); } Ok(()) } @@ -393,7 +397,11 @@ mod tests { // When the largest reconstruction threshold is 7 (above governance). // Then the relation is rejected. assert_matches!( - ThresholdParameters::validate_governance_against_reconstruction(n, governance, 7), + ThresholdParameters::validate_governance_against_reconstruction( + n, + governance, + Some(ReconstructionThreshold::new(7)) + ), Err(Error::InvalidThreshold( InvalidThreshold::BelowReconstructionThreshold { reconstruction_threshold: 7, @@ -402,8 +410,18 @@ mod tests { )) ); // ...but is accepted when governance meets or exceeds the max reconstruction threshold. - ThresholdParameters::validate_governance_against_reconstruction(n, governance, 6).unwrap(); - ThresholdParameters::validate_governance_against_reconstruction(n, governance, 5).unwrap(); + ThresholdParameters::validate_governance_against_reconstruction( + n, + governance, + Some(ReconstructionThreshold::new(6)), + ) + .unwrap(); + ThresholdParameters::validate_governance_against_reconstruction( + n, + governance, + Some(ReconstructionThreshold::new(5)), + ) + .unwrap(); } #[test] diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index 18ad0cffe3..be453c4ec5 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -168,10 +168,10 @@ fn validate_threshold_relation_on_migration(running: &RunningContractState) { max_reconstruction_threshold, ) { env::panic_str(&format!( - "Migration aborted: existing state violates the GovernanceThreshold/ReconstructionThreshold relation ({err:?}). num_participants={}, governance_threshold={}, max_reconstruction_threshold={}. Correct it via vote_new_parameters before upgrading.", + "Migration aborted: existing state violates the GovernanceThreshold/ReconstructionThreshold relation ({err:?}). num_participants={}, governance_threshold={}, max_reconstruction_threshold={:?}. Correct it via vote_new_parameters before upgrading.", num_participants, running.parameters.threshold().value(), - max_reconstruction_threshold, + max_reconstruction_threshold.map(|t| t.inner()), )); } } From 4f52bdd56b958affd9a92106f074f63cf670b34b Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:09:05 +0200 Subject: [PATCH 026/121] Using 1 as values --- crates/contract/src/primitives/thresholds.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 502df598e2..865af95321 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -18,12 +18,12 @@ const MIN_THRESHOLD_DENOMINATOR: u64 = 5; /// Maximum fraction of participants the GovernanceThreshold may reach, expressed /// as `MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR`. Currently set to 100% -/// (5/5), so the relative upper cap is effectively disabled: the GovernanceThreshold +/// (1/1), so the relative upper cap is effectively disabled: the GovernanceThreshold /// may go all the way up to the participant count (the absolute `k <= n` check still /// applies). Kept as an explicit fraction so a stricter cap can be re-introduced by /// lowering the numerator should a future policy require it. -const MAX_THRESHOLD_NUMERATOR: u64 = 5; -const MAX_THRESHOLD_DENOMINATOR: u64 = 5; +const MAX_THRESHOLD_NUMERATOR: u64 = 1; +const MAX_THRESHOLD_DENOMINATOR: u64 = 1; /// Lower bound on the GovernanceThreshold for `n` participants: 60% rounded up. /// Single source of truth shared by validation and test fixtures. From 29613b8bd870c13b71561ba1dbc7068b57bbbcda Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:32:13 +0200 Subject: [PATCH 027/121] editting doc to make fast ci check pass --- crates/contract/src/primitives/thresholds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 865af95321..4434586712 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -107,7 +107,7 @@ impl ThresholdParameters { /// Validates the GovernanceThreshold `k` against both the participant count and the /// largest ReconstructionThreshold across all domains. Layers the cross-domain rule - /// `GovernanceThreshold >= max(ReconstructionThreshold)` on top of [`Self::validate_threshold`]: + /// `GovernanceThreshold >= max(ReconstructionThreshold)` on top of `validate_threshold`: /// the network must never be able to govern with fewer parties than are required to /// reconstruct any domain's key. Call this at every point where the GovernanceThreshold, /// a ReconstructionThreshold, or the participant set changes. From 3b329e27d3df53aeb1631de13f17b706008ff6e6 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:36:48 +0200 Subject: [PATCH 028/121] collapsing if statement --- crates/contract/src/primitives/thresholds.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 4434586712..cdd4b90b0d 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -117,14 +117,14 @@ impl ThresholdParameters { max_reconstruction_threshold: Option, ) -> Result<(), Error> { Self::validate_threshold(num_participants, governance)?; - if let Some(max_reconstruction_threshold) = max_reconstruction_threshold { - if governance.value() < max_reconstruction_threshold.inner() { - return Err(InvalidThreshold::BelowReconstructionThreshold { - reconstruction_threshold: max_reconstruction_threshold.inner(), - governance_threshold: governance.value(), - } - .into()); + if let Some(max_reconstruction_threshold) = max_reconstruction_threshold + && governance.value() < max_reconstruction_threshold.inner() + { + return Err(InvalidThreshold::BelowReconstructionThreshold { + reconstruction_threshold: max_reconstruction_threshold.inner(), + governance_threshold: governance.value(), } + .into()); } Ok(()) } From f16846828edbf4c2b2556caf009118d4b2c2ae55 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:12:46 +0200 Subject: [PATCH 029/121] Reducing verbosity of the upper/lower bounds functions --- crates/contract/src/primitives/thresholds.rs | 28 ++++---------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index cdd4b90b0d..03cf0afcea 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -10,36 +10,18 @@ pub use near_mpc_contract_interface::types::Threshold; /// Minimum absolute threshold required. const MIN_THRESHOLD_ABSOLUTE: u64 = 2; -/// Minimum fraction of participants the GovernanceThreshold must reach, expressed -/// as `MIN_THRESHOLD_NUMERATOR / MIN_THRESHOLD_DENOMINATOR` (currently 60%, rounded -/// up) so a key stays reconstructible/signable by a robust majority. -const MIN_THRESHOLD_NUMERATOR: u64 = 3; -const MIN_THRESHOLD_DENOMINATOR: u64 = 5; - -/// Maximum fraction of participants the GovernanceThreshold may reach, expressed -/// as `MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR`. Currently set to 100% -/// (1/1), so the relative upper cap is effectively disabled: the GovernanceThreshold -/// may go all the way up to the participant count (the absolute `k <= n` check still -/// applies). Kept as an explicit fraction so a stricter cap can be re-introduced by -/// lowering the numerator should a future policy require it. -const MAX_THRESHOLD_NUMERATOR: u64 = 1; -const MAX_THRESHOLD_DENOMINATOR: u64 = 1; - /// Lower bound on the GovernanceThreshold for `n` participants: 60% rounded up. /// Single source of truth shared by validation and test fixtures. pub(crate) fn governance_threshold_lower_bound(n: u64) -> u64 { - MIN_THRESHOLD_NUMERATOR - .saturating_mul(n) - .div_ceil(MIN_THRESHOLD_DENOMINATOR) + 3_u64.saturating_mul(n).div_ceil(5) } /// Upper bound on the GovernanceThreshold for `n` participants: -/// `MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR` floored (currently 100%, i.e. -/// `n`), clamped up to the lower bound so the feasible window is never empty for small -/// `n`. Single source of truth shared by validation and test fixtures. +/// Currently set to 100% of participants but would be a discussion subject +/// to drop this upper bound down not to have problems with smart contract +/// being locked if t = n and if an operator stops voting pub(crate) fn governance_threshold_upper_bound(n: u64) -> u64 { - (MAX_THRESHOLD_NUMERATOR.saturating_mul(n) / MAX_THRESHOLD_DENOMINATOR) - .max(governance_threshold_lower_bound(n)) + n } /// Stores the threshold key parameters: the owners of key shares From cd557e14a1df8fecef9f76f9257e5c96f948252a Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:15:08 +0200 Subject: [PATCH 030/121] nit --- crates/contract/src/primitives/domain.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/contract/src/primitives/domain.rs b/crates/contract/src/primitives/domain.rs index 29fbe716e0..fc6d89db31 100644 --- a/crates/contract/src/primitives/domain.rs +++ b/crates/contract/src/primitives/domain.rs @@ -74,11 +74,9 @@ pub fn validate_domain_threshold( /// The largest `ReconstructionThreshold` across `domains`, or `None` if there are none /// (an empty set imposes no cross-domain lower bound on the GovernanceThreshold). /// Feeds [`ThresholdParameters::validate_governance_against_reconstruction`](crate::primitives::thresholds::ThresholdParameters::validate_governance_against_reconstruction). -pub fn max_reconstruction_threshold<'a>( - domains: impl IntoIterator, -) -> Option { +pub fn max_reconstruction_threshold(domains: &[DomainConfig]) -> Option { domains - .into_iter() + .iter() .map(|domain| domain.reconstruction_threshold) .max() } From df267368b210861c2c997c1ef155e7bc5ca8ea71 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:30:53 +0200 Subject: [PATCH 031/121] modifying the design doc --- docs/design/domain-separation.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index e0b4a5e19f..4d2a6c3da3 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -348,12 +348,10 @@ pub fn validate_governance(governance: &GovernanceBody) -> Result<(), Error> { if t < min_relative { return Err(Error::GovernanceThresholdBelowMinimumRelative); } - // Governance relative upper cap, expressed as a fraction of n. Currently set to - // 100% (5/5 = n) — effectively disabled — so it never binds below the absolute - // `t <= n` check above. Kept as an explicit fraction (clamped up to the 60% lower - // bound so the window is never empty) so a stricter cap can be re-introduced later. - // See §7.1. - let max_relative = (5 * n / 5).max(min_relative); + // Currently, the governance relative upper cap is set to 100% but could and should + // be lowered to prevent getting the smart contract locked when a participant stops + // suddenly voting + let max_relative = n; if t > max_relative { return Err(Error::GovernanceThresholdAboveMaximumRelative); } From 876bcfec2496a9afd7ab52d6f43428d3a50eae34 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:59:43 +0200 Subject: [PATCH 032/121] Improving upper cap paragraph --- docs/design/domain-separation.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index 4d2a6c3da3..ea58d0b5e7 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -935,7 +935,8 @@ let threshold = match dk.protocol { Resolved: the `GovernanceThreshold` is now constrained relative to the cryptographic `ReconstructionThreshold`. Concretely, for `n` participants and per-domain reconstruction thresholds `t_i`: - `max(t_i) <= GovernanceThreshold` — a governance majority can never approve a reshare into a set smaller than what any domain needs to reconstruct its key (keeps trust assumptions consistent). -- `ceil(0.6*n) <= GovernanceThreshold <= n` — the existing 60% lower bound. A relative upper cap (`MAX_THRESHOLD_NUMERATOR / MAX_THRESHOLD_DENOMINATOR`) is retained in the code but currently set to 100% (5/5 = n), so it is effectively disabled and only the absolute `GovernanceThreshold <= n` check binds. An 80%-floor cap was considered (to stop a minority that stops serving from locking the contract) but not adopted; the fraction is kept explicit so a stricter cap can be re-introduced later. +- `ceil(0.6*n) <= GovernanceThreshold <= n` — the existing 60% lower bound. A relative upper cap is retained in the code but currently set to 100% +(effectively disabled due to non-convergence of opinions on what would be a good upper bound). These are enforced at every mutation point (governance threshold updates, reconstruction threshold updates, participant add, participant kick) and re-validated once on migration, where a violation hard-blocks the upgrade (panic) — state that breaks the relation must be corrected via `vote_new_parameters` before upgrading. See `ThresholdParameters::validate_threshold` and `validate_governance_against_reconstruction` in `crates/contract/src/primitives/thresholds.rs`. From fc57a338b4c154d3c645b8b36e82459649eaf4d2 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:06:52 +0200 Subject: [PATCH 033/121] cargo fmt --- crates/contract/src/v3_12_0_state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/v3_12_0_state.rs b/crates/contract/src/v3_12_0_state.rs index 33eef8d30a..6cddfd9949 100644 --- a/crates/contract/src/v3_12_0_state.rs +++ b/crates/contract/src/v3_12_0_state.rs @@ -96,4 +96,4 @@ fn validate_threshold_relation_on_migration(running: &RunningContractState) { max_reconstruction_threshold.map(|t| t.inner()), )); } -} \ No newline at end of file +} From 484d40ed59e17344af0cd274e53dc126aca5cd98 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:40:47 +0200 Subject: [PATCH 034/121] calling function in crate --- crates/contract/src/lib.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index f80fb86a2d..9b97f49137 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -78,7 +78,7 @@ use near_sdk::{ }; use node_migrations::NodeMigrations; use primitives::{ - domain::DomainRegistry, + domain::{DomainRegistry, max_reconstruction_threshold}, key_state::{AuthenticatedParticipantId, EpochId, KeyEventId, Keyset}, signature::{SignRequestArgs, SignatureRequest, YieldIndex}, thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, @@ -1673,9 +1673,7 @@ impl MpcContract { // keeps the existing per-domain thresholds). Otherwise we refuse and // wait for manual intervention. let max_reconstruction_threshold = - crate::primitives::domain::max_reconstruction_threshold( - running_state.domains.domains(), - ); + max_reconstruction_threshold(running_state.domains.domains()); if let Err(err) = ThresholdParameters::validate_governance_against_reconstruction( remaining as u64, current_params.threshold(), @@ -1933,7 +1931,7 @@ impl MpcContract { ThresholdParameters::validate_governance_against_reconstruction( num_participants, parameters.threshold(), - crate::primitives::domain::max_reconstruction_threshold(domains.domains()), + max_reconstruction_threshold(domains.domains()), )?; // Check that the domains match exactly those in the keyset. From ec871da602d4508df817a5c004020157ef6bb33b Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:17:40 +0200 Subject: [PATCH 035/121] No need for variable --- crates/contract/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 9b97f49137..ca021cdc9c 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -1663,7 +1663,6 @@ impl MpcContract { TeeValidationResult::Partial { participants_with_valid_attestation, } => { - let threshold = current_params.threshold().value() as usize; let remaining = participants_with_valid_attestation.len(); // Defense in depth: the surviving participant set must keep the full // threshold relation intact — the GovernanceThreshold must still sit @@ -1696,7 +1695,7 @@ impl MpcContract { //let n_participants_new = new_participants.len(); //let new_threshold = (3 * n_participants_new + 4) / 5; // minimum 60% //let new_threshold = new_threshold.max(2); // but also minimum 2 - let new_threshold = threshold; + let new_threshold = current_params.threshold().value() as usize;; let threshold_parameters = ThresholdParameters::new( participants_with_valid_attestation, From ee76a110b9b56c9a23bc5b92127c0d33bd9fcf10 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:23:35 +0200 Subject: [PATCH 036/121] no panic --- crates/contract/src/lib.rs | 2 +- crates/contract/src/state/running.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index ca021cdc9c..0c10f7b4bb 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -1695,7 +1695,7 @@ impl MpcContract { //let n_participants_new = new_participants.len(); //let new_threshold = (3 * n_participants_new + 4) / 5; // minimum 60% //let new_threshold = new_threshold.max(2); // but also minimum 2 - let new_threshold = current_params.threshold().value() as usize;; + let new_threshold = current_params.threshold().value() as usize; let threshold_parameters = ThresholdParameters::new( participants_with_valid_attestation, diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 2542ff1b94..22fc278828 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -222,7 +222,9 @@ impl RunningContractState { return Err(DomainError::AddDomainsMustAddAtLeastOneDomain.into()); } let num_participants = u64::try_from(self.parameters.participants().len()) - .expect("participant list should be wayyyy smaller than u64::MAX"); + .map_err(|e| ConversionError::DataConversion { + reason: format!("participant count does not fit in u64: {e}"), + })?; for domain in &domains { validate_domain_purpose(domain)?; validate_domain_threshold(domain, num_participants)?; From 6a4fa9eb9a651495c725b2fe1260de550e6c9ec3 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:35:57 +0200 Subject: [PATCH 037/121] deterministic test --- crates/contract/src/primitives/test_utils.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/primitives/test_utils.rs b/crates/contract/src/primitives/test_utils.rs index 6a39808b05..2a7c5dde39 100644 --- a/crates/contract/src/primitives/test_utils.rs +++ b/crates/contract/src/primitives/test_utils.rs @@ -14,7 +14,7 @@ use near_account_id::AccountId; use near_mpc_contract_interface::types::{ DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold, }; -use rand::{Rng, distributions::Uniform}; +use rand::{Rng, SeedableRng, distributions::Uniform, rngs::StdRng}; use std::collections::BTreeMap; // Re-export for convenience @@ -156,10 +156,11 @@ pub fn gen_threshold_params(max_n: usize) -> ThresholdParameters { // Lower bound is 3 (not 2) so the produced parameters are compatible with // every protocol — `DamgardEtAl` requires `n >= 2t - 1`, which forces // `n >= 3` even at the minimum `t = 2`. - let n: usize = rand::thread_rng().gen_range(3..max_n + 1); + let mut rng = StdRng::seed_from_u64(42); + let n: usize = rng.gen_range(3..max_n + 1); let k_min = governance_threshold_lower_bound(n as u64) as usize; let k_max = governance_threshold_upper_bound(n as u64) as usize; - let k = rand::thread_rng().gen_range(k_min..k_max + 1); + let k = rng.gen_range(k_min..k_max + 1); ThresholdParameters::new(gen_participants(n), Threshold::new(k as u64)).unwrap() } From 0ef09e5a2c70b997f0b643851afffd239ee58c9a Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:39:42 +0200 Subject: [PATCH 038/121] explaining relative to the participant count --- crates/contract/src/errors.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index 070a1c1192..7c67f0c100 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -178,13 +178,13 @@ pub enum InvalidThreshold { #[error("Threshold does not meet the minimum absolute requirement")] MinAbsRequirementFailed, #[error( - "Threshold does not meet the minimum relative requirement: require at least {required}, found {found}" + "GovernanceThreshold is below the minimum required relative to the participant count: require at least {required}, found {found}" )] MinRelRequirementFailed { required: u64, found: u64 }, #[error("Threshold must not exceed number of participants: max {max}, found {found}")] MaxRequirementFailed { max: u64, found: u64 }, #[error( - "GovernanceThreshold does not meet the maximum relative requirement: max {max}, found {found}" + "GovernanceThreshold exceeds the maximum allowed relative to the participant count: max {max}, found {found}" )] MaxRelRequirementFailed { max: u64, found: u64 }, #[error( From bc928d4de33d5cfda19f1004268e737036245eee Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:40:31 +0200 Subject: [PATCH 039/121] cargo fmt --- crates/contract/src/state/running.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 22fc278828..2ba772d649 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -221,9 +221,11 @@ impl RunningContractState { if domains.is_empty() { return Err(DomainError::AddDomainsMustAddAtLeastOneDomain.into()); } - let num_participants = u64::try_from(self.parameters.participants().len()) - .map_err(|e| ConversionError::DataConversion { - reason: format!("participant count does not fit in u64: {e}"), + let num_participants = + u64::try_from(self.parameters.participants().len()).map_err(|e| { + ConversionError::DataConversion { + reason: format!("participant count does not fit in u64: {e}"), + } })?; for domain in &domains { validate_domain_purpose(domain)?; From e3d6c893c1a8751b470bdc64e249abfdaf5c5c42 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:18:01 +0200 Subject: [PATCH 040/121] A good pass over the node integration to the robust ecdsa --- .../tests/request_during_resharing.rs | 10 +- crates/node/src/coordinator.rs | 48 ++++-- crates/node/src/key_events.rs | 63 ++++--- crates/node/src/providers/ckd.rs | 40 ++++- crates/node/src/providers/ckd/sign.rs | 14 +- crates/node/src/providers/ecdsa.rs | 132 ++++++++------- crates/node/src/providers/ecdsa/presign.rs | 17 +- crates/node/src/providers/ecdsa/sign.rs | 4 +- crates/node/src/providers/eddsa.rs | 41 ++++- crates/node/src/providers/eddsa/sign.rs | 17 +- crates/node/src/providers/robust_ecdsa.rs | 154 +++--------------- .../src/providers/robust_ecdsa/presign.rs | 101 +++++------- .../node/src/providers/robust_ecdsa/sign.rs | 12 +- 13 files changed, 325 insertions(+), 328 deletions(-) diff --git a/crates/e2e-tests/tests/request_during_resharing.rs b/crates/e2e-tests/tests/request_during_resharing.rs index da3a146e0f..6a37984881 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -11,11 +11,11 @@ use rand::SeedableRng; /// /// Setup: 6 nodes, 5 initial participants (threshold 5). Domains cover /// classic ECDSA (CaitSith), robust ECDSA (DamgardEtAl), EdDSA (Frost) and -/// CKD (ConfidentialKeyDerivation). Threshold is 5 because robust ECDSA -/// requires ≥ 5 signers (see `robust_ecdsa::translate_threshold`). Begin -/// resharing to all 6 with threshold 6, then kill node 5 so resharing can't -/// complete. Requests should still succeed using the old threshold of 5 -/// across all signing schemes. +/// CKD (ConfidentialKeyDerivation). The robust-ECDSA domain uses a +/// reconstruction threshold of `t = 3`, which requires `2t - 1 = 5` signers, +/// so we need at least 5 participants. Begin resharing to all 6 with threshold +/// 6, then kill node 5 so resharing can't complete. Requests should still +/// succeed using the previous running state across all signing schemes. #[tokio::test] async fn test_request_during_resharing() { // given diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index d5ff81ebca..2fa246df6e 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -38,7 +38,6 @@ use near_time::Clock; use std::collections::HashMap; use std::future::Future; use std::sync::{Arc, Mutex}; -use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use threshold_signatures::{confidential_key_derivation, ecdsa, frost::eddsa}; use tokio::select; use tokio::sync::mpsc::unbounded_channel; @@ -331,15 +330,12 @@ where let (sender, receiver) = new_tls_mesh_network(&mpc_config, p2p_key).await?; let (network_client, channel_receiver, _handle) = run_network_client(Arc::new(sender), Box::new(receiver)); - let threshold: usize = mpc_config.participants.threshold.try_into()?; - let threshold = TSReconstructionThreshold::from(threshold); if mpc_config.is_leader_for_key_event() { keygen_leader( network_client, keyshare_storage, key_event_receiver, chain_txn_sender, - threshold, ) .await?; } else { @@ -348,7 +344,6 @@ where keyshare_storage, key_event_receiver, chain_txn_sender, - threshold, ) .await?; } @@ -392,12 +387,18 @@ where epoch_id: current_epoch_id, participants: current_participants_config, }; - // TODO(#3164): once each domain may declare its own - // `reconstruction_threshold`, collect the distinct `t`s across all - // CaitSith domains here instead of just the network-wide threshold. - let triple_thresholds = vec![ReconstructionThreshold::new( - running_state.participants.threshold, - )]; + // Triples are keyed by the reconstruction threshold `t` they were + // generated for. Collect the distinct `t`s across the CaitSith + // domains (the only protocol that uses triples) so stale assets are + // cleaned for every store this node maintains. + let mut triple_thresholds: Vec = running_state + .domains + .iter() + .filter(|d| d.protocol == Protocol::CaitSith) + .map(|d| d.reconstruction_threshold) + .collect(); + triple_thresholds.sort(); + triple_thresholds.dedup(); delete_stale_triples_and_presignatures( &secret_db, current_epoch_data, @@ -607,6 +608,11 @@ where .iter() .map(|d| (d.id, d.protocol)) .collect(); + let domain_to_threshold: HashMap = running_state + .domains + .iter() + .map(|d| (d.id, d.reconstruction_threshold)) + .collect(); for keyshare in keyshares { let domain_id = keyshare.key_id.domain_id; @@ -652,6 +658,7 @@ where secret_db.clone(), sign_request_store.clone(), ecdsa_keyshares, + domain_to_threshold.clone(), )?); let robust_ecdsa_signature_provider = Arc::new(RobustEcdsaSignatureProvider::new( @@ -662,6 +669,7 @@ where secret_db, sign_request_store.clone(), robust_ecdsa_keyshares, + domain_to_threshold.clone(), )?); let eddsa_signature_provider = Arc::new(EddsaSignatureProvider::new( @@ -670,7 +678,8 @@ where network_client.clone(), sign_request_store.clone(), eddsa_keyshares, - )); + domain_to_threshold.clone(), + )?); let ckd_provider = Arc::new(CKDProvider::new( config_file.clone().into(), @@ -678,7 +687,8 @@ where network_client.clone(), ckd_request_store.clone(), ckd_keyshares, - )); + domain_to_threshold, + )?); let verify_foreign_tx_provider = Arc::new(VerifyForeignTxProvider::new( config_file.clone().into(), @@ -776,11 +786,19 @@ where None }; - let new_threshold: usize = mpc_config.participants.threshold.try_into()?; + // The previous epoch's per-domain reconstruction thresholds. The new + // threshold for each key is read from the resharing key event's domain + // config inside `resharing_computation_inner`. + let old_reconstruction_thresholds: HashMap = + current_running_state + .domains + .iter() + .map(|d| (d.id, d.reconstruction_threshold)) + .collect(); let args = Arc::new(ResharingArgs { previous_keyset, existing_keyshares, - new_threshold: TSReconstructionThreshold::from(new_threshold), + old_reconstruction_thresholds, old_participants: current_running_state.participants, }); diff --git a/crates/node/src/key_events.rs b/crates/node/src/key_events.rs index b9d1d9cc6a..74950fb9e4 100644 --- a/crates/node/src/key_events.rs +++ b/crates/node/src/key_events.rs @@ -21,10 +21,12 @@ use crate::{ }, }; use mpc_primitives::KeyEventId; -use mpc_primitives::domain::Protocol; +use mpc_primitives::ReconstructionThreshold as MpcReconstructionThreshold; +use mpc_primitives::domain::{DomainId, Protocol}; use near_mpc_contract_interface::types as dtos; use near_mpc_contract_interface::types::DomainConfig; use near_mpc_crypto_types::{KeyForDomain, Keyset}; +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use threshold_signatures::{ @@ -47,9 +49,13 @@ pub async fn keygen_computation_inner( generated_keys: Vec, key_id: KeyEventId, domain: DomainConfig, - threshold: ReconstructionThreshold, ) -> anyhow::Result<()> { anyhow::ensure!(key_id.domain_id == domain.id, "Domain mismatch"); + // The reconstruction threshold `t` is the per-domain source of truth. For + // every protocol (including robust-ECDSA, whose reconstruction lower bound + // equals `t`) the keygen runs with lower bound `t`. + let threshold = + ReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); let keyshare_handle = keyshare_storage .write() .await @@ -124,7 +130,6 @@ async fn keygen_computation( keyshare_storage: Arc>, chain_txn_sender: impl TransactionSender, key_id: KeyEventId, - threshold: ReconstructionThreshold, ) -> anyhow::Result<()> { let key_event = wait_for_contract_catchup(&mut contract_key_event_id, key_id).await; let inner = keygen_computation_inner( @@ -134,7 +139,6 @@ async fn keygen_computation( key_event.completed_domains, key_id, key_event.domain, - threshold, ); let expiration = key_event_id_expiration(contract_key_event_id, key_id); tokio::select! { @@ -160,7 +164,12 @@ async fn keygen_computation( pub struct ResharingArgs { pub previous_keyset: Keyset, pub existing_keyshares: Option>, - pub new_threshold: ReconstructionThreshold, + /// The previous epoch's per-domain reconstruction thresholds. The new + /// threshold for each key comes from the resharing key event's + /// `DomainConfig.reconstruction_threshold`; the old one is patched into + /// `old_participants.threshold` per-key so the underlying resharing protocol + /// sees the right `t` on each side of the transition. + pub old_reconstruction_thresholds: HashMap, pub old_participants: ParticipantsConfig, } @@ -183,6 +192,27 @@ async fn resharing_computation_inner( args: Arc, ) -> anyhow::Result<()> { anyhow::ensure!(key_id.domain_id == domain.id, "Domain mismatch"); + + // The new reconstruction threshold comes from the resharing key event's + // domain config; the old one is the previous epoch's per-domain `t`. We patch + // `old_participants.threshold` per-key so the underlying resharing protocol + // sees the correct `t` on each side of the transition. For robust-ECDSA the + // reconstruction lower bound equals `t`, so no protocol-specific translation + // is needed here. + let new_threshold = + ReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); + let old_threshold = args + .old_reconstruction_thresholds + .get(&key_id.domain_id) + .ok_or_else(|| { + anyhow::anyhow!( + "No previous reconstruction threshold for domain {:?}", + key_id.domain_id + ) + })?; + let mut old_participants = args.old_participants.clone(); + old_participants.threshold = old_threshold.inner(); + let keyshare_handle = keyshare_storage .write() .await @@ -226,10 +256,10 @@ async fn resharing_computation_inner( }) .transpose()?; let res = EcdsaSignatureProvider::run_key_resharing_client( - args.new_threshold, + new_threshold, my_share, public_key, - &args.old_participants, + &old_participants, channel, ) .await?; @@ -248,10 +278,10 @@ async fn resharing_computation_inner( }) .transpose()?; let res = RobustEcdsaSignatureProvider::run_key_resharing_client( - args.new_threshold, + new_threshold, my_share, public_key, - &args.old_participants, + &old_participants, channel, ) .await?; @@ -269,10 +299,10 @@ async fn resharing_computation_inner( }) .transpose()?; let res = EddsaSignatureProvider::run_key_resharing_client( - args.new_threshold, + new_threshold, my_share, public_key, - &args.old_participants, + &old_participants, channel, ) .await?; @@ -287,10 +317,10 @@ async fn resharing_computation_inner( }) .transpose()?; let res = CKDProvider::run_key_resharing_client( - args.new_threshold, + new_threshold, my_share, public_key, - &args.old_participants, + &old_participants, channel, ) .await?; @@ -416,7 +446,6 @@ pub async fn keygen_leader( keyshare_storage: Arc>, mut key_event_receiver: watch::Receiver, chain_txn_sender: impl TransactionSender, - threshold: ReconstructionThreshold, ) -> anyhow::Result<()> { loop { // Wait for all participants to be connected. Otherwise, computations are most likely going @@ -480,7 +509,6 @@ pub async fn keygen_leader( keyshare_storage.clone(), chain_txn_sender.clone(), key_event_id, - threshold, ) .await { @@ -500,7 +528,6 @@ pub async fn keygen_follower( keyshare_storage: Arc>, key_event_receiver: watch::Receiver, chain_txn_sender: impl TransactionSender + 'static, - threshold: ReconstructionThreshold, ) -> anyhow::Result<()> { let mut tasks = AutoAbortTaskCollection::new(); loop { @@ -529,7 +556,6 @@ pub async fn keygen_follower( keyshare_storage.clone(), chain_txn_sender.clone(), key_event_id, - threshold, ), ); } @@ -731,7 +757,6 @@ mod tests { }; use std::collections::BTreeSet; use std::sync::atomic::{AtomicUsize, Ordering}; - use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; #[rstest::rstest] #[tokio::test(start_paused = true)] @@ -901,7 +926,7 @@ mod tests { Arc::new(ResharingArgs { previous_keyset: Keyset::new(EpochId::new(5), vec![]), existing_keyshares: None, - new_threshold: TSReconstructionThreshold::from(3), + old_reconstruction_thresholds: HashMap::new(), old_participants: ParticipantsConfig { threshold: 3, participants: vec![], diff --git a/crates/node/src/providers/ckd.rs b/crates/node/src/providers/ckd.rs index 39394769b4..80b16d6a10 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -5,6 +5,7 @@ mod sign; use std::{collections::HashMap, sync::Arc}; use borsh::{BorshDeserialize, BorshSerialize}; +use mpc_primitives::ReconstructionThreshold as MpcReconstructionThreshold; use mpc_primitives::domain::DomainId; use near_mpc_contract_interface::types::KeyEventId; use threshold_signatures::confidential_key_derivation::{ @@ -43,7 +44,14 @@ pub struct CKDProvider { mpc_config: Arc, client: Arc, ckd_request_store: Arc, - keyshares: HashMap, + per_domain_data: HashMap, +} + +#[derive(Clone)] +pub(super) struct PerDomainData { + pub keyshare: KeygenOutput, + /// Per-domain reconstruction threshold `t`, used as the CKD threshold. + pub reconstruction_threshold: MpcReconstructionThreshold, } impl CKDProvider { @@ -53,14 +61,36 @@ impl CKDProvider { client: Arc, ckd_request_store: Arc, keyshares: HashMap, - ) -> Self { - Self { + reconstruction_thresholds: HashMap, + ) -> anyhow::Result { + let mut per_domain_data = HashMap::new(); + for (domain_id, keyshare) in keyshares { + let reconstruction_threshold = + *reconstruction_thresholds.get(&domain_id).ok_or_else(|| { + anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) + })?; + per_domain_data.insert( + domain_id, + PerDomainData { + keyshare, + reconstruction_threshold, + }, + ); + } + Ok(Self { config, mpc_config, client, ckd_request_store, - keyshares, - } + per_domain_data, + }) + } + + pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { + self.per_domain_data + .get(&domain_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) } } diff --git a/crates/node/src/providers/ckd/sign.rs b/crates/node/src/providers/ckd/sign.rs index 74182141e7..4686bfeb78 100644 --- a/crates/node/src/providers/ckd/sign.rs +++ b/crates/node/src/providers/ckd/sign.rs @@ -29,7 +29,8 @@ impl CKDProvider { ) -> anyhow::Result<((ElementG1, ElementG1), VerifyingKey)> { let ckd_request = self.ckd_request_store.get(id).await?; - let threshold: usize = self.mpc_config.participants.threshold.try_into()?; + let domain_data = self.domain_data(ckd_request.domain_id)?; + let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let running_participants: Vec<_> = self .mpc_config @@ -51,10 +52,7 @@ impl CKDProvider { .client .new_channel_for_task(CKDTaskId::Ckd { id }, participants)?; - let Some(keygen_output) = self.keyshares.get(&ckd_request.domain_id).cloned() else { - anyhow::bail!("No keyshare for domain {:?}", ckd_request.domain_id); - }; - + let keygen_output = domain_data.keyshare; let public_key = keygen_output.public_key; let participants = channel.participants().to_vec(); let result = CKDComputation { @@ -95,12 +93,10 @@ impl CKDProvider { .await??; metrics::MPC_NUM_PASSIVE_CKD_REQUESTS_LOOKUP_SUCCEEDED.inc(); - let Some(keygen_output) = self.keyshares.get(&ckd_request.domain_id) else { - anyhow::bail!("No keyshare for domain {:?}", ckd_request.domain_id); - }; + let domain_data = self.domain_data(ckd_request.domain_id)?; let participants = channel.participants().to_vec(); CKDComputation { - keygen_output: keygen_output.clone(), + keygen_output: domain_data.keyshare, app_public_key: ckd_request.app_public_key, app_id: ckd_request.app_id, } diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 2f749a49b0..c684675973 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -49,9 +49,13 @@ pub struct EcdsaSignatureProvider { pub(super) struct PerDomainData { pub keyshare: KeygenOutput, pub presignature_store: Arc, + /// Per-domain reconstruction threshold `t`, the source of truth for this + /// domain's keygen/presign/sign and the triple store it consumes from. + pub reconstruction_threshold: ReconstructionThreshold, } impl EcdsaSignatureProvider { + #[expect(clippy::too_many_arguments)] pub fn new( config: Arc, mpc_config: Arc, @@ -60,32 +64,19 @@ impl EcdsaSignatureProvider { db: Arc, sign_request_store: Arc, keyshares: HashMap, + reconstruction_thresholds: HashMap, ) -> anyhow::Result { let active_participants_query = { let network_client = client.clone(); Arc::new(move || network_client.all_alive_participant_ids()) }; - // The set of distinct `t`s the node needs to serve is fully known at - // startup. Today every ECDSA domain shares the network-wide threshold; - // once #3164 lands and each domain may declare its own - // `reconstruction_threshold`, derive this set from the keyshares' - // domain configs instead. - let network_threshold = ReconstructionThreshold::new(mpc_config.participants.threshold); - let mut triple_stores = HashMap::new(); - triple_stores.insert( - network_threshold, - Arc::new(TripleStorage::new( - clock.clone(), - db.clone(), - client.my_participant_id(), - active_participants_query.clone(), - network_threshold, - )?), - ); - let mut per_domain_data = HashMap::new(); for (domain_id, keyshare) in keyshares { + let reconstruction_threshold = + *reconstruction_thresholds.get(&domain_id).ok_or_else(|| { + anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) + })?; let presignature_store = Arc::new(PresignatureStorage::new( clock.clone(), db.clone(), @@ -98,10 +89,34 @@ impl EcdsaSignatureProvider { PerDomainData { keyshare, presignature_store, + reconstruction_threshold, }, ); } + // cait-sith triple generation runs with exactly `t` parties, so the set + // of stores this node maintains is the distinct per-domain + // reconstruction thresholds — fully known up front, no on-demand + // creation needed. The contract enforces a single `t` across CaitSith + // domains today, so this is typically one store. + let mut triple_stores = HashMap::new(); + for data in per_domain_data.values() { + let t = data.reconstruction_threshold; + if triple_stores.contains_key(&t) { + continue; + } + triple_stores.insert( + t, + Arc::new(TripleStorage::new( + clock.clone(), + db.clone(), + client.my_participant_id(), + active_participants_query.clone(), + t, + )?), + ); + } + Ok(Self { config, mpc_config, @@ -266,47 +281,50 @@ impl SignatureProvider for EcdsaSignatureProvider { } async fn spawn_background_tasks(self: Arc) -> anyhow::Result<()> { - // TODO(#3164): once each domain may carry its own `ReconstructionThreshold`, - // spawn one background generator per distinct `t` across CaitSith domains - // and source `t` from `domain.reconstruction_threshold` rather than the - // network-wide threshold. - let threshold = ReconstructionThreshold::new(self.mpc_config.participants.threshold); - let threshold_usize: usize = threshold.inner().try_into()?; - let threshold_bound = TSReconstructionThreshold::from(threshold_usize); - let triple_store = self.triple_store_for_t(threshold)?; - - let generate_triples = tracking::spawn( - "generate triples", - Self::run_background_triple_generation( - self.client.clone(), - self.mpc_config.clone(), - self.config.triple.clone().into(), - triple_store.clone(), - threshold_bound, - ), - ); + // One triple generator per distinct `t` this node serves; cait-sith + // triples are generated with exactly `t` parties, so each store is fed + // by a generator running at its own threshold. + let mut generate_triples = Vec::new(); + for (&t, triple_store) in &self.triple_stores { + let threshold_usize: usize = t.inner().try_into()?; + let threshold_bound = TSReconstructionThreshold::from(threshold_usize); + generate_triples.push(tracking::spawn( + &format!("generate triples for t={}", t.inner()), + Self::run_background_triple_generation( + self.client.clone(), + self.mpc_config.clone(), + self.config.triple.clone().into(), + triple_store.clone(), + threshold_bound, + ), + )); + } - let generate_presignatures = self - .per_domain_data - .iter() - .map(|(domain_id, data)| { - tracking::spawn( - &format!("generate presignatures for domain {}", domain_id.0), - Self::run_background_presignature_generation( - self.client.clone(), - threshold_bound, - self.config.presignature.clone().into(), - triple_store.clone(), - *domain_id, - data.presignature_store.clone(), - data.keyshare.clone(), - ), - ) - }) - .collect::>(); + let mut generate_presignatures = Vec::new(); + for (domain_id, data) in &self.per_domain_data { + let t = data.reconstruction_threshold; + let threshold_usize: usize = t.inner().try_into()?; + let threshold_bound = TSReconstructionThreshold::from(threshold_usize); + let triple_store = self.triple_store_for_t(t)?; + generate_presignatures.push(tracking::spawn( + &format!("generate presignatures for domain {}", domain_id.0), + Self::run_background_presignature_generation( + self.client.clone(), + threshold_bound, + self.config.presignature.clone().into(), + triple_store, + *domain_id, + data.presignature_store.clone(), + data.keyshare.clone(), + ), + )); + } - let Err(join_error) = generate_triples.await; - tracing::error!("ecdsa background triple generation task ended unexpectedly: {join_error}"); + for Err(join_error) in futures::future::join_all(generate_triples).await { + tracing::error!( + "ecdsa background triple generation task ended unexpectedly: {join_error}" + ); + } for Err(join_error) in futures::future::join_all(generate_presignatures).await { tracing::error!("ecdsa background presignature task ended unexpectedly: {join_error}"); } diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index 6c8bf85fb6..a31b62bc50 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -12,7 +12,6 @@ use crate::providers::ecdsa::{EcdsaSignatureProvider, EcdsaTaskId, KeygenOutput, use crate::tracking::AutoAbortTaskCollection; use crate::{metrics, tracking}; use mpc_node_config::PresignatureConfig; -use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; use near_time::Clock; use serde::{Deserialize, Serialize}; @@ -179,11 +178,17 @@ impl EcdsaSignatureProvider { paired_triple_id.validate_owned_by(leader)?; let domain_data = self.domain_data(domain_id)?; - // Triple store to consume from is keyed by the presign's `t`, which - // equals the number of presign participants (same as triple - // participants — the leader pairs them). - let threshold_usize: usize = channel.participants().len(); - let threshold = ReconstructionThreshold::new(threshold_usize.try_into()?); + // The triple store is keyed by the domain's reconstruction threshold + // `t`. For cait-sith the leader pairs exactly `t` participants, so the + // channel participant count must match — cross-check it. + let threshold = domain_data.reconstruction_threshold; + let threshold_usize: usize = threshold.inner().try_into()?; + anyhow::ensure!( + channel.participants().len() == threshold_usize, + "presign participant count ({}) does not match domain threshold t={}", + channel.participants().len(), + threshold_usize, + ); let triple_store = self.triple_store_for_t(threshold)?; FollowerPresignComputation { threshold: TSReconstructionThreshold::from(threshold_usize), diff --git a/crates/node/src/providers/ecdsa/sign.rs b/crates/node/src/providers/ecdsa/sign.rs index 562632a9f3..b18f99d345 100644 --- a/crates/node/src/providers/ecdsa/sign.rs +++ b/crates/node/src/providers/ecdsa/sign.rs @@ -31,7 +31,7 @@ impl EcdsaSignatureProvider { ) -> anyhow::Result<(Signature, VerifyingKey)> { let domain_data = self.domain_data(sign_request.domain)?; let participants = presignature.participants.clone(); - let threshold: usize = self.mpc_config.participants.threshold.try_into()?; + let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let (signature, public_key) = SignComputation { @@ -92,7 +92,7 @@ impl EcdsaSignatureProvider { // The presignature must be owned by the leader, never one of ours. presignature_id.validate_owned_by(channel.sender().get_leader())?; let domain_data = self.domain_data(sign_request.domain)?; - let threshold: usize = self.mpc_config.participants.threshold.try_into()?; + let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let participants = channel.participants().to_vec(); diff --git a/crates/node/src/providers/eddsa.rs b/crates/node/src/providers/eddsa.rs index a4c24d3275..64753e6c01 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -15,6 +15,7 @@ use crate::types::SignatureId; use anyhow::Context; use borsh::{BorshDeserialize, BorshSerialize}; use mpc_node_config::ConfigFile; +use mpc_primitives::ReconstructionThreshold as MpcReconstructionThreshold; use mpc_primitives::domain::DomainId; #[cfg(test)] use near_mpc_contract_interface::types::Ed25519PublicKey; @@ -32,7 +33,15 @@ pub struct EddsaSignatureProvider { mpc_config: Arc, client: Arc, sign_request_store: Arc, - keyshares: HashMap, + per_domain_data: HashMap, +} + +#[derive(Clone)] +pub(super) struct PerDomainData { + pub keyshare: KeygenOutput, + /// Per-domain reconstruction threshold `t`, used as the FROST signing + /// threshold. + pub reconstruction_threshold: MpcReconstructionThreshold, } impl EddsaSignatureProvider { @@ -42,14 +51,36 @@ impl EddsaSignatureProvider { client: Arc, sign_request_store: Arc, keyshares: HashMap, - ) -> Self { - Self { + reconstruction_thresholds: HashMap, + ) -> anyhow::Result { + let mut per_domain_data = HashMap::new(); + for (domain_id, keyshare) in keyshares { + let reconstruction_threshold = + *reconstruction_thresholds.get(&domain_id).ok_or_else(|| { + anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) + })?; + per_domain_data.insert( + domain_id, + PerDomainData { + keyshare, + reconstruction_threshold, + }, + ); + } + Ok(Self { config, mpc_config, client, sign_request_store, - keyshares, - } + per_domain_data, + }) + } + + pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { + self.per_domain_data + .get(&domain_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) } } diff --git a/crates/node/src/providers/eddsa/sign.rs b/crates/node/src/providers/eddsa/sign.rs index 64c08208af..bc5c2d2230 100644 --- a/crates/node/src/providers/eddsa/sign.rs +++ b/crates/node/src/providers/eddsa/sign.rs @@ -24,7 +24,8 @@ impl EddsaSignatureProvider { ) -> anyhow::Result<(Signature, VerifyingKey)> { let sign_request = self.sign_request_store.get(id).await?; - let threshold: usize = self.mpc_config.participants.threshold.try_into()?; + let domain_data = self.domain_data(sign_request.domain)?; + let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let running_participants: Vec<_> = self .mpc_config @@ -46,12 +47,8 @@ impl EddsaSignatureProvider { .client .new_channel_for_task(EddsaTaskId::Signature { id }, participants.clone())?; - let Some(keygen_output) = self.keyshares.get(&sign_request.domain).cloned() else { - anyhow::bail!("No keyshare for domain {:?}", sign_request.domain); - }; - let result = SignComputation { - keygen_output, + keygen_output: domain_data.keyshare, threshold, message: sign_request .payload @@ -95,15 +92,13 @@ impl EddsaSignatureProvider { .await??; metrics::MPC_NUM_PASSIVE_SIGN_REQUESTS_LOOKUP_SUCCEEDED.inc(); - let threshold: usize = self.mpc_config.participants.threshold.try_into()?; + let domain_data = self.domain_data(sign_request.domain)?; + let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); - let Some(keygen_output) = self.keyshares.get(&sign_request.domain) else { - anyhow::bail!("No keyshare for domain {:?}", sign_request.domain); - }; let participants = channel.participants().to_vec(); let _ = SignComputation { - keygen_output: keygen_output.clone(), + keygen_output: domain_data.keyshare, threshold, message: sign_request .payload diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 338a464fd1..80f4d42d52 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -17,10 +17,10 @@ use mpc_node_config::ConfigFile; use crate::types::SignatureId; use borsh::{BorshDeserialize, BorshSerialize}; +use mpc_primitives::ReconstructionThreshold as MpcReconstructionThreshold; use mpc_primitives::domain::DomainId; use near_time::Clock; use std::sync::Arc; -use threshold_signatures::MaxMalicious; use threshold_signatures::ReconstructionThreshold; use threshold_signatures::ecdsa::KeygenOutput; use threshold_signatures::ecdsa::Signature; @@ -39,6 +39,9 @@ pub struct RobustEcdsaSignatureProvider { pub(super) struct PerDomainData { pub keyshare: KeygenOutput, pub presignature_store: Arc, + /// Per-domain reconstruction threshold `t`. For robust-ECDSA the signing + /// tolerates `MaxMalicious = t - 1` and requires `2t - 1` signers. + pub reconstruction_threshold: MpcReconstructionThreshold, } #[derive( @@ -53,6 +56,7 @@ impl EcdsaMessageHash { } impl RobustEcdsaSignatureProvider { + #[expect(clippy::too_many_arguments)] pub fn new( config: Arc, mpc_config: Arc, @@ -61,6 +65,7 @@ impl RobustEcdsaSignatureProvider { db: Arc, sign_request_store: Arc, keyshares: HashMap, + reconstruction_thresholds: HashMap, ) -> anyhow::Result { let active_participants_query = { let network_client = client.clone(); @@ -69,6 +74,10 @@ impl RobustEcdsaSignatureProvider { let mut per_domain_data = HashMap::new(); for (domain_id, keyshare) in keyshares { + let reconstruction_threshold = + *reconstruction_thresholds.get(&domain_id).ok_or_else(|| { + anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) + })?; let presignature_store = Arc::new(PresignatureStorage::new( clock.clone(), db.clone(), @@ -81,6 +90,7 @@ impl RobustEcdsaSignatureProvider { PerDomainData { keyshare, presignature_store, + reconstruction_threshold, }, ); } @@ -147,14 +157,11 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - let number_of_participants = channel.participants().len(); - let robust_ecdsa_threshold = - translate_threshold(threshold.value(), number_of_participants)?; - EcdsaSignatureProvider::run_key_generation_client_internal( - ReconstructionThreshold::try_from(robust_ecdsa_threshold)?, - channel, - ) - .await + // robust-ECDSA shares the secret on a degree `MaxMalicious = t - 1` + // polynomial, i.e. reconstruction lower bound `= MaxMalicious + 1 = t`. + // `threshold` is already that lower bound (= the domain's `t`), so the + // keygen is identical to cait-sith — pass it straight through. + EcdsaSignatureProvider::run_key_generation_client_internal(threshold, channel).await } async fn run_key_resharing_client( @@ -164,27 +171,16 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { - let number_of_participants = channel.participants().len(); - let new_robust_ecdsa_threshold = - translate_threshold(new_threshold.value(), number_of_participants)?; - - // This is a bad hack, but cannot think of a better way to solve it, as the struct - // comes directly from generic implementations, so probably this is the best place - // to do so anyway - let mut old_participants_patched = old_participants.clone(); - let old_translated = translate_threshold( - old_participants.threshold.try_into()?, - old_participants.participants.len(), - )?; - old_participants_patched.threshold = ReconstructionThreshold::try_from(old_translated)? - .value() - .try_into()?; - + // Both `new_threshold` and `old_participants.threshold` are already the + // per-domain reconstruction thresholds `t` (the caller patches the old + // one per-key in `resharing_computation_inner`). For robust-ECDSA the + // reconstruction lower bound equals `t`, so resharing is identical to + // cait-sith — delegate straight to the shared internal. EcdsaSignatureProvider::run_key_resharing_client_internal( - ReconstructionThreshold::try_from(new_robust_ecdsa_threshold)?, + new_threshold, my_share, public_key, - &old_participants_patched, + old_participants, channel, ) .await @@ -236,6 +232,7 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { presign::run_background_presignature_generation( self.client.clone(), self.mpc_config.clone(), + data.reconstruction_threshold, self.config.presignature.clone().into(), *domain_id, data.presignature_store.clone(), @@ -254,106 +251,3 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { Ok(()) } } - -/// Although currently the threshold is always equal to the number of signers, if in -/// the future we might want to change that invariant, for example to achieve -/// higher security guarantees for robust-ecdsa. In that case, -/// this function enforces that the number of signers and the threshold -/// computed below in `translate_threshold` stay consistent -pub(super) fn get_number_of_signers( - threshold: usize, - number_of_participants: usize, -) -> anyhow::Result { - anyhow::ensure!( - threshold <= number_of_participants, - "threshold ({threshold}) exceeds number of participants ({number_of_participants})" - ); - Ok(threshold) -} - -/// This function translates the current threshold from the contract -/// to the threshold expected by the robust-ecdsa scheme, which -/// is semantically different. -/// The function should be no longer needed when these issues are solved: -/// https://github.com/near/threshold-signatures/issues/255 -/// https://github.com/near/mpc/issues/1649 -pub(super) fn translate_threshold( - threshold: usize, - number_of_participants: usize, -) -> anyhow::Result { - let number_of_signers = get_number_of_signers(threshold, number_of_participants)?; - anyhow::ensure!( - number_of_signers >= 5, - "Robust ECDSA requires the threshold to be at least 2, which implies that the number of signers needs to be at least 5" - ); - Ok(MaxMalicious::from((number_of_signers - 1) / 2)) -} - -#[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - - // The resulting threshold for robust-ecdsa must always satisfy - // the underlying invariant that 2 * threshold + 1 <= number of signers - #[test] - fn test_translate_threshold() { - let max_size = 30; - for threshold in 5..max_size { - for number_of_participants in threshold..max_size { - let number_of_signers = - get_number_of_signers(threshold, number_of_participants).unwrap(); - let new_threshold = translate_threshold(threshold, number_of_participants) - .unwrap() - .value(); - assert!( - 2 * new_threshold < number_of_signers, - "Failed for threshold={threshold}, number_of_participants={number_of_participants}" - ); - assert!( - new_threshold >= (threshold - 1) / 2, - "The new threshold should not decrease security more than necessary: new_threshold={new_threshold}, threshold={threshold}" - ); - } - } - } - - // Tests that the number of signers is below the threshold, - // guaranteeing that security is not reduced - #[test] - fn test_get_number_of_signers_not_lower_than_threshold() { - let max_size = 30; - for threshold in 5..max_size { - for number_of_participants in threshold..max_size { - let number_of_signers = - get_number_of_signers(threshold, number_of_participants).unwrap(); - assert!( - threshold <= number_of_signers && number_of_signers <= number_of_participants, - "Failed for threshold={threshold}, number_of_participants={number_of_participants}" - ); - } - } - } - - #[rstest] - #[case(0, 10, true, 0)] - #[case(1, 10, true, 0)] - #[case(2, 10, true, 0)] - #[case(3, 10, true, 0)] - #[case(4, 10, true, 0)] - #[case(5, 10, false, 2)] - #[case(6, 10, false, 2)] - #[case(7, 10, false, 3)] - fn test_translate_threshold_special_cases( - #[case] threshold: usize, - #[case] number_of_participants: usize, - #[case] is_err: bool, - #[case] expected_threshold: usize, - ) { - let result = translate_threshold(threshold, number_of_participants); - assert_eq!(result.is_err(), is_err); - if !is_err { - assert_eq!(result.unwrap(), MaxMalicious::from(expected_threshold)); - } - } -} diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index c16f91d253..7903b254ef 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -9,12 +9,12 @@ use crate::primitives::{ParticipantId, UniqueId}; use crate::protocol::run_protocol; use crate::providers::HasParticipants; use crate::providers::robust_ecdsa::{ - KeygenOutput, RobustEcdsaSignatureProvider, RobustEcdsaTaskId, get_number_of_signers, - translate_threshold, + KeygenOutput, RobustEcdsaSignatureProvider, RobustEcdsaTaskId, }; use crate::tracking::AutoAbortTaskCollection; use crate::{metrics, tracking}; use mpc_node_config::PresignatureConfig; +use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; use near_time::Clock; use rand::rngs::OsRng; @@ -66,6 +66,7 @@ impl PresignatureStorage { pub(super) async fn run_background_presignature_generation( client: Arc, mpc_config: Arc, + reconstruction_threshold: ReconstructionThreshold, config: Arc, domain_id: DomainId, presignature_store: Arc, @@ -87,11 +88,8 @@ pub(super) async fn run_background_presignature_generation( .map(|p| p.id) .collect(); - let (num_signers, robust_ecdsa_threshold) = compute_thresholds( - mpc_config.participants.threshold, - running_participants.len(), - ) - .expect("invalid governance threshold for robust-ECDSA"); + let (num_signers, robust_ecdsa_threshold) = compute_thresholds(reconstruction_threshold) + .expect("invalid reconstruction threshold for robust-ECDSA"); loop { progress_tracker.update_progress(); @@ -180,28 +178,22 @@ pub(super) async fn run_background_presignature_generation( } } -/// Computes `(num_signers, robust_ecdsa_threshold)` and validates the -/// `2 * max_malicious + 1 <= num_signers` invariant. Returns an error only if -/// the configured governance threshold is invalid for robust-ECDSA. -/// -/// TODO(#3164): once the node supports per-domain thresholds, this should -/// take the domain-specific threshold instead of the single governance threshold. -fn compute_thresholds( - governance_threshold: u64, - num_running_participants: usize, +/// Derives `(num_signers, max_malicious)` for robust-ECDSA from the domain's +/// reconstruction threshold `t`: the scheme tolerates `MaxMalicious = t - 1` +/// dishonest parties and signs over `num_signers = 2t - 1` participants (the +/// honest-majority bound `2 * max_malicious + 1`). Returns an error if `t < 2`, +/// which the contract's threshold validation already rejects. +pub(super) fn compute_thresholds( + reconstruction_threshold: ReconstructionThreshold, ) -> anyhow::Result<(usize, MaxMalicious)> { - let governance_threshold: usize = governance_threshold.try_into()?; - let num_signers = get_number_of_signers(governance_threshold, num_running_participants)?; - let robust_ecdsa_threshold = - translate_threshold(governance_threshold, num_running_participants)?; + let t: usize = reconstruction_threshold.inner().try_into()?; anyhow::ensure!( - robust_ecdsa_threshold - .value() - .checked_mul(2) - .and_then(|v| v.checked_add(1)) - .is_some_and(|v| v <= num_signers) + t >= 2, + "robust-ECDSA requires a reconstruction threshold of at least 2, got {t}" ); - Ok((num_signers, robust_ecdsa_threshold)) + let max_malicious = MaxMalicious::from(t - 1); + let num_signers = 2 * t - 1; + Ok((num_signers, max_malicious)) } impl RobustEcdsaSignatureProvider { @@ -214,9 +206,8 @@ impl RobustEcdsaSignatureProvider { id.validate_owned_by(channel.sender().get_leader())?; let domain_data = self.domain_data(domain_id)?; - let number_of_participants = self.mpc_config.participants.participants.len(); - let threshold = self.mpc_config.participants.threshold.try_into()?; - let robust_ecdsa_threshold = translate_threshold(threshold, number_of_participants)?; + let (_num_signers, robust_ecdsa_threshold) = + compute_thresholds(domain_data.reconstruction_threshold)?; FollowerPresignComputation { max_malicious: robust_ecdsa_threshold, @@ -341,45 +332,41 @@ impl PresignatureGenerationProgressTracker { #[expect(non_snake_case)] mod tests { use super::compute_thresholds; + use mpc_primitives::ReconstructionThreshold; + use threshold_signatures::MaxMalicious; #[test] - fn compute_thresholds__should_succeed_for_valid_governance_threshold() { - // Given: in the current node, governance threshold == num_participants - let governance_threshold = 5u64; - let num_participants = 5; + fn compute_thresholds__should_map_t_to_2t_minus_1_signers_and_max_malicious_t_minus_1() { + // Given a domain reconstruction threshold t = 3 + let t = ReconstructionThreshold::new(3); // When - let result = compute_thresholds(governance_threshold, num_participants); + let (num_signers, max_malicious) = compute_thresholds(t).unwrap(); - // Then - let (num_signers, robust_ecdsa_threshold) = result.unwrap(); + // Then num_signers = 2t - 1 = 5 and max_malicious = t - 1 = 2 assert_eq!(num_signers, 5); - assert!(2 * robust_ecdsa_threshold.value() < num_signers); + assert_eq!(max_malicious, MaxMalicious::from(2)); + // and the honest-majority invariant 2 * max_malicious + 1 <= num_signers holds. + assert!(2 * max_malicious.value() < num_signers); } #[test] - fn compute_thresholds__should_err_when_governance_threshold_too_small_for_robust_ecdsa() { - // Given: robust-ECDSA requires the governance threshold to be at least 5 - let governance_threshold = 4u64; - let num_participants = 4; - - // When - let result = compute_thresholds(governance_threshold, num_participants); - - // Then - result.unwrap_err(); + fn compute_thresholds__should_hold_invariant_across_valid_thresholds() { + for t in 2..30u64 { + let (num_signers, max_malicious) = + compute_thresholds(ReconstructionThreshold::new(t)).unwrap(); + assert_eq!(num_signers, 2 * (t as usize) - 1); + assert_eq!(max_malicious, MaxMalicious::from((t as usize) - 1)); + assert!(2 * max_malicious.value() < num_signers); + } } #[test] - fn compute_thresholds__should_err_when_governance_threshold_exceeds_participants() { - // Given - let governance_threshold = 8u64; - let num_participants = 5; - - // When - let result = compute_thresholds(governance_threshold, num_participants); - - // Then - result.unwrap_err(); + fn compute_thresholds__should_err_when_threshold_below_two() { + // Given: robust-ECDSA requires a reconstruction threshold of at least 2 + for t in 0..2u64 { + // When / Then + compute_thresholds(ReconstructionThreshold::new(t)).unwrap_err(); + } } } diff --git a/crates/node/src/providers/robust_ecdsa/sign.rs b/crates/node/src/providers/robust_ecdsa/sign.rs index e5440cdcf4..f89be8e368 100644 --- a/crates/node/src/providers/robust_ecdsa/sign.rs +++ b/crates/node/src/providers/robust_ecdsa/sign.rs @@ -5,7 +5,7 @@ use crate::primitives::UniqueId; use crate::protocol::run_protocol; use crate::providers::robust_ecdsa::{ EcdsaMessageHash, KeygenOutput, PresignatureStorage, RobustEcdsaSignatureProvider, - RobustEcdsaTaskId, translate_threshold, + RobustEcdsaTaskId, }; use crate::types::SignatureId; use anyhow::Context; @@ -38,9 +38,8 @@ impl RobustEcdsaSignatureProvider { }, presignature.participants, )?; - let number_of_participants = self.mpc_config.participants.participants.len(); - let threshold = self.mpc_config.participants.threshold.try_into()?; - let robust_ecdsa_threshold = translate_threshold(threshold, number_of_participants)?; + let (_num_signers, robust_ecdsa_threshold) = + super::presign::compute_thresholds(domain_data.reconstruction_threshold)?; let msg_hash = *sign_request .payload @@ -91,9 +90,8 @@ impl RobustEcdsaSignatureProvider { metrics::MPC_NUM_PASSIVE_SIGN_REQUESTS_LOOKUP_SUCCEEDED.inc(); let domain_data = self.domain_data(sign_request.domain)?; - let number_of_participants = self.mpc_config.participants.participants.len(); - let threshold = self.mpc_config.participants.threshold.try_into()?; - let robust_ecdsa_threshold = translate_threshold(threshold, number_of_participants)?; + let (_num_signers, robust_ecdsa_threshold) = + super::presign::compute_thresholds(domain_data.reconstruction_threshold)?; let msg_hash = *sign_request .payload From 158c8385eddcf0a5a81f5d906e1be5bc9d9f5ffc Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:43:16 +0200 Subject: [PATCH 041/121] Fixing problems post conflict --- crates/contract/src/lib.rs | 3 +++ crates/contract/src/state/resharing.rs | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 68f1c7f0b1..b1c2a0340f 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -4034,6 +4034,8 @@ mod tests { #[test] fn vote_new_parameters__should_reject_when_shrinking_below_governance_threshold() { // Given: a Running contract with 4 participants and a GovernanceThreshold of 3. + let (mut contract, participants, signer, _domain_id) = + setup_running_contract_with_domain(4, 3, 3); // ...and a proposal that shrinks the participant set to 2 without touching // the per-domain thresholds. let proposal = ProposedThresholdParameters::new( @@ -4058,6 +4060,7 @@ mod tests { fn vote_new_parameters__should_reject_when_signing_threshold_exceeds_participants() { // Given: a Running contract with 3 participants and one domain. let (mut contract, participants, signer, _domain_id) = + setup_running_contract_with_domain(3, 2, 2); // ...and a proposal whose signing threshold (4) exceeds the participant set. let proposal = ProposedThresholdParameters::new( ThresholdParameters::new_unvalidated(participants, Threshold::new(4)), diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index a622817747..756255c797 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -207,7 +207,7 @@ impl ResharingContractState { } #[cfg(test)] pub mod tests { - use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_participants}; + use crate::primitives::test_utils::NUM_PROTOCOLS; use crate::state::{ key_event::tests::{Environment, find_leader}, running::RunningContractState, @@ -423,8 +423,7 @@ pub mod tests { let new_participants_2 = new_participants_1 .subset(new_participants_1.len() - old_participants.len()..new_participants_1.len()); let new_params_1 = ThresholdParameters::new(new_participants_1, new_threshold).unwrap(); - let new_threshold_2 = Threshold::new((3 * new_participants_2.len() as u64).div_ceil(5)); - let new_params_2 = ThresholdParameters::new(new_participants_2, new_threshold_2).unwrap(); + let new_params_2 = ThresholdParameters::new(new_participants_2, new_threshold).unwrap(); // Proposals carry an empty (no-change) set of per-domain threshold updates. let proposed_1 = ProposedThresholdParameters::new(new_params_1.clone(), BTreeMap::new()); let proposed_2 = ProposedThresholdParameters::new(new_params_2.clone(), BTreeMap::new()); From 6b08b91fb1eff0e81c8453700516b884f5811c0a Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:44:06 +0200 Subject: [PATCH 042/121] fixing running.rs calls --- crates/contract/src/state/running.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index acc36bfb2b..3a8d88221b 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -267,11 +267,8 @@ pub mod running_tests { use super::RunningContractState; use crate::errors::{Error, InvalidThreshold}; use crate::primitives::domain::AddDomainsVotes; - use crate::primitives::test_utils::{ - NUM_PROTOCOLS, gen_participants, gen_proposed_threshold_params, - }; + use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_proposed_threshold_params}; use crate::primitives::threshold_votes::ThresholdParametersVotes; - use crate::primitives::thresholds::{Threshold, ThresholdParameters}; use crate::state::key_event::tests::Environment; use crate::state::test_utils::{ gen_running_state, gen_running_state_with_params, gen_valid_params_proposal, From fa672892c3842c942a9dce006243c5d9cdf41e8e Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:55:32 +0200 Subject: [PATCH 043/121] updating design document --- docs/design/domain-separation.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index ea58d0b5e7..7f3cc6c619 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -4,7 +4,7 @@ The addition of Robust ECDSA (aka DamgardEtAl) invalidates three assumptions in ✗ There is one protocol per curve (now: both CaitSith and DamgardEtAl operate over Secp256k1). -✗ All domains share a single cryptographic threshold. The node already has a `translate_threshold()` hack to bridge this gap. +✗ All domains share a single cryptographic threshold. The node had a `translate_threshold()` hack to bridge this gap. ✗ Governance voting threshold and cryptographic reconstruction threshold are the same value. The threshold of how many participants must vote to change parameters is currently the same `Threshold` value as the cryptographic reconstruction threshold. @@ -92,8 +92,7 @@ Contract ThresholdParameters.threshold (Threshold(u64)) → For DamgardEtAl: translate_threshold() → MaxMalicious::from((n_signers - 1) / 2) ``` -The `translate_threshold()` function in `crates/node/src/providers/robust_ecdsa.rs` is an explicit workaround for the mismatch between the contract's single threshold and DamgardEtAl's `MaxMalicious` semantics. The code itself documents this as a hack: -> "This function translates the current threshold from the contract to the threshold expected by the robust-ecdsa scheme, which is semantically different." +The `translate_threshold()` function in `crates/node/src/providers/robust_ecdsa.rs` was an explicit workaround for the mismatch between the contract's single threshold and DamgardEtAl's `MaxMalicious` semantics. It has since been removed: the node now derives `(num_signers, max_malicious)` from a per-domain `ReconstructionThreshold` via `compute_thresholds()` in `robust_ecdsa/presign.rs`. ### 1.5 Current Curve-Protocol Pairings @@ -584,12 +583,13 @@ fn migrate(old: OldRunningContractState) -> RunningContractState { }; ``` - Coordinator reads per-key `DistributedKeyConfig` from contract state instead of using global threshold. -- Replace `translate_threshold()` hack in `robust_ecdsa.rs` with the `min_active_participants()` helper: +- Replace the `translate_threshold()` hack in `robust_ecdsa.rs` with `compute_thresholds()` in `robust_ecdsa/presign.rs`, which derives `(num_signers, max_malicious)` directly from the domain's `ReconstructionThreshold`: ```rust - // Node computes required active signers from DistributedKeyConfig - let active_signers = min_active_participants(&dk.protocol, &dk.reconstruction_threshold); + // Node derives the robust-ECDSA signer set from the per-domain reconstruction threshold t: + // num_signers = 2t - 1, max_malicious = t - 1 + let (num_signers, max_malicious) = compute_thresholds(dk.reconstruction_threshold)?; ``` - Note: `translate_threshold()` is still needed on the `state()` fallback path (it's effectively moved into the synthetic `DistributedKeyConfig` construction above). It can be fully removed once the old contract is guaranteed gone. + `translate_threshold()` is removed entirely — the per-domain `ReconstructionThreshold` comes from contract state (or the synthetic `DistributedKeyConfig` on the `state()` fallback path), so no threshold translation is needed. - Provider routing uses `Protocol` enum instead of pattern-matching on `SignatureScheme`/`Curve`: ```rust match dk.protocol { From 5c9a3fe2e0f2a799f56798cf8e1e14cda8836be8 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:13:57 +0200 Subject: [PATCH 044/121] Compacting a comment --- crates/node/src/providers/robust_ecdsa/presign.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index 7903b254ef..7b747a6384 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -179,9 +179,7 @@ pub(super) async fn run_background_presignature_generation( } /// Derives `(num_signers, max_malicious)` for robust-ECDSA from the domain's -/// reconstruction threshold `t`: the scheme tolerates `MaxMalicious = t - 1` -/// dishonest parties and signs over `num_signers = 2t - 1` participants (the -/// honest-majority bound `2 * max_malicious + 1`). Returns an error if `t < 2`, +/// reconstruction threshold `t`. Returns an error if `t < 2`, /// which the contract's threshold validation already rejects. pub(super) fn compute_thresholds( reconstruction_threshold: ReconstructionThreshold, From be0b5177723e2b5f13f42fa61bd771b869847e42 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:41:11 +0200 Subject: [PATCH 045/121] reconstruction threshold --- crates/node/src/key_events.rs | 40 +++++++++------ crates/node/src/providers.rs | 4 +- crates/node/src/providers/ckd.rs | 8 +-- .../node/src/providers/ckd/key_generation.rs | 16 +++--- .../node/src/providers/ckd/key_resharing.rs | 22 ++++---- crates/node/src/providers/ckd/sign.rs | 7 +-- crates/node/src/providers/ecdsa.rs | 50 +++++++++++-------- .../src/providers/ecdsa/key_generation.rs | 16 +++--- .../node/src/providers/ecdsa/key_resharing.rs | 22 ++++---- crates/node/src/providers/ecdsa/presign.rs | 26 +++++----- crates/node/src/providers/ecdsa/sign.rs | 22 ++++---- crates/node/src/providers/ecdsa/triple.rs | 47 +++++++++-------- crates/node/src/providers/eddsa.rs | 8 +-- .../src/providers/eddsa/key_generation.rs | 16 +++--- .../node/src/providers/eddsa/key_resharing.rs | 22 ++++---- crates/node/src/providers/eddsa/sign.rs | 20 ++++---- crates/node/src/providers/robust_ecdsa.rs | 24 +++++---- .../node/src/providers/verify_foreign_tx.rs | 4 +- 18 files changed, 211 insertions(+), 163 deletions(-) diff --git a/crates/node/src/key_events.rs b/crates/node/src/key_events.rs index 74950fb9e4..2d932e1ee4 100644 --- a/crates/node/src/key_events.rs +++ b/crates/node/src/key_events.rs @@ -54,7 +54,7 @@ pub async fn keygen_computation_inner( // The reconstruction threshold `t` is the per-domain source of truth. For // every protocol (including robust-ECDSA, whose reconstruction lower bound // equals `t`) the keygen runs with lower bound `t`. - let threshold = + let reconstruction_threshold = ReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); let keyshare_handle = keyshare_storage .write() @@ -68,31 +68,41 @@ pub async fn keygen_computation_inner( let (keyshare, public_key) = match domain.protocol { Protocol::CaitSith => { - let keyshare = - EcdsaSignatureProvider::run_key_generation_client(threshold, channel).await?; + let keyshare = EcdsaSignatureProvider::run_key_generation_client( + reconstruction_threshold, + channel, + ) + .await?; let public_key = dtos::PublicKey::Secp256k1(dtos::Secp256k1PublicKey::try_from( keyshare.public_key.to_element().to_affine(), )?); (KeyshareData::Secp256k1(keyshare), public_key) } Protocol::DamgardEtAl => { - let keyshare = - RobustEcdsaSignatureProvider::run_key_generation_client(threshold, channel).await?; + let keyshare = RobustEcdsaSignatureProvider::run_key_generation_client( + reconstruction_threshold, + channel, + ) + .await?; let public_key = dtos::PublicKey::Secp256k1(dtos::Secp256k1PublicKey::try_from( keyshare.public_key.to_element().to_affine(), )?); (KeyshareData::Secp256k1(keyshare), public_key) } Protocol::Frost => { - let keyshare = - EddsaSignatureProvider::run_key_generation_client(threshold, channel).await?; + let keyshare = EddsaSignatureProvider::run_key_generation_client( + reconstruction_threshold, + channel, + ) + .await?; let public_key = dtos::PublicKey::Ed25519(dtos::Ed25519PublicKey::from( keyshare.public_key.to_element().compress(), )); (KeyshareData::Ed25519(keyshare), public_key) } Protocol::ConfidentialKeyDerivation => { - let keyshare = CKDProvider::run_key_generation_client(threshold, channel).await?; + let keyshare = + CKDProvider::run_key_generation_client(reconstruction_threshold, channel).await?; let public_key = dtos::PublicKey::Bls12381(dtos::Bls12381G2PublicKey::from( &keyshare.public_key.to_element(), )); @@ -199,9 +209,9 @@ async fn resharing_computation_inner( // sees the correct `t` on each side of the transition. For robust-ECDSA the // reconstruction lower bound equals `t`, so no protocol-specific translation // is needed here. - let new_threshold = + let new_reconstruction_threshold = ReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); - let old_threshold = args + let old_reconstruction_threshold = args .old_reconstruction_thresholds .get(&key_id.domain_id) .ok_or_else(|| { @@ -211,7 +221,7 @@ async fn resharing_computation_inner( ) })?; let mut old_participants = args.old_participants.clone(); - old_participants.threshold = old_threshold.inner(); + old_participants.threshold = old_reconstruction_threshold.inner(); let keyshare_handle = keyshare_storage .write() @@ -256,7 +266,7 @@ async fn resharing_computation_inner( }) .transpose()?; let res = EcdsaSignatureProvider::run_key_resharing_client( - new_threshold, + new_reconstruction_threshold, my_share, public_key, &old_participants, @@ -278,7 +288,7 @@ async fn resharing_computation_inner( }) .transpose()?; let res = RobustEcdsaSignatureProvider::run_key_resharing_client( - new_threshold, + new_reconstruction_threshold, my_share, public_key, &old_participants, @@ -299,7 +309,7 @@ async fn resharing_computation_inner( }) .transpose()?; let res = EddsaSignatureProvider::run_key_resharing_client( - new_threshold, + new_reconstruction_threshold, my_share, public_key, &old_participants, @@ -317,7 +327,7 @@ async fn resharing_computation_inner( }) .transpose()?; let res = CKDProvider::run_key_resharing_client( - new_threshold, + new_reconstruction_threshold, my_share, public_key, &old_participants, diff --git a/crates/node/src/providers.rs b/crates/node/src/providers.rs index 3eb654698e..7cd763af09 100644 --- a/crates/node/src/providers.rs +++ b/crates/node/src/providers.rs @@ -51,7 +51,7 @@ pub trait SignatureProvider { /// /// It drains `channel_receiver` until the required task is found, meaning these clients must not be run in parallel. async fn run_key_generation_client( - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result; @@ -59,7 +59,7 @@ pub trait SignatureProvider { /// Both leaders and followers call this function. /// It drains `channel_receiver` until the required task is found, meaning these clients must not be run in parallel. async fn run_key_resharing_client( - new_threshold: ReconstructionThreshold, + new_reconstruction_threshold: ReconstructionThreshold, key_share: Option, public_key: Self::PublicKey, old_participants: &ParticipantsConfig, diff --git a/crates/node/src/providers/ckd.rs b/crates/node/src/providers/ckd.rs index 80b16d6a10..3bcf172c24 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -109,21 +109,21 @@ impl SignatureProvider for CKDProvider { } async fn run_key_generation_client( - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - Self::run_key_generation_client_internal(threshold, channel).await + Self::run_key_generation_client_internal(reconstruction_threshold, channel).await } async fn run_key_resharing_client( - new_threshold: ReconstructionThreshold, + new_reconstruction_threshold: ReconstructionThreshold, key_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { Self::run_key_resharing_client_internal( - new_threshold, + new_reconstruction_threshold, key_share, public_key, old_participants, diff --git a/crates/node/src/providers/ckd/key_generation.rs b/crates/node/src/providers/ckd/key_generation.rs index 88aa5a3e94..1c3f723532 100644 --- a/crates/node/src/providers/ckd/key_generation.rs +++ b/crates/node/src/providers/ckd/key_generation.rs @@ -10,12 +10,14 @@ use threshold_signatures::participants::Participant; impl CKDProvider { pub(super) async fn run_key_generation_client_internal( - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - let key = KeyGenerationComputation { threshold } - .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) - .await?; + let key = KeyGenerationComputation { + reconstruction_threshold, + } + .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) + .await?; tracing::info!("CKD key generation completed"); Ok(key) @@ -25,7 +27,7 @@ impl CKDProvider { /// Runs the key generation protocol, returning the key generated. /// This protocol is identical for the leader and the followers. pub struct KeyGenerationComputation { - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, } #[async_trait::async_trait] @@ -41,7 +43,7 @@ impl MpcLeaderCentricComputation for KeyGenerationComputation { let protocol = threshold_signatures::keygen::( &cs_participants, me.into(), - self.threshold, + self.reconstruction_threshold, OsRng, )?; run_protocol("CKD key generation", channel, protocol).await @@ -120,7 +122,7 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyGenerationComputation { - threshold: ReconstructionThreshold::from(3), + reconstruction_threshold: ReconstructionThreshold::from(3), } .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) .await?; diff --git a/crates/node/src/providers/ckd/key_resharing.rs b/crates/node/src/providers/ckd/key_resharing.rs index 19d02716d7..b14e8f568f 100644 --- a/crates/node/src/providers/ckd/key_resharing.rs +++ b/crates/node/src/providers/ckd/key_resharing.rs @@ -13,17 +13,19 @@ use threshold_signatures::participants::Participant; impl CKDProvider { pub(super) async fn run_key_resharing_client_internal( - new_threshold: ReconstructionThreshold, + new_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { - let old_threshold: usize = old_participants.threshold.try_into()?; + let old_reconstruction_threshold: usize = old_participants.threshold.try_into()?; let new_keyshare = KeyResharingComputation { - threshold: new_threshold, + reconstruction_threshold: new_reconstruction_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_threshold: ReconstructionThreshold::from(old_threshold), + old_reconstruction_threshold: ReconstructionThreshold::from( + old_reconstruction_threshold, + ), my_share, public_key, } @@ -49,9 +51,9 @@ impl CKDProvider { /// the old threshold; or /// - the threshold is larger than the number of participants. pub struct KeyResharingComputation { - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, old_participants: Vec, - old_threshold: ReconstructionThreshold, + old_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, } @@ -75,11 +77,11 @@ impl MpcLeaderCentricComputation for KeyResharingComputation { let protocol = threshold_signatures::reshare::( &old_participants, - self.old_threshold, + self.old_reconstruction_threshold, self.my_share, self.public_key, &new_participants, - self.threshold, + self.reconstruction_threshold, me.into(), OsRng, )?; @@ -155,9 +157,9 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyResharingComputation { - threshold: ReconstructionThreshold::from(THRESHOLD), + reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), old_participants, - old_threshold: ReconstructionThreshold::from(THRESHOLD), + old_reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), my_share: keyshare, public_key: pubkey, } diff --git a/crates/node/src/providers/ckd/sign.rs b/crates/node/src/providers/ckd/sign.rs index 4686bfeb78..b3ade12949 100644 --- a/crates/node/src/providers/ckd/sign.rs +++ b/crates/node/src/providers/ckd/sign.rs @@ -30,8 +30,9 @@ impl CKDProvider { let ckd_request = self.ckd_request_store.get(id).await?; let domain_data = self.domain_data(ckd_request.domain_id)?; - let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; - let threshold = ReconstructionThreshold::from(threshold); + let reconstruction_threshold: usize = + domain_data.reconstruction_threshold.inner().try_into()?; + let reconstruction_threshold = ReconstructionThreshold::from(reconstruction_threshold); let running_participants: Vec<_> = self .mpc_config .participants @@ -43,7 +44,7 @@ impl CKDProvider { let participants = self .client .select_random_active_participants_including_me( - threshold.value(), + reconstruction_threshold.value(), &running_participants, ) .context("Could not choose active participants for a ckd")?; diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index c684675973..63cb7c94b0 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -139,17 +139,21 @@ impl EcdsaSignatureProvider { /// follower protocol with an unexpected `t`). pub(super) fn triple_store_for_t( &self, - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, ) -> anyhow::Result> { - self.triple_stores.get(&threshold).cloned().ok_or_else(|| { - let mut configured: Vec = self.triple_stores.keys().map(|t| t.inner()).collect(); - configured.sort(); - anyhow::anyhow!( - "No triple store configured for t = {} (configured: {:?})", - threshold.inner(), - configured, - ) - }) + self.triple_stores + .get(&reconstruction_threshold) + .cloned() + .ok_or_else(|| { + let mut configured: Vec = + self.triple_stores.keys().map(|t| t.inner()).collect(); + configured.sort(); + anyhow::anyhow!( + "No triple store configured for t = {} (configured: {:?})", + reconstruction_threshold.inner(), + configured, + ) + }) } pub(super) fn new_channel_for_task( @@ -208,21 +212,25 @@ impl SignatureProvider for EcdsaSignatureProvider { } async fn run_key_generation_client( - threshold: TSReconstructionThreshold, + reconstruction_threshold: TSReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - EcdsaSignatureProvider::run_key_generation_client_internal(threshold, channel).await + EcdsaSignatureProvider::run_key_generation_client_internal( + reconstruction_threshold, + channel, + ) + .await } async fn run_key_resharing_client( - new_threshold: TSReconstructionThreshold, + new_reconstruction_threshold: TSReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { EcdsaSignatureProvider::run_key_resharing_client_internal( - new_threshold, + new_reconstruction_threshold, my_share, public_key, old_participants, @@ -286,8 +294,9 @@ impl SignatureProvider for EcdsaSignatureProvider { // by a generator running at its own threshold. let mut generate_triples = Vec::new(); for (&t, triple_store) in &self.triple_stores { - let threshold_usize: usize = t.inner().try_into()?; - let threshold_bound = TSReconstructionThreshold::from(threshold_usize); + let reconstruction_threshold_usize: usize = t.inner().try_into()?; + let reconstruction_threshold_bound = + TSReconstructionThreshold::from(reconstruction_threshold_usize); generate_triples.push(tracking::spawn( &format!("generate triples for t={}", t.inner()), Self::run_background_triple_generation( @@ -295,7 +304,7 @@ impl SignatureProvider for EcdsaSignatureProvider { self.mpc_config.clone(), self.config.triple.clone().into(), triple_store.clone(), - threshold_bound, + reconstruction_threshold_bound, ), )); } @@ -303,14 +312,15 @@ impl SignatureProvider for EcdsaSignatureProvider { let mut generate_presignatures = Vec::new(); for (domain_id, data) in &self.per_domain_data { let t = data.reconstruction_threshold; - let threshold_usize: usize = t.inner().try_into()?; - let threshold_bound = TSReconstructionThreshold::from(threshold_usize); + let reconstruction_threshold_usize: usize = t.inner().try_into()?; + let reconstruction_threshold_bound = + TSReconstructionThreshold::from(reconstruction_threshold_usize); let triple_store = self.triple_store_for_t(t)?; generate_presignatures.push(tracking::spawn( &format!("generate presignatures for domain {}", domain_id.0), Self::run_background_presignature_generation( self.client.clone(), - threshold_bound, + reconstruction_threshold_bound, self.config.presignature.clone().into(), triple_store, *domain_id, diff --git a/crates/node/src/providers/ecdsa/key_generation.rs b/crates/node/src/providers/ecdsa/key_generation.rs index 4e26044d77..65708932a6 100644 --- a/crates/node/src/providers/ecdsa/key_generation.rs +++ b/crates/node/src/providers/ecdsa/key_generation.rs @@ -9,12 +9,14 @@ use threshold_signatures::participants::Participant; impl EcdsaSignatureProvider { pub(crate) async fn run_key_generation_client_internal( - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - let key = KeyGenerationComputation { threshold } - .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) - .await?; + let key = KeyGenerationComputation { + reconstruction_threshold, + } + .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) + .await?; tracing::info!("Ecdsa secp256k1 key generation completed"); Ok(key) @@ -24,7 +26,7 @@ impl EcdsaSignatureProvider { /// Runs the key generation protocol, returning the key generated. /// This protocol is identical for the leader and the followers. pub struct KeyGenerationComputation { - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, } #[async_trait::async_trait] @@ -40,7 +42,7 @@ impl MpcLeaderCentricComputation for KeyGenerationComputation { let protocol = threshold_signatures::keygen::( &cs_participants, me.into(), - self.threshold, + self.reconstruction_threshold, OsRng, )?; run_protocol("ecdsa key generation", channel, protocol).await @@ -106,7 +108,7 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyGenerationComputation { - threshold: ReconstructionThreshold::from(3), + reconstruction_threshold: ReconstructionThreshold::from(3), } .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) .await?; diff --git a/crates/node/src/providers/ecdsa/key_resharing.rs b/crates/node/src/providers/ecdsa/key_resharing.rs index 72b6c92539..0e4211e80f 100644 --- a/crates/node/src/providers/ecdsa/key_resharing.rs +++ b/crates/node/src/providers/ecdsa/key_resharing.rs @@ -12,17 +12,19 @@ use threshold_signatures::participants::Participant; impl EcdsaSignatureProvider { pub(crate) async fn run_key_resharing_client_internal( - new_threshold: ReconstructionThreshold, + new_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { - let old_threshold: usize = old_participants.threshold.try_into()?; + let old_reconstruction_threshold: usize = old_participants.threshold.try_into()?; let new_keyshare = KeyResharingComputation { - threshold: new_threshold, + reconstruction_threshold: new_reconstruction_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_threshold: ReconstructionThreshold::from(old_threshold), + old_reconstruction_threshold: ReconstructionThreshold::from( + old_reconstruction_threshold, + ), my_share, public_key, } @@ -48,9 +50,9 @@ impl EcdsaSignatureProvider { /// the old threshold; or /// - the threshold is larger than the number of participants. pub struct KeyResharingComputation { - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, old_participants: Vec, - old_threshold: ReconstructionThreshold, + old_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, } @@ -74,11 +76,11 @@ impl MpcLeaderCentricComputation for KeyResharingComputation { let protocol = threshold_signatures::reshare::( &old_participants, - self.old_threshold, + self.old_reconstruction_threshold, self.my_share, self.public_key, &new_participants, - self.threshold, + self.reconstruction_threshold, me.into(), OsRng, )?; @@ -150,9 +152,9 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyResharingComputation { - threshold: ReconstructionThreshold::from(THRESHOLD), + reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), old_participants, - old_threshold: ReconstructionThreshold::from(THRESHOLD), + old_reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), my_share: keyshare, public_key: pubkey, } diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index a31b62bc50..a039fb4e6f 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -65,7 +65,7 @@ impl EcdsaSignatureProvider { /// so that needs to be separately handled. pub(super) async fn run_background_presignature_generation( client: Arc, - threshold: TSReconstructionThreshold, + reconstruction_threshold: TSReconstructionThreshold, config: Arc, triple_store: Arc, domain_id: DomainId, @@ -131,7 +131,7 @@ impl EcdsaSignatureProvider { let _in_flight = in_flight; let _semaphore_guard = parallelism_limiter.acquire().await?; let presignature = PresignComputation { - threshold, + reconstruction_threshold, triple0, triple1, keygen_out, @@ -181,17 +181,19 @@ impl EcdsaSignatureProvider { // The triple store is keyed by the domain's reconstruction threshold // `t`. For cait-sith the leader pairs exactly `t` participants, so the // channel participant count must match — cross-check it. - let threshold = domain_data.reconstruction_threshold; - let threshold_usize: usize = threshold.inner().try_into()?; + let reconstruction_threshold = domain_data.reconstruction_threshold; + let reconstruction_threshold_usize: usize = reconstruction_threshold.inner().try_into()?; anyhow::ensure!( - channel.participants().len() == threshold_usize, + channel.participants().len() == reconstruction_threshold_usize, "presign participant count ({}) does not match domain threshold t={}", channel.participants().len(), - threshold_usize, + reconstruction_threshold_usize, ); - let triple_store = self.triple_store_for_t(threshold)?; + let triple_store = self.triple_store_for_t(reconstruction_threshold)?; FollowerPresignComputation { - threshold: TSReconstructionThreshold::from(threshold_usize), + reconstruction_threshold: TSReconstructionThreshold::from( + reconstruction_threshold_usize, + ), keygen_out: domain_data.keyshare, triple_store, paired_triple_id, @@ -225,7 +227,7 @@ impl HasParticipants for PresignOutputWithParticipants { /// Performs an MPC presignature operation. This is shared for the initiator /// and for passive participants. pub struct PresignComputation { - threshold: TSReconstructionThreshold, + reconstruction_threshold: TSReconstructionThreshold, triple0: TripleGenerationOutput, triple1: TripleGenerationOutput, keygen_out: KeygenOutput, @@ -248,7 +250,7 @@ impl MpcLeaderCentricComputation for PresignComputation { triple0: self.triple0, triple1: self.triple1, keygen_out: self.keygen_out, - threshold: self.threshold, + threshold: self.reconstruction_threshold, }, )?; let _timer = metrics::MPC_PRE_SIGNATURE_TIME_ELAPSED.start_timer(); @@ -265,7 +267,7 @@ impl MpcLeaderCentricComputation for PresignComputation { /// The difference is: we need to read the triples from the triple store (which may fail), /// and we need to write the presignature to the presignature store before completing. pub struct FollowerPresignComputation { - pub threshold: TSReconstructionThreshold, + pub reconstruction_threshold: TSReconstructionThreshold, pub paired_triple_id: UniqueId, pub keygen_out: KeygenOutput, pub triple_store: Arc, @@ -279,7 +281,7 @@ impl MpcLeaderCentricComputation<()> for FollowerPresignComputation { async fn compute(self, channel: &mut NetworkTaskChannel) -> anyhow::Result<()> { let (triple0, triple1) = self.triple_store.take_unowned(self.paired_triple_id)?; let presignature = PresignComputation { - threshold: self.threshold, + reconstruction_threshold: self.reconstruction_threshold, triple0, triple1, keygen_out: self.keygen_out, diff --git a/crates/node/src/providers/ecdsa/sign.rs b/crates/node/src/providers/ecdsa/sign.rs index b18f99d345..b688684378 100644 --- a/crates/node/src/providers/ecdsa/sign.rs +++ b/crates/node/src/providers/ecdsa/sign.rs @@ -31,12 +31,13 @@ impl EcdsaSignatureProvider { ) -> anyhow::Result<(Signature, VerifyingKey)> { let domain_data = self.domain_data(sign_request.domain)?; let participants = presignature.participants.clone(); - let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; - let threshold = ReconstructionThreshold::from(threshold); + let reconstruction_threshold: usize = + domain_data.reconstruction_threshold.inner().try_into()?; + let reconstruction_threshold = ReconstructionThreshold::from(reconstruction_threshold); let (signature, public_key) = SignComputation { keygen_out: domain_data.keyshare, - threshold, + reconstruction_threshold, presign_out: presignature.presignature, msg_hash: *sign_request .payload @@ -92,13 +93,14 @@ impl EcdsaSignatureProvider { // The presignature must be owned by the leader, never one of ours. presignature_id.validate_owned_by(channel.sender().get_leader())?; let domain_data = self.domain_data(sign_request.domain)?; - let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; - let threshold = ReconstructionThreshold::from(threshold); + let reconstruction_threshold: usize = + domain_data.reconstruction_threshold.inner().try_into()?; + let reconstruction_threshold = ReconstructionThreshold::from(reconstruction_threshold); let participants = channel.participants().to_vec(); FollowerSignComputation { keygen_out: domain_data.keyshare, - threshold, + reconstruction_threshold, presignature_store: domain_data.presignature_store.clone(), presignature_id, msg_hash: *sign_request @@ -149,7 +151,7 @@ impl EcdsaSignatureProvider { /// The tweak allows key derivation pub struct SignComputation { pub keygen_out: KeygenOutput, - pub threshold: ReconstructionThreshold, + pub reconstruction_threshold: ReconstructionThreshold, pub presign_out: PresignOutput, pub msg_hash: [u8; 32], pub tweak: Tweak, @@ -198,7 +200,7 @@ impl MpcLeaderCentricComputation<(SignatureOption, VerifyingKey)> for SignComput let protocol = threshold_signatures::ecdsa::ot_based_ecdsa::sign::sign( &cs_participants, channel.sender().get_leader().into(), - self.threshold, + self.reconstruction_threshold, channel.my_participant_id().into(), derived_public_key, rerandomized_presignature, @@ -218,7 +220,7 @@ impl MpcLeaderCentricComputation<(SignatureOption, VerifyingKey)> for SignComput /// The difference is that the follower needs to look up the presignature, which may fail. pub struct FollowerSignComputation { pub keygen_out: KeygenOutput, - pub threshold: ReconstructionThreshold, + pub reconstruction_threshold: ReconstructionThreshold, pub presignature_id: UniqueId, pub presignature_store: Arc, pub msg_hash: [u8; 32], @@ -235,7 +237,7 @@ impl MpcLeaderCentricComputation<()> for FollowerSignComputation { .presignature; SignComputation { keygen_out: self.keygen_out, - threshold: self.threshold, + reconstruction_threshold: self.reconstruction_threshold, presign_out, msg_hash: self.msg_hash, tweak: self.tweak, diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index d810d24a87..15c8056656 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -36,13 +36,13 @@ impl TripleStorage { db: Arc, my_participant_id: ParticipantId, alive_participant_ids_query: Arc Vec + Send + Sync>, - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, ) -> anyhow::Result { Ok(Self(DistributedAssetStorage::::new( clock, db, DBCol::TripleV2, - threshold.inner().to_be_bytes().to_vec(), + reconstruction_threshold.inner().to_be_bytes().to_vec(), my_participant_id, |participants, pair| pair.is_subset_of_active_participants(participants), alive_participant_ids_query, @@ -74,7 +74,7 @@ impl EcdsaSignatureProvider { mpc_config: Arc, config: Arc, triple_store: Arc, - threshold: TSReconstructionThreshold, + reconstruction_threshold: TSReconstructionThreshold, ) -> ! { let in_flight_generations = InFlightGenerationTracker::new(); let parallelism_limiter = Arc::new(tokio::sync::Semaphore::new(config.concurrency)); @@ -107,7 +107,7 @@ impl EcdsaSignatureProvider { < config.concurrency * 2 * SUPPORTED_TRIPLE_GENERATION_BATCH_SIZE { let participants = match client.select_random_active_participants_including_me( - threshold.value(), + reconstruction_threshold.value(), &running_participants, ) { Ok(participants) => participants, @@ -154,7 +154,7 @@ impl EcdsaSignatureProvider { let triples = (ManyTripleGenerationComputation::< SUPPORTED_TRIPLE_GENERATION_BATCH_SIZE, > { - threshold, + reconstruction_threshold, }) .perform_leader_centric_computation( channel, @@ -208,11 +208,14 @@ impl EcdsaSignatureProvider { // Cait-sith triple generation runs with exactly `t` participants, so we // can derive the store's `t` from the channel's participant list // without a wire-format change to `EcdsaTaskId::ManyTriples`. - let threshold_usize: usize = channel.participants().len(); - let threshold = ReconstructionThreshold::new(threshold_usize.try_into()?); - let triple_store = self.triple_store_for_t(threshold)?; + let reconstruction_threshold_usize: usize = channel.participants().len(); + let reconstruction_threshold = + ReconstructionThreshold::new(reconstruction_threshold_usize.try_into()?); + let triple_store = self.triple_store_for_t(reconstruction_threshold)?; FollowerManyTripleGenerationComputation:: { - threshold: TSReconstructionThreshold::from(threshold_usize), + reconstruction_threshold: TSReconstructionThreshold::from( + reconstruction_threshold_usize, + ), out_triple_id_start: start, out_triple_store: triple_store, } @@ -240,7 +243,7 @@ impl HasParticipants for PairedTriple { /// Generates many cait-sith triples at once. This can significantly save the /// *number* of network messages. pub struct ManyTripleGenerationComputation { - pub threshold: TSReconstructionThreshold, + pub reconstruction_threshold: TSReconstructionThreshold, } #[async_trait::async_trait] @@ -260,11 +263,13 @@ impl MpcLeaderCentricComputation> .map(Participant::from) .collect::>(); let me = channel.my_participant_id(); - let protocol = threshold_signatures::ecdsa::ot_based_ecdsa::triples::generate_triple_many::< - N, - _, - _, - >(&cs_participants, me.into(), self.threshold, OsRng)?; + let protocol = + threshold_signatures::ecdsa::ot_based_ecdsa::triples::generate_triple_many::( + &cs_participants, + me.into(), + self.reconstruction_threshold, + OsRng, + )?; let _timer = metrics::MPC_TRIPLES_GENERATION_TIME_ELAPSED.start_timer(); let triples = run_protocol("many triple gen", channel, protocol).await?; metrics::MPC_NUM_TRIPLES_GENERATED.inc_by(N as u64); @@ -288,7 +293,7 @@ impl MpcLeaderCentricComputation> /// The follower version of the triple generation. The difference is that the follower will only /// complete the computation after successfully persisting the triples to storage. pub struct FollowerManyTripleGenerationComputation { - pub threshold: TSReconstructionThreshold, + pub reconstruction_threshold: TSReconstructionThreshold, pub out_triple_store: Arc, pub out_triple_id_start: UniqueId, } @@ -299,7 +304,7 @@ impl MpcLeaderCentricComputation<()> { async fn compute(self, channel: &mut NetworkTaskChannel) -> anyhow::Result<()> { let triples = ManyTripleGenerationComputation:: { - threshold: self.threshold, + reconstruction_threshold: self.reconstruction_threshold, } .compute(channel) .await?; @@ -405,7 +410,7 @@ mod tests { panic!("Unexpected task id"); }; let triples = ManyTripleGenerationComputation:: { - threshold: TSReconstructionThreshold::from(THRESHOLD), + reconstruction_threshold: TSReconstructionThreshold::from(THRESHOLD), } .perform_leader_centric_computation( channel, @@ -453,7 +458,7 @@ mod tests { let result = tracking::spawn( &format!("task {:?}", task_id), ManyTripleGenerationComputation:: { - threshold: TSReconstructionThreshold::from(THRESHOLD), + reconstruction_threshold: TSReconstructionThreshold::from(THRESHOLD), } .perform_leader_centric_computation( channel, @@ -488,7 +493,7 @@ mod tests { fn new_triple_store( db: Arc, my_participant_id: ParticipantId, - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, alive: Vec, ) -> TripleStorage { TripleStorage::new( @@ -496,7 +501,7 @@ mod tests { db, my_participant_id, Arc::new(move || alive.clone()), - threshold, + reconstruction_threshold, ) .unwrap() } diff --git a/crates/node/src/providers/eddsa.rs b/crates/node/src/providers/eddsa.rs index 64753e6c01..311965243b 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -115,21 +115,21 @@ impl SignatureProvider for EddsaSignatureProvider { } async fn run_key_generation_client( - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - Self::run_key_generation_client_internal(threshold, channel).await + Self::run_key_generation_client_internal(reconstruction_threshold, channel).await } async fn run_key_resharing_client( - new_threshold: ReconstructionThreshold, + new_reconstruction_threshold: ReconstructionThreshold, key_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { Self::run_key_resharing_client_internal( - new_threshold, + new_reconstruction_threshold, key_share, public_key, old_participants, diff --git a/crates/node/src/providers/eddsa/key_generation.rs b/crates/node/src/providers/eddsa/key_generation.rs index e4cd856487..04c963b676 100644 --- a/crates/node/src/providers/eddsa/key_generation.rs +++ b/crates/node/src/providers/eddsa/key_generation.rs @@ -10,12 +10,14 @@ use threshold_signatures::participants::Participant; impl EddsaSignatureProvider { pub(super) async fn run_key_generation_client_internal( - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - let key = KeyGenerationComputation { threshold } - .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) - .await?; + let key = KeyGenerationComputation { + reconstruction_threshold, + } + .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) + .await?; tracing::info!("Eddsa key generation completed"); Ok(key) @@ -25,7 +27,7 @@ impl EddsaSignatureProvider { /// Runs the key generation protocol, returning the key generated. /// This protocol is identical for the leader and the followers. pub struct KeyGenerationComputation { - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, } #[async_trait::async_trait] @@ -41,7 +43,7 @@ impl MpcLeaderCentricComputation for KeyGenerationComputation { let protocol = threshold_signatures::keygen::( &cs_participants, me.into(), - self.threshold, + self.reconstruction_threshold, OsRng, )?; run_protocol("eddsa key generation", channel, protocol).await @@ -108,7 +110,7 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyGenerationComputation { - threshold: ReconstructionThreshold::from(3), + reconstruction_threshold: ReconstructionThreshold::from(3), } .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) .await?; diff --git a/crates/node/src/providers/eddsa/key_resharing.rs b/crates/node/src/providers/eddsa/key_resharing.rs index 6f40e55559..84d4c61898 100644 --- a/crates/node/src/providers/eddsa/key_resharing.rs +++ b/crates/node/src/providers/eddsa/key_resharing.rs @@ -13,17 +13,19 @@ use threshold_signatures::participants::Participant; impl EddsaSignatureProvider { pub(super) async fn run_key_resharing_client_internal( - new_threshold: ReconstructionThreshold, + new_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { - let old_threshold: usize = old_participants.threshold.try_into()?; + let old_reconstruction_threshold: usize = old_participants.threshold.try_into()?; let new_keyshare = KeyResharingComputation { - threshold: new_threshold, + reconstruction_threshold: new_reconstruction_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_threshold: ReconstructionThreshold::from(old_threshold), + old_reconstruction_threshold: ReconstructionThreshold::from( + old_reconstruction_threshold, + ), my_share, public_key, } @@ -49,9 +51,9 @@ impl EddsaSignatureProvider { /// the old threshold; or /// - the threshold is larger than the number of participants. pub struct KeyResharingComputation { - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, old_participants: Vec, - old_threshold: ReconstructionThreshold, + old_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, } @@ -75,11 +77,11 @@ impl MpcLeaderCentricComputation for KeyResharingComputation { let protocol = threshold_signatures::reshare::( &old_participants, - self.old_threshold, + self.old_reconstruction_threshold, self.my_share, self.public_key, &new_participants, - self.threshold, + self.reconstruction_threshold, me.into(), OsRng, )?; @@ -152,9 +154,9 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyResharingComputation { - threshold: ReconstructionThreshold::from(THRESHOLD), + reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), old_participants, - old_threshold: ReconstructionThreshold::from(THRESHOLD), + old_reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), my_share: keyshare, public_key: pubkey, } diff --git a/crates/node/src/providers/eddsa/sign.rs b/crates/node/src/providers/eddsa/sign.rs index bc5c2d2230..c198dcc936 100644 --- a/crates/node/src/providers/eddsa/sign.rs +++ b/crates/node/src/providers/eddsa/sign.rs @@ -25,8 +25,9 @@ impl EddsaSignatureProvider { let sign_request = self.sign_request_store.get(id).await?; let domain_data = self.domain_data(sign_request.domain)?; - let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; - let threshold = ReconstructionThreshold::from(threshold); + let reconstruction_threshold: usize = + domain_data.reconstruction_threshold.inner().try_into()?; + let reconstruction_threshold = ReconstructionThreshold::from(reconstruction_threshold); let running_participants: Vec<_> = self .mpc_config .participants @@ -38,7 +39,7 @@ impl EddsaSignatureProvider { let participants = self .client .select_random_active_participants_including_me( - threshold.value(), + reconstruction_threshold.value(), &running_participants, ) .context("Can't choose active participants for a eddsa signature")?; @@ -49,7 +50,7 @@ impl EddsaSignatureProvider { let result = SignComputation { keygen_output: domain_data.keyshare, - threshold, + reconstruction_threshold, message: sign_request .payload .as_eddsa() @@ -93,13 +94,14 @@ impl EddsaSignatureProvider { metrics::MPC_NUM_PASSIVE_SIGN_REQUESTS_LOOKUP_SUCCEEDED.inc(); let domain_data = self.domain_data(sign_request.domain)?; - let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; - let threshold = ReconstructionThreshold::from(threshold); + let reconstruction_threshold: usize = + domain_data.reconstruction_threshold.inner().try_into()?; + let reconstruction_threshold = ReconstructionThreshold::from(reconstruction_threshold); let participants = channel.participants().to_vec(); let _ = SignComputation { keygen_output: domain_data.keyshare, - threshold, + reconstruction_threshold, message: sign_request .payload .as_eddsa() @@ -131,7 +133,7 @@ impl EddsaSignatureProvider { /// The tweak allows key derivation pub struct SignComputation { pub keygen_output: KeygenOutput, - pub threshold: ReconstructionThreshold, + pub reconstruction_threshold: ReconstructionThreshold, pub message: Vec, pub tweak: Tweak, } @@ -158,7 +160,7 @@ impl MpcLeaderCentricComputation> for SignComp let protocol = sign( cs_participants.as_slice(), - self.threshold, + self.reconstruction_threshold, channel.my_participant_id().into(), channel.sender().get_leader().into(), derived_keygen_output.clone(), diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 80f4d42d52..99d68c4106 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -154,30 +154,34 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { } async fn run_key_generation_client( - threshold: ReconstructionThreshold, + reconstruction_threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { // robust-ECDSA shares the secret on a degree `MaxMalicious = t - 1` // polynomial, i.e. reconstruction lower bound `= MaxMalicious + 1 = t`. - // `threshold` is already that lower bound (= the domain's `t`), so the - // keygen is identical to cait-sith — pass it straight through. - EcdsaSignatureProvider::run_key_generation_client_internal(threshold, channel).await + // `reconstruction_threshold` is already that lower bound (= the domain's + // `t`), so the keygen is identical to cait-sith — pass it straight through. + EcdsaSignatureProvider::run_key_generation_client_internal( + reconstruction_threshold, + channel, + ) + .await } async fn run_key_resharing_client( - new_threshold: ReconstructionThreshold, + new_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { - // Both `new_threshold` and `old_participants.threshold` are already the - // per-domain reconstruction thresholds `t` (the caller patches the old - // one per-key in `resharing_computation_inner`). For robust-ECDSA the - // reconstruction lower bound equals `t`, so resharing is identical to + // Both `new_reconstruction_threshold` and `old_participants.threshold` are + // already the per-domain reconstruction thresholds `t` (the caller patches + // the old one per-key in `resharing_computation_inner`). For robust-ECDSA + // the reconstruction lower bound equals `t`, so resharing is identical to // cait-sith — delegate straight to the shared internal. EcdsaSignatureProvider::run_key_resharing_client_internal( - new_threshold, + new_reconstruction_threshold, my_share, public_key, old_participants, diff --git a/crates/node/src/providers/verify_foreign_tx.rs b/crates/node/src/providers/verify_foreign_tx.rs index b1857127c8..96e33b905b 100644 --- a/crates/node/src/providers/verify_foreign_tx.rs +++ b/crates/node/src/providers/verify_foreign_tx.rs @@ -186,7 +186,7 @@ where } async fn run_key_generation_client( - _threshold: ReconstructionThreshold, + _reconstruction_threshold: ReconstructionThreshold, _channel: NetworkTaskChannel, ) -> anyhow::Result { anyhow::bail!( @@ -195,7 +195,7 @@ where } async fn run_key_resharing_client( - _new_threshold: ReconstructionThreshold, + _new_reconstruction_threshold: ReconstructionThreshold, _key_share: Option, _public_key: VerifyingKey, _old_participants: &ParticipantsConfig, From db5692e9f4a2f7231809627c8ef613044184ad63 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:45:43 +0200 Subject: [PATCH 046/121] more reconstruction threshold --- crates/node/src/assets/test_utils.rs | 15 +++++++++------ crates/node/src/coordinator.rs | 19 ++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/crates/node/src/assets/test_utils.rs b/crates/node/src/assets/test_utils.rs index 2e63aee8f3..f3f1c6a313 100644 --- a/crates/node/src/assets/test_utils.rs +++ b/crates/node/src/assets/test_utils.rs @@ -43,13 +43,16 @@ pub fn triple_v2_key(t: ReconstructionThreshold, id: UniqueId) -> Vec { } /// Generates a 4-participant test fixture with threshold 3. Returns the epoch -/// data, the local participant's ID, and the threshold so callers don't have -/// to restate the magic number alongside the fixture. +/// data, the local participant's ID, and the reconstruction threshold so +/// callers don't have to restate the magic number alongside the fixture. pub fn gen_four_participants() -> (EpochData, ParticipantId, ReconstructionThreshold) { - let threshold = ReconstructionThreshold::new(3); + let reconstruction_threshold = ReconstructionThreshold::new(3); let epoch_id = EpochId::new(rand::thread_rng().next_u64()); - let parameters = - ThresholdParameters::new(gen_participants(4), Threshold::new(threshold.inner())).unwrap(); + let parameters = ThresholdParameters::new( + gen_participants(4), + Threshold::new(reconstruction_threshold.inner()), + ) + .unwrap(); let parameters_dto: near_mpc_contract_interface::types::ThresholdParameters = parameters.into(); let participants: ParticipantsConfig = convert_participant_infos(parameters_dto, None).unwrap(); let epoch_data = EpochData { @@ -57,7 +60,7 @@ pub fn gen_four_participants() -> (EpochData, ParticipantId, ReconstructionThres participants, }; let my_participant_id = epoch_data.participants.participants.first().unwrap().id; - (epoch_data, my_participant_id, threshold) + (epoch_data, my_participant_id, reconstruction_threshold) } pub fn get_participant_ids(epoch_data: EpochData) -> Vec { diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index 2fa246df6e..a3edf0e0f5 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -608,11 +608,12 @@ where .iter() .map(|d| (d.id, d.protocol)) .collect(); - let domain_to_threshold: HashMap = running_state - .domains - .iter() - .map(|d| (d.id, d.reconstruction_threshold)) - .collect(); + let domain_to_reconstruction_threshold: HashMap = + running_state + .domains + .iter() + .map(|d| (d.id, d.reconstruction_threshold)) + .collect(); for keyshare in keyshares { let domain_id = keyshare.key_id.domain_id; @@ -658,7 +659,7 @@ where secret_db.clone(), sign_request_store.clone(), ecdsa_keyshares, - domain_to_threshold.clone(), + domain_to_reconstruction_threshold.clone(), )?); let robust_ecdsa_signature_provider = Arc::new(RobustEcdsaSignatureProvider::new( @@ -669,7 +670,7 @@ where secret_db, sign_request_store.clone(), robust_ecdsa_keyshares, - domain_to_threshold.clone(), + domain_to_reconstruction_threshold.clone(), )?); let eddsa_signature_provider = Arc::new(EddsaSignatureProvider::new( @@ -678,7 +679,7 @@ where network_client.clone(), sign_request_store.clone(), eddsa_keyshares, - domain_to_threshold.clone(), + domain_to_reconstruction_threshold.clone(), )?); let ckd_provider = Arc::new(CKDProvider::new( @@ -687,7 +688,7 @@ where network_client.clone(), ckd_request_store.clone(), ckd_keyshares, - domain_to_threshold, + domain_to_reconstruction_threshold, )?); let verify_foreign_tx_provider = Arc::new(VerifyForeignTxProvider::new( From 41184ee3e6ff7bb8ea0c173527237290e41f2d8a Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:03:57 +0200 Subject: [PATCH 047/121] Add tests --- crates/e2e-tests/tests/common.rs | 1 + .../distinct_reconstruction_thresholds.rs | 99 ++++++++++++++++ crates/e2e-tests/tests/e2e.rs | 1 + crates/node/src/p2p.rs | 1 + crates/node/src/tests/multidomain.rs | 107 ++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs diff --git a/crates/e2e-tests/tests/common.rs b/crates/e2e-tests/tests/common.rs index eb81da0c96..673b137625 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -36,6 +36,7 @@ pub const CONTRACT_UPGRADE_COMPATIBILITY_TESTNET_PORT_SEED: u16 = 19; pub const TIMEOUT_METRIC_PORT_SEED: u16 = 20; pub const MIGRATION_BACK_PORT_SEED: u16 = 21; pub const SIGTERM_HANDLER_PORT_SEED: u16 = 22; +pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED: u16 = 23; /// Start a cluster, wait for Running state and presignatures to buffer. /// diff --git a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs new file mode 100644 index 0000000000..cd3e903027 --- /dev/null +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -0,0 +1,99 @@ +use crate::common; + +use mpc_primitives::domain::{Curve, DomainId}; +use near_mpc_contract_interface::types::{ + DomainConfig, DomainPurpose, Protocol, ReconstructionThreshold, +}; +use rand::SeedableRng; + +/// Every scheme signs when its domain's reconstruction threshold `t` differs +/// from the governance threshold (4): CaitSith/Frost/CKD at `t=2`, DamgardEtAl +/// at `t=3` (6 nodes). Robust ECDSA is the discriminator — it signs over `2t-1` +/// participants, so `t=3` needs 5 signers; using the governance threshold would +/// need `2*4-1=7` and fail. A pass proves the per-domain `t` is used. +#[tokio::test] +#[expect(non_snake_case)] +async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { + // Given + let (cluster, contract_state) = common::must_setup_cluster( + common::DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, + |c| { + c.num_nodes = 6; + c.initial_participant_indices = (0..6).collect(); + c.threshold = 4; + c.triples_to_buffer = 2; + c.presignatures_to_buffer = 2; + c.domains.push(DomainConfig { + id: DomainId(c.domains.len() as u64), + protocol: Protocol::DamgardEtAl, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }); + }, + ) + .await; + + let ecdsa_domain = contract_state + .domains + .domains + .iter() + .find(|d| d.protocol == Protocol::CaitSith && d.purpose == DomainPurpose::Sign) + .expect("no CaitSith Sign domain"); + let robust_ecdsa_domain = contract_state + .domains + .domains + .iter() + .find(|d| d.protocol == Protocol::DamgardEtAl && d.purpose == DomainPurpose::Sign) + .expect("no DamgardEtAl Sign domain"); + let eddsa_domain = contract_state + .domains + .domains + .iter() + .find(|d| { + Curve::from(d.protocol) == Curve::Edwards25519 && d.purpose == DomainPurpose::Sign + }) + .expect("no Edwards25519 Sign domain"); + let ckd_domain = contract_state + .domains + .domains + .iter() + .find(|d| d.purpose == DomainPurpose::CKD) + .expect("no CKD domain"); + + // When / Then + let mut rng = rand::rngs::StdRng::seed_from_u64(0); + for (label, domain_id, is_eddsa) in [ + ("ECDSA", ecdsa_domain.id, false), + ("robust ECDSA", robust_ecdsa_domain.id, false), + ("EdDSA", eddsa_domain.id, true), + ] { + let payload = if is_eddsa { + common::generate_eddsa_payload(&mut rng) + } else { + common::generate_ecdsa_payload(&mut rng) + }; + let outcome = cluster + .send_sign_request(domain_id, payload, cluster.default_user_account()) + .await + .expect("sign request failed"); + assert!( + outcome.is_success(), + "{label} sign request failed: {:?}", + outcome.failure_message() + ); + } + + let outcome = cluster + .send_ckd_request( + ckd_domain.id, + common::generate_ckd_app_public_key(&mut rng), + cluster.default_user_account(), + ) + .await + .expect("ckd request failed"); + assert!( + outcome.is_success(), + "ckd request failed: {:?}", + outcome.failure_message() + ); +} diff --git a/crates/e2e-tests/tests/e2e.rs b/crates/e2e-tests/tests/e2e.rs index 287fc7100d..028417904b 100644 --- a/crates/e2e-tests/tests/e2e.rs +++ b/crates/e2e-tests/tests/e2e.rs @@ -3,6 +3,7 @@ mod ckd_verification; mod cleanup_lagging_node; mod common; mod contract_upgrade_compatibility; +mod distinct_reconstruction_thresholds; mod foreign_chain_configuration; mod foreign_chain_tx_validation; mod key_resharing; diff --git a/crates/node/src/p2p.rs b/crates/node/src/p2p.rs index f0f9f85df6..5c8f7072ab 100644 --- a/crates/node/src/p2p.rs +++ b/crates/node/src/p2p.rs @@ -1017,6 +1017,7 @@ pub mod testing { pub const FOREIGN_CHAIN_POLICY_TEST: Self = Self::new(20); pub const BACKUP_CLI_WEBSERVER_PUT_KEYSHARES_HOSTNAME: Self = Self::new(21); pub const ASSET_GENERATION_SIGNING_CONTENTION_TEST: Self = Self::new(22); + pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_TEST: Self = Self::new(23); } pub fn generate_test_p2p_configs( diff --git a/crates/node/src/tests/multidomain.rs b/crates/node/src/tests/multidomain.rs index eca3136a83..a56bea9f7a 100644 --- a/crates/node/src/tests/multidomain.rs +++ b/crates/node/src/tests/multidomain.rs @@ -11,6 +11,113 @@ use near_mpc_contract_interface::types::{ }; use near_time::Clock; +// Domains carry per-domain reconstruction thresholds `t` differing from the +// governance threshold (4): CaitSith/Frost/CKD at `t=2`, DamgardEtAl at `t=3` +// (5 nodes). Robust ECDSA is the discriminator — it signs over `2t-1` +// participants, so `t=3` needs 5 signers; using the governance threshold would +// need `2*4-1=7` and fail. A pass proves the per-domain `t` is used. +#[tokio::test] +#[test_log::test] +#[expect(non_snake_case)] +async fn multidomain_with_distinct_reconstruction_thresholds__should_sign_for_every_domain() { + // Given + const NUM_PARTICIPANTS: usize = 5; + const THRESHOLD: usize = 4; + const TXN_DELAY_BLOCKS: u64 = 1; + let temp_dir = tempfile::tempdir().unwrap(); + let mut setup = IntegrationTestSetup::new( + Clock::real(), + temp_dir.path(), + (0..NUM_PARTICIPANTS) + .map(|i| format!("test{}", i).parse().unwrap()) + .collect(), + THRESHOLD, + TXN_DELAY_BLOCKS, + PortSeed::DISTINCT_RECONSTRUCTION_THRESHOLDS_TEST, + std::time::Duration::from_millis(600), // helps to avoid flaky test + ); + + let domains = vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(1), + protocol: Protocol::Frost, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(2), + protocol: Protocol::ConfidentialKeyDerivation, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::CKD, + }, + DomainConfig { + id: DomainId(3), + protocol: Protocol::DamgardEtAl, + reconstruction_threshold: ReconstructionThreshold::new(3), + purpose: DomainPurpose::Sign, + }, + ]; + + { + let mut contract = setup.indexer.contract_mut().await; + contract.initialize(setup.participants.clone()); + contract.add_domains(domains.clone()); + } + + let _runs = setup + .configs + .into_iter() + .map(|config| AutoAbortTask::from(tokio::spawn(config.run()))) + .collect::>(); + + // When + setup + .indexer + .wait_for_contract_state( + |state| matches!(state, ContractState::Running(_)), + DEFAULT_MAX_PROTOCOL_WAIT_TIME * domains.len() as u32, + ) + .await + .expect("must not exceed timeout"); + + // Then + tracing::info!("requesting signature"); + for domain in &domains { + match Curve::from(domain.protocol) { + Curve::Secp256k1 | Curve::Edwards25519 => { + assert!( + request_signature_and_await_response( + &mut setup.indexer, + &format!("user{}", domain.id.0), + domain, + DEFAULT_MAX_SIGNATURE_WAIT_TIME + ) + .await + .is_some() + ); + } + Curve::Bls12381 => { + assert!( + request_ckd_and_await_response( + &mut setup.indexer, + &format!("user{}", domain.id.0), + domain, + DEFAULT_MAX_SIGNATURE_WAIT_TIME + ) + .await + .is_some() + ); + } + } + } +} + // Make a cluster of four nodes, test that we can generate keyshares // and then produce signatures. #[tokio::test] From cb5725f638157f078088bd6599366b1eadf423ad Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:17:09 +0200 Subject: [PATCH 048/121] cargo commit --- .../tests/distinct_reconstruction_thresholds.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs index cd3e903027..ce38e205a6 100644 --- a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -15,9 +15,8 @@ use rand::SeedableRng; #[expect(non_snake_case)] async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { // Given - let (cluster, contract_state) = common::must_setup_cluster( - common::DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, - |c| { + let (cluster, contract_state) = + common::must_setup_cluster(common::DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, |c| { c.num_nodes = 6; c.initial_participant_indices = (0..6).collect(); c.threshold = 4; @@ -29,9 +28,8 @@ async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { reconstruction_threshold: ReconstructionThreshold::new(3), purpose: DomainPurpose::Sign, }); - }, - ) - .await; + }) + .await; let ecdsa_domain = contract_state .domains From 02e0d1371672bc57ad7daf6a2fd62d07c388ce3a Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:25:40 +0200 Subject: [PATCH 049/121] Removing a todo --- crates/node/src/providers/ecdsa.rs | 7 ++++++ crates/node/src/providers/ecdsa/triple.rs | 30 ++++++++++++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 63cb7c94b0..525f15c08c 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -309,6 +309,13 @@ impl SignatureProvider for EcdsaSignatureProvider { )); } + // Triple gauges are summed across all per-`t` stores by one reporter so + // concurrent generators don't clobber each other's values. + generate_triples.push(tracking::spawn( + "report triple metrics", + Self::run_triple_metrics_reporting(self.triple_stores.values().cloned().collect()), + )); + let mut generate_presignatures = Vec::new(); for (domain_id, data) in &self.per_domain_data { let t = data.reconstruction_threshold; diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index 15c8056656..11a81e2016 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -61,6 +61,27 @@ impl Deref for TripleStorage { pub const SUPPORTED_TRIPLE_GENERATION_BATCH_SIZE: usize = 64; impl EcdsaSignatureProvider { + /// Reports the triple-buffer gauges summed across every per-`t` store. + /// Each generator owns a distinct store keyed by its threshold, so a single + /// reporter prevents these unlabeled gauges from being clobbered by + /// whichever generator ticked last. + pub(super) async fn run_triple_metrics_reporting(triple_stores: Vec>) -> ! { + loop { + let mut online: i64 = 0; + let mut offline: i64 = 0; + let mut available: i64 = 0; + for store in &triple_stores { + online += store.num_owned_ready() as i64; + offline += store.num_owned_offline() as i64; + available += store.num_owned() as i64; + } + metrics::MPC_OWNED_NUM_TRIPLES_ONLINE.set(online); + metrics::MPC_OWNED_NUM_TRIPLES_WITH_OFFLINE_PARTICIPANT.set(offline); + metrics::MPC_OWNED_NUM_TRIPLES_AVAILABLE.set(available); + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + /// Continuously runs triple generation in the background, using the number of threads /// specified in the config, trying to maintain some number of available triples all the /// time as specified in the config. Generated triples will be written to `triple_store` @@ -87,16 +108,7 @@ impl EcdsaSignatureProvider { .collect(); loop { - // TODO(#3164): once per-`t` background generation lands and runs - // alongside this loop for other thresholds, these gauges will be - // overwritten by whichever generator ticks last. Either lift the - // updates into a single task that sums across `triple_stores`, or - // add a `t` label so each store reports independently. - metrics::MPC_OWNED_NUM_TRIPLES_ONLINE.set(triple_store.num_owned_ready() as i64); - metrics::MPC_OWNED_NUM_TRIPLES_WITH_OFFLINE_PARTICIPANT - .set(triple_store.num_owned_offline() as i64); let my_triples_count = triple_store.num_owned(); - metrics::MPC_OWNED_NUM_TRIPLES_AVAILABLE.set(my_triples_count as i64); let should_generate = my_triples_count + in_flight_generations.num_in_flight() < config.desired_triples_to_buffer; From eed4c8fe0f46b044c1229d95f6b3dff963ec0d1b Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:34:56 +0200 Subject: [PATCH 050/121] checked arithmetics --- crates/node/src/providers/robust_ecdsa/presign.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index 7b747a6384..b7afe93cf5 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -189,9 +189,14 @@ pub(super) fn compute_thresholds( t >= 2, "robust-ECDSA requires a reconstruction threshold of at least 2, got {t}" ); - let max_malicious = MaxMalicious::from(t - 1); - let num_signers = 2 * t - 1; - Ok((num_signers, max_malicious)) + let max_malicious = t + .checked_sub(1) + .ok_or_else(|| anyhow::anyhow!("robust-ECDSA max_malicious underflow for t={t}"))?; + let num_signers = t + .checked_mul(2) + .and_then(|two_t| two_t.checked_sub(1)) + .ok_or_else(|| anyhow::anyhow!("robust-ECDSA signer count overflow for t={t}"))?; + Ok((num_signers, MaxMalicious::from(max_malicious))) } impl RobustEcdsaSignatureProvider { From 8423f2ee8bd287ac41f89272dbbd569cc4ee5018 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:45:04 +0200 Subject: [PATCH 051/121] adjusting comment --- crates/contract/src/primitives/thresholds.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 109ab13118..5a67a3978d 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -16,10 +16,8 @@ pub(crate) fn governance_threshold_lower_relative_bound(n: u64) -> u64 { 3_u64.saturating_mul(n).div_ceil(5) } -/// Upper bound on the GovernanceThreshold for `n` participants: -/// Currently set to 100% of participants but would be a discussion subject -/// to drop this upper bound down not to have problems with smart contract -/// being locked if t = n and if an operator stops voting +/// Upper bound on the GovernanceThreshold for `n` participants: currently 100%. +/// Whether to lower it is open (see `docs/design/domain-separation.md` §7). pub(crate) fn governance_threshold_upper_relative_bound(n: u64) -> u64 { n } From 79f966689e6ff312d839e0ed3b5a862183fe2ad5 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:47:44 +0200 Subject: [PATCH 052/121] blocking claude bug --- crates/node/src/providers/ecdsa.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 525f15c08c..23672417f2 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -309,12 +309,12 @@ impl SignatureProvider for EcdsaSignatureProvider { )); } - // Triple gauges are summed across all per-`t` stores by one reporter so - // concurrent generators don't clobber each other's values. - generate_triples.push(tracking::spawn( + // Held outside the join group below: this reporter never completes, so + // joining it would mask generator failures. Aborted on drop when this returns. + let _metrics_task = tracking::spawn( "report triple metrics", Self::run_triple_metrics_reporting(self.triple_stores.values().cloned().collect()), - )); + ); let mut generate_presignatures = Vec::new(); for (domain_id, data) in &self.per_domain_data { From 05bdcfa2c9911fcb9057c953bd4781c4488d6cb7 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:31:25 +0200 Subject: [PATCH 053/121] populating the hashmap with a domain instead of nothing --- crates/node/src/key_events.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/node/src/key_events.rs b/crates/node/src/key_events.rs index 2d932e1ee4..a3162c4a3c 100644 --- a/crates/node/src/key_events.rs +++ b/crates/node/src/key_events.rs @@ -936,7 +936,10 @@ mod tests { Arc::new(ResharingArgs { previous_keyset: Keyset::new(EpochId::new(5), vec![]), existing_keyshares: None, - old_reconstruction_thresholds: HashMap::new(), + old_reconstruction_thresholds: HashMap::from([( + DomainId(1), + MpcReconstructionThreshold::new(3), + )]), old_participants: ParticipantsConfig { threshold: 3, participants: vec![], From fb858bd94672bb2036aeff659a902280fa0e45c3 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:26:45 +0200 Subject: [PATCH 054/121] Add metric for peer presign requests with mismatched participant count --- crates/node/src/metrics.rs | 10 ++++++++++ crates/node/src/providers/ecdsa/presign.rs | 14 ++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/node/src/metrics.rs b/crates/node/src/metrics.rs index cd7903f9ec..a75092dddd 100644 --- a/crates/node/src/metrics.rs +++ b/crates/node/src/metrics.rs @@ -14,6 +14,16 @@ pub static MPC_NUM_TRIPLES_GENERATED: LazyLock = LazyLoc .unwrap() }); +pub static MPC_NUM_BAD_PEER_PRESIGN_REQUESTS: LazyLock = LazyLock::new( + || { + prometheus::register_int_counter!( + "mpc_num_bad_peer_presign_requests", + "Presignature requests from a peer whose participant-set size did not match the domain's reconstruction threshold" + ) + .unwrap() + }, +); + pub static MPC_TRIPLES_GENERATION_TIME_ELAPSED: LazyLock = LazyLock::new(|| { prometheus::register_histogram!( diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index a039fb4e6f..babe0f0c95 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -183,12 +183,14 @@ impl EcdsaSignatureProvider { // channel participant count must match — cross-check it. let reconstruction_threshold = domain_data.reconstruction_threshold; let reconstruction_threshold_usize: usize = reconstruction_threshold.inner().try_into()?; - anyhow::ensure!( - channel.participants().len() == reconstruction_threshold_usize, - "presign participant count ({}) does not match domain threshold t={}", - channel.participants().len(), - reconstruction_threshold_usize, - ); + if channel.participants().len() != reconstruction_threshold_usize { + metrics::MPC_NUM_BAD_PEER_PRESIGN_REQUESTS.inc(); + anyhow::bail!( + "presign participant count ({}) does not match domain threshold t={}", + channel.participants().len(), + reconstruction_threshold_usize, + ); + } let triple_store = self.triple_store_for_t(reconstruction_threshold)?; FollowerPresignComputation { reconstruction_threshold: TSReconstructionThreshold::from( From 50cf59ea109840b4540969ebfb0df330268d082f Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:26:45 +0200 Subject: [PATCH 055/121] Thread old reconstruction threshold through key resharing and bubble robust-ECDSA threshold errors --- crates/node/src/key_events.rs | 47 +++++++++---------- crates/node/src/providers.rs | 1 + crates/node/src/providers/ckd.rs | 2 + .../node/src/providers/ckd/key_resharing.rs | 6 +-- crates/node/src/providers/ecdsa.rs | 2 + .../node/src/providers/ecdsa/key_resharing.rs | 6 +-- crates/node/src/providers/eddsa.rs | 2 + .../node/src/providers/eddsa/key_resharing.rs | 6 +-- crates/node/src/providers/robust_ecdsa.rs | 22 +++++---- .../src/providers/robust_ecdsa/presign.rs | 5 +- .../node/src/providers/verify_foreign_tx.rs | 1 + 11 files changed, 50 insertions(+), 50 deletions(-) diff --git a/crates/node/src/key_events.rs b/crates/node/src/key_events.rs index a3162c4a3c..9bb2af807c 100644 --- a/crates/node/src/key_events.rs +++ b/crates/node/src/key_events.rs @@ -174,11 +174,8 @@ async fn keygen_computation( pub struct ResharingArgs { pub previous_keyset: Keyset, pub existing_keyshares: Option>, - /// The previous epoch's per-domain reconstruction thresholds. The new - /// threshold for each key comes from the resharing key event's - /// `DomainConfig.reconstruction_threshold`; the old one is patched into - /// `old_participants.threshold` per-key so the underlying resharing protocol - /// sees the right `t` on each side of the transition. + /// The previous epoch's per-domain reconstruction thresholds, passed to the + /// resharing protocol as the old-side `t` for each key. pub old_reconstruction_thresholds: HashMap, pub old_participants: ParticipantsConfig, } @@ -203,25 +200,19 @@ async fn resharing_computation_inner( ) -> anyhow::Result<()> { anyhow::ensure!(key_id.domain_id == domain.id, "Domain mismatch"); - // The new reconstruction threshold comes from the resharing key event's - // domain config; the old one is the previous epoch's per-domain `t`. We patch - // `old_participants.threshold` per-key so the underlying resharing protocol - // sees the correct `t` on each side of the transition. For robust-ECDSA the - // reconstruction lower bound equals `t`, so no protocol-specific translation - // is needed here. let new_reconstruction_threshold = ReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); - let old_reconstruction_threshold = args - .old_reconstruction_thresholds - .get(&key_id.domain_id) - .ok_or_else(|| { - anyhow::anyhow!( - "No previous reconstruction threshold for domain {:?}", - key_id.domain_id - ) - })?; - let mut old_participants = args.old_participants.clone(); - old_participants.threshold = old_reconstruction_threshold.inner(); + let old_reconstruction_threshold = ReconstructionThreshold::from(usize::try_from( + args.old_reconstruction_thresholds + .get(&key_id.domain_id) + .ok_or_else(|| { + anyhow::anyhow!( + "No previous reconstruction threshold for domain {:?}", + key_id.domain_id + ) + })? + .inner(), + )?); let keyshare_handle = keyshare_storage .write() @@ -267,9 +258,10 @@ async fn resharing_computation_inner( .transpose()?; let res = EcdsaSignatureProvider::run_key_resharing_client( new_reconstruction_threshold, + old_reconstruction_threshold, my_share, public_key, - &old_participants, + &args.old_participants, channel, ) .await?; @@ -289,9 +281,10 @@ async fn resharing_computation_inner( .transpose()?; let res = RobustEcdsaSignatureProvider::run_key_resharing_client( new_reconstruction_threshold, + old_reconstruction_threshold, my_share, public_key, - &old_participants, + &args.old_participants, channel, ) .await?; @@ -310,9 +303,10 @@ async fn resharing_computation_inner( .transpose()?; let res = EddsaSignatureProvider::run_key_resharing_client( new_reconstruction_threshold, + old_reconstruction_threshold, my_share, public_key, - &old_participants, + &args.old_participants, channel, ) .await?; @@ -328,9 +322,10 @@ async fn resharing_computation_inner( .transpose()?; let res = CKDProvider::run_key_resharing_client( new_reconstruction_threshold, + old_reconstruction_threshold, my_share, public_key, - &old_participants, + &args.old_participants, channel, ) .await?; diff --git a/crates/node/src/providers.rs b/crates/node/src/providers.rs index 7cd763af09..36f26b5ea6 100644 --- a/crates/node/src/providers.rs +++ b/crates/node/src/providers.rs @@ -60,6 +60,7 @@ pub trait SignatureProvider { /// It drains `channel_receiver` until the required task is found, meaning these clients must not be run in parallel. async fn run_key_resharing_client( new_reconstruction_threshold: ReconstructionThreshold, + old_reconstruction_threshold: ReconstructionThreshold, key_share: Option, public_key: Self::PublicKey, old_participants: &ParticipantsConfig, diff --git a/crates/node/src/providers/ckd.rs b/crates/node/src/providers/ckd.rs index 3bcf172c24..9d6553b74e 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -117,6 +117,7 @@ impl SignatureProvider for CKDProvider { async fn run_key_resharing_client( new_reconstruction_threshold: ReconstructionThreshold, + old_reconstruction_threshold: ReconstructionThreshold, key_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, @@ -124,6 +125,7 @@ impl SignatureProvider for CKDProvider { ) -> anyhow::Result { Self::run_key_resharing_client_internal( new_reconstruction_threshold, + old_reconstruction_threshold, key_share, public_key, old_participants, diff --git a/crates/node/src/providers/ckd/key_resharing.rs b/crates/node/src/providers/ckd/key_resharing.rs index b14e8f568f..a0834abc67 100644 --- a/crates/node/src/providers/ckd/key_resharing.rs +++ b/crates/node/src/providers/ckd/key_resharing.rs @@ -14,18 +14,16 @@ use threshold_signatures::participants::Participant; impl CKDProvider { pub(super) async fn run_key_resharing_client_internal( new_reconstruction_threshold: ReconstructionThreshold, + old_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { - let old_reconstruction_threshold: usize = old_participants.threshold.try_into()?; let new_keyshare = KeyResharingComputation { reconstruction_threshold: new_reconstruction_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_reconstruction_threshold: ReconstructionThreshold::from( - old_reconstruction_threshold, - ), + old_reconstruction_threshold, my_share, public_key, } diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 23672417f2..1f5733b4f0 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -224,6 +224,7 @@ impl SignatureProvider for EcdsaSignatureProvider { async fn run_key_resharing_client( new_reconstruction_threshold: TSReconstructionThreshold, + old_reconstruction_threshold: TSReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, @@ -231,6 +232,7 @@ impl SignatureProvider for EcdsaSignatureProvider { ) -> anyhow::Result { EcdsaSignatureProvider::run_key_resharing_client_internal( new_reconstruction_threshold, + old_reconstruction_threshold, my_share, public_key, old_participants, diff --git a/crates/node/src/providers/ecdsa/key_resharing.rs b/crates/node/src/providers/ecdsa/key_resharing.rs index 0e4211e80f..1210b0cc1c 100644 --- a/crates/node/src/providers/ecdsa/key_resharing.rs +++ b/crates/node/src/providers/ecdsa/key_resharing.rs @@ -13,18 +13,16 @@ use threshold_signatures::participants::Participant; impl EcdsaSignatureProvider { pub(crate) async fn run_key_resharing_client_internal( new_reconstruction_threshold: ReconstructionThreshold, + old_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { - let old_reconstruction_threshold: usize = old_participants.threshold.try_into()?; let new_keyshare = KeyResharingComputation { reconstruction_threshold: new_reconstruction_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_reconstruction_threshold: ReconstructionThreshold::from( - old_reconstruction_threshold, - ), + old_reconstruction_threshold, my_share, public_key, } diff --git a/crates/node/src/providers/eddsa.rs b/crates/node/src/providers/eddsa.rs index 311965243b..a6b92721f6 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -123,6 +123,7 @@ impl SignatureProvider for EddsaSignatureProvider { async fn run_key_resharing_client( new_reconstruction_threshold: ReconstructionThreshold, + old_reconstruction_threshold: ReconstructionThreshold, key_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, @@ -130,6 +131,7 @@ impl SignatureProvider for EddsaSignatureProvider { ) -> anyhow::Result { Self::run_key_resharing_client_internal( new_reconstruction_threshold, + old_reconstruction_threshold, key_share, public_key, old_participants, diff --git a/crates/node/src/providers/eddsa/key_resharing.rs b/crates/node/src/providers/eddsa/key_resharing.rs index 84d4c61898..babf7c6dcd 100644 --- a/crates/node/src/providers/eddsa/key_resharing.rs +++ b/crates/node/src/providers/eddsa/key_resharing.rs @@ -14,18 +14,16 @@ use threshold_signatures::participants::Participant; impl EddsaSignatureProvider { pub(super) async fn run_key_resharing_client_internal( new_reconstruction_threshold: ReconstructionThreshold, + old_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { - let old_reconstruction_threshold: usize = old_participants.threshold.try_into()?; let new_keyshare = KeyResharingComputation { reconstruction_threshold: new_reconstruction_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_reconstruction_threshold: ReconstructionThreshold::from( - old_reconstruction_threshold, - ), + old_reconstruction_threshold, my_share, public_key, } diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 99d68c4106..bdaaac7095 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -170,18 +170,16 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { async fn run_key_resharing_client( new_reconstruction_threshold: ReconstructionThreshold, + old_reconstruction_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { - // Both `new_reconstruction_threshold` and `old_participants.threshold` are - // already the per-domain reconstruction thresholds `t` (the caller patches - // the old one per-key in `resharing_computation_inner`). For robust-ECDSA - // the reconstruction lower bound equals `t`, so resharing is identical to - // cait-sith — delegate straight to the shared internal. + // For robust-ECDSA the reconstruction lower bound equals `t`, so resharing is identical to cait-sith. EcdsaSignatureProvider::run_key_resharing_client_internal( new_reconstruction_threshold, + old_reconstruction_threshold, my_share, public_key, old_participants, @@ -246,10 +244,16 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { }) .collect::>(); - for Err(join_error) in futures::future::join_all(generate_presignatures).await { - tracing::error!( - "robust-ecdsa background presignature task ended unexpectedly: {join_error}" - ); + for result in futures::future::join_all(generate_presignatures).await { + match result { + Err(join_error) => tracing::error!( + "robust-ecdsa background presignature task panicked: {join_error}" + ), + Ok(Err(task_error)) => tracing::error!( + "robust-ecdsa background presignature task errored: {task_error}" + ), + Ok(Ok(())) => {} + } } Ok(()) diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index b7afe93cf5..2c5281f11f 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -71,7 +71,7 @@ pub(super) async fn run_background_presignature_generation( domain_id: DomainId, presignature_store: Arc, keygen_out: KeygenOutput, -) -> ! { +) -> anyhow::Result<()> { let in_flight_generations = InFlightGenerationTracker::new(); let progress_tracker = Arc::new(PresignatureGenerationProgressTracker { desired_presignatures_to_buffer: config.desired_presignatures_to_buffer, @@ -88,8 +88,7 @@ pub(super) async fn run_background_presignature_generation( .map(|p| p.id) .collect(); - let (num_signers, robust_ecdsa_threshold) = compute_thresholds(reconstruction_threshold) - .expect("invalid reconstruction threshold for robust-ECDSA"); + let (num_signers, robust_ecdsa_threshold) = compute_thresholds(reconstruction_threshold)?; loop { progress_tracker.update_progress(); diff --git a/crates/node/src/providers/verify_foreign_tx.rs b/crates/node/src/providers/verify_foreign_tx.rs index 96e33b905b..9d0cb945d2 100644 --- a/crates/node/src/providers/verify_foreign_tx.rs +++ b/crates/node/src/providers/verify_foreign_tx.rs @@ -196,6 +196,7 @@ where async fn run_key_resharing_client( _new_reconstruction_threshold: ReconstructionThreshold, + _old_reconstruction_threshold: ReconstructionThreshold, _key_share: Option, _public_key: VerifyingKey, _old_participants: &ParticipantsConfig, From 49b39a7f3a699fd7eec26604b508e41ae7021614 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:44:39 +0200 Subject: [PATCH 056/121] Revert reconstruction_threshold rename (moved to follow-up branch) --- crates/node/src/assets/test_utils.rs | 11 ++-- crates/node/src/coordinator.rs | 19 +++--- crates/node/src/key_events.rs | 46 +++++-------- crates/node/src/providers.rs | 6 +- crates/node/src/providers/ckd.rs | 23 ++++--- .../node/src/providers/ckd/key_generation.rs | 16 ++--- .../node/src/providers/ckd/key_resharing.rs | 20 +++--- crates/node/src/providers/ckd/sign.rs | 7 +- crates/node/src/providers/ecdsa.rs | 65 ++++++++----------- .../src/providers/ecdsa/key_generation.rs | 16 ++--- .../node/src/providers/ecdsa/key_resharing.rs | 20 +++--- crates/node/src/providers/ecdsa/presign.rs | 26 ++++---- crates/node/src/providers/ecdsa/sign.rs | 22 +++---- crates/node/src/providers/ecdsa/triple.rs | 47 ++++++-------- crates/node/src/providers/eddsa.rs | 23 ++++--- .../src/providers/eddsa/key_generation.rs | 16 ++--- .../node/src/providers/eddsa/key_resharing.rs | 20 +++--- crates/node/src/providers/eddsa/sign.rs | 20 +++--- crates/node/src/providers/robust_ecdsa.rs | 29 ++++----- .../src/providers/robust_ecdsa/presign.rs | 8 +-- .../node/src/providers/verify_foreign_tx.rs | 6 +- 21 files changed, 208 insertions(+), 258 deletions(-) diff --git a/crates/node/src/assets/test_utils.rs b/crates/node/src/assets/test_utils.rs index f3f1c6a313..2664eedc3c 100644 --- a/crates/node/src/assets/test_utils.rs +++ b/crates/node/src/assets/test_utils.rs @@ -46,13 +46,10 @@ pub fn triple_v2_key(t: ReconstructionThreshold, id: UniqueId) -> Vec { /// data, the local participant's ID, and the reconstruction threshold so /// callers don't have to restate the magic number alongside the fixture. pub fn gen_four_participants() -> (EpochData, ParticipantId, ReconstructionThreshold) { - let reconstruction_threshold = ReconstructionThreshold::new(3); + let threshold = ReconstructionThreshold::new(3); let epoch_id = EpochId::new(rand::thread_rng().next_u64()); - let parameters = ThresholdParameters::new( - gen_participants(4), - Threshold::new(reconstruction_threshold.inner()), - ) - .unwrap(); + let parameters = + ThresholdParameters::new(gen_participants(4), Threshold::new(threshold.inner())).unwrap(); let parameters_dto: near_mpc_contract_interface::types::ThresholdParameters = parameters.into(); let participants: ParticipantsConfig = convert_participant_infos(parameters_dto, None).unwrap(); let epoch_data = EpochData { @@ -60,7 +57,7 @@ pub fn gen_four_participants() -> (EpochData, ParticipantId, ReconstructionThres participants, }; let my_participant_id = epoch_data.participants.participants.first().unwrap().id; - (epoch_data, my_participant_id, reconstruction_threshold) + (epoch_data, my_participant_id, threshold) } pub fn get_participant_ids(epoch_data: EpochData) -> Vec { diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index a3edf0e0f5..2fa246df6e 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -608,12 +608,11 @@ where .iter() .map(|d| (d.id, d.protocol)) .collect(); - let domain_to_reconstruction_threshold: HashMap = - running_state - .domains - .iter() - .map(|d| (d.id, d.reconstruction_threshold)) - .collect(); + let domain_to_threshold: HashMap = running_state + .domains + .iter() + .map(|d| (d.id, d.reconstruction_threshold)) + .collect(); for keyshare in keyshares { let domain_id = keyshare.key_id.domain_id; @@ -659,7 +658,7 @@ where secret_db.clone(), sign_request_store.clone(), ecdsa_keyshares, - domain_to_reconstruction_threshold.clone(), + domain_to_threshold.clone(), )?); let robust_ecdsa_signature_provider = Arc::new(RobustEcdsaSignatureProvider::new( @@ -670,7 +669,7 @@ where secret_db, sign_request_store.clone(), robust_ecdsa_keyshares, - domain_to_reconstruction_threshold.clone(), + domain_to_threshold.clone(), )?); let eddsa_signature_provider = Arc::new(EddsaSignatureProvider::new( @@ -679,7 +678,7 @@ where network_client.clone(), sign_request_store.clone(), eddsa_keyshares, - domain_to_reconstruction_threshold.clone(), + domain_to_threshold.clone(), )?); let ckd_provider = Arc::new(CKDProvider::new( @@ -688,7 +687,7 @@ where network_client.clone(), ckd_request_store.clone(), ckd_keyshares, - domain_to_reconstruction_threshold, + domain_to_threshold, )?); let verify_foreign_tx_provider = Arc::new(VerifyForeignTxProvider::new( diff --git a/crates/node/src/key_events.rs b/crates/node/src/key_events.rs index 9bb2af807c..859a053472 100644 --- a/crates/node/src/key_events.rs +++ b/crates/node/src/key_events.rs @@ -54,7 +54,7 @@ pub async fn keygen_computation_inner( // The reconstruction threshold `t` is the per-domain source of truth. For // every protocol (including robust-ECDSA, whose reconstruction lower bound // equals `t`) the keygen runs with lower bound `t`. - let reconstruction_threshold = + let threshold = ReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); let keyshare_handle = keyshare_storage .write() @@ -68,41 +68,31 @@ pub async fn keygen_computation_inner( let (keyshare, public_key) = match domain.protocol { Protocol::CaitSith => { - let keyshare = EcdsaSignatureProvider::run_key_generation_client( - reconstruction_threshold, - channel, - ) - .await?; + let keyshare = + EcdsaSignatureProvider::run_key_generation_client(threshold, channel).await?; let public_key = dtos::PublicKey::Secp256k1(dtos::Secp256k1PublicKey::try_from( keyshare.public_key.to_element().to_affine(), )?); (KeyshareData::Secp256k1(keyshare), public_key) } Protocol::DamgardEtAl => { - let keyshare = RobustEcdsaSignatureProvider::run_key_generation_client( - reconstruction_threshold, - channel, - ) - .await?; + let keyshare = + RobustEcdsaSignatureProvider::run_key_generation_client(threshold, channel).await?; let public_key = dtos::PublicKey::Secp256k1(dtos::Secp256k1PublicKey::try_from( keyshare.public_key.to_element().to_affine(), )?); (KeyshareData::Secp256k1(keyshare), public_key) } Protocol::Frost => { - let keyshare = EddsaSignatureProvider::run_key_generation_client( - reconstruction_threshold, - channel, - ) - .await?; + let keyshare = + EddsaSignatureProvider::run_key_generation_client(threshold, channel).await?; let public_key = dtos::PublicKey::Ed25519(dtos::Ed25519PublicKey::from( keyshare.public_key.to_element().compress(), )); (KeyshareData::Ed25519(keyshare), public_key) } Protocol::ConfidentialKeyDerivation => { - let keyshare = - CKDProvider::run_key_generation_client(reconstruction_threshold, channel).await?; + let keyshare = CKDProvider::run_key_generation_client(threshold, channel).await?; let public_key = dtos::PublicKey::Bls12381(dtos::Bls12381G2PublicKey::from( &keyshare.public_key.to_element(), )); @@ -200,9 +190,9 @@ async fn resharing_computation_inner( ) -> anyhow::Result<()> { anyhow::ensure!(key_id.domain_id == domain.id, "Domain mismatch"); - let new_reconstruction_threshold = + let new_threshold = ReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); - let old_reconstruction_threshold = ReconstructionThreshold::from(usize::try_from( + let old_threshold = ReconstructionThreshold::from(usize::try_from( args.old_reconstruction_thresholds .get(&key_id.domain_id) .ok_or_else(|| { @@ -257,8 +247,8 @@ async fn resharing_computation_inner( }) .transpose()?; let res = EcdsaSignatureProvider::run_key_resharing_client( - new_reconstruction_threshold, - old_reconstruction_threshold, + new_threshold, + old_threshold, my_share, public_key, &args.old_participants, @@ -280,8 +270,8 @@ async fn resharing_computation_inner( }) .transpose()?; let res = RobustEcdsaSignatureProvider::run_key_resharing_client( - new_reconstruction_threshold, - old_reconstruction_threshold, + new_threshold, + old_threshold, my_share, public_key, &args.old_participants, @@ -302,8 +292,8 @@ async fn resharing_computation_inner( }) .transpose()?; let res = EddsaSignatureProvider::run_key_resharing_client( - new_reconstruction_threshold, - old_reconstruction_threshold, + new_threshold, + old_threshold, my_share, public_key, &args.old_participants, @@ -321,8 +311,8 @@ async fn resharing_computation_inner( }) .transpose()?; let res = CKDProvider::run_key_resharing_client( - new_reconstruction_threshold, - old_reconstruction_threshold, + new_threshold, + old_threshold, my_share, public_key, &args.old_participants, diff --git a/crates/node/src/providers.rs b/crates/node/src/providers.rs index 36f26b5ea6..06c05080de 100644 --- a/crates/node/src/providers.rs +++ b/crates/node/src/providers.rs @@ -51,7 +51,7 @@ pub trait SignatureProvider { /// /// It drains `channel_receiver` until the required task is found, meaning these clients must not be run in parallel. async fn run_key_generation_client( - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result; @@ -59,8 +59,8 @@ pub trait SignatureProvider { /// Both leaders and followers call this function. /// It drains `channel_receiver` until the required task is found, meaning these clients must not be run in parallel. async fn run_key_resharing_client( - new_reconstruction_threshold: ReconstructionThreshold, - old_reconstruction_threshold: ReconstructionThreshold, + new_threshold: ReconstructionThreshold, + old_threshold: ReconstructionThreshold, key_share: Option, public_key: Self::PublicKey, old_participants: &ParticipantsConfig, diff --git a/crates/node/src/providers/ckd.rs b/crates/node/src/providers/ckd.rs index 9d6553b74e..6374c3c487 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -61,19 +61,18 @@ impl CKDProvider { client: Arc, ckd_request_store: Arc, keyshares: HashMap, - reconstruction_thresholds: HashMap, + thresholds: HashMap, ) -> anyhow::Result { let mut per_domain_data = HashMap::new(); for (domain_id, keyshare) in keyshares { - let reconstruction_threshold = - *reconstruction_thresholds.get(&domain_id).ok_or_else(|| { - anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) - })?; + let threshold = *thresholds.get(&domain_id).ok_or_else(|| { + anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) + })?; per_domain_data.insert( domain_id, PerDomainData { keyshare, - reconstruction_threshold, + reconstruction_threshold: threshold, }, ); } @@ -109,23 +108,23 @@ impl SignatureProvider for CKDProvider { } async fn run_key_generation_client( - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - Self::run_key_generation_client_internal(reconstruction_threshold, channel).await + Self::run_key_generation_client_internal(threshold, channel).await } async fn run_key_resharing_client( - new_reconstruction_threshold: ReconstructionThreshold, - old_reconstruction_threshold: ReconstructionThreshold, + new_threshold: ReconstructionThreshold, + old_threshold: ReconstructionThreshold, key_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { Self::run_key_resharing_client_internal( - new_reconstruction_threshold, - old_reconstruction_threshold, + new_threshold, + old_threshold, key_share, public_key, old_participants, diff --git a/crates/node/src/providers/ckd/key_generation.rs b/crates/node/src/providers/ckd/key_generation.rs index 1c3f723532..88aa5a3e94 100644 --- a/crates/node/src/providers/ckd/key_generation.rs +++ b/crates/node/src/providers/ckd/key_generation.rs @@ -10,14 +10,12 @@ use threshold_signatures::participants::Participant; impl CKDProvider { pub(super) async fn run_key_generation_client_internal( - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - let key = KeyGenerationComputation { - reconstruction_threshold, - } - .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) - .await?; + let key = KeyGenerationComputation { threshold } + .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) + .await?; tracing::info!("CKD key generation completed"); Ok(key) @@ -27,7 +25,7 @@ impl CKDProvider { /// Runs the key generation protocol, returning the key generated. /// This protocol is identical for the leader and the followers. pub struct KeyGenerationComputation { - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, } #[async_trait::async_trait] @@ -43,7 +41,7 @@ impl MpcLeaderCentricComputation for KeyGenerationComputation { let protocol = threshold_signatures::keygen::( &cs_participants, me.into(), - self.reconstruction_threshold, + self.threshold, OsRng, )?; run_protocol("CKD key generation", channel, protocol).await @@ -122,7 +120,7 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyGenerationComputation { - reconstruction_threshold: ReconstructionThreshold::from(3), + threshold: ReconstructionThreshold::from(3), } .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) .await?; diff --git a/crates/node/src/providers/ckd/key_resharing.rs b/crates/node/src/providers/ckd/key_resharing.rs index a0834abc67..fda5ae4ccc 100644 --- a/crates/node/src/providers/ckd/key_resharing.rs +++ b/crates/node/src/providers/ckd/key_resharing.rs @@ -13,17 +13,17 @@ use threshold_signatures::participants::Participant; impl CKDProvider { pub(super) async fn run_key_resharing_client_internal( - new_reconstruction_threshold: ReconstructionThreshold, - old_reconstruction_threshold: ReconstructionThreshold, + new_threshold: ReconstructionThreshold, + old_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { let new_keyshare = KeyResharingComputation { - reconstruction_threshold: new_reconstruction_threshold, + threshold: new_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_reconstruction_threshold, + old_threshold, my_share, public_key, } @@ -49,9 +49,9 @@ impl CKDProvider { /// the old threshold; or /// - the threshold is larger than the number of participants. pub struct KeyResharingComputation { - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, old_participants: Vec, - old_reconstruction_threshold: ReconstructionThreshold, + old_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, } @@ -75,11 +75,11 @@ impl MpcLeaderCentricComputation for KeyResharingComputation { let protocol = threshold_signatures::reshare::( &old_participants, - self.old_reconstruction_threshold, + self.old_threshold, self.my_share, self.public_key, &new_participants, - self.reconstruction_threshold, + self.threshold, me.into(), OsRng, )?; @@ -155,9 +155,9 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyResharingComputation { - reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), + threshold: ReconstructionThreshold::from(THRESHOLD), old_participants, - old_reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), + old_threshold: ReconstructionThreshold::from(THRESHOLD), my_share: keyshare, public_key: pubkey, } diff --git a/crates/node/src/providers/ckd/sign.rs b/crates/node/src/providers/ckd/sign.rs index b3ade12949..4686bfeb78 100644 --- a/crates/node/src/providers/ckd/sign.rs +++ b/crates/node/src/providers/ckd/sign.rs @@ -30,9 +30,8 @@ impl CKDProvider { let ckd_request = self.ckd_request_store.get(id).await?; let domain_data = self.domain_data(ckd_request.domain_id)?; - let reconstruction_threshold: usize = - domain_data.reconstruction_threshold.inner().try_into()?; - let reconstruction_threshold = ReconstructionThreshold::from(reconstruction_threshold); + let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; + let threshold = ReconstructionThreshold::from(threshold); let running_participants: Vec<_> = self .mpc_config .participants @@ -44,7 +43,7 @@ impl CKDProvider { let participants = self .client .select_random_active_participants_including_me( - reconstruction_threshold.value(), + threshold.value(), &running_participants, ) .context("Could not choose active participants for a ckd")?; diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 1f5733b4f0..9c2cc32349 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -64,7 +64,7 @@ impl EcdsaSignatureProvider { db: Arc, sign_request_store: Arc, keyshares: HashMap, - reconstruction_thresholds: HashMap, + thresholds: HashMap, ) -> anyhow::Result { let active_participants_query = { let network_client = client.clone(); @@ -73,10 +73,9 @@ impl EcdsaSignatureProvider { let mut per_domain_data = HashMap::new(); for (domain_id, keyshare) in keyshares { - let reconstruction_threshold = - *reconstruction_thresholds.get(&domain_id).ok_or_else(|| { - anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) - })?; + let threshold = *thresholds.get(&domain_id).ok_or_else(|| { + anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) + })?; let presignature_store = Arc::new(PresignatureStorage::new( clock.clone(), db.clone(), @@ -89,7 +88,7 @@ impl EcdsaSignatureProvider { PerDomainData { keyshare, presignature_store, - reconstruction_threshold, + reconstruction_threshold: threshold, }, ); } @@ -139,21 +138,17 @@ impl EcdsaSignatureProvider { /// follower protocol with an unexpected `t`). pub(super) fn triple_store_for_t( &self, - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, ) -> anyhow::Result> { - self.triple_stores - .get(&reconstruction_threshold) - .cloned() - .ok_or_else(|| { - let mut configured: Vec = - self.triple_stores.keys().map(|t| t.inner()).collect(); - configured.sort(); - anyhow::anyhow!( - "No triple store configured for t = {} (configured: {:?})", - reconstruction_threshold.inner(), - configured, - ) - }) + self.triple_stores.get(&threshold).cloned().ok_or_else(|| { + let mut configured: Vec = self.triple_stores.keys().map(|t| t.inner()).collect(); + configured.sort(); + anyhow::anyhow!( + "No triple store configured for t = {} (configured: {:?})", + threshold.inner(), + configured, + ) + }) } pub(super) fn new_channel_for_task( @@ -212,27 +207,23 @@ impl SignatureProvider for EcdsaSignatureProvider { } async fn run_key_generation_client( - reconstruction_threshold: TSReconstructionThreshold, + threshold: TSReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - EcdsaSignatureProvider::run_key_generation_client_internal( - reconstruction_threshold, - channel, - ) - .await + EcdsaSignatureProvider::run_key_generation_client_internal(threshold, channel).await } async fn run_key_resharing_client( - new_reconstruction_threshold: TSReconstructionThreshold, - old_reconstruction_threshold: TSReconstructionThreshold, + new_threshold: TSReconstructionThreshold, + old_threshold: TSReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { EcdsaSignatureProvider::run_key_resharing_client_internal( - new_reconstruction_threshold, - old_reconstruction_threshold, + new_threshold, + old_threshold, my_share, public_key, old_participants, @@ -296,9 +287,8 @@ impl SignatureProvider for EcdsaSignatureProvider { // by a generator running at its own threshold. let mut generate_triples = Vec::new(); for (&t, triple_store) in &self.triple_stores { - let reconstruction_threshold_usize: usize = t.inner().try_into()?; - let reconstruction_threshold_bound = - TSReconstructionThreshold::from(reconstruction_threshold_usize); + let threshold_usize: usize = t.inner().try_into()?; + let threshold_bound = TSReconstructionThreshold::from(threshold_usize); generate_triples.push(tracking::spawn( &format!("generate triples for t={}", t.inner()), Self::run_background_triple_generation( @@ -306,7 +296,7 @@ impl SignatureProvider for EcdsaSignatureProvider { self.mpc_config.clone(), self.config.triple.clone().into(), triple_store.clone(), - reconstruction_threshold_bound, + threshold_bound, ), )); } @@ -321,15 +311,14 @@ impl SignatureProvider for EcdsaSignatureProvider { let mut generate_presignatures = Vec::new(); for (domain_id, data) in &self.per_domain_data { let t = data.reconstruction_threshold; - let reconstruction_threshold_usize: usize = t.inner().try_into()?; - let reconstruction_threshold_bound = - TSReconstructionThreshold::from(reconstruction_threshold_usize); + let threshold_usize: usize = t.inner().try_into()?; + let threshold_bound = TSReconstructionThreshold::from(threshold_usize); let triple_store = self.triple_store_for_t(t)?; generate_presignatures.push(tracking::spawn( &format!("generate presignatures for domain {}", domain_id.0), Self::run_background_presignature_generation( self.client.clone(), - reconstruction_threshold_bound, + threshold_bound, self.config.presignature.clone().into(), triple_store, *domain_id, diff --git a/crates/node/src/providers/ecdsa/key_generation.rs b/crates/node/src/providers/ecdsa/key_generation.rs index 65708932a6..4e26044d77 100644 --- a/crates/node/src/providers/ecdsa/key_generation.rs +++ b/crates/node/src/providers/ecdsa/key_generation.rs @@ -9,14 +9,12 @@ use threshold_signatures::participants::Participant; impl EcdsaSignatureProvider { pub(crate) async fn run_key_generation_client_internal( - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - let key = KeyGenerationComputation { - reconstruction_threshold, - } - .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) - .await?; + let key = KeyGenerationComputation { threshold } + .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) + .await?; tracing::info!("Ecdsa secp256k1 key generation completed"); Ok(key) @@ -26,7 +24,7 @@ impl EcdsaSignatureProvider { /// Runs the key generation protocol, returning the key generated. /// This protocol is identical for the leader and the followers. pub struct KeyGenerationComputation { - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, } #[async_trait::async_trait] @@ -42,7 +40,7 @@ impl MpcLeaderCentricComputation for KeyGenerationComputation { let protocol = threshold_signatures::keygen::( &cs_participants, me.into(), - self.reconstruction_threshold, + self.threshold, OsRng, )?; run_protocol("ecdsa key generation", channel, protocol).await @@ -108,7 +106,7 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyGenerationComputation { - reconstruction_threshold: ReconstructionThreshold::from(3), + threshold: ReconstructionThreshold::from(3), } .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) .await?; diff --git a/crates/node/src/providers/ecdsa/key_resharing.rs b/crates/node/src/providers/ecdsa/key_resharing.rs index 1210b0cc1c..ec2f1a4396 100644 --- a/crates/node/src/providers/ecdsa/key_resharing.rs +++ b/crates/node/src/providers/ecdsa/key_resharing.rs @@ -12,17 +12,17 @@ use threshold_signatures::participants::Participant; impl EcdsaSignatureProvider { pub(crate) async fn run_key_resharing_client_internal( - new_reconstruction_threshold: ReconstructionThreshold, - old_reconstruction_threshold: ReconstructionThreshold, + new_threshold: ReconstructionThreshold, + old_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { let new_keyshare = KeyResharingComputation { - reconstruction_threshold: new_reconstruction_threshold, + threshold: new_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_reconstruction_threshold, + old_threshold, my_share, public_key, } @@ -48,9 +48,9 @@ impl EcdsaSignatureProvider { /// the old threshold; or /// - the threshold is larger than the number of participants. pub struct KeyResharingComputation { - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, old_participants: Vec, - old_reconstruction_threshold: ReconstructionThreshold, + old_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, } @@ -74,11 +74,11 @@ impl MpcLeaderCentricComputation for KeyResharingComputation { let protocol = threshold_signatures::reshare::( &old_participants, - self.old_reconstruction_threshold, + self.old_threshold, self.my_share, self.public_key, &new_participants, - self.reconstruction_threshold, + self.threshold, me.into(), OsRng, )?; @@ -150,9 +150,9 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyResharingComputation { - reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), + threshold: ReconstructionThreshold::from(THRESHOLD), old_participants, - old_reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), + old_threshold: ReconstructionThreshold::from(THRESHOLD), my_share: keyshare, public_key: pubkey, } diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index babe0f0c95..c8a7946b88 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -65,7 +65,7 @@ impl EcdsaSignatureProvider { /// so that needs to be separately handled. pub(super) async fn run_background_presignature_generation( client: Arc, - reconstruction_threshold: TSReconstructionThreshold, + threshold: TSReconstructionThreshold, config: Arc, triple_store: Arc, domain_id: DomainId, @@ -131,7 +131,7 @@ impl EcdsaSignatureProvider { let _in_flight = in_flight; let _semaphore_guard = parallelism_limiter.acquire().await?; let presignature = PresignComputation { - reconstruction_threshold, + threshold, triple0, triple1, keygen_out, @@ -181,21 +181,19 @@ impl EcdsaSignatureProvider { // The triple store is keyed by the domain's reconstruction threshold // `t`. For cait-sith the leader pairs exactly `t` participants, so the // channel participant count must match — cross-check it. - let reconstruction_threshold = domain_data.reconstruction_threshold; - let reconstruction_threshold_usize: usize = reconstruction_threshold.inner().try_into()?; - if channel.participants().len() != reconstruction_threshold_usize { + let threshold = domain_data.reconstruction_threshold; + let threshold_usize: usize = threshold.inner().try_into()?; + if channel.participants().len() != threshold_usize { metrics::MPC_NUM_BAD_PEER_PRESIGN_REQUESTS.inc(); anyhow::bail!( "presign participant count ({}) does not match domain threshold t={}", channel.participants().len(), - reconstruction_threshold_usize, + threshold_usize, ); } - let triple_store = self.triple_store_for_t(reconstruction_threshold)?; + let triple_store = self.triple_store_for_t(threshold)?; FollowerPresignComputation { - reconstruction_threshold: TSReconstructionThreshold::from( - reconstruction_threshold_usize, - ), + threshold: TSReconstructionThreshold::from(threshold_usize), keygen_out: domain_data.keyshare, triple_store, paired_triple_id, @@ -229,7 +227,7 @@ impl HasParticipants for PresignOutputWithParticipants { /// Performs an MPC presignature operation. This is shared for the initiator /// and for passive participants. pub struct PresignComputation { - reconstruction_threshold: TSReconstructionThreshold, + threshold: TSReconstructionThreshold, triple0: TripleGenerationOutput, triple1: TripleGenerationOutput, keygen_out: KeygenOutput, @@ -252,7 +250,7 @@ impl MpcLeaderCentricComputation for PresignComputation { triple0: self.triple0, triple1: self.triple1, keygen_out: self.keygen_out, - threshold: self.reconstruction_threshold, + threshold: self.threshold, }, )?; let _timer = metrics::MPC_PRE_SIGNATURE_TIME_ELAPSED.start_timer(); @@ -269,7 +267,7 @@ impl MpcLeaderCentricComputation for PresignComputation { /// The difference is: we need to read the triples from the triple store (which may fail), /// and we need to write the presignature to the presignature store before completing. pub struct FollowerPresignComputation { - pub reconstruction_threshold: TSReconstructionThreshold, + pub threshold: TSReconstructionThreshold, pub paired_triple_id: UniqueId, pub keygen_out: KeygenOutput, pub triple_store: Arc, @@ -283,7 +281,7 @@ impl MpcLeaderCentricComputation<()> for FollowerPresignComputation { async fn compute(self, channel: &mut NetworkTaskChannel) -> anyhow::Result<()> { let (triple0, triple1) = self.triple_store.take_unowned(self.paired_triple_id)?; let presignature = PresignComputation { - reconstruction_threshold: self.reconstruction_threshold, + threshold: self.threshold, triple0, triple1, keygen_out: self.keygen_out, diff --git a/crates/node/src/providers/ecdsa/sign.rs b/crates/node/src/providers/ecdsa/sign.rs index b688684378..b18f99d345 100644 --- a/crates/node/src/providers/ecdsa/sign.rs +++ b/crates/node/src/providers/ecdsa/sign.rs @@ -31,13 +31,12 @@ impl EcdsaSignatureProvider { ) -> anyhow::Result<(Signature, VerifyingKey)> { let domain_data = self.domain_data(sign_request.domain)?; let participants = presignature.participants.clone(); - let reconstruction_threshold: usize = - domain_data.reconstruction_threshold.inner().try_into()?; - let reconstruction_threshold = ReconstructionThreshold::from(reconstruction_threshold); + let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; + let threshold = ReconstructionThreshold::from(threshold); let (signature, public_key) = SignComputation { keygen_out: domain_data.keyshare, - reconstruction_threshold, + threshold, presign_out: presignature.presignature, msg_hash: *sign_request .payload @@ -93,14 +92,13 @@ impl EcdsaSignatureProvider { // The presignature must be owned by the leader, never one of ours. presignature_id.validate_owned_by(channel.sender().get_leader())?; let domain_data = self.domain_data(sign_request.domain)?; - let reconstruction_threshold: usize = - domain_data.reconstruction_threshold.inner().try_into()?; - let reconstruction_threshold = ReconstructionThreshold::from(reconstruction_threshold); + let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; + let threshold = ReconstructionThreshold::from(threshold); let participants = channel.participants().to_vec(); FollowerSignComputation { keygen_out: domain_data.keyshare, - reconstruction_threshold, + threshold, presignature_store: domain_data.presignature_store.clone(), presignature_id, msg_hash: *sign_request @@ -151,7 +149,7 @@ impl EcdsaSignatureProvider { /// The tweak allows key derivation pub struct SignComputation { pub keygen_out: KeygenOutput, - pub reconstruction_threshold: ReconstructionThreshold, + pub threshold: ReconstructionThreshold, pub presign_out: PresignOutput, pub msg_hash: [u8; 32], pub tweak: Tweak, @@ -200,7 +198,7 @@ impl MpcLeaderCentricComputation<(SignatureOption, VerifyingKey)> for SignComput let protocol = threshold_signatures::ecdsa::ot_based_ecdsa::sign::sign( &cs_participants, channel.sender().get_leader().into(), - self.reconstruction_threshold, + self.threshold, channel.my_participant_id().into(), derived_public_key, rerandomized_presignature, @@ -220,7 +218,7 @@ impl MpcLeaderCentricComputation<(SignatureOption, VerifyingKey)> for SignComput /// The difference is that the follower needs to look up the presignature, which may fail. pub struct FollowerSignComputation { pub keygen_out: KeygenOutput, - pub reconstruction_threshold: ReconstructionThreshold, + pub threshold: ReconstructionThreshold, pub presignature_id: UniqueId, pub presignature_store: Arc, pub msg_hash: [u8; 32], @@ -237,7 +235,7 @@ impl MpcLeaderCentricComputation<()> for FollowerSignComputation { .presignature; SignComputation { keygen_out: self.keygen_out, - reconstruction_threshold: self.reconstruction_threshold, + threshold: self.threshold, presign_out, msg_hash: self.msg_hash, tweak: self.tweak, diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index 11a81e2016..9632ae53ca 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -36,13 +36,13 @@ impl TripleStorage { db: Arc, my_participant_id: ParticipantId, alive_participant_ids_query: Arc Vec + Send + Sync>, - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, ) -> anyhow::Result { Ok(Self(DistributedAssetStorage::::new( clock, db, DBCol::TripleV2, - reconstruction_threshold.inner().to_be_bytes().to_vec(), + threshold.inner().to_be_bytes().to_vec(), my_participant_id, |participants, pair| pair.is_subset_of_active_participants(participants), alive_participant_ids_query, @@ -95,7 +95,7 @@ impl EcdsaSignatureProvider { mpc_config: Arc, config: Arc, triple_store: Arc, - reconstruction_threshold: TSReconstructionThreshold, + threshold: TSReconstructionThreshold, ) -> ! { let in_flight_generations = InFlightGenerationTracker::new(); let parallelism_limiter = Arc::new(tokio::sync::Semaphore::new(config.concurrency)); @@ -119,7 +119,7 @@ impl EcdsaSignatureProvider { < config.concurrency * 2 * SUPPORTED_TRIPLE_GENERATION_BATCH_SIZE { let participants = match client.select_random_active_participants_including_me( - reconstruction_threshold.value(), + threshold.value(), &running_participants, ) { Ok(participants) => participants, @@ -166,7 +166,7 @@ impl EcdsaSignatureProvider { let triples = (ManyTripleGenerationComputation::< SUPPORTED_TRIPLE_GENERATION_BATCH_SIZE, > { - reconstruction_threshold, + threshold, }) .perform_leader_centric_computation( channel, @@ -220,14 +220,11 @@ impl EcdsaSignatureProvider { // Cait-sith triple generation runs with exactly `t` participants, so we // can derive the store's `t` from the channel's participant list // without a wire-format change to `EcdsaTaskId::ManyTriples`. - let reconstruction_threshold_usize: usize = channel.participants().len(); - let reconstruction_threshold = - ReconstructionThreshold::new(reconstruction_threshold_usize.try_into()?); - let triple_store = self.triple_store_for_t(reconstruction_threshold)?; + let threshold_usize: usize = channel.participants().len(); + let threshold = ReconstructionThreshold::new(threshold_usize.try_into()?); + let triple_store = self.triple_store_for_t(threshold)?; FollowerManyTripleGenerationComputation:: { - reconstruction_threshold: TSReconstructionThreshold::from( - reconstruction_threshold_usize, - ), + threshold: TSReconstructionThreshold::from(threshold_usize), out_triple_id_start: start, out_triple_store: triple_store, } @@ -255,7 +252,7 @@ impl HasParticipants for PairedTriple { /// Generates many cait-sith triples at once. This can significantly save the /// *number* of network messages. pub struct ManyTripleGenerationComputation { - pub reconstruction_threshold: TSReconstructionThreshold, + pub threshold: TSReconstructionThreshold, } #[async_trait::async_trait] @@ -275,13 +272,11 @@ impl MpcLeaderCentricComputation> .map(Participant::from) .collect::>(); let me = channel.my_participant_id(); - let protocol = - threshold_signatures::ecdsa::ot_based_ecdsa::triples::generate_triple_many::( - &cs_participants, - me.into(), - self.reconstruction_threshold, - OsRng, - )?; + let protocol = threshold_signatures::ecdsa::ot_based_ecdsa::triples::generate_triple_many::< + N, + _, + _, + >(&cs_participants, me.into(), self.threshold, OsRng)?; let _timer = metrics::MPC_TRIPLES_GENERATION_TIME_ELAPSED.start_timer(); let triples = run_protocol("many triple gen", channel, protocol).await?; metrics::MPC_NUM_TRIPLES_GENERATED.inc_by(N as u64); @@ -305,7 +300,7 @@ impl MpcLeaderCentricComputation> /// The follower version of the triple generation. The difference is that the follower will only /// complete the computation after successfully persisting the triples to storage. pub struct FollowerManyTripleGenerationComputation { - pub reconstruction_threshold: TSReconstructionThreshold, + pub threshold: TSReconstructionThreshold, pub out_triple_store: Arc, pub out_triple_id_start: UniqueId, } @@ -316,7 +311,7 @@ impl MpcLeaderCentricComputation<()> { async fn compute(self, channel: &mut NetworkTaskChannel) -> anyhow::Result<()> { let triples = ManyTripleGenerationComputation:: { - reconstruction_threshold: self.reconstruction_threshold, + threshold: self.threshold, } .compute(channel) .await?; @@ -422,7 +417,7 @@ mod tests { panic!("Unexpected task id"); }; let triples = ManyTripleGenerationComputation:: { - reconstruction_threshold: TSReconstructionThreshold::from(THRESHOLD), + threshold: TSReconstructionThreshold::from(THRESHOLD), } .perform_leader_centric_computation( channel, @@ -470,7 +465,7 @@ mod tests { let result = tracking::spawn( &format!("task {:?}", task_id), ManyTripleGenerationComputation:: { - reconstruction_threshold: TSReconstructionThreshold::from(THRESHOLD), + threshold: TSReconstructionThreshold::from(THRESHOLD), } .perform_leader_centric_computation( channel, @@ -505,7 +500,7 @@ mod tests { fn new_triple_store( db: Arc, my_participant_id: ParticipantId, - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, alive: Vec, ) -> TripleStorage { TripleStorage::new( @@ -513,7 +508,7 @@ mod tests { db, my_participant_id, Arc::new(move || alive.clone()), - reconstruction_threshold, + threshold, ) .unwrap() } diff --git a/crates/node/src/providers/eddsa.rs b/crates/node/src/providers/eddsa.rs index a6b92721f6..1146e93c91 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -51,19 +51,18 @@ impl EddsaSignatureProvider { client: Arc, sign_request_store: Arc, keyshares: HashMap, - reconstruction_thresholds: HashMap, + thresholds: HashMap, ) -> anyhow::Result { let mut per_domain_data = HashMap::new(); for (domain_id, keyshare) in keyshares { - let reconstruction_threshold = - *reconstruction_thresholds.get(&domain_id).ok_or_else(|| { - anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) - })?; + let threshold = *thresholds.get(&domain_id).ok_or_else(|| { + anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) + })?; per_domain_data.insert( domain_id, PerDomainData { keyshare, - reconstruction_threshold, + reconstruction_threshold: threshold, }, ); } @@ -115,23 +114,23 @@ impl SignatureProvider for EddsaSignatureProvider { } async fn run_key_generation_client( - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - Self::run_key_generation_client_internal(reconstruction_threshold, channel).await + Self::run_key_generation_client_internal(threshold, channel).await } async fn run_key_resharing_client( - new_reconstruction_threshold: ReconstructionThreshold, - old_reconstruction_threshold: ReconstructionThreshold, + new_threshold: ReconstructionThreshold, + old_threshold: ReconstructionThreshold, key_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { Self::run_key_resharing_client_internal( - new_reconstruction_threshold, - old_reconstruction_threshold, + new_threshold, + old_threshold, key_share, public_key, old_participants, diff --git a/crates/node/src/providers/eddsa/key_generation.rs b/crates/node/src/providers/eddsa/key_generation.rs index 04c963b676..e4cd856487 100644 --- a/crates/node/src/providers/eddsa/key_generation.rs +++ b/crates/node/src/providers/eddsa/key_generation.rs @@ -10,14 +10,12 @@ use threshold_signatures::participants::Participant; impl EddsaSignatureProvider { pub(super) async fn run_key_generation_client_internal( - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - let key = KeyGenerationComputation { - reconstruction_threshold, - } - .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) - .await?; + let key = KeyGenerationComputation { threshold } + .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) + .await?; tracing::info!("Eddsa key generation completed"); Ok(key) @@ -27,7 +25,7 @@ impl EddsaSignatureProvider { /// Runs the key generation protocol, returning the key generated. /// This protocol is identical for the leader and the followers. pub struct KeyGenerationComputation { - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, } #[async_trait::async_trait] @@ -43,7 +41,7 @@ impl MpcLeaderCentricComputation for KeyGenerationComputation { let protocol = threshold_signatures::keygen::( &cs_participants, me.into(), - self.reconstruction_threshold, + self.threshold, OsRng, )?; run_protocol("eddsa key generation", channel, protocol).await @@ -110,7 +108,7 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyGenerationComputation { - reconstruction_threshold: ReconstructionThreshold::from(3), + threshold: ReconstructionThreshold::from(3), } .perform_leader_centric_computation(channel, std::time::Duration::from_secs(60)) .await?; diff --git a/crates/node/src/providers/eddsa/key_resharing.rs b/crates/node/src/providers/eddsa/key_resharing.rs index babf7c6dcd..bc35b5f4c6 100644 --- a/crates/node/src/providers/eddsa/key_resharing.rs +++ b/crates/node/src/providers/eddsa/key_resharing.rs @@ -13,17 +13,17 @@ use threshold_signatures::participants::Participant; impl EddsaSignatureProvider { pub(super) async fn run_key_resharing_client_internal( - new_reconstruction_threshold: ReconstructionThreshold, - old_reconstruction_threshold: ReconstructionThreshold, + new_threshold: ReconstructionThreshold, + old_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { let new_keyshare = KeyResharingComputation { - reconstruction_threshold: new_reconstruction_threshold, + threshold: new_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_reconstruction_threshold, + old_threshold, my_share, public_key, } @@ -49,9 +49,9 @@ impl EddsaSignatureProvider { /// the old threshold; or /// - the threshold is larger than the number of participants. pub struct KeyResharingComputation { - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, old_participants: Vec, - old_reconstruction_threshold: ReconstructionThreshold, + old_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, } @@ -75,11 +75,11 @@ impl MpcLeaderCentricComputation for KeyResharingComputation { let protocol = threshold_signatures::reshare::( &old_participants, - self.old_reconstruction_threshold, + self.old_threshold, self.my_share, self.public_key, &new_participants, - self.reconstruction_threshold, + self.threshold, me.into(), OsRng, )?; @@ -152,9 +152,9 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("No channel"))? }; let key = KeyResharingComputation { - reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), + threshold: ReconstructionThreshold::from(THRESHOLD), old_participants, - old_reconstruction_threshold: ReconstructionThreshold::from(THRESHOLD), + old_threshold: ReconstructionThreshold::from(THRESHOLD), my_share: keyshare, public_key: pubkey, } diff --git a/crates/node/src/providers/eddsa/sign.rs b/crates/node/src/providers/eddsa/sign.rs index c198dcc936..bc5c2d2230 100644 --- a/crates/node/src/providers/eddsa/sign.rs +++ b/crates/node/src/providers/eddsa/sign.rs @@ -25,9 +25,8 @@ impl EddsaSignatureProvider { let sign_request = self.sign_request_store.get(id).await?; let domain_data = self.domain_data(sign_request.domain)?; - let reconstruction_threshold: usize = - domain_data.reconstruction_threshold.inner().try_into()?; - let reconstruction_threshold = ReconstructionThreshold::from(reconstruction_threshold); + let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; + let threshold = ReconstructionThreshold::from(threshold); let running_participants: Vec<_> = self .mpc_config .participants @@ -39,7 +38,7 @@ impl EddsaSignatureProvider { let participants = self .client .select_random_active_participants_including_me( - reconstruction_threshold.value(), + threshold.value(), &running_participants, ) .context("Can't choose active participants for a eddsa signature")?; @@ -50,7 +49,7 @@ impl EddsaSignatureProvider { let result = SignComputation { keygen_output: domain_data.keyshare, - reconstruction_threshold, + threshold, message: sign_request .payload .as_eddsa() @@ -94,14 +93,13 @@ impl EddsaSignatureProvider { metrics::MPC_NUM_PASSIVE_SIGN_REQUESTS_LOOKUP_SUCCEEDED.inc(); let domain_data = self.domain_data(sign_request.domain)?; - let reconstruction_threshold: usize = - domain_data.reconstruction_threshold.inner().try_into()?; - let reconstruction_threshold = ReconstructionThreshold::from(reconstruction_threshold); + let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; + let threshold = ReconstructionThreshold::from(threshold); let participants = channel.participants().to_vec(); let _ = SignComputation { keygen_output: domain_data.keyshare, - reconstruction_threshold, + threshold, message: sign_request .payload .as_eddsa() @@ -133,7 +131,7 @@ impl EddsaSignatureProvider { /// The tweak allows key derivation pub struct SignComputation { pub keygen_output: KeygenOutput, - pub reconstruction_threshold: ReconstructionThreshold, + pub threshold: ReconstructionThreshold, pub message: Vec, pub tweak: Tweak, } @@ -160,7 +158,7 @@ impl MpcLeaderCentricComputation> for SignComp let protocol = sign( cs_participants.as_slice(), - self.reconstruction_threshold, + self.threshold, channel.my_participant_id().into(), channel.sender().get_leader().into(), derived_keygen_output.clone(), diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index bdaaac7095..8c93471065 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -65,7 +65,7 @@ impl RobustEcdsaSignatureProvider { db: Arc, sign_request_store: Arc, keyshares: HashMap, - reconstruction_thresholds: HashMap, + thresholds: HashMap, ) -> anyhow::Result { let active_participants_query = { let network_client = client.clone(); @@ -74,10 +74,9 @@ impl RobustEcdsaSignatureProvider { let mut per_domain_data = HashMap::new(); for (domain_id, keyshare) in keyshares { - let reconstruction_threshold = - *reconstruction_thresholds.get(&domain_id).ok_or_else(|| { - anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) - })?; + let threshold = *thresholds.get(&domain_id).ok_or_else(|| { + anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) + })?; let presignature_store = Arc::new(PresignatureStorage::new( clock.clone(), db.clone(), @@ -90,7 +89,7 @@ impl RobustEcdsaSignatureProvider { PerDomainData { keyshare, presignature_store, - reconstruction_threshold, + reconstruction_threshold: threshold, }, ); } @@ -154,23 +153,19 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { } async fn run_key_generation_client( - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { // robust-ECDSA shares the secret on a degree `MaxMalicious = t - 1` // polynomial, i.e. reconstruction lower bound `= MaxMalicious + 1 = t`. - // `reconstruction_threshold` is already that lower bound (= the domain's + // `threshold` is already that lower bound (= the domain's // `t`), so the keygen is identical to cait-sith — pass it straight through. - EcdsaSignatureProvider::run_key_generation_client_internal( - reconstruction_threshold, - channel, - ) - .await + EcdsaSignatureProvider::run_key_generation_client_internal(threshold, channel).await } async fn run_key_resharing_client( - new_reconstruction_threshold: ReconstructionThreshold, - old_reconstruction_threshold: ReconstructionThreshold, + new_threshold: ReconstructionThreshold, + old_threshold: ReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, @@ -178,8 +173,8 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { ) -> anyhow::Result { // For robust-ECDSA the reconstruction lower bound equals `t`, so resharing is identical to cait-sith. EcdsaSignatureProvider::run_key_resharing_client_internal( - new_reconstruction_threshold, - old_reconstruction_threshold, + new_threshold, + old_threshold, my_share, public_key, old_participants, diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index 2c5281f11f..c3a16cf143 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -66,7 +66,7 @@ impl PresignatureStorage { pub(super) async fn run_background_presignature_generation( client: Arc, mpc_config: Arc, - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, config: Arc, domain_id: DomainId, presignature_store: Arc, @@ -88,7 +88,7 @@ pub(super) async fn run_background_presignature_generation( .map(|p| p.id) .collect(); - let (num_signers, robust_ecdsa_threshold) = compute_thresholds(reconstruction_threshold)?; + let (num_signers, robust_ecdsa_threshold) = compute_thresholds(threshold)?; loop { progress_tracker.update_progress(); @@ -181,9 +181,9 @@ pub(super) async fn run_background_presignature_generation( /// reconstruction threshold `t`. Returns an error if `t < 2`, /// which the contract's threshold validation already rejects. pub(super) fn compute_thresholds( - reconstruction_threshold: ReconstructionThreshold, + threshold: ReconstructionThreshold, ) -> anyhow::Result<(usize, MaxMalicious)> { - let t: usize = reconstruction_threshold.inner().try_into()?; + let t: usize = threshold.inner().try_into()?; anyhow::ensure!( t >= 2, "robust-ECDSA requires a reconstruction threshold of at least 2, got {t}" diff --git a/crates/node/src/providers/verify_foreign_tx.rs b/crates/node/src/providers/verify_foreign_tx.rs index 9d0cb945d2..a4b9718d76 100644 --- a/crates/node/src/providers/verify_foreign_tx.rs +++ b/crates/node/src/providers/verify_foreign_tx.rs @@ -186,7 +186,7 @@ where } async fn run_key_generation_client( - _reconstruction_threshold: ReconstructionThreshold, + _threshold: ReconstructionThreshold, _channel: NetworkTaskChannel, ) -> anyhow::Result { anyhow::bail!( @@ -195,8 +195,8 @@ where } async fn run_key_resharing_client( - _new_reconstruction_threshold: ReconstructionThreshold, - _old_reconstruction_threshold: ReconstructionThreshold, + _new_threshold: ReconstructionThreshold, + _old_threshold: ReconstructionThreshold, _key_share: Option, _public_key: VerifyingKey, _old_participants: &ParticipantsConfig, From 3c3912fcb69d1ebd5951e9a1ff0176338683ee53 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:38:28 +0200 Subject: [PATCH 057/121] Reducing code redundancy --- .../tests/request_during_resharing.rs | 51 ++++++++----------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/crates/e2e-tests/tests/request_during_resharing.rs b/crates/e2e-tests/tests/request_during_resharing.rs index da3a146e0f..0a3b4d6aca 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -1,11 +1,26 @@ use crate::common; -use mpc_primitives::domain::{Curve, DomainId}; +use mpc_primitives::domain::DomainId; use near_mpc_contract_interface::types::{ DomainConfig, DomainPurpose, Protocol, ProtocolContractState, ReconstructionThreshold, + RunningContractState, }; use rand::SeedableRng; +/// Returns the single domain running `protocol_type`. +/// +/// Each protocol appears at most once in this test's domain registry, so the +/// protocol uniquely identifies its domain. +fn create_domain(contract_state: &RunningContractState, protocol_type: Protocol) -> DomainConfig { + contract_state + .domains + .domains + .iter() + .find(|d| d.protocol == protocol_type) + .unwrap_or_else(|| panic!("no domain with protocol {protocol_type:?}")) + .clone() +} + /// Tests that signature and CKD requests are processed using the previous /// running state's threshold while resharing is in progress. /// @@ -46,36 +61,10 @@ async fn test_request_during_resharing() { cluster.kill_nodes(&[5]).expect("failed to kill node 5"); // then - let ecdsa_domain = contract_state - .domains - .domains - .iter() - .find(|d| { - Curve::from(d.protocol) == Curve::Secp256k1 - && d.protocol == Protocol::CaitSith - && d.purpose == DomainPurpose::Sign - }) - .expect("no CaitSith Sign domain"); - let robust_ecdsa_domain = contract_state - .domains - .domains - .iter() - .find(|d| d.protocol == Protocol::DamgardEtAl && d.purpose == DomainPurpose::Sign) - .expect("no DamgardEtAl Sign domain"); - let eddsa_domain = contract_state - .domains - .domains - .iter() - .find(|d| { - Curve::from(d.protocol) == Curve::Edwards25519 && d.purpose == DomainPurpose::Sign - }) - .expect("no Edwards25519 Sign domain"); - let ckd_domain = contract_state - .domains - .domains - .iter() - .find(|d| d.purpose == DomainPurpose::CKD) - .expect("no CKD domain"); + let ecdsa_domain = create_domain(&contract_state, Protocol::CaitSith); + let robust_ecdsa_domain = create_domain(&contract_state, Protocol::DamgardEtAl); + let eddsa_domain = create_domain(&contract_state, Protocol::Frost); + let ckd_domain = create_domain(&contract_state, Protocol::ConfidentialKeyDerivation); let mut rng = rand::rngs::StdRng::seed_from_u64(0); for i in 0..3 { From aa1b927d5ec6f55b93635b2a0dbaca233e8adc76 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:44:54 +0200 Subject: [PATCH 058/121] Reducing code redundancy --- .../tests/request_during_resharing.rs | 51 ++++++++----------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/crates/e2e-tests/tests/request_during_resharing.rs b/crates/e2e-tests/tests/request_during_resharing.rs index 6a37984881..2b7d079adf 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -1,11 +1,26 @@ use crate::common; -use mpc_primitives::domain::{Curve, DomainId}; +use mpc_primitives::domain::DomainId; use near_mpc_contract_interface::types::{ DomainConfig, DomainPurpose, Protocol, ProtocolContractState, ReconstructionThreshold, + RunningContractState, }; use rand::SeedableRng; +/// Returns the single domain running `protocol_type`. +/// +/// Each protocol appears at most once in this test's domain registry, so the +/// protocol uniquely identifies its domain. +fn create_domain(contract_state: &RunningContractState, protocol_type: Protocol) -> DomainConfig { + contract_state + .domains + .domains + .iter() + .find(|d| d.protocol == protocol_type) + .unwrap_or_else(|| panic!("no domain with protocol {protocol_type:?}")) + .clone() +} + /// Tests that signature and CKD requests are processed using the previous /// running state's threshold while resharing is in progress. /// @@ -46,36 +61,10 @@ async fn test_request_during_resharing() { cluster.kill_nodes(&[5]).expect("failed to kill node 5"); // then - let ecdsa_domain = contract_state - .domains - .domains - .iter() - .find(|d| { - Curve::from(d.protocol) == Curve::Secp256k1 - && d.protocol == Protocol::CaitSith - && d.purpose == DomainPurpose::Sign - }) - .expect("no CaitSith Sign domain"); - let robust_ecdsa_domain = contract_state - .domains - .domains - .iter() - .find(|d| d.protocol == Protocol::DamgardEtAl && d.purpose == DomainPurpose::Sign) - .expect("no DamgardEtAl Sign domain"); - let eddsa_domain = contract_state - .domains - .domains - .iter() - .find(|d| { - Curve::from(d.protocol) == Curve::Edwards25519 && d.purpose == DomainPurpose::Sign - }) - .expect("no Edwards25519 Sign domain"); - let ckd_domain = contract_state - .domains - .domains - .iter() - .find(|d| d.purpose == DomainPurpose::CKD) - .expect("no CKD domain"); + let ecdsa_domain = create_domain(&contract_state, Protocol::CaitSith); + let robust_ecdsa_domain = create_domain(&contract_state, Protocol::DamgardEtAl); + let eddsa_domain = create_domain(&contract_state, Protocol::Frost); + let ckd_domain = create_domain(&contract_state, Protocol::ConfidentialKeyDerivation); let mut rng = rand::rngs::StdRng::seed_from_u64(0); for i in 0..3 { From 159764713fbf90d7e3cf361af05783c635ac590c Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:54:47 +0200 Subject: [PATCH 059/121] reducing more code redundancy --- .../distinct_reconstruction_thresholds.rs | 33 ++++--------------- .../tests/request_during_resharing.rs | 13 +++++--- 2 files changed, 14 insertions(+), 32 deletions(-) diff --git a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs index ce38e205a6..293228d002 100644 --- a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -1,6 +1,7 @@ use crate::common; +use crate::request_during_resharing::must_get_domain; -use mpc_primitives::domain::{Curve, DomainId}; +use mpc_primitives::domain::DomainId; use near_mpc_contract_interface::types::{ DomainConfig, DomainPurpose, Protocol, ReconstructionThreshold, }; @@ -31,32 +32,10 @@ async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { }) .await; - let ecdsa_domain = contract_state - .domains - .domains - .iter() - .find(|d| d.protocol == Protocol::CaitSith && d.purpose == DomainPurpose::Sign) - .expect("no CaitSith Sign domain"); - let robust_ecdsa_domain = contract_state - .domains - .domains - .iter() - .find(|d| d.protocol == Protocol::DamgardEtAl && d.purpose == DomainPurpose::Sign) - .expect("no DamgardEtAl Sign domain"); - let eddsa_domain = contract_state - .domains - .domains - .iter() - .find(|d| { - Curve::from(d.protocol) == Curve::Edwards25519 && d.purpose == DomainPurpose::Sign - }) - .expect("no Edwards25519 Sign domain"); - let ckd_domain = contract_state - .domains - .domains - .iter() - .find(|d| d.purpose == DomainPurpose::CKD) - .expect("no CKD domain"); + let ecdsa_domain = must_get_domain(&contract_state, Protocol::CaitSith); + let robust_ecdsa_domain = must_get_domain(&contract_state, Protocol::DamgardEtAl); + let eddsa_domain = must_get_domain(&contract_state, Protocol::Frost); + let ckd_domain = must_get_domain(&contract_state, Protocol::ConfidentialKeyDerivation); // When / Then let mut rng = rand::rngs::StdRng::seed_from_u64(0); diff --git a/crates/e2e-tests/tests/request_during_resharing.rs b/crates/e2e-tests/tests/request_during_resharing.rs index 2b7d079adf..a6c0c2bb37 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -11,7 +11,10 @@ use rand::SeedableRng; /// /// Each protocol appears at most once in this test's domain registry, so the /// protocol uniquely identifies its domain. -fn create_domain(contract_state: &RunningContractState, protocol_type: Protocol) -> DomainConfig { +pub(crate) fn must_get_domain( + contract_state: &RunningContractState, + protocol_type: Protocol, +) -> DomainConfig { contract_state .domains .domains @@ -61,10 +64,10 @@ async fn test_request_during_resharing() { cluster.kill_nodes(&[5]).expect("failed to kill node 5"); // then - let ecdsa_domain = create_domain(&contract_state, Protocol::CaitSith); - let robust_ecdsa_domain = create_domain(&contract_state, Protocol::DamgardEtAl); - let eddsa_domain = create_domain(&contract_state, Protocol::Frost); - let ckd_domain = create_domain(&contract_state, Protocol::ConfidentialKeyDerivation); + let ecdsa_domain = must_get_domain(&contract_state, Protocol::CaitSith); + let robust_ecdsa_domain = must_get_domain(&contract_state, Protocol::DamgardEtAl); + let eddsa_domain = must_get_domain(&contract_state, Protocol::Frost); + let ckd_domain = must_get_domain(&contract_state, Protocol::ConfidentialKeyDerivation); let mut rng = rand::rngs::StdRng::seed_from_u64(0); for i in 0..3 { From 5055b0e35447e6f60abe854d25330c6ed3a24057 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:06:16 +0200 Subject: [PATCH 060/121] Reducing more code redundancy. Making must_get_domain more generic --- crates/e2e-tests/tests/ckd_verification.rs | 46 ++++++------------- crates/e2e-tests/tests/common.rs | 16 ++++++- .../distinct_reconstruction_thresholds.rs | 14 +++--- .../tests/request_during_resharing.rs | 31 ++++--------- crates/e2e-tests/tests/request_lifecycle.rs | 25 +++++----- 5 files changed, 56 insertions(+), 76 deletions(-) diff --git a/crates/e2e-tests/tests/ckd_verification.rs b/crates/e2e-tests/tests/ckd_verification.rs index a2abfae4fe..3f68714819 100644 --- a/crates/e2e-tests/tests/ckd_verification.rs +++ b/crates/e2e-tests/tests/ckd_verification.rs @@ -1,4 +1,7 @@ -use crate::common; +use crate::common::{ + CKD_PV_VERIFICATION_PORT_SEED, CKD_VERIFICATION_PORT_SEED, must_get_bls_public_key, + must_get_domain, must_setup_cluster, +}; use anyhow::Context; use blstrs::{G1Projective, G2Projective, Scalar}; @@ -7,8 +10,7 @@ use group::ff::Field as _; use near_account_id::AccountId; use near_mpc_contract_interface::types::kdf::derive_app_id; use near_mpc_contract_interface::types::{ - Bls12381G1PublicKey, Bls12381G2PublicKey, CKDAppPublicKey, CKDAppPublicKeyPV, Curve, - DomainPurpose, + Bls12381G1PublicKey, Bls12381G2PublicKey, CKDAppPublicKey, CKDAppPublicKeyPV, Protocol, }; use rand::SeedableRng; use threshold_signatures::confidential_key_derivation::{ @@ -42,20 +44,11 @@ fn verify_ckd( #[expect(non_snake_case)] async fn ckd_response__passes_cryptographic_verification() { // given - let (cluster, running) = - common::must_setup_cluster(common::CKD_VERIFICATION_PORT_SEED, |_| {}).await; - - let bls_domain = running - .domains - .domains - .iter() - .find(|d| { - Curve::from(d.protocol) == Curve::Bls12381 && matches!(d.purpose, DomainPurpose::CKD) - }) - .expect("no Bls12381 CKD domain found") - .clone(); - - let mpc_pk = common::must_get_bls_public_key(&running, bls_domain.id); + let (cluster, running) = must_setup_cluster(CKD_VERIFICATION_PORT_SEED, |_| {}).await; + + let bls_domain = must_get_domain(&running, Protocol::ConfidentialKeyDerivation); + + let mpc_pk = must_get_bls_public_key(&running, bls_domain.id); let user = cluster.default_user_account().clone(); let mut rng = rand::rngs::StdRng::seed_from_u64(1); @@ -95,20 +88,11 @@ async fn ckd_response__passes_cryptographic_verification() { #[expect(non_snake_case)] async fn ckd_pv_response__passes_cryptographic_verification() { // given - let (cluster, running) = - common::must_setup_cluster(common::CKD_PV_VERIFICATION_PORT_SEED, |_| {}).await; - - let bls_domain = running - .domains - .domains - .iter() - .find(|d| { - Curve::from(d.protocol) == Curve::Bls12381 && matches!(d.purpose, DomainPurpose::CKD) - }) - .expect("no Bls12381 CKD domain found") - .clone(); - - let mpc_pk = common::must_get_bls_public_key(&running, bls_domain.id); + let (cluster, running) = must_setup_cluster(CKD_PV_VERIFICATION_PORT_SEED, |_| {}).await; + + let bls_domain = must_get_domain(&running, Protocol::ConfidentialKeyDerivation); + + let mpc_pk = must_get_bls_public_key(&running, bls_domain.id); let user = cluster.default_user_account().clone(); let mut rng = rand::rngs::StdRng::seed_from_u64(2); diff --git a/crates/e2e-tests/tests/common.rs b/crates/e2e-tests/tests/common.rs index 673b137625..1e8e0a5cdf 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -7,8 +7,8 @@ use blstrs::{G1Projective, Scalar}; use e2e_tests::{CLUSTER_WAIT_TIMEOUT, MpcCluster, MpcClusterConfig, metrics}; use group::Group; use near_mpc_contract_interface::types::{ - Bls12381G2PublicKey, CKDAppPublicKey, Curve, DomainId, DomainPurpose, ProtocolContractState, - PublicKey, PublicKeyExtended, RunningContractState, + Bls12381G2PublicKey, CKDAppPublicKey, Curve, DomainConfig, DomainId, DomainPurpose, Protocol, + ProtocolContractState, PublicKey, PublicKeyExtended, RunningContractState, }; use near_mpc_crypto_types::Bls12381G1PublicKey; use serde_json::json; @@ -377,6 +377,18 @@ pub fn must_get_bls_public_key( } } +/// Returns the domain running `protocol_type`, panicking if absent. Each +/// protocol appears at most once per registry, so it identifies a unique domain. +pub fn must_get_domain(running: &RunningContractState, protocol_type: Protocol) -> DomainConfig { + running + .domains + .domains + .iter() + .find(|d| d.protocol == protocol_type) + .unwrap_or_else(|| panic!("no domain with protocol {protocol_type:?}")) + .clone() +} + /// Send a sign request and assert the network produced a successful response. /// /// Panics if the request can't be submitted to the contract — the test cannot diff --git a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs index 293228d002..2eae52f959 100644 --- a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -1,5 +1,7 @@ -use crate::common; -use crate::request_during_resharing::must_get_domain; +use crate::common::{ + DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, generate_ckd_app_public_key, + generate_ecdsa_payload, generate_eddsa_payload, must_get_domain, must_setup_cluster, +}; use mpc_primitives::domain::DomainId; use near_mpc_contract_interface::types::{ @@ -17,7 +19,7 @@ use rand::SeedableRng; async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { // Given let (cluster, contract_state) = - common::must_setup_cluster(common::DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, |c| { + must_setup_cluster(DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, |c| { c.num_nodes = 6; c.initial_participant_indices = (0..6).collect(); c.threshold = 4; @@ -45,9 +47,9 @@ async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { ("EdDSA", eddsa_domain.id, true), ] { let payload = if is_eddsa { - common::generate_eddsa_payload(&mut rng) + generate_eddsa_payload(&mut rng) } else { - common::generate_ecdsa_payload(&mut rng) + generate_ecdsa_payload(&mut rng) }; let outcome = cluster .send_sign_request(domain_id, payload, cluster.default_user_account()) @@ -63,7 +65,7 @@ async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { let outcome = cluster .send_ckd_request( ckd_domain.id, - common::generate_ckd_app_public_key(&mut rng), + generate_ckd_app_public_key(&mut rng), cluster.default_user_account(), ) .await diff --git a/crates/e2e-tests/tests/request_during_resharing.rs b/crates/e2e-tests/tests/request_during_resharing.rs index a6c0c2bb37..c73c067cf6 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -1,29 +1,14 @@ -use crate::common; +use crate::common::{ + REQUEST_DURING_RESHARING_PORT_SEED, generate_ckd_app_public_key, generate_ecdsa_payload, + generate_eddsa_payload, must_get_domain, must_setup_cluster, +}; use mpc_primitives::domain::DomainId; use near_mpc_contract_interface::types::{ DomainConfig, DomainPurpose, Protocol, ProtocolContractState, ReconstructionThreshold, - RunningContractState, }; use rand::SeedableRng; -/// Returns the single domain running `protocol_type`. -/// -/// Each protocol appears at most once in this test's domain registry, so the -/// protocol uniquely identifies its domain. -pub(crate) fn must_get_domain( - contract_state: &RunningContractState, - protocol_type: Protocol, -) -> DomainConfig { - contract_state - .domains - .domains - .iter() - .find(|d| d.protocol == protocol_type) - .unwrap_or_else(|| panic!("no domain with protocol {protocol_type:?}")) - .clone() -} - /// Tests that signature and CKD requests are processed using the previous /// running state's threshold while resharing is in progress. /// @@ -38,7 +23,7 @@ pub(crate) fn must_get_domain( async fn test_request_during_resharing() { // given let (mut cluster, contract_state) = - common::must_setup_cluster(common::REQUEST_DURING_RESHARING_PORT_SEED, |c| { + must_setup_cluster(REQUEST_DURING_RESHARING_PORT_SEED, |c| { c.num_nodes = 6; c.initial_participant_indices = (0..5).collect(); c.threshold = 5; @@ -77,9 +62,9 @@ async fn test_request_during_resharing() { ("EdDSA", eddsa_domain.id, true), ] { let payload = if is_eddsa { - common::generate_eddsa_payload(&mut rng) + generate_eddsa_payload(&mut rng) } else { - common::generate_ecdsa_payload(&mut rng) + generate_ecdsa_payload(&mut rng) }; tracing::info!(i, label, "sending sign request during resharing"); let outcome = cluster @@ -97,7 +82,7 @@ async fn test_request_during_resharing() { let outcome = cluster .send_ckd_request( ckd_domain.id, - common::generate_ckd_app_public_key(&mut rng), + generate_ckd_app_public_key(&mut rng), cluster.default_user_account(), ) .await diff --git a/crates/e2e-tests/tests/request_lifecycle.rs b/crates/e2e-tests/tests/request_lifecycle.rs index bc8d4e3001..9f5775e0a4 100644 --- a/crates/e2e-tests/tests/request_lifecycle.rs +++ b/crates/e2e-tests/tests/request_lifecycle.rs @@ -1,4 +1,7 @@ -use crate::common; +use crate::common::{ + ROBUST_ECDSA_PORT_SEED, SIGN_REQUEST_PER_SCHEME_PORT_SEED, generate_ckd_app_public_key, + generate_ecdsa_payload, generate_eddsa_payload, must_get_domain, must_setup_cluster, +}; use near_mpc_contract_interface::types::{ Curve, DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold, @@ -10,8 +13,7 @@ use rand::SeedableRng; #[expect(non_snake_case)] async fn mpc_cluster__should_sign_with_scheme_matching_domain() { // given - let (cluster, running) = - common::must_setup_cluster(common::SIGN_REQUEST_PER_SCHEME_PORT_SEED, |_| {}).await; + let (cluster, running) = must_setup_cluster(SIGN_REQUEST_PER_SCHEME_PORT_SEED, |_| {}).await; assert!( !running.domains.domains.is_empty(), @@ -23,8 +25,8 @@ async fn mpc_cluster__should_sign_with_scheme_matching_domain() { match domain.purpose { DomainPurpose::Sign => { let payload = match Curve::from(domain.protocol) { - Curve::Secp256k1 => common::generate_ecdsa_payload(&mut rng), - Curve::Edwards25519 => common::generate_eddsa_payload(&mut rng), + Curve::Secp256k1 => generate_ecdsa_payload(&mut rng), + Curve::Edwards25519 => generate_eddsa_payload(&mut rng), _ => continue, }; @@ -62,7 +64,7 @@ async fn mpc_cluster__should_sign_with_scheme_matching_domain() { let outcome = cluster .send_ckd_request( domain.id, - common::generate_ckd_app_public_key(&mut rng), + generate_ckd_app_public_key(&mut rng), cluster.default_user_account(), ) .await @@ -86,7 +88,7 @@ async fn mpc_cluster__should_sign_with_scheme_matching_domain() { #[expect(non_snake_case)] async fn mpc_cluster__should_successfully_process_robust_ecdsa_requests() { // given - let (cluster, running) = common::must_setup_cluster(common::ROBUST_ECDSA_PORT_SEED, |c| { + let (cluster, running) = must_setup_cluster(ROBUST_ECDSA_PORT_SEED, |c| { c.num_nodes = 6; c.initial_participant_indices = (0..6).collect(); c.threshold = 5; @@ -101,12 +103,7 @@ async fn mpc_cluster__should_successfully_process_robust_ecdsa_requests() { }) .await; - let domain = running - .domains - .domains - .iter() - .find(|d| d.protocol == Protocol::DamgardEtAl) - .expect("no DamgardEtAl domain found"); + let domain = must_get_domain(&running, Protocol::DamgardEtAl); let mut rng = rand::rngs::StdRng::seed_from_u64(0); @@ -114,7 +111,7 @@ async fn mpc_cluster__should_successfully_process_robust_ecdsa_requests() { let outcome = cluster .send_sign_request( domain.id, - common::generate_ecdsa_payload(&mut rng), + generate_ecdsa_payload(&mut rng), cluster.default_user_account(), ) .await From 5f71e41fcdcbe0a3109b66ef03bd5480c02ec482 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:17:29 +0200 Subject: [PATCH 061/121] Improving comment --- .../tests/distinct_reconstruction_thresholds.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs index 2eae52f959..f44cf0fe54 100644 --- a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -9,11 +9,12 @@ use near_mpc_contract_interface::types::{ }; use rand::SeedableRng; -/// Every scheme signs when its domain's reconstruction threshold `t` differs -/// from the governance threshold (4): CaitSith/Frost/CKD at `t=2`, DamgardEtAl -/// at `t=3` (6 nodes). Robust ECDSA is the discriminator — it signs over `2t-1` -/// participants, so `t=3` needs 5 signers; using the governance threshold would -/// need `2*4-1=7` and fail. A pass proves the per-domain `t` is used. +/// Each domain signs using its own reconstruction threshold rather than the +/// governance threshold. The governance threshold is 4 with a total of 6 nodes. +/// The signing procedure succeeds despite Damgard et al. requiring at least 7 +/// participants under a governance-threshold signing model. +/// Therefore, signing is performed using the reconstruction threshold, +/// not the governance threshold. #[tokio::test] #[expect(non_snake_case)] async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { From 4874fb33519326dc606234a8d0075e09152940af Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:24:10 +0200 Subject: [PATCH 062/121] no more robust ECDSA comments --- crates/e2e-tests/tests/parallel_sign_calls.rs | 2 +- crates/e2e-tests/tests/request_during_resharing.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/e2e-tests/tests/parallel_sign_calls.rs b/crates/e2e-tests/tests/parallel_sign_calls.rs index 7e6b3ac1b4..b39a56a292 100644 --- a/crates/e2e-tests/tests/parallel_sign_calls.rs +++ b/crates/e2e-tests/tests/parallel_sign_calls.rs @@ -7,7 +7,7 @@ use near_mpc_contract_interface::types::{ }; use serde_json::json; -/// 9 parallel calls (3 robust ECDSA + 2 ECDSA + 2 EdDSA + 2 CKD) via the test parallel +/// 9 parallel calls (3 DamgardEtAl + 2 ECDSA + 2 EdDSA + 2 CKD) via the test parallel /// contract, against a 6-node / threshold-5 cluster that carries all four signing-scheme /// domains. Verifies all calls succeed and both the signature and CKD queues drain. #[tokio::test] diff --git a/crates/e2e-tests/tests/request_during_resharing.rs b/crates/e2e-tests/tests/request_during_resharing.rs index c73c067cf6..ee99e45146 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -13,8 +13,8 @@ use rand::SeedableRng; /// running state's threshold while resharing is in progress. /// /// Setup: 6 nodes, 5 initial participants (threshold 5). Domains cover -/// classic ECDSA (CaitSith), robust ECDSA (DamgardEtAl), EdDSA (Frost) and -/// CKD (ConfidentialKeyDerivation). The robust-ECDSA domain uses a +/// classic ECDSA (CaitSith), DamgardEtAl, EdDSA (Frost) and +/// CKD (ConfidentialKeyDerivation). The DamgardEtAl domain uses a /// reconstruction threshold of `t = 3`, which requires `2t - 1 = 5` signers, /// so we need at least 5 participants. Begin resharing to all 6 with threshold /// 6, then kill node 5 so resharing can't complete. Requests should still From 6c535c50f43df6c8fc81894a7aedba5deb7269c6 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:46:18 +0200 Subject: [PATCH 063/121] reducing even more code redundancy by introducing damgard_etal_domain function --- crates/e2e-tests/tests/common.rs | 13 ++++++++++++- .../tests/distinct_reconstruction_thresholds.rs | 15 ++++----------- crates/e2e-tests/tests/parallel_sign_calls.rs | 7 +------ .../e2e-tests/tests/request_during_resharing.rs | 17 +++++------------ crates/e2e-tests/tests/request_lifecycle.rs | 17 +++++------------ 5 files changed, 27 insertions(+), 42 deletions(-) diff --git a/crates/e2e-tests/tests/common.rs b/crates/e2e-tests/tests/common.rs index 1e8e0a5cdf..92daa2652d 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -8,7 +8,8 @@ use e2e_tests::{CLUSTER_WAIT_TIMEOUT, MpcCluster, MpcClusterConfig, metrics}; use group::Group; use near_mpc_contract_interface::types::{ Bls12381G2PublicKey, CKDAppPublicKey, Curve, DomainConfig, DomainId, DomainPurpose, Protocol, - ProtocolContractState, PublicKey, PublicKeyExtended, RunningContractState, + ProtocolContractState, PublicKey, PublicKeyExtended, ReconstructionThreshold, + RunningContractState, }; use near_mpc_crypto_types::Bls12381G1PublicKey; use serde_json::json; @@ -377,6 +378,16 @@ pub fn must_get_bls_public_key( } } +/// Builds a `DamgardEtAl` signing domain with reconstruction threshold `t`, which needs `2t - 1` signers. +pub fn damgard_etal_domain(id: u64, t: u64) -> DomainConfig { + DomainConfig { + id: DomainId(id), + protocol: Protocol::DamgardEtAl, + reconstruction_threshold: ReconstructionThreshold::new(t), + purpose: DomainPurpose::Sign, + } +} + /// Returns the domain running `protocol_type`, panicking if absent. Each /// protocol appears at most once per registry, so it identifies a unique domain. pub fn must_get_domain(running: &RunningContractState, protocol_type: Protocol) -> DomainConfig { diff --git a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs index f44cf0fe54..c6eca5f10e 100644 --- a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -1,12 +1,9 @@ use crate::common::{ - DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, generate_ckd_app_public_key, + DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, damgard_etal_domain, generate_ckd_app_public_key, generate_ecdsa_payload, generate_eddsa_payload, must_get_domain, must_setup_cluster, }; -use mpc_primitives::domain::DomainId; -use near_mpc_contract_interface::types::{ - DomainConfig, DomainPurpose, Protocol, ReconstructionThreshold, -}; +use near_mpc_contract_interface::types::Protocol; use rand::SeedableRng; /// Each domain signs using its own reconstruction threshold rather than the @@ -26,12 +23,8 @@ async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { c.threshold = 4; c.triples_to_buffer = 2; c.presignatures_to_buffer = 2; - c.domains.push(DomainConfig { - id: DomainId(c.domains.len() as u64), - protocol: Protocol::DamgardEtAl, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }); + c.domains + .push(damgard_etal_domain(c.domains.len() as u64, 3)); }) .await; diff --git a/crates/e2e-tests/tests/parallel_sign_calls.rs b/crates/e2e-tests/tests/parallel_sign_calls.rs index b39a56a292..d538a3dbb9 100644 --- a/crates/e2e-tests/tests/parallel_sign_calls.rs +++ b/crates/e2e-tests/tests/parallel_sign_calls.rs @@ -25,12 +25,7 @@ async fn mpc_cluster_should_successfully_process_parallel_requests() { c.initial_participant_indices = (0..6).collect(); c.threshold = 5; c.domains = vec![ - DomainConfig { - id: DomainId(0), - protocol: Protocol::DamgardEtAl, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, + common::damgard_etal_domain(0, 3), DomainConfig { id: DomainId(1), protocol: Protocol::CaitSith, diff --git a/crates/e2e-tests/tests/request_during_resharing.rs b/crates/e2e-tests/tests/request_during_resharing.rs index ee99e45146..09943e5ced 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -1,12 +1,9 @@ use crate::common::{ - REQUEST_DURING_RESHARING_PORT_SEED, generate_ckd_app_public_key, generate_ecdsa_payload, - generate_eddsa_payload, must_get_domain, must_setup_cluster, + REQUEST_DURING_RESHARING_PORT_SEED, damgard_etal_domain, generate_ckd_app_public_key, + generate_ecdsa_payload, generate_eddsa_payload, must_get_domain, must_setup_cluster, }; -use mpc_primitives::domain::DomainId; -use near_mpc_contract_interface::types::{ - DomainConfig, DomainPurpose, Protocol, ProtocolContractState, ReconstructionThreshold, -}; +use near_mpc_contract_interface::types::{Protocol, ProtocolContractState}; use rand::SeedableRng; /// Tests that signature and CKD requests are processed using the previous @@ -29,12 +26,8 @@ async fn test_request_during_resharing() { c.threshold = 5; c.triples_to_buffer = 2; c.presignatures_to_buffer = 2; - c.domains.push(DomainConfig { - id: DomainId(c.domains.len() as u64), - protocol: Protocol::DamgardEtAl, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }); + c.domains + .push(damgard_etal_domain(c.domains.len() as u64, 3)); }) .await; diff --git a/crates/e2e-tests/tests/request_lifecycle.rs b/crates/e2e-tests/tests/request_lifecycle.rs index 9f5775e0a4..4a01b6be09 100644 --- a/crates/e2e-tests/tests/request_lifecycle.rs +++ b/crates/e2e-tests/tests/request_lifecycle.rs @@ -1,12 +1,10 @@ use crate::common::{ - ROBUST_ECDSA_PORT_SEED, SIGN_REQUEST_PER_SCHEME_PORT_SEED, generate_ckd_app_public_key, - generate_ecdsa_payload, generate_eddsa_payload, must_get_domain, must_setup_cluster, + ROBUST_ECDSA_PORT_SEED, SIGN_REQUEST_PER_SCHEME_PORT_SEED, damgard_etal_domain, + generate_ckd_app_public_key, generate_ecdsa_payload, generate_eddsa_payload, must_get_domain, + must_setup_cluster, }; -use near_mpc_contract_interface::types::{ - Curve, DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold, - SignatureResponse, -}; +use near_mpc_contract_interface::types::{Curve, DomainPurpose, Protocol, SignatureResponse}; use rand::SeedableRng; #[tokio::test] @@ -92,12 +90,7 @@ async fn mpc_cluster__should_successfully_process_robust_ecdsa_requests() { c.num_nodes = 6; c.initial_participant_indices = (0..6).collect(); c.threshold = 5; - c.domains = vec![DomainConfig { - id: DomainId(0), - protocol: Protocol::DamgardEtAl, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }]; + c.domains = vec![damgard_etal_domain(0, 3)]; c.triples_to_buffer = 0; c.presignatures_to_buffer = 6; }) From 446f162e1d900d37e88733ee38da26849b034bed Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:59:39 +0200 Subject: [PATCH 064/121] Reducing drastically redundancy discovered similar to the ones in e2e tests --- crates/node/src/tests/common.rs | 24 +++++++++ crates/node/src/tests/multidomain.rs | 81 ++++++---------------------- 2 files changed, 39 insertions(+), 66 deletions(-) diff --git a/crates/node/src/tests/common.rs b/crates/node/src/tests/common.rs index a2629d42c2..11dc51dba6 100644 --- a/crates/node/src/tests/common.rs +++ b/crates/node/src/tests/common.rs @@ -1,3 +1,7 @@ +use mpc_primitives::domain::DomainId; +use near_mpc_contract_interface::types::{ + DomainConfig, DomainPurpose, Protocol, ReconstructionThreshold, +}; use tokio::sync::mpsc; use crate::indexer::{ @@ -5,6 +9,26 @@ use crate::indexer::{ types::ChainSendTransactionRequest, }; +/// Builds a signing `DomainConfig` for `protocol` with reconstruction threshold `t`. +pub fn sign_domain(id: u64, protocol: Protocol, t: u64) -> DomainConfig { + DomainConfig { + id: DomainId(id), + protocol, + reconstruction_threshold: ReconstructionThreshold::new(t), + purpose: DomainPurpose::Sign, + } +} + +/// Builds a `ConfidentialKeyDerivation` (CKD) `DomainConfig` with reconstruction threshold `t`. +pub fn ckd_domain(id: u64, t: u64) -> DomainConfig { + DomainConfig { + id: DomainId(id), + protocol: Protocol::ConfidentialKeyDerivation, + reconstruction_threshold: ReconstructionThreshold::new(t), + purpose: DomainPurpose::CKD, + } +} + #[derive(Debug, Clone)] pub struct MockTransactionSender { pub transaction_sender: mpsc::Sender, diff --git a/crates/node/src/tests/multidomain.rs b/crates/node/src/tests/multidomain.rs index a56bea9f7a..4b703b0e77 100644 --- a/crates/node/src/tests/multidomain.rs +++ b/crates/node/src/tests/multidomain.rs @@ -1,19 +1,18 @@ use crate::indexer::participants::ContractState; use crate::p2p::testing::PortSeed; +use crate::tests::common::{ckd_domain, sign_domain}; use crate::tests::{ DEFAULT_MAX_PROTOCOL_WAIT_TIME, DEFAULT_MAX_SIGNATURE_WAIT_TIME, IntegrationTestSetup, request_ckd_and_await_response, request_signature_and_await_response, }; use crate::tracking::AutoAbortTask; -use mpc_primitives::domain::{Curve, DomainId}; -use near_mpc_contract_interface::types::{ - DomainConfig, DomainPurpose, Protocol, ReconstructionThreshold, -}; +use mpc_primitives::domain::Curve; +use near_mpc_contract_interface::types::Protocol; use near_time::Clock; // Domains carry per-domain reconstruction thresholds `t` differing from the // governance threshold (4): CaitSith/Frost/CKD at `t=2`, DamgardEtAl at `t=3` -// (5 nodes). Robust ECDSA is the discriminator — it signs over `2t-1` +// (5 nodes). DamgardEtAl is the discriminator — it signs over `2t-1` // participants, so `t=3` needs 5 signers; using the governance threshold would // need `2*4-1=7` and fail. A pass proves the per-domain `t` is used. #[tokio::test] @@ -38,30 +37,10 @@ async fn multidomain_with_distinct_reconstruction_thresholds__should_sign_for_ev ); let domains = vec![ - DomainConfig { - id: DomainId(0), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(2), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(1), - protocol: Protocol::Frost, - reconstruction_threshold: ReconstructionThreshold::new(2), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(2), - protocol: Protocol::ConfidentialKeyDerivation, - reconstruction_threshold: ReconstructionThreshold::new(2), - purpose: DomainPurpose::CKD, - }, - DomainConfig { - id: DomainId(3), - protocol: Protocol::DamgardEtAl, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, + sign_domain(0, Protocol::CaitSith, 2), + sign_domain(1, Protocol::Frost, 2), + ckd_domain(2, 2), + sign_domain(3, Protocol::DamgardEtAl, 3), ]; { @@ -139,28 +118,13 @@ async fn test_basic_multidomain() { std::time::Duration::from_millis(600), // helps to avoid flaky test ); - // TODO(#1689): in this test it would be desirable to add Robust ECDSA. + // TODO(#1689): in this test it would be desirable to add DamgardEtAl. // That requires having NUM_PARTICIPANTS = 5 and THRESHOLD = 5 // which makes this test too slow to pass in CI, which should be fixed let mut domains = vec![ - DomainConfig { - id: DomainId(0), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(1), - protocol: Protocol::Frost, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(2), - protocol: Protocol::ConfidentialKeyDerivation, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::CKD, - }, + sign_domain(0, Protocol::CaitSith, 3), + sign_domain(1, Protocol::Frost, 3), + ckd_domain(2, 3), ]; { @@ -214,24 +178,9 @@ async fn test_basic_multidomain() { } } let new_domains = vec![ - DomainConfig { - id: DomainId(3), - protocol: Protocol::Frost, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(4), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(5), - protocol: Protocol::ConfidentialKeyDerivation, - reconstruction_threshold: ReconstructionThreshold::new(3), - purpose: DomainPurpose::CKD, - }, + sign_domain(3, Protocol::Frost, 3), + sign_domain(4, Protocol::CaitSith, 3), + ckd_domain(5, 3), ]; { From 1395243b8932be60238fccb9f0f9657f49429ffa Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:31:16 +0200 Subject: [PATCH 065/121] Using helper --- crates/e2e-tests/tests/common.rs | 32 ++++++++++++++++++ .../distinct_reconstruction_thresholds.rs | 26 ++------------- .../tests/request_during_resharing.rs | 28 ++-------------- .../src/providers/robust_ecdsa/presign.rs | 8 ++--- .../node/src/providers/robust_ecdsa/sign.rs | 33 +++++++++---------- 5 files changed, 57 insertions(+), 70 deletions(-) diff --git a/crates/e2e-tests/tests/common.rs b/crates/e2e-tests/tests/common.rs index 92daa2652d..d8ccf8559a 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -430,6 +430,38 @@ pub async fn send_sign_request( Ok(()) } +/// Send a sign request for each signing scheme (classic ECDSA, robust ECDSA and +/// EdDSA) in `running`, asserting every request produces a successful response. +/// +/// Panics if a domain is missing or a request can't be submitted: both are +/// test-setup bugs the caller cannot recover from. +pub async fn sign_all_schemes( + cluster: &MpcCluster, + running: &RunningContractState, + rng: &mut impl rand::Rng, +) { + for (label, protocol) in [ + ("ECDSA", Protocol::CaitSith), + ("Damgard et al", Protocol::DamgardEtAl), + ("EdDSA", Protocol::Frost), + ] { + let domain = must_get_domain(running, protocol); + let payload = match Curve::from(protocol) { + Curve::Edwards25519 => generate_eddsa_payload(rng), + _ => generate_ecdsa_payload(rng), + }; + let outcome = cluster + .send_sign_request(domain.id, payload, cluster.default_user_account()) + .await + .expect("sign request failed"); + assert!( + outcome.is_success(), + "{label} sign request failed: {:?}", + outcome.failure_message() + ); + } +} + /// Send a CKD request and assert the network produced a successful response. /// /// Panics if the request can't be submitted to the contract — the test cannot diff --git a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs index c6eca5f10e..a47570c63f 100644 --- a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -1,6 +1,6 @@ use crate::common::{ DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, damgard_etal_domain, generate_ckd_app_public_key, - generate_ecdsa_payload, generate_eddsa_payload, must_get_domain, must_setup_cluster, + must_get_domain, must_setup_cluster, sign_all_schemes, }; use near_mpc_contract_interface::types::Protocol; @@ -28,33 +28,11 @@ async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { }) .await; - let ecdsa_domain = must_get_domain(&contract_state, Protocol::CaitSith); - let robust_ecdsa_domain = must_get_domain(&contract_state, Protocol::DamgardEtAl); - let eddsa_domain = must_get_domain(&contract_state, Protocol::Frost); let ckd_domain = must_get_domain(&contract_state, Protocol::ConfidentialKeyDerivation); // When / Then let mut rng = rand::rngs::StdRng::seed_from_u64(0); - for (label, domain_id, is_eddsa) in [ - ("ECDSA", ecdsa_domain.id, false), - ("robust ECDSA", robust_ecdsa_domain.id, false), - ("EdDSA", eddsa_domain.id, true), - ] { - let payload = if is_eddsa { - generate_eddsa_payload(&mut rng) - } else { - generate_ecdsa_payload(&mut rng) - }; - let outcome = cluster - .send_sign_request(domain_id, payload, cluster.default_user_account()) - .await - .expect("sign request failed"); - assert!( - outcome.is_success(), - "{label} sign request failed: {:?}", - outcome.failure_message() - ); - } + sign_all_schemes(&cluster, &contract_state, &mut rng).await; let outcome = cluster .send_ckd_request( diff --git a/crates/e2e-tests/tests/request_during_resharing.rs b/crates/e2e-tests/tests/request_during_resharing.rs index 09943e5ced..64f9326447 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -1,6 +1,6 @@ use crate::common::{ REQUEST_DURING_RESHARING_PORT_SEED, damgard_etal_domain, generate_ckd_app_public_key, - generate_ecdsa_payload, generate_eddsa_payload, must_get_domain, must_setup_cluster, + must_get_domain, must_setup_cluster, sign_all_schemes, }; use near_mpc_contract_interface::types::{Protocol, ProtocolContractState}; @@ -42,34 +42,12 @@ async fn test_request_during_resharing() { cluster.kill_nodes(&[5]).expect("failed to kill node 5"); // then - let ecdsa_domain = must_get_domain(&contract_state, Protocol::CaitSith); - let robust_ecdsa_domain = must_get_domain(&contract_state, Protocol::DamgardEtAl); - let eddsa_domain = must_get_domain(&contract_state, Protocol::Frost); let ckd_domain = must_get_domain(&contract_state, Protocol::ConfidentialKeyDerivation); let mut rng = rand::rngs::StdRng::seed_from_u64(0); for i in 0..3 { - for (label, domain_id, is_eddsa) in [ - ("ECDSA", ecdsa_domain.id, false), - ("robust ECDSA", robust_ecdsa_domain.id, false), - ("EdDSA", eddsa_domain.id, true), - ] { - let payload = if is_eddsa { - generate_eddsa_payload(&mut rng) - } else { - generate_ecdsa_payload(&mut rng) - }; - tracing::info!(i, label, "sending sign request during resharing"); - let outcome = cluster - .send_sign_request(domain_id, payload, cluster.default_user_account()) - .await - .expect("sign request failed"); - assert!( - outcome.is_success(), - "{label} sign request {i} failed: {:?}", - outcome.failure_message() - ); - } + tracing::info!(i, "sending sign requests during resharing"); + sign_all_schemes(&cluster, &contract_state, &mut rng).await; tracing::info!(i, "sending CKD request during resharing"); let outcome = cluster diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index c3a16cf143..bb9be9db0d 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -88,7 +88,7 @@ pub(super) async fn run_background_presignature_generation( .map(|p| p.id) .collect(); - let (num_signers, robust_ecdsa_threshold) = compute_thresholds(threshold)?; + let (num_signers, damgard_et_al_threshold) = compute_thresholds(threshold)?; loop { progress_tracker.update_progress(); @@ -146,7 +146,7 @@ pub(super) async fn run_background_presignature_generation( let _in_flight = in_flight; let _semaphore_guard = parallelism_limiter.acquire().await?; let presignature = PresignComputation { - max_malicious: robust_ecdsa_threshold, + max_malicious: damgard_et_al_threshold, keygen_out, } .perform_leader_centric_computation( @@ -208,11 +208,11 @@ impl RobustEcdsaSignatureProvider { id.validate_owned_by(channel.sender().get_leader())?; let domain_data = self.domain_data(domain_id)?; - let (_num_signers, robust_ecdsa_threshold) = + let (_num_signers, damgard_et_al_threshold) = compute_thresholds(domain_data.reconstruction_threshold)?; FollowerPresignComputation { - max_malicious: robust_ecdsa_threshold, + max_malicious: damgard_et_al_threshold, keygen_out: domain_data.keyshare, out_presignature_store: domain_data.presignature_store, out_presignature_id: id, diff --git a/crates/node/src/providers/robust_ecdsa/sign.rs b/crates/node/src/providers/robust_ecdsa/sign.rs index f89be8e368..0f25b263bb 100644 --- a/crates/node/src/providers/robust_ecdsa/sign.rs +++ b/crates/node/src/providers/robust_ecdsa/sign.rs @@ -5,21 +5,20 @@ use crate::primitives::UniqueId; use crate::protocol::run_protocol; use crate::providers::robust_ecdsa::{ EcdsaMessageHash, KeygenOutput, PresignatureStorage, RobustEcdsaSignatureProvider, - RobustEcdsaTaskId, + RobustEcdsaTaskId, presign::compute_thresholds, }; use crate::types::SignatureId; use anyhow::Context; -use k256::Scalar; -use k256::elliptic_curve::PrimeField; +use k256::{Scalar, elliptic_curve::PrimeField}; use near_mpc_contract_interface::types::Tweak; -use std::sync::Arc; -use std::time::Duration; -use threshold_signatures::MaxMalicious; -use threshold_signatures::ParticipantList; -use threshold_signatures::ecdsa::robust_ecdsa::{PresignOutput, RerandomizedPresignOutput}; -use threshold_signatures::ecdsa::{RerandomizationArguments, Signature, SignatureOption}; -use threshold_signatures::frost_secp256k1::VerifyingKey; -use threshold_signatures::participants::Participant; +use std::{sync::Arc, time::Duration}; +use threshold_signatures::{ + MaxMalicious, ParticipantList, + ecdsa::robust_ecdsa::{PresignOutput, RerandomizedPresignOutput}, + ecdsa::{RerandomizationArguments, Signature, SignatureOption}, + frost_secp256k1::VerifyingKey, + participants::Participant, +}; use tokio::time::timeout; impl RobustEcdsaSignatureProvider { @@ -38,8 +37,8 @@ impl RobustEcdsaSignatureProvider { }, presignature.participants, )?; - let (_num_signers, robust_ecdsa_threshold) = - super::presign::compute_thresholds(domain_data.reconstruction_threshold)?; + let (_num_signers, damgard_et_al_threshold) = + compute_thresholds(domain_data.reconstruction_threshold)?; let msg_hash = *sign_request .payload @@ -48,7 +47,7 @@ impl RobustEcdsaSignatureProvider { let (signature, public_key) = SignComputation { keygen_out: domain_data.keyshare, - max_malicious: robust_ecdsa_threshold, + max_malicious: damgard_et_al_threshold, presign_out: presignature.presignature, msg_hash: msg_hash.into(), tweak: sign_request.tweak, @@ -90,8 +89,8 @@ impl RobustEcdsaSignatureProvider { metrics::MPC_NUM_PASSIVE_SIGN_REQUESTS_LOOKUP_SUCCEEDED.inc(); let domain_data = self.domain_data(sign_request.domain)?; - let (_num_signers, robust_ecdsa_threshold) = - super::presign::compute_thresholds(domain_data.reconstruction_threshold)?; + let (_num_signers, damgard_et_al_threshold) = + compute_thresholds(domain_data.reconstruction_threshold)?; let msg_hash = *sign_request .payload @@ -101,7 +100,7 @@ impl RobustEcdsaSignatureProvider { let participants = channel.participants().to_vec(); FollowerSignComputation { keygen_out: domain_data.keyshare, - max_malicious: robust_ecdsa_threshold, + max_malicious: damgard_et_al_threshold, presignature_store: domain_data.presignature_store.clone(), presignature_id, msg_hash: msg_hash.into(), From 37cf136c90b673af789a88de06f7709868ad012e Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:32:05 +0200 Subject: [PATCH 066/121] compacting the comments --- crates/e2e-tests/tests/common.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/e2e-tests/tests/common.rs b/crates/e2e-tests/tests/common.rs index d8ccf8559a..7b3f923f95 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -430,11 +430,7 @@ pub async fn send_sign_request( Ok(()) } -/// Send a sign request for each signing scheme (classic ECDSA, robust ECDSA and -/// EdDSA) in `running`, asserting every request produces a successful response. -/// -/// Panics if a domain is missing or a request can't be submitted: both are -/// test-setup bugs the caller cannot recover from. +/// Sign with every scheme in `running`, asserting each request succeeds. pub async fn sign_all_schemes( cluster: &MpcCluster, running: &RunningContractState, From 392549d28b271be997e8b38c2fa153bc1605acee Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:45:11 +0200 Subject: [PATCH 067/121] introducing ! and reducing code redundancy --- crates/node/src/providers/robust_ecdsa.rs | 12 ++---------- crates/node/src/providers/robust_ecdsa/presign.rs | 5 +++-- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 8c93471065..0b8a03687f 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -239,16 +239,8 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { }) .collect::>(); - for result in futures::future::join_all(generate_presignatures).await { - match result { - Err(join_error) => tracing::error!( - "robust-ecdsa background presignature task panicked: {join_error}" - ), - Ok(Err(task_error)) => tracing::error!( - "robust-ecdsa background presignature task errored: {task_error}" - ), - Ok(Ok(())) => {} - } + for Err(join_error) in futures::future::join_all(generate_presignatures).await { + tracing::error!("Damgard et al background presignature task ended unexpectedly: {join_error}"); } Ok(()) diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index bb9be9db0d..fd4efffe2b 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -71,7 +71,7 @@ pub(super) async fn run_background_presignature_generation( domain_id: DomainId, presignature_store: Arc, keygen_out: KeygenOutput, -) -> anyhow::Result<()> { +) -> ! { let in_flight_generations = InFlightGenerationTracker::new(); let progress_tracker = Arc::new(PresignatureGenerationProgressTracker { desired_presignatures_to_buffer: config.desired_presignatures_to_buffer, @@ -88,7 +88,8 @@ pub(super) async fn run_background_presignature_generation( .map(|p| p.id) .collect(); - let (num_signers, damgard_et_al_threshold) = compute_thresholds(threshold)?; + let (num_signers, damgard_et_al_threshold) = + compute_thresholds(threshold).expect("contract validation guarantees a valid threshold"); loop { progress_tracker.update_progress(); From 3d944169a941a6cb141c3b52ba69325cf0392d1d Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:48:21 +0200 Subject: [PATCH 068/121] cargo fmt --- crates/node/src/providers/robust_ecdsa.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 0b8a03687f..955ded98dd 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -240,7 +240,9 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { .collect::>(); for Err(join_error) in futures::future::join_all(generate_presignatures).await { - tracing::error!("Damgard et al background presignature task ended unexpectedly: {join_error}"); + tracing::error!( + "Damgard et al background presignature task ended unexpectedly: {join_error}" + ); } Ok(()) From 71392e0f0b548b776c76aaf4fbee0f9661812de3 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:52:35 +0200 Subject: [PATCH 069/121] mentioning cait sith in error --- crates/node/src/providers/ecdsa/presign.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index c8a7946b88..c9b968653b 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -186,7 +186,7 @@ impl EcdsaSignatureProvider { if channel.participants().len() != threshold_usize { metrics::MPC_NUM_BAD_PEER_PRESIGN_REQUESTS.inc(); anyhow::bail!( - "presign participant count ({}) does not match domain threshold t={}", + "CaitSith presign participant count ({}) does not match domain threshold t={}", channel.participants().len(), threshold_usize, ); From a0c3637fa477fb0406da0c83ea7c6d95a0f09352 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:04:54 +0200 Subject: [PATCH 070/121] Switching aliases --- crates/node/src/providers/eddsa.rs | 14 +++++++------- crates/node/src/providers/robust_ecdsa.rs | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/node/src/providers/eddsa.rs b/crates/node/src/providers/eddsa.rs index 1146e93c91..93f1da1750 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -15,14 +15,14 @@ use crate::types::SignatureId; use anyhow::Context; use borsh::{BorshDeserialize, BorshSerialize}; use mpc_node_config::ConfigFile; -use mpc_primitives::ReconstructionThreshold as MpcReconstructionThreshold; +use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; #[cfg(test)] use near_mpc_contract_interface::types::Ed25519PublicKey; use near_mpc_contract_interface::types::KeyEventId; use std::collections::HashMap; use std::sync::Arc; -use threshold_signatures::ReconstructionThreshold; +use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use threshold_signatures::frost::eddsa::KeygenOutput; use threshold_signatures::frost_ed25519::keys::SigningShare; use threshold_signatures::frost_ed25519::{Signature, VerifyingKey}; @@ -41,7 +41,7 @@ pub(super) struct PerDomainData { pub keyshare: KeygenOutput, /// Per-domain reconstruction threshold `t`, used as the FROST signing /// threshold. - pub reconstruction_threshold: MpcReconstructionThreshold, + pub reconstruction_threshold: ReconstructionThreshold, } impl EddsaSignatureProvider { @@ -51,7 +51,7 @@ impl EddsaSignatureProvider { client: Arc, sign_request_store: Arc, keyshares: HashMap, - thresholds: HashMap, + thresholds: HashMap, ) -> anyhow::Result { let mut per_domain_data = HashMap::new(); for (domain_id, keyshare) in keyshares { @@ -114,15 +114,15 @@ impl SignatureProvider for EddsaSignatureProvider { } async fn run_key_generation_client( - threshold: ReconstructionThreshold, + threshold: TSReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { Self::run_key_generation_client_internal(threshold, channel).await } async fn run_key_resharing_client( - new_threshold: ReconstructionThreshold, - old_threshold: ReconstructionThreshold, + new_threshold: TSReconstructionThreshold, + old_threshold: TSReconstructionThreshold, key_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 955ded98dd..f8877a6dfe 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -17,11 +17,11 @@ use mpc_node_config::ConfigFile; use crate::types::SignatureId; use borsh::{BorshDeserialize, BorshSerialize}; -use mpc_primitives::ReconstructionThreshold as MpcReconstructionThreshold; +use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; use near_time::Clock; use std::sync::Arc; -use threshold_signatures::ReconstructionThreshold; +use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use threshold_signatures::ecdsa::KeygenOutput; use threshold_signatures::ecdsa::Signature; use threshold_signatures::frost_secp256k1::VerifyingKey; @@ -41,7 +41,7 @@ pub(super) struct PerDomainData { pub presignature_store: Arc, /// Per-domain reconstruction threshold `t`. For robust-ECDSA the signing /// tolerates `MaxMalicious = t - 1` and requires `2t - 1` signers. - pub reconstruction_threshold: MpcReconstructionThreshold, + pub reconstruction_threshold: ReconstructionThreshold, } #[derive( @@ -65,7 +65,7 @@ impl RobustEcdsaSignatureProvider { db: Arc, sign_request_store: Arc, keyshares: HashMap, - thresholds: HashMap, + thresholds: HashMap, ) -> anyhow::Result { let active_participants_query = { let network_client = client.clone(); @@ -153,7 +153,7 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { } async fn run_key_generation_client( - threshold: ReconstructionThreshold, + threshold: TSReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { // robust-ECDSA shares the secret on a degree `MaxMalicious = t - 1` @@ -164,8 +164,8 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { } async fn run_key_resharing_client( - new_threshold: ReconstructionThreshold, - old_threshold: ReconstructionThreshold, + new_threshold: TSReconstructionThreshold, + old_threshold: TSReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, From 7c6164b5b767dfe681e281141c61269f432bca31 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:11:44 +0200 Subject: [PATCH 071/121] Some more aliases sync fix --- crates/node/src/key_events.rs | 15 ++++++++------- crates/node/src/providers/ckd.rs | 14 +++++++------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/crates/node/src/key_events.rs b/crates/node/src/key_events.rs index 859a053472..5948433675 100644 --- a/crates/node/src/key_events.rs +++ b/crates/node/src/key_events.rs @@ -21,7 +21,7 @@ use crate::{ }, }; use mpc_primitives::KeyEventId; -use mpc_primitives::ReconstructionThreshold as MpcReconstructionThreshold; +use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::{DomainId, Protocol}; use near_mpc_contract_interface::types as dtos; use near_mpc_contract_interface::types::DomainConfig; @@ -30,7 +30,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use threshold_signatures::{ - ReconstructionThreshold, confidential_key_derivation as ckd, frost_ed25519, frost_secp256k1, + ReconstructionThreshold as TSReconstructionThreshold, confidential_key_derivation as ckd, + frost_ed25519, frost_secp256k1, }; use tokio::sync::{RwLock, mpsc, watch}; use tokio::time::timeout; @@ -55,7 +56,7 @@ pub async fn keygen_computation_inner( // every protocol (including robust-ECDSA, whose reconstruction lower bound // equals `t`) the keygen runs with lower bound `t`. let threshold = - ReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); + TSReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); let keyshare_handle = keyshare_storage .write() .await @@ -166,7 +167,7 @@ pub struct ResharingArgs { pub existing_keyshares: Option>, /// The previous epoch's per-domain reconstruction thresholds, passed to the /// resharing protocol as the old-side `t` for each key. - pub old_reconstruction_thresholds: HashMap, + pub old_reconstruction_thresholds: HashMap, pub old_participants: ParticipantsConfig, } @@ -191,8 +192,8 @@ async fn resharing_computation_inner( anyhow::ensure!(key_id.domain_id == domain.id, "Domain mismatch"); let new_threshold = - ReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); - let old_threshold = ReconstructionThreshold::from(usize::try_from( + TSReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); + let old_threshold = TSReconstructionThreshold::from(usize::try_from( args.old_reconstruction_thresholds .get(&key_id.domain_id) .ok_or_else(|| { @@ -923,7 +924,7 @@ mod tests { existing_keyshares: None, old_reconstruction_thresholds: HashMap::from([( DomainId(1), - MpcReconstructionThreshold::new(3), + ReconstructionThreshold::new(3), )]), old_participants: ParticipantsConfig { threshold: 3, diff --git a/crates/node/src/providers/ckd.rs b/crates/node/src/providers/ckd.rs index 6374c3c487..ea55fecb67 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -5,14 +5,14 @@ mod sign; use std::{collections::HashMap, sync::Arc}; use borsh::{BorshDeserialize, BorshSerialize}; -use mpc_primitives::ReconstructionThreshold as MpcReconstructionThreshold; +use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; use near_mpc_contract_interface::types::KeyEventId; use threshold_signatures::confidential_key_derivation::{ ElementG1, KeygenOutput, SigningShare, VerifyingKey, }; -use threshold_signatures::ReconstructionThreshold; +use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use mpc_node_config::ConfigFile; @@ -51,7 +51,7 @@ pub struct CKDProvider { pub(super) struct PerDomainData { pub keyshare: KeygenOutput, /// Per-domain reconstruction threshold `t`, used as the CKD threshold. - pub reconstruction_threshold: MpcReconstructionThreshold, + pub reconstruction_threshold: ReconstructionThreshold, } impl CKDProvider { @@ -61,7 +61,7 @@ impl CKDProvider { client: Arc, ckd_request_store: Arc, keyshares: HashMap, - thresholds: HashMap, + thresholds: HashMap, ) -> anyhow::Result { let mut per_domain_data = HashMap::new(); for (domain_id, keyshare) in keyshares { @@ -108,15 +108,15 @@ impl SignatureProvider for CKDProvider { } async fn run_key_generation_client( - threshold: ReconstructionThreshold, + threshold: TSReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { Self::run_key_generation_client_internal(threshold, channel).await } async fn run_key_resharing_client( - new_threshold: ReconstructionThreshold, - old_threshold: ReconstructionThreshold, + new_threshold: TSReconstructionThreshold, + old_threshold: TSReconstructionThreshold, key_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, From d80827e41030d245b0edcdb763b7af64683f83dc Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:14:18 +0200 Subject: [PATCH 072/121] comment reverted --- docs/design/domain-separation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index 7f3cc6c619..513096b781 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -4,7 +4,7 @@ The addition of Robust ECDSA (aka DamgardEtAl) invalidates three assumptions in ✗ There is one protocol per curve (now: both CaitSith and DamgardEtAl operate over Secp256k1). -✗ All domains share a single cryptographic threshold. The node had a `translate_threshold()` hack to bridge this gap. +✗ All domains share a single cryptographic threshold. The node already has a `translate_threshold()` hack to bridge this gap. ✗ Governance voting threshold and cryptographic reconstruction threshold are the same value. The threshold of how many participants must vote to change parameters is currently the same `Threshold` value as the cryptographic reconstruction threshold. From fad6726e6ff5c061a07caa5d3f184b840793266d Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:39:38 +0200 Subject: [PATCH 073/121] deleting as usage --- crates/node/src/providers/ecdsa/triple.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index 9632ae53ca..d4b0163e98 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -71,9 +71,10 @@ impl EcdsaSignatureProvider { let mut offline: i64 = 0; let mut available: i64 = 0; for store in &triple_stores { - online += store.num_owned_ready() as i64; - offline += store.num_owned_offline() as i64; - available += store.num_owned() as i64; + online += i64::try_from(store.num_owned_ready()).expect("triple count fits in i64"); + offline += + i64::try_from(store.num_owned_offline()).expect("triple count fits in i64"); + available += i64::try_from(store.num_owned()).expect("triple count fits in i64"); } metrics::MPC_OWNED_NUM_TRIPLES_ONLINE.set(online); metrics::MPC_OWNED_NUM_TRIPLES_WITH_OFFLINE_PARTICIPANT.set(offline); From 5792061922d6c14ce51d63945f4c38e53cc2b8ba Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:28:59 +0200 Subject: [PATCH 074/121] Reducing immensly code redundancy by unifying underlying providers for ECDSA schemes --- crates/node/src/providers.rs | 1 + crates/node/src/providers/ecdsa.rs | 46 +----- crates/node/src/providers/ecdsa/presign.rs | 50 +----- crates/node/src/providers/ecdsa_common.rs | 151 ++++++++++++++++++ crates/node/src/providers/robust_ecdsa.rs | 44 +---- .../src/providers/robust_ecdsa/presign.rs | 51 +----- 6 files changed, 173 insertions(+), 170 deletions(-) create mode 100644 crates/node/src/providers/ecdsa_common.rs diff --git a/crates/node/src/providers.rs b/crates/node/src/providers.rs index 06c05080de..50044e4f4e 100644 --- a/crates/node/src/providers.rs +++ b/crates/node/src/providers.rs @@ -8,6 +8,7 @@ pub mod ckd; pub mod ecdsa; +pub mod ecdsa_common; pub mod eddsa; pub mod robust_ecdsa; pub mod verify_foreign_tx; diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 9c2cc32349..7e557dd5c3 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -16,6 +16,7 @@ use crate::metrics::tokio_task_metrics::ECDSA_TASK_MONITORS; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::{MpcTaskId, ParticipantId, UniqueId}; use crate::providers::SignatureProvider; +use crate::providers::ecdsa_common; use crate::storage::SignRequestStorage; use crate::tracking; use mpc_node_config::ConfigFile; @@ -29,6 +30,7 @@ use std::sync::Arc; use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use threshold_signatures::ecdsa::KeygenOutput; use threshold_signatures::ecdsa::Signature; +use threshold_signatures::ecdsa::ot_based_ecdsa::PresignOutput; use threshold_signatures::frost_secp256k1::VerifyingKey; use threshold_signatures::frost_secp256k1::keys::SigningShare; @@ -45,14 +47,7 @@ pub struct EcdsaSignatureProvider { per_domain_data: HashMap, } -#[derive(Clone)] -pub(super) struct PerDomainData { - pub keyshare: KeygenOutput, - pub presignature_store: Arc, - /// Per-domain reconstruction threshold `t`, the source of truth for this - /// domain's keygen/presign/sign and the triple store it consumes from. - pub reconstruction_threshold: ReconstructionThreshold, -} +pub(super) type PerDomainData = ecdsa_common::PerDomainData; impl EcdsaSignatureProvider { #[expect(clippy::too_many_arguments)] @@ -66,32 +61,8 @@ impl EcdsaSignatureProvider { keyshares: HashMap, thresholds: HashMap, ) -> anyhow::Result { - let active_participants_query = { - let network_client = client.clone(); - Arc::new(move || network_client.all_alive_participant_ids()) - }; - - let mut per_domain_data = HashMap::new(); - for (domain_id, keyshare) in keyshares { - let threshold = *thresholds.get(&domain_id).ok_or_else(|| { - anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) - })?; - let presignature_store = Arc::new(PresignatureStorage::new( - clock.clone(), - db.clone(), - client.my_participant_id(), - active_participants_query.clone(), - domain_id, - )?); - per_domain_data.insert( - domain_id, - PerDomainData { - keyshare, - presignature_store, - reconstruction_threshold: threshold, - }, - ); - } + let per_domain_data = + ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares, &thresholds)?; // cait-sith triple generation runs with exactly `t` parties, so the set // of stores this node maintains is the distinct per-domain @@ -110,7 +81,7 @@ impl EcdsaSignatureProvider { clock.clone(), db.clone(), client.my_participant_id(), - active_participants_query.clone(), + ecdsa_common::active_participants_query(&client), t, )?), ); @@ -127,10 +98,7 @@ impl EcdsaSignatureProvider { } pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { - self.per_domain_data - .get(&domain_id) - .cloned() - .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) + ecdsa_common::lookup_domain_data(&self.per_domain_data, domain_id) } /// Returns the triple store for `t`, or an error if no store was diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index c9b968653b..f35f86126b 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -1,20 +1,16 @@ -use crate::assets::DistributedAssetStorage; use crate::background::InFlightGenerationTracker; -use crate::db::SecretDB; use crate::metrics::tokio_task_metrics::ECDSA_TASK_MONITORS; use crate::network::computation::MpcLeaderCentricComputation; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; -use crate::primitives::{ParticipantId, UniqueId}; +use crate::primitives::UniqueId; use crate::protocol::run_protocol; -use crate::providers::HasParticipants; use crate::providers::ecdsa::triple::participants_from_triples; use crate::providers::ecdsa::{EcdsaSignatureProvider, EcdsaTaskId, KeygenOutput, TripleStorage}; +use crate::providers::ecdsa_common; use crate::tracking::AutoAbortTaskCollection; use crate::{metrics, tracking}; use mpc_node_config::PresignatureConfig; use mpc_primitives::domain::DomainId; -use near_time::Clock; -use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::time::Duration; @@ -25,32 +21,8 @@ use threshold_signatures::ecdsa::ot_based_ecdsa::{ }; use threshold_signatures::participants::Participant; -#[derive(derive_more::Deref)] -pub struct PresignatureStorage(DistributedAssetStorage); - -impl PresignatureStorage { - pub fn new( - clock: Clock, - db: Arc, - my_participant_id: ParticipantId, - alive_participant_ids_query: Arc Vec + Send + Sync>, - domain_id: DomainId, - ) -> anyhow::Result { - Ok(Self(DistributedAssetStorage::< - PresignOutputWithParticipants, - >::new( - clock, - db, - crate::db::DBCol::Presignature, - domain_id.0.to_be_bytes().to_vec(), - my_participant_id, - |participants, presignature| { - presignature.is_subset_of_active_participants(participants) - }, - alive_participant_ids_query, - )?)) - } -} +pub type PresignatureStorage = ecdsa_common::PresignatureStorage; +pub type PresignOutputWithParticipants = ecdsa_common::PresignOutputWithParticipants; impl EcdsaSignatureProvider { /// Continuously generates presignatures, trying to maintain the desired number of @@ -210,20 +182,6 @@ impl EcdsaSignatureProvider { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PresignOutputWithParticipants { - pub presignature: PresignOutput, - pub participants: Vec, -} - -impl HasParticipants for PresignOutputWithParticipants { - fn is_subset_of_active_participants(&self, active_participants: &[ParticipantId]) -> bool { - self.participants - .iter() - .all(|p| active_participants.contains(p)) - } -} - /// Performs an MPC presignature operation. This is shared for the initiator /// and for passive participants. pub struct PresignComputation { diff --git a/crates/node/src/providers/ecdsa_common.rs b/crates/node/src/providers/ecdsa_common.rs new file mode 100644 index 0000000000..0f175f770f --- /dev/null +++ b/crates/node/src/providers/ecdsa_common.rs @@ -0,0 +1,151 @@ +//! Plumbing shared by the two ECDSA providers (cait-sith [`EcdsaSignatureProvider`] and +//! Damgård-et-al [`RobustEcdsaSignatureProvider`], both over secp256k1). Both keep the same +//! per-domain keyshare + presignature store; only the presignature payload `P` and the surrounding +//! protocol differ, so the storage and per-domain scaffolding are generic over `P` here. + +use crate::assets::DistributedAssetStorage; +use crate::db::SecretDB; +use crate::network::MeshNetworkClient; +use crate::primitives::ParticipantId; +use crate::providers::HasParticipants; +use mpc_primitives::ReconstructionThreshold; +use mpc_primitives::domain::DomainId; +use near_time::Clock; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use threshold_signatures::ecdsa::KeygenOutput; + +/// A stored presignature together with the participants that produced it, so the store can drop it +/// once any of those participants goes offline. Generic over the presignature payload `P`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PresignOutputWithParticipants

{ + pub presignature: P, + pub participants: Vec, +} + +impl

HasParticipants for PresignOutputWithParticipants

{ + fn is_subset_of_active_participants(&self, active_participants: &[ParticipantId]) -> bool { + self.participants + .iter() + .all(|p| active_participants.contains(p)) + } +} + +/// Per-domain presignature store, keyed on disk by `domain_id` under [`crate::db::DBCol::Presignature`]. +#[derive(derive_more::Deref)] +pub struct PresignatureStorage

(DistributedAssetStorage>) +where + P: Serialize + DeserializeOwned + Send + 'static; + +impl

PresignatureStorage

+where + P: Serialize + DeserializeOwned + Send + 'static, +{ + pub fn new( + clock: Clock, + db: Arc, + my_participant_id: ParticipantId, + alive_participant_ids_query: Arc Vec + Send + Sync>, + domain_id: DomainId, + ) -> anyhow::Result { + Ok(Self(DistributedAssetStorage::< + PresignOutputWithParticipants

, + >::new( + clock, + db, + crate::db::DBCol::Presignature, + domain_id.0.to_be_bytes().to_vec(), + my_participant_id, + |participants, presignature| { + presignature.is_subset_of_active_participants(participants) + }, + alive_participant_ids_query, + )?)) + } +} + +/// Everything a secp256k1 provider keeps per signing domain. +pub struct PerDomainData

+where + P: Serialize + DeserializeOwned + Send + 'static, +{ + pub keyshare: KeygenOutput, + pub presignature_store: Arc>, + /// Per-domain reconstruction threshold `t`, the source of truth for this domain's + /// keygen/presign/sign. + pub reconstruction_threshold: ReconstructionThreshold, +} + +// Manual `Clone` so callers don't need `P: Clone` — every field is `Clone` regardless of `P`. +impl

Clone for PerDomainData

+where + P: Serialize + DeserializeOwned + Send + 'static, +{ + fn clone(&self) -> Self { + Self { + keyshare: self.keyshare.clone(), + presignature_store: self.presignature_store.clone(), + reconstruction_threshold: self.reconstruction_threshold, + } + } +} + +/// The "are all these participants still alive?" query both the presignature and triple stores use. +pub fn active_participants_query( + client: &Arc, +) -> Arc Vec + Send + Sync> { + let network_client = client.clone(); + Arc::new(move || network_client.all_alive_participant_ids()) +} + +/// Builds the per-domain map shared by both secp256k1 providers: one presignature store per domain, +/// each paired with its keyshare and reconstruction threshold. +pub fn build_per_domain_data

( + clock: &Clock, + db: &Arc, + client: &Arc, + keyshares: HashMap, + thresholds: &HashMap, +) -> anyhow::Result>> +where + P: Serialize + DeserializeOwned + Send + 'static, +{ + let mut per_domain_data = HashMap::new(); + for (domain_id, keyshare) in keyshares { + let reconstruction_threshold = *thresholds.get(&domain_id).ok_or_else(|| { + anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) + })?; + let presignature_store = Arc::new(PresignatureStorage::new( + clock.clone(), + db.clone(), + client.my_participant_id(), + active_participants_query(client), + domain_id, + )?); + per_domain_data.insert( + domain_id, + PerDomainData { + keyshare, + presignature_store, + reconstruction_threshold, + }, + ); + } + Ok(per_domain_data) +} + +/// Looks up a domain's data, cloning it out of the map. +pub fn lookup_domain_data

( + per_domain_data: &HashMap>, + domain_id: DomainId, +) -> anyhow::Result> +where + P: Serialize + DeserializeOwned + Send + 'static, +{ + per_domain_data + .get(&domain_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) +} diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index f8877a6dfe..601200cdd9 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -10,6 +10,7 @@ use crate::db::SecretDB; use crate::metrics::tokio_task_metrics::ROBUST_ECDSA_TASK_MONITORS; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::{MpcTaskId, UniqueId}; +use crate::providers::ecdsa_common; use crate::providers::{EcdsaSignatureProvider, SignatureProvider}; use crate::storage::SignRequestStorage; use crate::tracking; @@ -24,6 +25,7 @@ use std::sync::Arc; use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use threshold_signatures::ecdsa::KeygenOutput; use threshold_signatures::ecdsa::Signature; +use threshold_signatures::ecdsa::robust_ecdsa::PresignOutput; use threshold_signatures::frost_secp256k1::VerifyingKey; use threshold_signatures::frost_secp256k1::keys::SigningShare; @@ -35,14 +37,7 @@ pub struct RobustEcdsaSignatureProvider { per_domain_data: HashMap, } -#[derive(Clone)] -pub(super) struct PerDomainData { - pub keyshare: KeygenOutput, - pub presignature_store: Arc, - /// Per-domain reconstruction threshold `t`. For robust-ECDSA the signing - /// tolerates `MaxMalicious = t - 1` and requires `2t - 1` signers. - pub reconstruction_threshold: ReconstructionThreshold, -} +pub(super) type PerDomainData = ecdsa_common::PerDomainData; #[derive( Debug, Copy, Clone, Eq, Ord, PartialEq, PartialOrd, derive_more::From, derive_more::Into, @@ -67,32 +62,8 @@ impl RobustEcdsaSignatureProvider { keyshares: HashMap, thresholds: HashMap, ) -> anyhow::Result { - let active_participants_query = { - let network_client = client.clone(); - Arc::new(move || network_client.all_alive_participant_ids()) - }; - - let mut per_domain_data = HashMap::new(); - for (domain_id, keyshare) in keyshares { - let threshold = *thresholds.get(&domain_id).ok_or_else(|| { - anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) - })?; - let presignature_store = Arc::new(PresignatureStorage::new( - clock.clone(), - db.clone(), - client.my_participant_id(), - active_participants_query.clone(), - domain_id, - )?); - per_domain_data.insert( - domain_id, - PerDomainData { - keyshare, - presignature_store, - reconstruction_threshold: threshold, - }, - ); - } + let per_domain_data = + ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares, &thresholds)?; Ok(Self { config, @@ -104,10 +75,7 @@ impl RobustEcdsaSignatureProvider { } pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { - self.per_domain_data - .get(&domain_id) - .cloned() - .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) + ecdsa_common::lookup_domain_data(&self.per_domain_data, domain_id) } } diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index fd4efffe2b..4eb58daad8 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -1,13 +1,11 @@ -use crate::assets::DistributedAssetStorage; use crate::background::InFlightGenerationTracker; use crate::config::MpcConfig; -use crate::db::SecretDB; use crate::metrics::tokio_task_metrics::ROBUST_ECDSA_TASK_MONITORS; use crate::network::computation::MpcLeaderCentricComputation; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; -use crate::primitives::{ParticipantId, UniqueId}; +use crate::primitives::UniqueId; use crate::protocol::run_protocol; -use crate::providers::HasParticipants; +use crate::providers::ecdsa_common; use crate::providers::robust_ecdsa::{ KeygenOutput, RobustEcdsaSignatureProvider, RobustEcdsaTaskId, }; @@ -16,9 +14,7 @@ use crate::{metrics, tracking}; use mpc_node_config::PresignatureConfig; use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; -use near_time::Clock; use rand::rngs::OsRng; -use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::time::Duration; @@ -28,33 +24,8 @@ use threshold_signatures::ecdsa::robust_ecdsa::{ }; use threshold_signatures::participants::Participant; -#[derive(derive_more::Deref)] -pub struct PresignatureStorage(DistributedAssetStorage); - -// TODO(#1680): simplify alive_participant_ids_query parameter type -impl PresignatureStorage { - pub fn new( - clock: Clock, - db: Arc, - my_participant_id: ParticipantId, - alive_participant_ids_query: Arc Vec + Send + Sync>, - domain_id: DomainId, - ) -> anyhow::Result { - Ok(Self(DistributedAssetStorage::< - PresignOutputWithParticipants, - >::new( - clock, - db, - crate::db::DBCol::Presignature, - domain_id.0.to_be_bytes().to_vec(), - my_participant_id, - |participants, presignature| { - presignature.is_subset_of_active_participants(participants) - }, - alive_participant_ids_query, - )?)) - } -} +pub type PresignatureStorage = ecdsa_common::PresignatureStorage; +pub type PresignOutputWithParticipants = ecdsa_common::PresignOutputWithParticipants; /// Continuously generates presignatures, trying to maintain the desired number of /// presignatures available, using the desired number of concurrent computations as @@ -228,20 +199,6 @@ impl RobustEcdsaSignatureProvider { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PresignOutputWithParticipants { - pub presignature: PresignOutput, - pub participants: Vec, -} - -impl HasParticipants for PresignOutputWithParticipants { - fn is_subset_of_active_participants(&self, active_participants: &[ParticipantId]) -> bool { - self.participants - .iter() - .all(|p| active_participants.contains(p)) - } -} - /// Performs an MPC presignature operation. This is shared for the initiator /// and for passive participants. pub struct PresignComputation { From 0b8b20196f4b7d97d2e081b0c8e8147b43fbcceb Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:17:43 +0200 Subject: [PATCH 075/121] Fixing todo #1680 --- crates/node/src/providers/ecdsa_common.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/node/src/providers/ecdsa_common.rs b/crates/node/src/providers/ecdsa_common.rs index 0f175f770f..c79a8350a1 100644 --- a/crates/node/src/providers/ecdsa_common.rs +++ b/crates/node/src/providers/ecdsa_common.rs @@ -46,8 +46,7 @@ where pub fn new( clock: Clock, db: Arc, - my_participant_id: ParticipantId, - alive_participant_ids_query: Arc Vec + Send + Sync>, + client: &Arc, domain_id: DomainId, ) -> anyhow::Result { Ok(Self(DistributedAssetStorage::< @@ -57,11 +56,11 @@ where db, crate::db::DBCol::Presignature, domain_id.0.to_be_bytes().to_vec(), - my_participant_id, + client.my_participant_id(), |participants, presignature| { presignature.is_subset_of_active_participants(participants) }, - alive_participant_ids_query, + active_participants_query(client), )?)) } } @@ -120,8 +119,7 @@ where let presignature_store = Arc::new(PresignatureStorage::new( clock.clone(), db.clone(), - client.my_participant_id(), - active_participants_query(client), + client, domain_id, )?); per_domain_data.insert( From 4b2f8537d8d3e5ed70143f108aeb9e17730f4d99 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:22:58 +0200 Subject: [PATCH 076/121] Deleting non-necessary comment --- crates/node/src/providers/robust_ecdsa.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 601200cdd9..1ef7ff4ae0 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -124,10 +124,6 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { threshold: TSReconstructionThreshold, channel: NetworkTaskChannel, ) -> anyhow::Result { - // robust-ECDSA shares the secret on a degree `MaxMalicious = t - 1` - // polynomial, i.e. reconstruction lower bound `= MaxMalicious + 1 = t`. - // `threshold` is already that lower bound (= the domain's - // `t`), so the keygen is identical to cait-sith — pass it straight through. EcdsaSignatureProvider::run_key_generation_client_internal(threshold, channel).await } From 48b5fcaccfd53066b769844d18fb10b9d54f7bdb Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:48:13 +0200 Subject: [PATCH 077/121] fixing the triples in 1645 --- crates/node/src/assets/cleanup.rs | 25 +++++++---------------- crates/node/src/network.rs | 25 +++++++++++++++++++++++ crates/node/src/providers/ecdsa.rs | 3 +-- crates/node/src/providers/ecdsa/triple.rs | 15 +++++++------- 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/crates/node/src/assets/cleanup.rs b/crates/node/src/assets/cleanup.rs index 31e7b26a9c..eebedea266 100644 --- a/crates/node/src/assets/cleanup.rs +++ b/crates/node/src/assets/cleanup.rs @@ -138,6 +138,7 @@ mod tests { use crate::assets::test_utils::triple_v2_key; use crate::db::EPOCH_ID_KEY; use crate::db::{DBCol, SecretDB}; + use crate::network::testing::new_test_client; use crate::primitives::UniqueId; use crate::providers::ecdsa::triple::TripleStorage; use mpc_primitives::domain::DomainId; @@ -237,17 +238,13 @@ mod tests { // Given a node has written a stale triple via TripleStorage. let (mut start_data, my_participant_id, threshold) = test_utils::gen_four_participants(); let all_participants = get_participant_ids(start_data.clone()); - let alive_participants = Arc::new(Mutex::new(all_participants.clone())); + let client = new_test_client(all_participants.clone(), my_participant_id); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); let triple_store = TripleStorage::new( FakeClock::default().clock(), db.clone(), - my_participant_id, - { - let alive = alive_participants.clone(); - Arc::new(move || alive.lock().unwrap().clone()) - }, + &client, threshold, ) .unwrap(); @@ -305,17 +302,13 @@ mod tests { .take(all_participants.len() - 1) .copied() .collect(); - let alive_participants = Arc::new(Mutex::new(all_participants.clone())); + let client = new_test_client(all_participants.clone(), my_participant_id); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); let triple_store = TripleStorage::new( FakeClock::default().clock(), db.clone(), - my_participant_id, - { - let alive = alive_participants.clone(); - Arc::new(move || alive.lock().unwrap().clone()) - }, + &client, threshold, ) .unwrap(); @@ -363,17 +356,13 @@ mod tests { .iter() .find(|p| **p != my_participant_id) .unwrap(); - let alive_participants = Arc::new(Mutex::new(all_participants.clone())); + let client = new_test_client(all_participants.clone(), my_participant_id); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); let triple_store = TripleStorage::new( FakeClock::default().clock(), db.clone(), - my_participant_id, - { - let alive = alive_participants.clone(); - Arc::new(move || alive.lock().unwrap().clone()) - }, + &client, threshold, ) .unwrap(); diff --git a/crates/node/src/network.rs b/crates/node/src/network.rs index a2dd08d150..a07ce8a8c7 100644 --- a/crates/node/src/network.rs +++ b/crates/node/src/network.rs @@ -921,6 +921,31 @@ pub mod testing { transports } + /// Builds a [`MeshNetworkClient`] synchronously (no background receiver task) for unit tests + /// that only need participant / alive-set queries. All participants report indexer height 0 + /// and the test transport reports everyone bidirectionally connected, so + /// [`MeshNetworkClient::all_alive_participant_ids`] returns the full participant set. + pub fn new_test_client( + participants: Vec, + my_participant_id: ParticipantId, + ) -> Arc { + let transport = Arc::new(TestMeshTransportSender { + transport: Arc::new(TestMeshTransport { + participant_ids: participants.clone(), + senders: HashMap::new(), + }), + my_participant_id, + }); + let channels = Arc::new(std::sync::Mutex::new(super::NetworkTaskChannelManager::new())); + let indexer_heights = + Arc::new(crate::network::indexer_heights::IndexerHeightTracker::new(&participants)); + Arc::new(super::MeshNetworkClient::new( + transport, + channels, + indexer_heights, + )) + } + pub async fn run_test_clients( participants: Vec, client_runner: F, diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 7e557dd5c3..30196d0e89 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -80,8 +80,7 @@ impl EcdsaSignatureProvider { Arc::new(TripleStorage::new( clock.clone(), db.clone(), - client.my_participant_id(), - ecdsa_common::active_participants_query(&client), + &client, t, )?), ); diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index d4b0163e98..74d67fa2fd 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -34,8 +34,7 @@ impl TripleStorage { pub fn new( clock: Clock, db: Arc, - my_participant_id: ParticipantId, - alive_participant_ids_query: Arc Vec + Send + Sync>, + client: &Arc, threshold: ReconstructionThreshold, ) -> anyhow::Result { Ok(Self(DistributedAssetStorage::::new( @@ -43,9 +42,9 @@ impl TripleStorage { db, DBCol::TripleV2, threshold.inner().to_be_bytes().to_vec(), - my_participant_id, + client.my_participant_id(), |participants, pair| pair.is_subset_of_active_participants(participants), - alive_participant_ids_query, + crate::providers::ecdsa_common::active_participants_query(client), )?)) } } @@ -352,7 +351,7 @@ mod tests { use crate::assets::test_utils::{make_triple, triple_v2_key}; use crate::db::{DBCol, SecretDB}; use crate::network::computation::MpcLeaderCentricComputation; - use crate::network::testing::run_test_clients; + use crate::network::testing::{new_test_client, run_test_clients}; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::{MpcTaskId, ParticipantId, UniqueId}; use crate::providers::ecdsa::EcdsaTaskId; @@ -502,13 +501,13 @@ mod tests { db: Arc, my_participant_id: ParticipantId, threshold: ReconstructionThreshold, - alive: Vec, + participants: Vec, ) -> TripleStorage { + let client = new_test_client(participants, my_participant_id); TripleStorage::new( near_time::FakeClock::default().clock(), db, - my_participant_id, - Arc::new(move || alive.clone()), + &client, threshold, ) .unwrap() From a111531679a7a5c066f99190e571407e5f8d7bbb Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:49:15 +0200 Subject: [PATCH 078/121] reducing the comment --- crates/node/src/network.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/node/src/network.rs b/crates/node/src/network.rs index a07ce8a8c7..1fa27082b2 100644 --- a/crates/node/src/network.rs +++ b/crates/node/src/network.rs @@ -921,10 +921,7 @@ pub mod testing { transports } - /// Builds a [`MeshNetworkClient`] synchronously (no background receiver task) for unit tests - /// that only need participant / alive-set queries. All participants report indexer height 0 - /// and the test transport reports everyone bidirectionally connected, so - /// [`MeshNetworkClient::all_alive_participant_ids`] returns the full participant set. + /// Synchronous [`MeshNetworkClient`] for unit tests. All participants are reported alive. pub fn new_test_client( participants: Vec, my_participant_id: ParticipantId, From e73b2a09664e9623e41ca3cb1074f2e8aa6d8bdb Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:55:08 +0200 Subject: [PATCH 079/121] cargo fmt --- crates/node/src/assets/cleanup.rs | 30 +++++++++--------------------- crates/node/src/network.rs | 9 ++++++--- crates/node/src/providers/ecdsa.rs | 7 +------ 3 files changed, 16 insertions(+), 30 deletions(-) diff --git a/crates/node/src/assets/cleanup.rs b/crates/node/src/assets/cleanup.rs index eebedea266..0a3a67dec5 100644 --- a/crates/node/src/assets/cleanup.rs +++ b/crates/node/src/assets/cleanup.rs @@ -241,13 +241,9 @@ mod tests { let client = new_test_client(all_participants.clone(), my_participant_id); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); - let triple_store = TripleStorage::new( - FakeClock::default().clock(), - db.clone(), - &client, - threshold, - ) - .unwrap(); + let triple_store = + TripleStorage::new(FakeClock::default().clock(), db.clone(), &client, threshold) + .unwrap(); let id = triple_store.generate_and_reserve_id(); triple_store.add_owned(id, make_triple(&all_participants)); let v2_key = triple_v2_key(threshold, id); @@ -305,13 +301,9 @@ mod tests { let client = new_test_client(all_participants.clone(), my_participant_id); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); - let triple_store = TripleStorage::new( - FakeClock::default().clock(), - db.clone(), - &client, - threshold, - ) - .unwrap(); + let triple_store = + TripleStorage::new(FakeClock::default().clock(), db.clone(), &client, threshold) + .unwrap(); let id = triple_store.generate_and_reserve_id(); triple_store.add_owned(id, make_triple(&active_subset)); let v2_key = triple_v2_key(threshold, id); @@ -359,13 +351,9 @@ mod tests { let client = new_test_client(all_participants.clone(), my_participant_id); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); - let triple_store = TripleStorage::new( - FakeClock::default().clock(), - db.clone(), - &client, - threshold, - ) - .unwrap(); + let triple_store = + TripleStorage::new(FakeClock::default().clock(), db.clone(), &client, threshold) + .unwrap(); // Even an outright-stale peer-owned triple stays put — the peer cleans // its own. let peer_id = UniqueId::new(peer, 100, 0); diff --git a/crates/node/src/network.rs b/crates/node/src/network.rs index 1fa27082b2..99de92f2b7 100644 --- a/crates/node/src/network.rs +++ b/crates/node/src/network.rs @@ -933,9 +933,12 @@ pub mod testing { }), my_participant_id, }); - let channels = Arc::new(std::sync::Mutex::new(super::NetworkTaskChannelManager::new())); - let indexer_heights = - Arc::new(crate::network::indexer_heights::IndexerHeightTracker::new(&participants)); + let channels = Arc::new(std::sync::Mutex::new( + super::NetworkTaskChannelManager::new(), + )); + let indexer_heights = Arc::new(crate::network::indexer_heights::IndexerHeightTracker::new( + &participants, + )); Arc::new(super::MeshNetworkClient::new( transport, channels, diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 30196d0e89..9469c5be36 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -77,12 +77,7 @@ impl EcdsaSignatureProvider { } triple_stores.insert( t, - Arc::new(TripleStorage::new( - clock.clone(), - db.clone(), - &client, - t, - )?), + Arc::new(TripleStorage::new(clock.clone(), db.clone(), &client, t)?), ); } From f89e30daf2de515e67e573cd5c8e142622237932 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:58:26 +0200 Subject: [PATCH 080/121] Removing non-necessary comment --- crates/node/src/coordinator.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index 2fa246df6e..6d0304ab62 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -786,9 +786,6 @@ where None }; - // The previous epoch's per-domain reconstruction thresholds. The new - // threshold for each key is read from the resharing key event's domain - // config inside `resharing_computation_inner`. let old_reconstruction_thresholds: HashMap = current_running_state .domains From 766fef6522fa72aa4b446c786d51aa68c03a403e Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:12:38 +0200 Subject: [PATCH 081/121] Moving the compute_thresholds function to robust_ecdsa.rs --- crates/node/src/providers/robust_ecdsa.rs | 65 ++++++++++++++++++ .../src/providers/robust_ecdsa/presign.rs | 66 +------------------ .../node/src/providers/robust_ecdsa/sign.rs | 2 +- 3 files changed, 67 insertions(+), 66 deletions(-) diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 1ef7ff4ae0..4d5732ebe2 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -22,6 +22,7 @@ use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; use near_time::Clock; use std::sync::Arc; +use threshold_signatures::MaxMalicious; use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use threshold_signatures::ecdsa::KeygenOutput; use threshold_signatures::ecdsa::Signature; @@ -212,3 +213,67 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { Ok(()) } } + +/// Derives `(num_signers, max_malicious)` for robust-ECDSA from the domain's +/// reconstruction threshold `t`. Returns an error if `t < 2`, +/// which the contract's threshold validation already rejects. +pub(super) fn compute_thresholds( + threshold: ReconstructionThreshold, +) -> anyhow::Result<(usize, MaxMalicious)> { + let t: usize = threshold.inner().try_into()?; + anyhow::ensure!( + t >= 2, + "robust-ECDSA requires a reconstruction threshold of at least 2, got {t}" + ); + let max_malicious = t + .checked_sub(1) + .ok_or_else(|| anyhow::anyhow!("robust-ECDSA max_malicious underflow for t={t}"))?; + let num_signers = t + .checked_mul(2) + .and_then(|two_t| two_t.checked_sub(1)) + .ok_or_else(|| anyhow::anyhow!("robust-ECDSA signer count overflow for t={t}"))?; + Ok((num_signers, MaxMalicious::from(max_malicious))) +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::compute_thresholds; + use mpc_primitives::ReconstructionThreshold; + use threshold_signatures::MaxMalicious; + + #[test] + fn compute_thresholds__should_map_t_to_2t_minus_1_signers_and_max_malicious_t_minus_1() { + // Given a domain reconstruction threshold t = 3 + let t = ReconstructionThreshold::new(3); + + // When + let (num_signers, max_malicious) = compute_thresholds(t).unwrap(); + + // Then num_signers = 2t - 1 = 5 and max_malicious = t - 1 = 2 + assert_eq!(num_signers, 5); + assert_eq!(max_malicious, MaxMalicious::from(2)); + // and the honest-majority invariant 2 * max_malicious + 1 <= num_signers holds. + assert!(2 * max_malicious.value() < num_signers); + } + + #[test] + fn compute_thresholds__should_hold_invariant_across_valid_thresholds() { + for t in 2..30u64 { + let (num_signers, max_malicious) = + compute_thresholds(ReconstructionThreshold::new(t)).unwrap(); + assert_eq!(num_signers, 2 * (t as usize) - 1); + assert_eq!(max_malicious, MaxMalicious::from((t as usize) - 1)); + assert!(2 * max_malicious.value() < num_signers); + } + } + + #[test] + fn compute_thresholds__should_err_when_threshold_below_two() { + // Given: robust-ECDSA requires a reconstruction threshold of at least 2 + for t in 0..2u64 { + // When / Then + compute_thresholds(ReconstructionThreshold::new(t)).unwrap_err(); + } + } +} diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index 4eb58daad8..d4f31f7b6f 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -7,7 +7,7 @@ use crate::primitives::UniqueId; use crate::protocol::run_protocol; use crate::providers::ecdsa_common; use crate::providers::robust_ecdsa::{ - KeygenOutput, RobustEcdsaSignatureProvider, RobustEcdsaTaskId, + KeygenOutput, RobustEcdsaSignatureProvider, RobustEcdsaTaskId, compute_thresholds, }; use crate::tracking::AutoAbortTaskCollection; use crate::{metrics, tracking}; @@ -149,27 +149,6 @@ pub(super) async fn run_background_presignature_generation( } } -/// Derives `(num_signers, max_malicious)` for robust-ECDSA from the domain's -/// reconstruction threshold `t`. Returns an error if `t < 2`, -/// which the contract's threshold validation already rejects. -pub(super) fn compute_thresholds( - threshold: ReconstructionThreshold, -) -> anyhow::Result<(usize, MaxMalicious)> { - let t: usize = threshold.inner().try_into()?; - anyhow::ensure!( - t >= 2, - "robust-ECDSA requires a reconstruction threshold of at least 2, got {t}" - ); - let max_malicious = t - .checked_sub(1) - .ok_or_else(|| anyhow::anyhow!("robust-ECDSA max_malicious underflow for t={t}"))?; - let num_signers = t - .checked_mul(2) - .and_then(|two_t| two_t.checked_sub(1)) - .ok_or_else(|| anyhow::anyhow!("robust-ECDSA signer count overflow for t={t}"))?; - Ok((num_signers, MaxMalicious::from(max_malicious))) -} - impl RobustEcdsaSignatureProvider { pub(super) async fn run_presignature_generation_follower( &self, @@ -287,46 +266,3 @@ impl PresignatureGenerationProgressTracker { )) } } - -#[cfg(test)] -#[expect(non_snake_case)] -mod tests { - use super::compute_thresholds; - use mpc_primitives::ReconstructionThreshold; - use threshold_signatures::MaxMalicious; - - #[test] - fn compute_thresholds__should_map_t_to_2t_minus_1_signers_and_max_malicious_t_minus_1() { - // Given a domain reconstruction threshold t = 3 - let t = ReconstructionThreshold::new(3); - - // When - let (num_signers, max_malicious) = compute_thresholds(t).unwrap(); - - // Then num_signers = 2t - 1 = 5 and max_malicious = t - 1 = 2 - assert_eq!(num_signers, 5); - assert_eq!(max_malicious, MaxMalicious::from(2)); - // and the honest-majority invariant 2 * max_malicious + 1 <= num_signers holds. - assert!(2 * max_malicious.value() < num_signers); - } - - #[test] - fn compute_thresholds__should_hold_invariant_across_valid_thresholds() { - for t in 2..30u64 { - let (num_signers, max_malicious) = - compute_thresholds(ReconstructionThreshold::new(t)).unwrap(); - assert_eq!(num_signers, 2 * (t as usize) - 1); - assert_eq!(max_malicious, MaxMalicious::from((t as usize) - 1)); - assert!(2 * max_malicious.value() < num_signers); - } - } - - #[test] - fn compute_thresholds__should_err_when_threshold_below_two() { - // Given: robust-ECDSA requires a reconstruction threshold of at least 2 - for t in 0..2u64 { - // When / Then - compute_thresholds(ReconstructionThreshold::new(t)).unwrap_err(); - } - } -} diff --git a/crates/node/src/providers/robust_ecdsa/sign.rs b/crates/node/src/providers/robust_ecdsa/sign.rs index 0f25b263bb..7892fa39ab 100644 --- a/crates/node/src/providers/robust_ecdsa/sign.rs +++ b/crates/node/src/providers/robust_ecdsa/sign.rs @@ -5,7 +5,7 @@ use crate::primitives::UniqueId; use crate::protocol::run_protocol; use crate::providers::robust_ecdsa::{ EcdsaMessageHash, KeygenOutput, PresignatureStorage, RobustEcdsaSignatureProvider, - RobustEcdsaTaskId, presign::compute_thresholds, + RobustEcdsaTaskId, compute_thresholds, }; use crate::types::SignatureId; use anyhow::Context; From d759fc81ee6bb64d6b38078c4109e448f8b06ca4 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:45:17 +0200 Subject: [PATCH 082/121] per domain data assigning each domain with reconstruction threshold test --- crates/node/src/providers/ecdsa_common.rs | 78 +++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/node/src/providers/ecdsa_common.rs b/crates/node/src/providers/ecdsa_common.rs index c79a8350a1..2d01a2f7ea 100644 --- a/crates/node/src/providers/ecdsa_common.rs +++ b/crates/node/src/providers/ecdsa_common.rs @@ -147,3 +147,81 @@ where .cloned() .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) } + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::build_per_domain_data; + use crate::db::SecretDB; + use crate::network::testing::run_test_clients; + use crate::tests::into_participant_ids; + use crate::tracking::testing::start_root_task_with_periodic_dump; + use mpc_primitives::ReconstructionThreshold; + use mpc_primitives::domain::DomainId; + use near_time::Clock; + use rand::SeedableRng; + use std::collections::HashMap; + use threshold_signatures::ecdsa::KeygenOutput; + use threshold_signatures::frost_secp256k1::Secp256K1Sha256; + use threshold_signatures::test_utils::{generate_participants, run_keygen}; + + fn dummy_keygen_output() -> KeygenOutput { + let mut rng = rand::rngs::StdRng::from_seed([7u8; 32]); + run_keygen::(&generate_participants(2), 2usize, &mut rng) + .into_iter() + .next() + .unwrap() + .1 + } + + // Directly asserts the plumbing that `multidomain_with_distinct_reconstruction_thresholds` + // only checks indirectly (by running a full multi-node signing round): each domain must keep + // its OWN reconstruction threshold, never a single shared/governance value. + #[tokio::test] + async fn build_per_domain_data__should_pair_each_domain_with_its_own_reconstruction_threshold() + { + start_root_task_with_periodic_dump(async move { + run_test_clients( + into_participant_ids(&generate_participants(2)), + |client, _channel_receiver| async move { + // Given two domains configured with distinct reconstruction thresholds + let low = DomainId(0); + let high = DomainId(1); + let keygen_output = dummy_keygen_output(); + let keyshares = + HashMap::from([(low, keygen_output.clone()), (high, keygen_output)]); + let thresholds = HashMap::from([ + (low, ReconstructionThreshold::new(2)), + (high, ReconstructionThreshold::new(3)), + ]); + let dir = tempfile::tempdir().unwrap(); + let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); + + // When + let per_domain_data = build_per_domain_data::>( + &Clock::real(), + &db, + &client, + keyshares, + &thresholds, + ) + .unwrap(); + + // Then each domain keeps the threshold it was configured with + assert_eq!( + per_domain_data[&low].reconstruction_threshold, + ReconstructionThreshold::new(2) + ); + assert_eq!( + per_domain_data[&high].reconstruction_threshold, + ReconstructionThreshold::new(3) + ); + Ok(()) + }, + ) + .await + .unwrap(); + }) + .await; + } +} From 7733fd03e49132b094f8694e61274a0b4a954ecb Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:52:15 +0200 Subject: [PATCH 083/121] Making the comment more accurate --- crates/node/src/providers/ecdsa.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 7e557dd5c3..6b45cc1c40 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -64,11 +64,10 @@ impl EcdsaSignatureProvider { let per_domain_data = ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares, &thresholds)?; - // cait-sith triple generation runs with exactly `t` parties, so the set - // of stores this node maintains is the distinct per-domain - // reconstruction thresholds — fully known up front, no on-demand - // creation needed. The contract enforces a single `t` across CaitSith - // domains today, so this is typically one store. + // cait-sith triple generation runs with exactly `t` parties, so we keep + // one store per distinct per-domain reconstruction threshold — known up + // front, no on-demand creation. Domains may share a `t` or diverge; the + // contract validates each domain's threshold independently. let mut triple_stores = HashMap::new(); for data in per_domain_data.values() { let t = data.reconstruction_threshold; From 9cc8741bbd6f7f8a8ddf9a36dcadd841dc135744 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:00:56 +0200 Subject: [PATCH 084/121] Adding the reconstruction threshold in the domain registry --- crates/node/src/coordinator.rs | 53 ++++++++++++----------- crates/node/src/providers/ckd.rs | 34 +++++++-------- crates/node/src/providers/ecdsa.rs | 7 +-- crates/node/src/providers/ecdsa_common.rs | 30 +++++-------- crates/node/src/providers/eddsa.rs | 34 +++++++-------- crates/node/src/providers/robust_ecdsa.rs | 7 +-- 6 files changed, 75 insertions(+), 90 deletions(-) diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index 6d0304ab62..0f49099f62 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -589,34 +589,35 @@ where let mut ecdsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - ecdsa::KeygenOutput, + (ecdsa::KeygenOutput, ReconstructionThreshold), > = HashMap::new(); let mut robust_ecdsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - ecdsa::KeygenOutput, + (ecdsa::KeygenOutput, ReconstructionThreshold), > = HashMap::new(); let mut eddsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - eddsa::KeygenOutput, + (eddsa::KeygenOutput, ReconstructionThreshold), > = HashMap::new(); let mut ckd_keyshares: HashMap< mpc_primitives::domain::DomainId, - confidential_key_derivation::KeygenOutput, + ( + confidential_key_derivation::KeygenOutput, + ReconstructionThreshold, + ), > = HashMap::new(); - let domain_to_protocol: HashMap = running_state - .domains - .iter() - .map(|d| (d.id, d.protocol)) - .collect(); - let domain_to_threshold: HashMap = running_state - .domains - .iter() - .map(|d| (d.id, d.reconstruction_threshold)) - .collect(); + let domain_registry: HashMap = + running_state + .domains + .iter() + .map(|d| (d.id, (d.protocol, d.reconstruction_threshold))) + .collect(); for keyshare in keyshares { let domain_id = keyshare.key_id.domain_id; - let Some(protocol) = domain_to_protocol.get(&domain_id).copied() else { + let Some((protocol, reconstruction_threshold)) = + domain_registry.get(&domain_id).copied() + else { anyhow::bail!( "Keyshare references domain {domain_id:?} which is not in the contract registry", ); @@ -625,20 +626,21 @@ where match (expected_curve, keyshare.data) { (Curve::Secp256k1, KeyshareData::Secp256k1(data)) => match protocol { Protocol::CaitSith => { - ecdsa_keyshares.insert(domain_id, data); + ecdsa_keyshares.insert(domain_id, (data, reconstruction_threshold)); } Protocol::DamgardEtAl => { - robust_ecdsa_keyshares.insert(domain_id, data); + robust_ecdsa_keyshares + .insert(domain_id, (data, reconstruction_threshold)); } other => anyhow::bail!( "Unexpected protocol {other:?} for Secp256k1 keyshare on domain {domain_id:?}", ), }, (Curve::Edwards25519, KeyshareData::Ed25519(data)) => { - eddsa_keyshares.insert(domain_id, data); + eddsa_keyshares.insert(domain_id, (data, reconstruction_threshold)); } (Curve::Bls12381, KeyshareData::Bls12381(data)) => { - ckd_keyshares.insert(domain_id, data); + ckd_keyshares.insert(domain_id, (data, reconstruction_threshold)); } (expected, data) => anyhow::bail!( "Keyshare data does not match the domain protocol's expected curve: domain_id={:?}, protocol={:?}, expected_curve={:?}, data_kind={:?}", @@ -650,6 +652,11 @@ where } } + let domain_to_protocol: HashMap = domain_registry + .into_iter() + .map(|(id, (protocol, _))| (id, protocol)) + .collect(); + let ecdsa_signature_provider = Arc::new(EcdsaSignatureProvider::new( config_file.clone().into(), running_mpc_config.clone().into(), @@ -658,7 +665,6 @@ where secret_db.clone(), sign_request_store.clone(), ecdsa_keyshares, - domain_to_threshold.clone(), )?); let robust_ecdsa_signature_provider = Arc::new(RobustEcdsaSignatureProvider::new( @@ -669,7 +675,6 @@ where secret_db, sign_request_store.clone(), robust_ecdsa_keyshares, - domain_to_threshold.clone(), )?); let eddsa_signature_provider = Arc::new(EddsaSignatureProvider::new( @@ -678,8 +683,7 @@ where network_client.clone(), sign_request_store.clone(), eddsa_keyshares, - domain_to_threshold.clone(), - )?); + )); let ckd_provider = Arc::new(CKDProvider::new( config_file.clone().into(), @@ -687,8 +691,7 @@ where network_client.clone(), ckd_request_store.clone(), ckd_keyshares, - domain_to_threshold, - )?); + )); let verify_foreign_tx_provider = Arc::new(VerifyForeignTxProvider::new( config_file.clone().into(), diff --git a/crates/node/src/providers/ckd.rs b/crates/node/src/providers/ckd.rs index ea55fecb67..306873f119 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -60,29 +60,27 @@ impl CKDProvider { mpc_config: Arc, client: Arc, ckd_request_store: Arc, - keyshares: HashMap, - thresholds: HashMap, - ) -> anyhow::Result { - let mut per_domain_data = HashMap::new(); - for (domain_id, keyshare) in keyshares { - let threshold = *thresholds.get(&domain_id).ok_or_else(|| { - anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) - })?; - per_domain_data.insert( - domain_id, - PerDomainData { - keyshare, - reconstruction_threshold: threshold, - }, - ); - } - Ok(Self { + keyshares: HashMap, + ) -> Self { + let per_domain_data = keyshares + .into_iter() + .map(|(domain_id, (keyshare, reconstruction_threshold))| { + ( + domain_id, + PerDomainData { + keyshare, + reconstruction_threshold, + }, + ) + }) + .collect(); + Self { config, mpc_config, client, ckd_request_store, per_domain_data, - }) + } } pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 6b45cc1c40..575774bd01 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -50,7 +50,6 @@ pub struct EcdsaSignatureProvider { pub(super) type PerDomainData = ecdsa_common::PerDomainData; impl EcdsaSignatureProvider { - #[expect(clippy::too_many_arguments)] pub fn new( config: Arc, mpc_config: Arc, @@ -58,11 +57,9 @@ impl EcdsaSignatureProvider { clock: Clock, db: Arc, sign_request_store: Arc, - keyshares: HashMap, - thresholds: HashMap, + keyshares: HashMap, ) -> anyhow::Result { - let per_domain_data = - ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares, &thresholds)?; + let per_domain_data = ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares)?; // cait-sith triple generation runs with exactly `t` parties, so we keep // one store per distinct per-domain reconstruction threshold — known up diff --git a/crates/node/src/providers/ecdsa_common.rs b/crates/node/src/providers/ecdsa_common.rs index 2d01a2f7ea..58a9943028 100644 --- a/crates/node/src/providers/ecdsa_common.rs +++ b/crates/node/src/providers/ecdsa_common.rs @@ -105,17 +105,13 @@ pub fn build_per_domain_data

( clock: &Clock, db: &Arc, client: &Arc, - keyshares: HashMap, - thresholds: &HashMap, + keyshares: HashMap, ) -> anyhow::Result>> where P: Serialize + DeserializeOwned + Send + 'static, { let mut per_domain_data = HashMap::new(); - for (domain_id, keyshare) in keyshares { - let reconstruction_threshold = *thresholds.get(&domain_id).ok_or_else(|| { - anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) - })?; + for (domain_id, (keyshare, reconstruction_threshold)) in keyshares { let presignature_store = Arc::new(PresignatureStorage::new( clock.clone(), db.clone(), @@ -188,24 +184,20 @@ mod tests { let low = DomainId(0); let high = DomainId(1); let keygen_output = dummy_keygen_output(); - let keyshares = - HashMap::from([(low, keygen_output.clone()), (high, keygen_output)]); - let thresholds = HashMap::from([ - (low, ReconstructionThreshold::new(2)), - (high, ReconstructionThreshold::new(3)), + let keyshares = HashMap::from([ + ( + low, + (keygen_output.clone(), ReconstructionThreshold::new(2)), + ), + (high, (keygen_output, ReconstructionThreshold::new(3))), ]); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); // When - let per_domain_data = build_per_domain_data::>( - &Clock::real(), - &db, - &client, - keyshares, - &thresholds, - ) - .unwrap(); + let per_domain_data = + build_per_domain_data::>(&Clock::real(), &db, &client, keyshares) + .unwrap(); // Then each domain keeps the threshold it was configured with assert_eq!( diff --git a/crates/node/src/providers/eddsa.rs b/crates/node/src/providers/eddsa.rs index 93f1da1750..16a2aa53cc 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -50,29 +50,27 @@ impl EddsaSignatureProvider { mpc_config: Arc, client: Arc, sign_request_store: Arc, - keyshares: HashMap, - thresholds: HashMap, - ) -> anyhow::Result { - let mut per_domain_data = HashMap::new(); - for (domain_id, keyshare) in keyshares { - let threshold = *thresholds.get(&domain_id).ok_or_else(|| { - anyhow::anyhow!("No reconstruction threshold for domain {:?}", domain_id) - })?; - per_domain_data.insert( - domain_id, - PerDomainData { - keyshare, - reconstruction_threshold: threshold, - }, - ); - } - Ok(Self { + keyshares: HashMap, + ) -> Self { + let per_domain_data = keyshares + .into_iter() + .map(|(domain_id, (keyshare, reconstruction_threshold))| { + ( + domain_id, + PerDomainData { + keyshare, + reconstruction_threshold, + }, + ) + }) + .collect(); + Self { config, mpc_config, client, sign_request_store, per_domain_data, - }) + } } pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 4d5732ebe2..3b80fee659 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -52,7 +52,6 @@ impl EcdsaMessageHash { } impl RobustEcdsaSignatureProvider { - #[expect(clippy::too_many_arguments)] pub fn new( config: Arc, mpc_config: Arc, @@ -60,11 +59,9 @@ impl RobustEcdsaSignatureProvider { clock: Clock, db: Arc, sign_request_store: Arc, - keyshares: HashMap, - thresholds: HashMap, + keyshares: HashMap, ) -> anyhow::Result { - let per_domain_data = - ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares, &thresholds)?; + let per_domain_data = ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares)?; Ok(Self { config, From 954a69a3e323ef2b01da65053aad68a1de027444 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:04:40 +0200 Subject: [PATCH 085/121] Improving output to prevent misleading understanding --- crates/node/src/metrics.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/node/src/metrics.rs b/crates/node/src/metrics.rs index a75092dddd..e90ead3ec2 100644 --- a/crates/node/src/metrics.rs +++ b/crates/node/src/metrics.rs @@ -18,7 +18,7 @@ pub static MPC_NUM_BAD_PEER_PRESIGN_REQUESTS: LazyLock = || { prometheus::register_int_counter!( "mpc_num_bad_peer_presign_requests", - "Presignature requests from a peer whose participant-set size did not match the domain's reconstruction threshold" + "CaitSith presignature requests from a peer whose participant-set size did not match the domain's reconstruction threshold (only meaningful for CaitSith, which pairs exactly `t` participants; robust ECDSA does not have this constraint)" ) .unwrap() }, From 18e1ed18164c12f415df7f04a824be8d23f31b25 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:04:55 +0200 Subject: [PATCH 086/121] Adding extra migration tests --- crates/node/src/p2p.rs | 2 + crates/node/src/tests.rs | 1 + .../src/tests/reconstruction_thresholds.rs | 212 ++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 crates/node/src/tests/reconstruction_thresholds.rs diff --git a/crates/node/src/p2p.rs b/crates/node/src/p2p.rs index 5c8f7072ab..4d72bd5f5c 100644 --- a/crates/node/src/p2p.rs +++ b/crates/node/src/p2p.rs @@ -1018,6 +1018,8 @@ pub mod testing { pub const BACKUP_CLI_WEBSERVER_PUT_KEYSHARES_HOSTNAME: Self = Self::new(21); pub const ASSET_GENERATION_SIGNING_CONTENTION_TEST: Self = Self::new(22); pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_TEST: Self = Self::new(23); + pub const RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST: Self = Self::new(24); + pub const RECONSTRUCTION_THRESHOLD_RESHARING_TEST: Self = Self::new(25); } pub fn generate_test_p2p_configs( diff --git a/crates/node/src/tests.rs b/crates/node/src/tests.rs index c080eea68d..85e0dfecec 100644 --- a/crates/node/src/tests.rs +++ b/crates/node/src/tests.rs @@ -61,6 +61,7 @@ mod foreign_chain_configuration; mod multidomain; mod onboarding; mod protocol_yielding; +mod reconstruction_thresholds; mod resharing; const DEFAULT_BLOCK_TIME: std::time::Duration = std::time::Duration::from_millis(300); diff --git a/crates/node/src/tests/reconstruction_thresholds.rs b/crates/node/src/tests/reconstruction_thresholds.rs new file mode 100644 index 0000000000..ebb79462ef --- /dev/null +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -0,0 +1,212 @@ +//! Integration tests asserting signing availability is gated by each domain's own +//! reconstruction threshold `t`, not the governance threshold. +//! +//! Online signers needed to sign: `t` for CaitSith/Frost/CKD, `2t - 1` for DamgardEtAl. + +use crate::indexer::fake::FakeIndexerManager; +use crate::indexer::participants::ContractState; +use crate::p2p::testing::PortSeed; +use crate::tests::common::sign_domain; +use crate::tests::{ + DEFAULT_MAX_PROTOCOL_WAIT_TIME, DEFAULT_MAX_SIGNATURE_WAIT_TIME, IntegrationTestSetup, + request_signature_and_await_response, +}; +use crate::tracking::AutoAbortTask; +use near_mpc_contract_interface::types::{DomainConfig, Protocol}; +use near_time::Clock; + +// Slow enough that the DamgardEtAl domains don't flake (matches the existing +// distinct-reconstruction-thresholds test). +const BLOCK_TIME: std::time::Duration = std::time::Duration::from_millis(600); + +async fn assert_can_sign(indexer: &mut FakeIndexerManager, user: &str, domain: &DomainConfig) { + assert!( + request_signature_and_await_response(indexer, user, domain, DEFAULT_MAX_SIGNATURE_WAIT_TIME) + .await + .is_some(), + "domain {:?} (t={}) should be able to sign with the currently-online nodes", + domain.id, + domain.reconstruction_threshold.inner(), + ); +} + +async fn assert_cannot_sign(indexer: &mut FakeIndexerManager, user: &str, domain: &DomainConfig) { + assert!( + request_signature_and_await_response(indexer, user, domain, DEFAULT_MAX_SIGNATURE_WAIT_TIME) + .await + .is_none(), + "domain {:?} (t={}) must NOT be able to sign: too few nodes are online for its threshold", + domain.id, + domain.reconstruction_threshold.inner(), + ); +} + +/// Nodes going offline leave low-`t` domains signing while higher-`t` domains in the +/// same cluster stop. +#[tokio::test] +#[test_log::test] +#[expect(non_snake_case)] +async fn per_domain_reconstruction_threshold__should_gate_signing_availability_when_nodes_go_offline() + { + // Given a 5-node cluster with three domains at distinct thresholds. + const NUM_PARTICIPANTS: usize = 5; + const THRESHOLD: usize = 3; + const TXN_DELAY_BLOCKS: u64 = 1; + let temp_dir = tempfile::tempdir().unwrap(); + let mut setup = IntegrationTestSetup::new( + Clock::real(), + temp_dir.path(), + (0..NUM_PARTICIPANTS) + .map(|i| format!("test{}", i).parse().unwrap()) + .collect(), + THRESHOLD, + TXN_DELAY_BLOCKS, + PortSeed::RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST, + BLOCK_TIME, + ); + + // low needs 2 online, high needs 4 online, robust (DamgardEtAl) needs 2*3-1 = 5 online. + let low = sign_domain(0, Protocol::CaitSith, 2); + let high = sign_domain(1, Protocol::CaitSith, 4); + let robust = sign_domain(2, Protocol::DamgardEtAl, 3); + let domains = vec![low.clone(), high.clone(), robust.clone()]; + + { + let mut contract = setup.indexer.contract_mut().await; + contract.initialize(setup.participants.clone()); + contract.add_domains(domains.clone()); + } + + let _runs = setup + .configs + .into_iter() + .map(|config| AutoAbortTask::from(tokio::spawn(config.run()))) + .collect::>(); + + setup + .indexer + .wait_for_contract_state( + |state| matches!(state, ContractState::Running(_)), + DEFAULT_MAX_PROTOCOL_WAIT_TIME * domains.len() as u32, + ) + .await + .expect("must not exceed timeout"); + + // When all 5 are online: every domain signs. + assert_can_sign(&mut setup.indexer, "user_all_low", &low).await; + assert_can_sign(&mut setup.indexer, "user_all_high", &high).await; + assert_can_sign(&mut setup.indexer, "user_all_robust", &robust).await; + + // One node down (4 online): only robust (needs 5) stops. + let disabled_a = setup.indexer.disable(4.into()).await; + assert_can_sign(&mut setup.indexer, "user_4_low", &low).await; + assert_can_sign(&mut setup.indexer, "user_4_high", &high).await; + assert_cannot_sign(&mut setup.indexer, "user_4_robust", &robust).await; + + // Two nodes down (3 online): high (t=4) stops too. + let disabled_b = setup.indexer.disable(3.into()).await; + assert_can_sign(&mut setup.indexer, "user_3_low", &low).await; + assert_cannot_sign(&mut setup.indexer, "user_3_high", &high).await; + + // Then restoring both nodes restores signing for every domain. + disabled_b.reenable_and_wait_till_running().await; + disabled_a.reenable_and_wait_till_running().await; + assert_can_sign(&mut setup.indexer, "user_restored_high", &high).await; + assert_can_sign(&mut setup.indexer, "user_restored_robust", &robust).await; +} + +/// Resharing (a new node joining) preserves each domain's own reconstruction threshold: +/// afterwards the high-`t` domain still requires its higher online-signer count. +#[tokio::test] +#[test_log::test] +#[expect(non_snake_case)] +async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_domain_across_resharing() + { + // Given a cluster starting with 4 of an eventual 5 participants. + const NUM_PARTICIPANTS: usize = 5; + const THRESHOLD: usize = 3; + const TXN_DELAY_BLOCKS: u64 = 1; + let temp_dir = tempfile::tempdir().unwrap(); + let mut setup = IntegrationTestSetup::new( + Clock::real(), + temp_dir.path(), + (0..NUM_PARTICIPANTS) + .map(|i| format!("test{}", i).parse().unwrap()) + .collect(), + THRESHOLD, + TXN_DELAY_BLOCKS, + PortSeed::RECONSTRUCTION_THRESHOLD_RESHARING_TEST, + BLOCK_TIME, + ); + + // low needs 2 online, mid (Frost) needs 3 online, high needs 4 online. + let low = sign_domain(0, Protocol::CaitSith, 2); + let mid = sign_domain(1, Protocol::Frost, 3); + let high = sign_domain(2, Protocol::CaitSith, 4); + let domains = vec![low.clone(), mid.clone(), high.clone()]; + + // Initialize with one fewer participant; the fifth joins during resharing. + let mut initial_participants = setup.participants.clone(); + initial_participants.participants.pop(); + + { + let mut contract = setup.indexer.contract_mut().await; + contract.initialize(initial_participants); + contract.add_domains(domains.clone()); + } + + let _runs = setup + .configs + .into_iter() + .map(|config| AutoAbortTask::from(tokio::spawn(config.run()))) + .collect::>(); + + setup + .indexer + .wait_for_contract_state( + |state| matches!(state, ContractState::Running(_)), + DEFAULT_MAX_PROTOCOL_WAIT_TIME * domains.len() as u32, + ) + .await + .expect("must not exceed timeout"); + + // Sanity: all four initial nodes online, every domain signs (high's t=4 needs all 4). + assert_can_sign(&mut setup.indexer, "user_pre_low", &low).await; + assert_can_sign(&mut setup.indexer, "user_pre_mid", &mid).await; + assert_can_sign(&mut setup.indexer, "user_pre_high", &high).await; + + // When the fifth node joins via resharing. + setup + .indexer + .contract_mut() + .await + .start_resharing(setup.participants.clone()); + + setup + .indexer + .wait_for_contract_state( + |state| match state { + ContractState::Running(running) => { + running.keyset.epoch_id.get() == 1 + && running.participants.participants.len() == NUM_PARTICIPANTS + } + _ => false, + }, + DEFAULT_MAX_PROTOCOL_WAIT_TIME * domains.len() as u32, + ) + .await + .expect("Timeout waiting for resharing to complete"); + + // Then all domains still sign with the full reshared set. + assert_can_sign(&mut setup.indexer, "user_post_low", &low).await; + assert_can_sign(&mut setup.indexer, "user_post_mid", &mid).await; + assert_can_sign(&mut setup.indexer, "user_post_high", &high).await; + + // Then with two nodes down (3 online), high (t=4) can't sign — its threshold + // survived the reshare while low/mid still work. + let _disabled_a = setup.indexer.disable(4.into()).await; + let _disabled_b = setup.indexer.disable(3.into()).await; + assert_can_sign(&mut setup.indexer, "user_drop_low", &low).await; + assert_can_sign(&mut setup.indexer, "user_drop_mid", &mid).await; + assert_cannot_sign(&mut setup.indexer, "user_drop_high", &high).await; +} From a2cc899d763ac9753fcde5bad3f28f079d2a826d Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:43:07 +0200 Subject: [PATCH 087/121] Reshare keys to the updated reconstruction threshold Changing a domain's reconstruction threshold via a resharing proposal had no cryptographic effect. The resharing key event was built from the old domain registry, so the node reshared at the old threshold, and per_domain_thresholds was only folded into the registry on completion. Lowering t left an unusable (higher-degree) key; raising t left the advertised threshold unenforced. Build the resharing key events from the effective (threshold-updated) domains via a new DomainRegistry::effective_domain_by_index, so the reshare targets the new degree. The old-side threshold still comes from the previous registry, so reshare(old_t, .., new_t) receives the correct pair. --- crates/contract/src/primitives/domain.rs | 49 ++++++++++++++++++++++++ crates/contract/src/state/resharing.rs | 9 ++--- crates/contract/src/state/running.rs | 33 +++++++++++++--- 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/crates/contract/src/primitives/domain.rs b/crates/contract/src/primitives/domain.rs index e204d57b47..e8376d7e87 100644 --- a/crates/contract/src/primitives/domain.rs +++ b/crates/contract/src/primitives/domain.rs @@ -147,6 +147,26 @@ impl DomainRegistry { self.domains.get(index) } + /// Like [`Self::get_domain_by_index`], but with `reconstruction_threshold` overridden + /// by `threshold_updates` when present, so a resharing key event reshares to the new + /// degree. Matches the update [`Self::with_threshold_updates`] folds in on completion. + pub fn effective_domain_by_index( + &self, + index: usize, + threshold_updates: &BTreeMap, + ) -> Option { + self.get_domain_by_index(index).map(|domain| { + let reconstruction_threshold = threshold_updates + .get(&domain.id) + .copied() + .unwrap_or(domain.reconstruction_threshold); + DomainConfig { + reconstruction_threshold, + ..domain.clone() + } + }) + } + /// Returns the given domain by the DomainId. pub fn get_domain_by_domain_id(&self, id: DomainId) -> Option<&DomainConfig> { self.domains.iter().find(|domain| domain.id == id) @@ -792,4 +812,33 @@ pub mod tests { ReconstructionThreshold::new(2) ); } + + #[test] + fn effective_domain_by_index__should_override_only_the_targeted_domains_threshold() { + // Given a registry with two domains and an update targeting the second. + let registry = registry_of(vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }, + DomainConfig { + id: DomainId(1), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(2), + purpose: DomainPurpose::Sign, + }, + ]); + let updates = BTreeMap::from([(DomainId(1), ReconstructionThreshold::new(4))]); + + // When reading each domain's effective config. + let d0 = registry.effective_domain_by_index(0, &updates).unwrap(); + let d1 = registry.effective_domain_by_index(1, &updates).unwrap(); + + // Then only the targeted domain reports the new threshold. + assert_eq!(d0.reconstruction_threshold, ReconstructionThreshold::new(2)); + assert_eq!(d1.reconstruction_threshold, ReconstructionThreshold::new(4)); + assert!(registry.effective_domain_by_index(2, &updates).is_none()); + } } diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index 756255c797..fed7df828d 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -82,9 +82,8 @@ impl ResharingContractState { self.prospective_epoch_id().next(), self.previous_running_state .domains - .get_domain_by_index(0) - .unwrap() - .clone(), + .effective_domain_by_index(0, proposal.per_domain_thresholds()) + .unwrap(), proposal.parameters().clone(), ), cancellation_requests: HashSet::new(), @@ -136,11 +135,11 @@ impl ResharingContractState { if let Some(next_domain) = self .previous_running_state .domains - .get_domain_by_index(self.reshared_keys.len()) + .effective_domain_by_index(self.reshared_keys.len(), &self.per_domain_thresholds) { self.resharing_key = KeyEvent::new( self.prospective_epoch_id(), - next_domain.clone(), + next_domain, self.resharing_key.proposed_parameters().clone(), ); } else { diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 3a8d88221b..4c1c10061c 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -67,7 +67,10 @@ impl RunningContractState { &mut self, proposal: &ProposedThresholdParameters, ) -> Option { - if let Some(first_domain) = self.domains.get_domain_by_index(0) { + if let Some(first_domain) = self + .domains + .effective_domain_by_index(0, proposal.per_domain_thresholds()) + { let epoch_id = self.prospective_epoch_id(); Some(ResharingContractState { @@ -78,11 +81,7 @@ impl RunningContractState { self.add_domains_votes.clone(), ), reshared_keys: Vec::new(), - resharing_key: KeyEvent::new( - epoch_id, - first_domain.clone(), - proposal.parameters().clone(), - ), + resharing_key: KeyEvent::new(epoch_id, first_domain, proposal.parameters().clone()), cancellation_requests: HashSet::new(), per_domain_thresholds: proposal.per_domain_thresholds().clone(), }) @@ -641,6 +640,28 @@ pub mod running_tests { ); } + #[test] + fn transition_to_resharing__should_carry_threshold_update_into_the_resharing_key_event() { + // Given a running state with one domain and a proposal changing its threshold. + let mut state = gen_running_state_with_params(1, 5, 5); + let domain_id = state.domains.get_domain_by_index(0).unwrap().id; + let proposal = gen_valid_params_proposal(&state.parameters).with_per_domain_thresholds( + BTreeMap::from([(domain_id, ReconstructionThreshold::new(3))]), + ); + + // When transitioning into resharing. + let resharing = state + .transition_to_resharing_no_checks(&proposal) + .expect("state has a domain, so it transitions into resharing"); + + // Then the resharing key event carries the updated threshold, so the node + // reshares to the new degree rather than the stale one. + assert_eq!( + resharing.resharing_key.domain().reconstruction_threshold, + ReconstructionThreshold::new(3), + ); + } + /// Builds a `DomainConfig` for the next domain id with the given protocol, /// purpose, and reconstruction threshold. fn single_domain_proposal( From aac09732ecb84c0e824518d226e14859da16cd2d Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:46:52 +0200 Subject: [PATCH 088/121] Add node tests for changing reconstruction threshold via resharing Adds an integration test that lowers a domain's reconstruction threshold from 4 to 2 via resharing and then signs with only 2 of 5 nodes online, proving the key was reshared to the new degree. Adds FakeIndexerManager::start_resharing_with_ threshold_updates to drive the change, and a PortSeed for the new test. --- crates/node/src/indexer/fake.rs | 22 +++-- crates/node/src/p2p.rs | 1 + .../src/tests/reconstruction_thresholds.rs | 83 ++++++++++++++++++- 3 files changed, 99 insertions(+), 7 deletions(-) diff --git a/crates/node/src/indexer/fake.rs b/crates/node/src/indexer/fake.rs index d2b86c6875..5bb2ba795b 100644 --- a/crates/node/src/indexer/fake.rs +++ b/crates/node/src/indexer/fake.rs @@ -205,6 +205,16 @@ impl FakeMpcContractState { } pub fn start_resharing(&mut self, new_participants: ParticipantsConfig) { + self.start_resharing_with_threshold_updates(new_participants, BTreeMap::new()); + } + + /// Like [`Self::start_resharing`], but also changes the given domains' reconstruction + /// thresholds, mirroring a proposal that carries `per_domain_thresholds`. + pub fn start_resharing_with_threshold_updates( + &mut self, + new_participants: ParticipantsConfig, + per_domain_thresholds: BTreeMap, + ) { let (previous_running_state, prev_epoch_id) = match &self.state { ProtocolContractState::Running(state) => (state, state.keyset.epoch_id), ProtocolContractState::Resharing(state) => { @@ -212,6 +222,10 @@ impl FakeMpcContractState { } _ => panic!("Cannot start resharing from non-running state"), }; + let resharing_domain = previous_running_state + .domains + .effective_domain_by_index(0, &per_domain_thresholds) + .unwrap(); self.state = ProtocolContractState::Resharing(ResharingContractState { previous_running_state: RunningContractState::new( previous_running_state.domains.clone(), @@ -222,15 +236,11 @@ impl FakeMpcContractState { reshared_keys: Vec::new(), resharing_key: KeyEvent::new( prev_epoch_id.next(), - previous_running_state - .domains - .get_domain_by_index(0) - .unwrap() - .clone(), + resharing_domain, participants_config_to_threshold_parameters(&new_participants), ), cancellation_requests: HashSet::new(), - per_domain_thresholds: std::collections::BTreeMap::new(), + per_domain_thresholds, }); } diff --git a/crates/node/src/p2p.rs b/crates/node/src/p2p.rs index 4d72bd5f5c..b84b8e5d4c 100644 --- a/crates/node/src/p2p.rs +++ b/crates/node/src/p2p.rs @@ -1020,6 +1020,7 @@ pub mod testing { pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_TEST: Self = Self::new(23); pub const RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST: Self = Self::new(24); pub const RECONSTRUCTION_THRESHOLD_RESHARING_TEST: Self = Self::new(25); + pub const RECONSTRUCTION_THRESHOLD_CHANGE_TEST: Self = Self::new(26); } pub fn generate_test_p2p_configs( diff --git a/crates/node/src/tests/reconstruction_thresholds.rs b/crates/node/src/tests/reconstruction_thresholds.rs index ebb79462ef..5e1a58c718 100644 --- a/crates/node/src/tests/reconstruction_thresholds.rs +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -12,8 +12,9 @@ use crate::tests::{ request_signature_and_await_response, }; use crate::tracking::AutoAbortTask; -use near_mpc_contract_interface::types::{DomainConfig, Protocol}; +use near_mpc_contract_interface::types::{DomainConfig, Protocol, ReconstructionThreshold}; use near_time::Clock; +use std::collections::BTreeMap; // Slow enough that the DamgardEtAl domains don't flake (matches the existing // distinct-reconstruction-thresholds test). @@ -210,3 +211,83 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma assert_can_sign(&mut setup.indexer, "user_drop_mid", &mid).await; assert_cannot_sign(&mut setup.indexer, "user_drop_high", &high).await; } + +/// Changing a domain's reconstruction threshold via a resharing proposal takes real +/// cryptographic effect: after lowering `t` from 4 to 2, only 2 nodes need be online to +/// sign — impossible unless the key was genuinely re-shared to the new degree. +#[tokio::test] +#[test_log::test] +#[expect(non_snake_case)] +async fn changing_reconstruction_threshold_via_resharing__should_reshare_the_key_to_the_new_degree() + { + // Given a 5-node cluster with a single CaitSith domain at t=4. + const NUM_PARTICIPANTS: usize = 5; + const THRESHOLD: usize = 3; + const TXN_DELAY_BLOCKS: u64 = 1; + let temp_dir = tempfile::tempdir().unwrap(); + let mut setup = IntegrationTestSetup::new( + Clock::real(), + temp_dir.path(), + (0..NUM_PARTICIPANTS) + .map(|i| format!("test{}", i).parse().unwrap()) + .collect(), + THRESHOLD, + TXN_DELAY_BLOCKS, + PortSeed::RECONSTRUCTION_THRESHOLD_CHANGE_TEST, + BLOCK_TIME, + ); + + let domain = sign_domain(0, Protocol::CaitSith, 4); + { + let mut contract = setup.indexer.contract_mut().await; + contract.initialize(setup.participants.clone()); + contract.add_domains(vec![domain.clone()]); + } + + let _runs = setup + .configs + .into_iter() + .map(|config| AutoAbortTask::from(tokio::spawn(config.run()))) + .collect::>(); + + setup + .indexer + .wait_for_contract_state( + |state| matches!(state, ContractState::Running(_)), + DEFAULT_MAX_PROTOCOL_WAIT_TIME, + ) + .await + .expect("must not exceed timeout"); + + // Sanity: at t=4 the domain signs with all nodes online. + assert_can_sign(&mut setup.indexer, "user_pre", &domain).await; + + // When resharing lowers the threshold to t=2 (participant set unchanged). + let lowered = sign_domain(0, Protocol::CaitSith, 2); + setup + .indexer + .contract_mut() + .await + .start_resharing_with_threshold_updates( + setup.participants.clone(), + BTreeMap::from([(domain.id, ReconstructionThreshold::new(2))]), + ); + + setup + .indexer + .wait_for_contract_state( + |state| match state { + ContractState::Running(running) => running.keyset.epoch_id.get() == 1, + _ => false, + }, + DEFAULT_MAX_PROTOCOL_WAIT_TIME, + ) + .await + .expect("Timeout waiting for resharing to complete"); + + // Then two online nodes suffice; the original t=4 sharing would have needed four. + let _d1 = setup.indexer.disable(4.into()).await; + let _d2 = setup.indexer.disable(3.into()).await; + let _d3 = setup.indexer.disable(2.into()).await; + assert_can_sign(&mut setup.indexer, "user_post", &lowered).await; +} From ec959e47822fe0025527e2bb20ee522d2dfda397 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:28:53 +0200 Subject: [PATCH 089/121] cargo fmt --- .../src/tests/reconstruction_thresholds.rs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/node/src/tests/reconstruction_thresholds.rs b/crates/node/src/tests/reconstruction_thresholds.rs index 5e1a58c718..7d875dd365 100644 --- a/crates/node/src/tests/reconstruction_thresholds.rs +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -22,9 +22,14 @@ const BLOCK_TIME: std::time::Duration = std::time::Duration::from_millis(600); async fn assert_can_sign(indexer: &mut FakeIndexerManager, user: &str, domain: &DomainConfig) { assert!( - request_signature_and_await_response(indexer, user, domain, DEFAULT_MAX_SIGNATURE_WAIT_TIME) - .await - .is_some(), + request_signature_and_await_response( + indexer, + user, + domain, + DEFAULT_MAX_SIGNATURE_WAIT_TIME + ) + .await + .is_some(), "domain {:?} (t={}) should be able to sign with the currently-online nodes", domain.id, domain.reconstruction_threshold.inner(), @@ -33,9 +38,14 @@ async fn assert_can_sign(indexer: &mut FakeIndexerManager, user: &str, domain: & async fn assert_cannot_sign(indexer: &mut FakeIndexerManager, user: &str, domain: &DomainConfig) { assert!( - request_signature_and_await_response(indexer, user, domain, DEFAULT_MAX_SIGNATURE_WAIT_TIME) - .await - .is_none(), + request_signature_and_await_response( + indexer, + user, + domain, + DEFAULT_MAX_SIGNATURE_WAIT_TIME + ) + .await + .is_none(), "domain {:?} (t={}) must NOT be able to sign: too few nodes are online for its threshold", domain.id, domain.reconstruction_threshold.inner(), @@ -219,7 +229,7 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma #[test_log::test] #[expect(non_snake_case)] async fn changing_reconstruction_threshold_via_resharing__should_reshare_the_key_to_the_new_degree() - { +{ // Given a 5-node cluster with a single CaitSith domain at t=4. const NUM_PARTICIPANTS: usize = 5; const THRESHOLD: usize = 3; From 955e6690f5203b683dd4db773fdd20f469a3e693 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:57:52 +0200 Subject: [PATCH 090/121] receiving the new threshold from the contract state at the indexer level --- crates/contract/src/primitives/domain.rs | 49 ------------------------ crates/contract/src/state/resharing.rs | 9 +++-- crates/contract/src/state/running.rs | 33 +++------------- crates/node/src/indexer/fake.rs | 7 +++- crates/node/src/indexer/participants.rs | 27 +++++++++---- 5 files changed, 36 insertions(+), 89 deletions(-) diff --git a/crates/contract/src/primitives/domain.rs b/crates/contract/src/primitives/domain.rs index e8376d7e87..e204d57b47 100644 --- a/crates/contract/src/primitives/domain.rs +++ b/crates/contract/src/primitives/domain.rs @@ -147,26 +147,6 @@ impl DomainRegistry { self.domains.get(index) } - /// Like [`Self::get_domain_by_index`], but with `reconstruction_threshold` overridden - /// by `threshold_updates` when present, so a resharing key event reshares to the new - /// degree. Matches the update [`Self::with_threshold_updates`] folds in on completion. - pub fn effective_domain_by_index( - &self, - index: usize, - threshold_updates: &BTreeMap, - ) -> Option { - self.get_domain_by_index(index).map(|domain| { - let reconstruction_threshold = threshold_updates - .get(&domain.id) - .copied() - .unwrap_or(domain.reconstruction_threshold); - DomainConfig { - reconstruction_threshold, - ..domain.clone() - } - }) - } - /// Returns the given domain by the DomainId. pub fn get_domain_by_domain_id(&self, id: DomainId) -> Option<&DomainConfig> { self.domains.iter().find(|domain| domain.id == id) @@ -812,33 +792,4 @@ pub mod tests { ReconstructionThreshold::new(2) ); } - - #[test] - fn effective_domain_by_index__should_override_only_the_targeted_domains_threshold() { - // Given a registry with two domains and an update targeting the second. - let registry = registry_of(vec![ - DomainConfig { - id: DomainId(0), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(2), - purpose: DomainPurpose::Sign, - }, - DomainConfig { - id: DomainId(1), - protocol: Protocol::CaitSith, - reconstruction_threshold: ReconstructionThreshold::new(2), - purpose: DomainPurpose::Sign, - }, - ]); - let updates = BTreeMap::from([(DomainId(1), ReconstructionThreshold::new(4))]); - - // When reading each domain's effective config. - let d0 = registry.effective_domain_by_index(0, &updates).unwrap(); - let d1 = registry.effective_domain_by_index(1, &updates).unwrap(); - - // Then only the targeted domain reports the new threshold. - assert_eq!(d0.reconstruction_threshold, ReconstructionThreshold::new(2)); - assert_eq!(d1.reconstruction_threshold, ReconstructionThreshold::new(4)); - assert!(registry.effective_domain_by_index(2, &updates).is_none()); - } } diff --git a/crates/contract/src/state/resharing.rs b/crates/contract/src/state/resharing.rs index fed7df828d..756255c797 100644 --- a/crates/contract/src/state/resharing.rs +++ b/crates/contract/src/state/resharing.rs @@ -82,8 +82,9 @@ impl ResharingContractState { self.prospective_epoch_id().next(), self.previous_running_state .domains - .effective_domain_by_index(0, proposal.per_domain_thresholds()) - .unwrap(), + .get_domain_by_index(0) + .unwrap() + .clone(), proposal.parameters().clone(), ), cancellation_requests: HashSet::new(), @@ -135,11 +136,11 @@ impl ResharingContractState { if let Some(next_domain) = self .previous_running_state .domains - .effective_domain_by_index(self.reshared_keys.len(), &self.per_domain_thresholds) + .get_domain_by_index(self.reshared_keys.len()) { self.resharing_key = KeyEvent::new( self.prospective_epoch_id(), - next_domain, + next_domain.clone(), self.resharing_key.proposed_parameters().clone(), ); } else { diff --git a/crates/contract/src/state/running.rs b/crates/contract/src/state/running.rs index 4c1c10061c..3a8d88221b 100644 --- a/crates/contract/src/state/running.rs +++ b/crates/contract/src/state/running.rs @@ -67,10 +67,7 @@ impl RunningContractState { &mut self, proposal: &ProposedThresholdParameters, ) -> Option { - if let Some(first_domain) = self - .domains - .effective_domain_by_index(0, proposal.per_domain_thresholds()) - { + if let Some(first_domain) = self.domains.get_domain_by_index(0) { let epoch_id = self.prospective_epoch_id(); Some(ResharingContractState { @@ -81,7 +78,11 @@ impl RunningContractState { self.add_domains_votes.clone(), ), reshared_keys: Vec::new(), - resharing_key: KeyEvent::new(epoch_id, first_domain, proposal.parameters().clone()), + resharing_key: KeyEvent::new( + epoch_id, + first_domain.clone(), + proposal.parameters().clone(), + ), cancellation_requests: HashSet::new(), per_domain_thresholds: proposal.per_domain_thresholds().clone(), }) @@ -640,28 +641,6 @@ pub mod running_tests { ); } - #[test] - fn transition_to_resharing__should_carry_threshold_update_into_the_resharing_key_event() { - // Given a running state with one domain and a proposal changing its threshold. - let mut state = gen_running_state_with_params(1, 5, 5); - let domain_id = state.domains.get_domain_by_index(0).unwrap().id; - let proposal = gen_valid_params_proposal(&state.parameters).with_per_domain_thresholds( - BTreeMap::from([(domain_id, ReconstructionThreshold::new(3))]), - ); - - // When transitioning into resharing. - let resharing = state - .transition_to_resharing_no_checks(&proposal) - .expect("state has a domain, so it transitions into resharing"); - - // Then the resharing key event carries the updated threshold, so the node - // reshares to the new degree rather than the stale one. - assert_eq!( - resharing.resharing_key.domain().reconstruction_threshold, - ReconstructionThreshold::new(3), - ); - } - /// Builds a `DomainConfig` for the next domain id with the given protocol, /// purpose, and reconstruction threshold. fn single_domain_proposal( diff --git a/crates/node/src/indexer/fake.rs b/crates/node/src/indexer/fake.rs index 5bb2ba795b..2e8e2f38ec 100644 --- a/crates/node/src/indexer/fake.rs +++ b/crates/node/src/indexer/fake.rs @@ -222,10 +222,13 @@ impl FakeMpcContractState { } _ => panic!("Cannot start resharing from non-running state"), }; + // Mirror the real contract: `resharing_key` keeps the old threshold; the update + // travels only in `per_domain_thresholds`, which the node applies itself. let resharing_domain = previous_running_state .domains - .effective_domain_by_index(0, &per_domain_thresholds) - .unwrap(); + .get_domain_by_index(0) + .unwrap() + .clone(); self.state = ProtocolContractState::Resharing(ResharingContractState { previous_running_state: RunningContractState::new( previous_running_state.domains.clone(), diff --git a/crates/node/src/indexer/participants.rs b/crates/node/src/indexer/participants.rs index c0bf8ec592..f298c99f3b 100644 --- a/crates/node/src/indexer/participants.rs +++ b/crates/node/src/indexer/participants.rs @@ -8,7 +8,7 @@ use near_account_id::AccountId; use near_mpc_contract_interface::types as dtos; use near_mpc_contract_interface::types::{KeyEvent, ProtocolContractState, ThresholdParameters}; use near_mpc_crypto_types::{KeyForDomain as ContractKeyForDomain, Keyset as ContractKeyset}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use tokio::sync::watch; use url::Url; @@ -123,6 +123,10 @@ pub struct ContractResharingState { pub new_participants: ParticipantsConfig, pub reshared_keys: ContractKeyset, pub key_event: ContractKeyEventInstance, + /// Per-domain threshold updates from the accepted proposal. The contract keeps + /// `resharing_key` at the old threshold and only folds these into the registry on + /// completion, so the node applies them to the resharing key event itself. + pub per_domain_thresholds: BTreeMap, } /// A stripped-down version of the contract state, containing only the state @@ -202,6 +206,19 @@ impl ContractState { }) } ProtocolContractState::Resharing(state) => { + let mut key_event = convert_key_event_to_instance( + &state.resharing_key, + height, + state.reshared_keys.clone(), + ) + .context("failed to convert resharing key event")?; + + // Reshare to the new degree: the contract carries the update only in + // `per_domain_thresholds`, not on the resharing key event's domain. + if let Some(new_threshold) = state.per_domain_thresholds.get(&key_event.domain.id) { + key_event.domain.reconstruction_threshold = *new_threshold; + } + let resharing_state = Some(ContractResharingState { new_participants: convert_participant_infos( state.resharing_key.parameters.clone(), @@ -211,12 +228,8 @@ impl ContractState { epoch_id: state.resharing_key.epoch_id, domains: state.reshared_keys.clone(), }, - key_event: convert_key_event_to_instance( - &state.resharing_key, - height, - state.reshared_keys.clone(), - ) - .context("failed to convert resharing key event")?, + key_event, + per_domain_thresholds: state.per_domain_thresholds.clone(), }); let running_state = state.previous_running_state.clone(); From e89f26143e9e96a3159764ec6baa50e5f47e4544 Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:02:07 +0200 Subject: [PATCH 091/121] Unit tests --- crates/node/src/indexer/participants.rs | 71 ++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/node/src/indexer/participants.rs b/crates/node/src/indexer/participants.rs index f298c99f3b..036acfa5ab 100644 --- a/crates/node/src/indexer/participants.rs +++ b/crates/node/src/indexer/participants.rs @@ -409,14 +409,20 @@ pub mod test_utils { } } #[cfg(test)] +#[expect(non_snake_case)] mod tests { + use super::ContractState; use crate::{indexer::participants::convert_participant_infos, providers::PublicKeyConversion}; + use mpc_contract::state::{ + ProtocolContractState as InternalContractState, test_utils::gen_resharing_state, + }; use near_indexer_primitives::types::AccountId; use near_mpc_contract_interface::types::AccountId as DtoAccountId; use near_mpc_contract_interface::types::{ - ParticipantId, ParticipantInfo, Participants, Threshold, ThresholdParameters, + ParticipantId, ParticipantInfo, Participants, ProtocolContractState, + ReconstructionThreshold, Threshold, ThresholdParameters, }; - use std::collections::HashMap; + use std::collections::{BTreeMap, HashMap}; fn create_participant_data_raw() -> Vec<(String, String, String)> { vec![ @@ -573,4 +579,65 @@ mod tests { let _ = converted.expect_err("Invalid participant data should be rejected"); } } + + #[test] + fn from_contract_state__should_override_resharing_key_threshold_from_per_domain_thresholds() { + // Given a resharing state where the threshold update travels only in + // per_domain_thresholds; the contract leaves resharing_key at the old degree. + let (_, mut resharing) = gen_resharing_state(1); + let domain_id = resharing.resharing_key.domain().id; + let old_threshold = resharing.resharing_key.domain().reconstruction_threshold; + let new_threshold = ReconstructionThreshold::new(old_threshold.inner() + 1); + resharing.per_domain_thresholds = BTreeMap::from([(domain_id, new_threshold)]); + let dto: ProtocolContractState = InternalContractState::Resharing(resharing).into(); + + // When converting the on-chain state into the node's representation. + let state = ContractState::from_contract_state(&dto, 0, None).unwrap(); + + // Then the resharing key event carries the new degree, the update is retained, + // and the previous registry (old side of the reshare) keeps the old threshold. + let ContractState::Running(running) = state else { + panic!("resharing maps to a running state with resharing_state populated"); + }; + let resharing_state = running.resharing_state.expect("resharing state present"); + assert_eq!( + resharing_state.key_event.domain.reconstruction_threshold, + new_threshold + ); + assert_eq!( + resharing_state.per_domain_thresholds, + BTreeMap::from([(domain_id, new_threshold)]) + ); + assert_eq!( + running + .domains + .iter() + .find(|d| d.id == domain_id) + .unwrap() + .reconstruction_threshold, + old_threshold + ); + } + + #[test] + fn from_contract_state__should_keep_resharing_key_threshold_when_no_update_present() { + // Given a resharing state with no per-domain threshold updates. + let (_, resharing) = gen_resharing_state(1); + let old_threshold = resharing.resharing_key.domain().reconstruction_threshold; + assert!(resharing.per_domain_thresholds.is_empty()); + let dto: ProtocolContractState = InternalContractState::Resharing(resharing).into(); + + // When converting the on-chain state. + let state = ContractState::from_contract_state(&dto, 0, None).unwrap(); + + // Then the resharing key event keeps the existing threshold. + let ContractState::Running(running) = state else { + panic!("resharing maps to a running state with resharing_state populated"); + }; + let resharing_state = running.resharing_state.expect("resharing state present"); + assert_eq!( + resharing_state.key_event.domain.reconstruction_threshold, + old_threshold + ); + } } From 133ba436ad9f7ad258c6da0c66161786a7f993cd Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:04:29 +0200 Subject: [PATCH 092/121] reverting comment on the SC to prevent having to upgrade the SC --- crates/contract/src/primitives/thresholds.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/contract/src/primitives/thresholds.rs b/crates/contract/src/primitives/thresholds.rs index 5a67a3978d..109ab13118 100644 --- a/crates/contract/src/primitives/thresholds.rs +++ b/crates/contract/src/primitives/thresholds.rs @@ -16,8 +16,10 @@ pub(crate) fn governance_threshold_lower_relative_bound(n: u64) -> u64 { 3_u64.saturating_mul(n).div_ceil(5) } -/// Upper bound on the GovernanceThreshold for `n` participants: currently 100%. -/// Whether to lower it is open (see `docs/design/domain-separation.md` §7). +/// Upper bound on the GovernanceThreshold for `n` participants: +/// Currently set to 100% of participants but would be a discussion subject +/// to drop this upper bound down not to have problems with smart contract +/// being locked if t = n and if an operator stops voting pub(crate) fn governance_threshold_upper_relative_bound(n: u64) -> u64 { n } From 2481a55a08fd38670095af0b540095001eddebe8 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:28:49 +0200 Subject: [PATCH 093/121] Updating design doc and getting rid of translate_threshold --- docs/design/domain-separation.md | 34 +++++++++++++------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index 513096b781..23d935e303 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -4,7 +4,7 @@ The addition of Robust ECDSA (aka DamgardEtAl) invalidates three assumptions in ✗ There is one protocol per curve (now: both CaitSith and DamgardEtAl operate over Secp256k1). -✗ All domains share a single cryptographic threshold. The node already has a `translate_threshold()` hack to bridge this gap. +✗ All domains share a single cryptographic threshold. ✗ Governance voting threshold and cryptographic reconstruction threshold are the same value. The threshold of how many participants must vote to change parameters is currently the same `Threshold` value as the cryptographic reconstruction threshold. @@ -85,14 +85,13 @@ The node (`crates/node/`) imports types from **both** the internal contract crat ### 1.4 Threshold Flow Through the System ``` -Contract ThresholdParameters.threshold (Threshold(u64)) - → Coordinator extracts: threshold: usize = mpc_config.participants.threshold.try_into()? - → Converts to: ReconstructionThreshold::from(threshold) +Contract per-domain DistributedKeyConfig.reconstruction_threshold (ReconstructionThreshold(u64)) + → Coordinator reads the per-domain ReconstructionThreshold from contract state → For CaitSith/FROST: passed directly to keygen/sign - → For DamgardEtAl: translate_threshold() → MaxMalicious::from((n_signers - 1) / 2) + → For DamgardEtAl: compute_thresholds() → (num_signers, max_malicious) ``` -The `translate_threshold()` function in `crates/node/src/providers/robust_ecdsa.rs` was an explicit workaround for the mismatch between the contract's single threshold and DamgardEtAl's `MaxMalicious` semantics. It has since been removed: the node now derives `(num_signers, max_malicious)` from a per-domain `ReconstructionThreshold` via `compute_thresholds()` in `robust_ecdsa/presign.rs`. +For DamgardEtAl, the node derives `(num_signers, max_malicious)` from the per-domain `ReconstructionThreshold` via `compute_thresholds()` in `crates/node/src/providers/robust_ecdsa.rs` (used by `robust_ecdsa/presign.rs` and `robust_ecdsa/sign.rs`). No threshold translation from a global value is needed, since each domain carries its own `ReconstructionThreshold`. ### 1.5 Current Curve-Protocol Pairings @@ -583,13 +582,13 @@ fn migrate(old: OldRunningContractState) -> RunningContractState { }; ``` - Coordinator reads per-key `DistributedKeyConfig` from contract state instead of using global threshold. -- Replace the `translate_threshold()` hack in `robust_ecdsa.rs` with `compute_thresholds()` in `robust_ecdsa/presign.rs`, which derives `(num_signers, max_malicious)` directly from the domain's `ReconstructionThreshold`: +- Derive the robust-ECDSA signer set via `compute_thresholds()` in `robust_ecdsa.rs`, which computes `(num_signers, max_malicious)` directly from the domain's `ReconstructionThreshold`: ```rust // Node derives the robust-ECDSA signer set from the per-domain reconstruction threshold t: // num_signers = 2t - 1, max_malicious = t - 1 let (num_signers, max_malicious) = compute_thresholds(dk.reconstruction_threshold)?; ``` - `translate_threshold()` is removed entirely — the per-domain `ReconstructionThreshold` comes from contract state (or the synthetic `DistributedKeyConfig` on the `state()` fallback path), so no threshold translation is needed. + The per-domain `ReconstructionThreshold` comes from contract state (or the synthetic `DistributedKeyConfig` on the `state()` fallback path), so no threshold translation from a global value is needed. - Provider routing uses `Protocol` enum instead of pattern-matching on `SignatureScheme`/`Curve`: ```rust match dk.protocol { @@ -906,23 +905,18 @@ With per-key `protocol` and `reconstruction_threshold`, `vote_add_domains` must During resharing, each domain's key must be reshared with its own `ReconstructionThreshold`. The `KeyEvent` for each domain already carries its config. The coordinator passes the per-domain threshold to the crypto protocol. Per-domain thresholds can only be changed via resharing — there is no independent vote function for threshold changes. -`ReconstructionThreshold` has uniform semantics across all protocols: it always means "number of shares needed to reconstruct the secret" (`t`). Each protocol may impose different constraints on `t` (e.g., DamgardEtAl requires `t < n/2`), but the stored value has the same meaning everywhere. The node handles the protocol-specific translation: - +`ReconstructionThreshold` has uniform semantics across all protocols: it always means "number of shares needed to reconstruct the secret" (`t`). Each protocol may impose different constraints on `t` (e.g., DamgardEtAl requires `t < n/2`), but the stored value has the same meaning everywhere. The node reads the per-domain `ReconstructionThreshold` from contract state and applies the protocol-specific interpretation: ```rust -// Current (hack): -let threshold: usize = mpc_config.participants.threshold.try_into()?; -let threshold = ReconstructionThreshold::from(threshold); - -// Proposed (clean): let dk = distributed_key_registry.get(distributed_key_id); -let active_signers = min_active_participants(&dk.protocol, &dk.reconstruction_threshold); -let threshold = match dk.protocol { +let threshold = dk.reconstruction_threshold; +match dk.protocol { + // DamgardEtAl: derive (num_signers, max_malicious) = (2t - 1, t - 1) Protocol::DamgardEtAl => { - let max_malicious = MaxMalicious::from(dk.reconstruction_threshold.inner() - 1); - // Use MaxMalicious directly, no translation needed + let (num_signers, max_malicious) = compute_thresholds(threshold)?; } - _ => ReconstructionThreshold::from(dk.reconstruction_threshold.inner()), + // CaitSith/FROST/CKD: t is passed directly to keygen/sign + _ => { /* use threshold directly */ } }; ``` From feaa80a028b45c2ec98d60f80c3c208cd6b5d68c Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:40:18 +0200 Subject: [PATCH 094/121] No silent failing using join_all --- crates/node/src/providers/ecdsa.rs | 24 ++++++++++++++--------- crates/node/src/providers/robust_ecdsa.rs | 17 ++++++++++------ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 575774bd01..dbe6461dbf 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -250,9 +250,11 @@ impl SignatureProvider for EcdsaSignatureProvider { // triples are generated with exactly `t` parties, so each store is fed // by a generator running at its own threshold. let mut generate_triples = Vec::new(); + let mut task_labels: Vec = Vec::new(); for (&t, triple_store) in &self.triple_stores { let threshold_usize: usize = t.inner().try_into()?; let threshold_bound = TSReconstructionThreshold::from(threshold_usize); + task_labels.push(format!("triple generation (t={})", t.inner())); generate_triples.push(tracking::spawn( &format!("generate triples for t={}", t.inner()), Self::run_background_triple_generation( @@ -278,6 +280,7 @@ impl SignatureProvider for EcdsaSignatureProvider { let threshold_usize: usize = t.inner().try_into()?; let threshold_bound = TSReconstructionThreshold::from(threshold_usize); let triple_store = self.triple_store_for_t(t)?; + task_labels.push(format!("presignature generation (domain {})", domain_id.0)); generate_presignatures.push(tracking::spawn( &format!("generate presignatures for domain {}", domain_id.0), Self::run_background_presignature_generation( @@ -292,15 +295,18 @@ impl SignatureProvider for EcdsaSignatureProvider { )); } - for Err(join_error) in futures::future::join_all(generate_triples).await { - tracing::error!( - "ecdsa background triple generation task ended unexpectedly: {join_error}" - ); - } - for Err(join_error) in futures::future::join_all(generate_presignatures).await { - tracing::error!("ecdsa background presignature task ended unexpectedly: {join_error}"); - } + let mut background_tasks = generate_triples; + background_tasks.extend(generate_presignatures); - Ok(()) + // Generators are `-> !`, so `select_all` fails fast on the first (panicking) exit rather than `join_all` masking it behind the siblings' infinite loops. + if background_tasks.is_empty() { + return Ok(()); + } + let (Err(join_error), index, _remaining) = + futures::future::select_all(background_tasks).await; + anyhow::bail!( + "ecdsa background {} task ended unexpectedly: {join_error}", + task_labels[index] + ) } } diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 3b80fee659..da267426d2 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -182,10 +182,12 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { } async fn spawn_background_tasks(self: Arc) -> anyhow::Result<()> { + let mut task_labels: Vec = Vec::new(); let generate_presignatures = self .per_domain_data .iter() .map(|(domain_id, data)| { + task_labels.push(format!("presignature generation (domain {})", domain_id.0)); tracking::spawn( &format!("generate presignatures for domain {}", domain_id.0), presign::run_background_presignature_generation( @@ -201,13 +203,16 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { }) .collect::>(); - for Err(join_error) in futures::future::join_all(generate_presignatures).await { - tracing::error!( - "Damgard et al background presignature task ended unexpectedly: {join_error}" - ); + // Generators are `-> !`, so `select_all` fails fast on the first (panicking) exit rather than `join_all` masking it behind the siblings' infinite loops. + if generate_presignatures.is_empty() { + return Ok(()); } - - Ok(()) + let (Err(join_error), index, _remaining) = + futures::future::select_all(generate_presignatures).await; + anyhow::bail!( + "Damgard et al background {} task ended unexpectedly: {join_error}", + task_labels[index] + ) } } From 857ac6f6f2224ae7656625e2cbf5391e1dc42816 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:30:18 +0200 Subject: [PATCH 095/121] Updating comments --- crates/node/src/indexer/participants.rs | 10 ++++------ crates/node/src/tests/reconstruction_thresholds.rs | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/node/src/indexer/participants.rs b/crates/node/src/indexer/participants.rs index d011fecd36..d9dc1f9baa 100644 --- a/crates/node/src/indexer/participants.rs +++ b/crates/node/src/indexer/participants.rs @@ -123,9 +123,7 @@ pub struct ContractResharingState { pub new_participants: ParticipantsConfig, pub reshared_keys: ContractKeyset, pub key_event: ContractKeyEventInstance, - /// Per-domain threshold updates from the accepted proposal. The contract keeps - /// `resharing_key` at the old threshold and only folds these into the registry on - /// completion, so the node applies them to the resharing key event itself. + /// Per-domain threshold updates from the accepted proposal. pub per_domain_thresholds: BTreeMap, } @@ -213,7 +211,7 @@ impl ContractState { ) .context("failed to convert resharing key event")?; - // Reshare to the new degree: the contract carries the update only in + // Reshare to the new threshold: the contract carries the update only in // `per_domain_thresholds`, not on the resharing key event's domain. if let Some(new_threshold) = state.per_domain_thresholds.get(&key_event.domain.id) { key_event.domain.reconstruction_threshold = *new_threshold; @@ -622,7 +620,7 @@ mod tests { #[test] fn from_contract_state__should_override_resharing_key_threshold_from_per_domain_thresholds() { // Given a resharing state where the threshold update travels only in - // per_domain_thresholds; the contract leaves resharing_key at the old degree. + // per_domain_thresholds; the contract leaves resharing_key at the old threshold. let (_, mut resharing) = gen_resharing_state(1); let domain_id = resharing.resharing_key.domain().id; let old_threshold = resharing.resharing_key.domain().reconstruction_threshold; @@ -633,7 +631,7 @@ mod tests { // When converting the on-chain state into the node's representation. let state = ContractState::from_contract_state(&dto, 0, None).unwrap(); - // Then the resharing key event carries the new degree, the update is retained, + // Then the resharing key event carries the new threshold, the update is retained, // and the previous registry (old side of the reshare) keeps the old threshold. let ContractState::Running(running) = state else { panic!("resharing maps to a running state with resharing_state populated"); diff --git a/crates/node/src/tests/reconstruction_thresholds.rs b/crates/node/src/tests/reconstruction_thresholds.rs index 7d875dd365..a47d69c3a1 100644 --- a/crates/node/src/tests/reconstruction_thresholds.rs +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -224,11 +224,11 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma /// Changing a domain's reconstruction threshold via a resharing proposal takes real /// cryptographic effect: after lowering `t` from 4 to 2, only 2 nodes need be online to -/// sign — impossible unless the key was genuinely re-shared to the new degree. +/// sign — impossible unless the key was genuinely re-shared to the new threshold. #[tokio::test] #[test_log::test] #[expect(non_snake_case)] -async fn changing_reconstruction_threshold_via_resharing__should_reshare_the_key_to_the_new_degree() +async fn changing_reconstruction_threshold_via_resharing__should_reshare_the_key_to_the_new_threshold() { // Given a 5-node cluster with a single CaitSith domain at t=4. const NUM_PARTICIPANTS: usize = 5; From 20669275b5d5572b58ee7c6649893d1f4bf7a57d Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:37:32 +0200 Subject: [PATCH 096/121] Renaming PerDomainData into Keyshare --- crates/node/src/providers/ckd.rs | 14 +++---- crates/node/src/providers/ckd/sign.rs | 10 ++--- crates/node/src/providers/ecdsa.rs | 16 ++++---- crates/node/src/providers/ecdsa/presign.rs | 8 ++-- crates/node/src/providers/ecdsa/sign.rs | 18 ++++----- crates/node/src/providers/ecdsa_common.rs | 39 +++++++++---------- crates/node/src/providers/eddsa.rs | 14 +++---- crates/node/src/providers/eddsa/sign.rs | 12 +++--- crates/node/src/providers/robust_ecdsa.rs | 14 +++---- .../src/providers/robust_ecdsa/presign.rs | 8 ++-- .../node/src/providers/robust_ecdsa/sign.rs | 16 ++++---- .../src/providers/verify_foreign_tx/sign.rs | 6 +-- .../src/tests/reconstruction_thresholds.rs | 2 +- 13 files changed, 88 insertions(+), 89 deletions(-) diff --git a/crates/node/src/providers/ckd.rs b/crates/node/src/providers/ckd.rs index 306873f119..4dce9f8317 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -44,11 +44,11 @@ pub struct CKDProvider { mpc_config: Arc, client: Arc, ckd_request_store: Arc, - per_domain_data: HashMap, + keyshares: HashMap, } #[derive(Clone)] -pub(super) struct PerDomainData { +pub(super) struct CkdKeyshare { pub keyshare: KeygenOutput, /// Per-domain reconstruction threshold `t`, used as the CKD threshold. pub reconstruction_threshold: ReconstructionThreshold, @@ -62,12 +62,12 @@ impl CKDProvider { ckd_request_store: Arc, keyshares: HashMap, ) -> Self { - let per_domain_data = keyshares + let keyshares = keyshares .into_iter() .map(|(domain_id, (keyshare, reconstruction_threshold))| { ( domain_id, - PerDomainData { + CkdKeyshare { keyshare, reconstruction_threshold, }, @@ -79,12 +79,12 @@ impl CKDProvider { mpc_config, client, ckd_request_store, - per_domain_data, + keyshares, } } - pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { - self.per_domain_data + pub(super) fn keyshare(&self, domain_id: DomainId) -> anyhow::Result { + self.keyshares .get(&domain_id) .cloned() .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) diff --git a/crates/node/src/providers/ckd/sign.rs b/crates/node/src/providers/ckd/sign.rs index 4686bfeb78..9754065b0e 100644 --- a/crates/node/src/providers/ckd/sign.rs +++ b/crates/node/src/providers/ckd/sign.rs @@ -29,8 +29,8 @@ impl CKDProvider { ) -> anyhow::Result<((ElementG1, ElementG1), VerifyingKey)> { let ckd_request = self.ckd_request_store.get(id).await?; - let domain_data = self.domain_data(ckd_request.domain_id)?; - let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; + let keyshare = self.keyshare(ckd_request.domain_id)?; + let threshold: usize = keyshare.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let running_participants: Vec<_> = self .mpc_config @@ -52,7 +52,7 @@ impl CKDProvider { .client .new_channel_for_task(CKDTaskId::Ckd { id }, participants)?; - let keygen_output = domain_data.keyshare; + let keygen_output = keyshare.keyshare; let public_key = keygen_output.public_key; let participants = channel.participants().to_vec(); let result = CKDComputation { @@ -93,10 +93,10 @@ impl CKDProvider { .await??; metrics::MPC_NUM_PASSIVE_CKD_REQUESTS_LOOKUP_SUCCEEDED.inc(); - let domain_data = self.domain_data(ckd_request.domain_id)?; + let keyshare = self.keyshare(ckd_request.domain_id)?; let participants = channel.participants().to_vec(); CKDComputation { - keygen_output: domain_data.keyshare, + keygen_output: keyshare.keyshare, app_public_key: ckd_request.app_public_key, app_id: ckd_request.app_id, } diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index dbe6461dbf..dfcafa7410 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -44,10 +44,10 @@ pub struct EcdsaSignatureProvider { /// `t`s is known up front and no on-demand creation is needed. triple_stores: HashMap>, sign_request_store: Arc, - per_domain_data: HashMap, + keyshares: HashMap, } -pub(super) type PerDomainData = ecdsa_common::PerDomainData; +pub(super) type EcdsaKeyshare = ecdsa_common::EcdsaKeyshare; impl EcdsaSignatureProvider { pub fn new( @@ -59,14 +59,14 @@ impl EcdsaSignatureProvider { sign_request_store: Arc, keyshares: HashMap, ) -> anyhow::Result { - let per_domain_data = ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares)?; + let keyshares = ecdsa_common::build_keyshares(&clock, &db, &client, keyshares)?; // cait-sith triple generation runs with exactly `t` parties, so we keep // one store per distinct per-domain reconstruction threshold — known up // front, no on-demand creation. Domains may share a `t` or diverge; the // contract validates each domain's threshold independently. let mut triple_stores = HashMap::new(); - for data in per_domain_data.values() { + for data in keyshares.values() { let t = data.reconstruction_threshold; if triple_stores.contains_key(&t) { continue; @@ -89,12 +89,12 @@ impl EcdsaSignatureProvider { client, triple_stores, sign_request_store, - per_domain_data, + keyshares, }) } - pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { - ecdsa_common::lookup_domain_data(&self.per_domain_data, domain_id) + pub(super) fn keyshare(&self, domain_id: DomainId) -> anyhow::Result { + ecdsa_common::lookup_keyshare(&self.keyshares, domain_id) } /// Returns the triple store for `t`, or an error if no store was @@ -275,7 +275,7 @@ impl SignatureProvider for EcdsaSignatureProvider { ); let mut generate_presignatures = Vec::new(); - for (domain_id, data) in &self.per_domain_data { + for (domain_id, data) in &self.keyshares { let t = data.reconstruction_threshold; let threshold_usize: usize = t.inner().try_into()?; let threshold_bound = TSReconstructionThreshold::from(threshold_usize); diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index f35f86126b..2b965de90d 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -148,12 +148,12 @@ impl EcdsaSignatureProvider { // owned by the leader, never one of ours. id.validate_owned_by(leader)?; paired_triple_id.validate_owned_by(leader)?; - let domain_data = self.domain_data(domain_id)?; + let keyshare = self.keyshare(domain_id)?; // The triple store is keyed by the domain's reconstruction threshold // `t`. For cait-sith the leader pairs exactly `t` participants, so the // channel participant count must match — cross-check it. - let threshold = domain_data.reconstruction_threshold; + let threshold = keyshare.reconstruction_threshold; let threshold_usize: usize = threshold.inner().try_into()?; if channel.participants().len() != threshold_usize { metrics::MPC_NUM_BAD_PEER_PRESIGN_REQUESTS.inc(); @@ -166,10 +166,10 @@ impl EcdsaSignatureProvider { let triple_store = self.triple_store_for_t(threshold)?; FollowerPresignComputation { threshold: TSReconstructionThreshold::from(threshold_usize), - keygen_out: domain_data.keyshare, + keygen_out: keyshare.keyshare, triple_store, paired_triple_id, - out_presignature_store: domain_data.presignature_store, + out_presignature_store: keyshare.presignature_store, out_presignature_id: id, } .perform_leader_centric_computation( diff --git a/crates/node/src/providers/ecdsa/sign.rs b/crates/node/src/providers/ecdsa/sign.rs index b18f99d345..fc8f0099f0 100644 --- a/crates/node/src/providers/ecdsa/sign.rs +++ b/crates/node/src/providers/ecdsa/sign.rs @@ -29,13 +29,13 @@ impl EcdsaSignatureProvider { presignature: PresignOutputWithParticipants, channel: NetworkTaskChannel, ) -> anyhow::Result<(Signature, VerifyingKey)> { - let domain_data = self.domain_data(sign_request.domain)?; + let keyshare = self.keyshare(sign_request.domain)?; let participants = presignature.participants.clone(); - let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; + let threshold: usize = keyshare.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let (signature, public_key) = SignComputation { - keygen_out: domain_data.keyshare, + keygen_out: keyshare.keyshare, threshold, presign_out: presignature.presignature, msg_hash: *sign_request @@ -69,8 +69,8 @@ impl EcdsaSignatureProvider { id: SignatureId, ) -> anyhow::Result<(Signature, VerifyingKey)> { let sign_request = self.sign_request_store.get(id).await?; - let domain_data = self.domain_data(sign_request.domain)?; - let (presignature_id, presignature) = domain_data.presignature_store.take_owned().await; + let keyshare = self.keyshare(sign_request.domain)?; + let (presignature_id, presignature) = keyshare.presignature_store.take_owned().await; let participants = presignature.participants.clone(); let channel = self.new_channel_for_task( EcdsaTaskId::Signature { @@ -91,15 +91,15 @@ impl EcdsaSignatureProvider { ) -> anyhow::Result<()> { // The presignature must be owned by the leader, never one of ours. presignature_id.validate_owned_by(channel.sender().get_leader())?; - let domain_data = self.domain_data(sign_request.domain)?; - let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; + let keyshare = self.keyshare(sign_request.domain)?; + let threshold: usize = keyshare.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let participants = channel.participants().to_vec(); FollowerSignComputation { - keygen_out: domain_data.keyshare, + keygen_out: keyshare.keyshare, threshold, - presignature_store: domain_data.presignature_store.clone(), + presignature_store: keyshare.presignature_store.clone(), presignature_id, msg_hash: *sign_request .payload diff --git a/crates/node/src/providers/ecdsa_common.rs b/crates/node/src/providers/ecdsa_common.rs index 58a9943028..80e3c0dd1b 100644 --- a/crates/node/src/providers/ecdsa_common.rs +++ b/crates/node/src/providers/ecdsa_common.rs @@ -66,7 +66,7 @@ where } /// Everything a secp256k1 provider keeps per signing domain. -pub struct PerDomainData

+pub struct EcdsaKeyshare

where P: Serialize + DeserializeOwned + Send + 'static, { @@ -78,7 +78,7 @@ where } // Manual `Clone` so callers don't need `P: Clone` — every field is `Clone` regardless of `P`. -impl

Clone for PerDomainData

+impl

Clone for EcdsaKeyshare

where P: Serialize + DeserializeOwned + Send + 'static, { @@ -101,16 +101,16 @@ pub fn active_participants_query( /// Builds the per-domain map shared by both secp256k1 providers: one presignature store per domain, /// each paired with its keyshare and reconstruction threshold. -pub fn build_per_domain_data

( +pub fn build_keyshares

( clock: &Clock, db: &Arc, client: &Arc, keyshares: HashMap, -) -> anyhow::Result>> +) -> anyhow::Result>> where P: Serialize + DeserializeOwned + Send + 'static, { - let mut per_domain_data = HashMap::new(); + let mut result = HashMap::new(); for (domain_id, (keyshare, reconstruction_threshold)) in keyshares { let presignature_store = Arc::new(PresignatureStorage::new( clock.clone(), @@ -118,27 +118,27 @@ where client, domain_id, )?); - per_domain_data.insert( + result.insert( domain_id, - PerDomainData { + EcdsaKeyshare { keyshare, presignature_store, reconstruction_threshold, }, ); } - Ok(per_domain_data) + Ok(result) } -/// Looks up a domain's data, cloning it out of the map. -pub fn lookup_domain_data

( - per_domain_data: &HashMap>, +/// Looks up a domain's keyshare, cloning it out of the map. +pub fn lookup_keyshare

( + keyshares: &HashMap>, domain_id: DomainId, -) -> anyhow::Result> +) -> anyhow::Result> where P: Serialize + DeserializeOwned + Send + 'static, { - per_domain_data + keyshares .get(&domain_id) .cloned() .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) @@ -147,7 +147,7 @@ where #[cfg(test)] #[expect(non_snake_case)] mod tests { - use super::build_per_domain_data; + use super::build_keyshares; use crate::db::SecretDB; use crate::network::testing::run_test_clients; use crate::tests::into_participant_ids; @@ -174,8 +174,7 @@ mod tests { // only checks indirectly (by running a full multi-node signing round): each domain must keep // its OWN reconstruction threshold, never a single shared/governance value. #[tokio::test] - async fn build_per_domain_data__should_pair_each_domain_with_its_own_reconstruction_threshold() - { + async fn build_keyshares__should_pair_each_domain_with_its_own_reconstruction_threshold() { start_root_task_with_periodic_dump(async move { run_test_clients( into_participant_ids(&generate_participants(2)), @@ -195,17 +194,17 @@ mod tests { let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); // When - let per_domain_data = - build_per_domain_data::>(&Clock::real(), &db, &client, keyshares) + let keyshares = + build_keyshares::>(&Clock::real(), &db, &client, keyshares) .unwrap(); // Then each domain keeps the threshold it was configured with assert_eq!( - per_domain_data[&low].reconstruction_threshold, + keyshares[&low].reconstruction_threshold, ReconstructionThreshold::new(2) ); assert_eq!( - per_domain_data[&high].reconstruction_threshold, + keyshares[&high].reconstruction_threshold, ReconstructionThreshold::new(3) ); Ok(()) diff --git a/crates/node/src/providers/eddsa.rs b/crates/node/src/providers/eddsa.rs index 16a2aa53cc..bff6d9bfac 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -33,11 +33,11 @@ pub struct EddsaSignatureProvider { mpc_config: Arc, client: Arc, sign_request_store: Arc, - per_domain_data: HashMap, + keyshares: HashMap, } #[derive(Clone)] -pub(super) struct PerDomainData { +pub(super) struct EddsaKeyshare { pub keyshare: KeygenOutput, /// Per-domain reconstruction threshold `t`, used as the FROST signing /// threshold. @@ -52,12 +52,12 @@ impl EddsaSignatureProvider { sign_request_store: Arc, keyshares: HashMap, ) -> Self { - let per_domain_data = keyshares + let keyshares = keyshares .into_iter() .map(|(domain_id, (keyshare, reconstruction_threshold))| { ( domain_id, - PerDomainData { + EddsaKeyshare { keyshare, reconstruction_threshold, }, @@ -69,12 +69,12 @@ impl EddsaSignatureProvider { mpc_config, client, sign_request_store, - per_domain_data, + keyshares, } } - pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { - self.per_domain_data + pub(super) fn keyshare(&self, domain_id: DomainId) -> anyhow::Result { + self.keyshares .get(&domain_id) .cloned() .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) diff --git a/crates/node/src/providers/eddsa/sign.rs b/crates/node/src/providers/eddsa/sign.rs index bc5c2d2230..82c8415aa6 100644 --- a/crates/node/src/providers/eddsa/sign.rs +++ b/crates/node/src/providers/eddsa/sign.rs @@ -24,8 +24,8 @@ impl EddsaSignatureProvider { ) -> anyhow::Result<(Signature, VerifyingKey)> { let sign_request = self.sign_request_store.get(id).await?; - let domain_data = self.domain_data(sign_request.domain)?; - let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; + let keyshare = self.keyshare(sign_request.domain)?; + let threshold: usize = keyshare.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let running_participants: Vec<_> = self .mpc_config @@ -48,7 +48,7 @@ impl EddsaSignatureProvider { .new_channel_for_task(EddsaTaskId::Signature { id }, participants.clone())?; let result = SignComputation { - keygen_output: domain_data.keyshare, + keygen_output: keyshare.keyshare, threshold, message: sign_request .payload @@ -92,13 +92,13 @@ impl EddsaSignatureProvider { .await??; metrics::MPC_NUM_PASSIVE_SIGN_REQUESTS_LOOKUP_SUCCEEDED.inc(); - let domain_data = self.domain_data(sign_request.domain)?; - let threshold: usize = domain_data.reconstruction_threshold.inner().try_into()?; + let keyshare = self.keyshare(sign_request.domain)?; + let threshold: usize = keyshare.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let participants = channel.participants().to_vec(); let _ = SignComputation { - keygen_output: domain_data.keyshare, + keygen_output: keyshare.keyshare, threshold, message: sign_request .payload diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index da267426d2..3431c4e52f 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -35,10 +35,10 @@ pub struct RobustEcdsaSignatureProvider { mpc_config: Arc, client: Arc, sign_request_store: Arc, - per_domain_data: HashMap, + keyshares: HashMap, } -pub(super) type PerDomainData = ecdsa_common::PerDomainData; +pub(super) type EcdsaKeyshare = ecdsa_common::EcdsaKeyshare; #[derive( Debug, Copy, Clone, Eq, Ord, PartialEq, PartialOrd, derive_more::From, derive_more::Into, @@ -61,19 +61,19 @@ impl RobustEcdsaSignatureProvider { sign_request_store: Arc, keyshares: HashMap, ) -> anyhow::Result { - let per_domain_data = ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares)?; + let keyshares = ecdsa_common::build_keyshares(&clock, &db, &client, keyshares)?; Ok(Self { config, mpc_config, client, sign_request_store, - per_domain_data, + keyshares, }) } - pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { - ecdsa_common::lookup_domain_data(&self.per_domain_data, domain_id) + pub(super) fn keyshare(&self, domain_id: DomainId) -> anyhow::Result { + ecdsa_common::lookup_keyshare(&self.keyshares, domain_id) } } @@ -184,7 +184,7 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { async fn spawn_background_tasks(self: Arc) -> anyhow::Result<()> { let mut task_labels: Vec = Vec::new(); let generate_presignatures = self - .per_domain_data + .keyshares .iter() .map(|(domain_id, data)| { task_labels.push(format!("presignature generation (domain {})", domain_id.0)); diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index d4f31f7b6f..5f7d3fa8f0 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -157,15 +157,15 @@ impl RobustEcdsaSignatureProvider { domain_id: DomainId, ) -> anyhow::Result<()> { id.validate_owned_by(channel.sender().get_leader())?; - let domain_data = self.domain_data(domain_id)?; + let keyshare = self.keyshare(domain_id)?; let (_num_signers, damgard_et_al_threshold) = - compute_thresholds(domain_data.reconstruction_threshold)?; + compute_thresholds(keyshare.reconstruction_threshold)?; FollowerPresignComputation { max_malicious: damgard_et_al_threshold, - keygen_out: domain_data.keyshare, - out_presignature_store: domain_data.presignature_store, + keygen_out: keyshare.keyshare, + out_presignature_store: keyshare.presignature_store, out_presignature_id: id, } .perform_leader_centric_computation( diff --git a/crates/node/src/providers/robust_ecdsa/sign.rs b/crates/node/src/providers/robust_ecdsa/sign.rs index 7892fa39ab..93c70aec83 100644 --- a/crates/node/src/providers/robust_ecdsa/sign.rs +++ b/crates/node/src/providers/robust_ecdsa/sign.rs @@ -27,8 +27,8 @@ impl RobustEcdsaSignatureProvider { id: SignatureId, ) -> anyhow::Result<(Signature, VerifyingKey)> { let sign_request = self.sign_request_store.get(id).await?; - let domain_data = self.domain_data(sign_request.domain)?; - let (presignature_id, presignature) = domain_data.presignature_store.take_owned().await; + let keyshare = self.keyshare(sign_request.domain)?; + let (presignature_id, presignature) = keyshare.presignature_store.take_owned().await; let participants = presignature.participants.clone(); let channel = self.client.new_channel_for_task( RobustEcdsaTaskId::Signature { @@ -38,7 +38,7 @@ impl RobustEcdsaSignatureProvider { presignature.participants, )?; let (_num_signers, damgard_et_al_threshold) = - compute_thresholds(domain_data.reconstruction_threshold)?; + compute_thresholds(keyshare.reconstruction_threshold)?; let msg_hash = *sign_request .payload @@ -46,7 +46,7 @@ impl RobustEcdsaSignatureProvider { .ok_or_else(|| anyhow::anyhow!("Payload is not an ECDSA payload"))?; let (signature, public_key) = SignComputation { - keygen_out: domain_data.keyshare, + keygen_out: keyshare.keyshare, max_malicious: damgard_et_al_threshold, presign_out: presignature.presignature, msg_hash: msg_hash.into(), @@ -88,9 +88,9 @@ impl RobustEcdsaSignatureProvider { .await??; metrics::MPC_NUM_PASSIVE_SIGN_REQUESTS_LOOKUP_SUCCEEDED.inc(); - let domain_data = self.domain_data(sign_request.domain)?; + let keyshare = self.keyshare(sign_request.domain)?; let (_num_signers, damgard_et_al_threshold) = - compute_thresholds(domain_data.reconstruction_threshold)?; + compute_thresholds(keyshare.reconstruction_threshold)?; let msg_hash = *sign_request .payload @@ -99,9 +99,9 @@ impl RobustEcdsaSignatureProvider { let participants = channel.participants().to_vec(); FollowerSignComputation { - keygen_out: domain_data.keyshare, + keygen_out: keyshare.keyshare, max_malicious: damgard_et_al_threshold, - presignature_store: domain_data.presignature_store.clone(), + presignature_store: keyshare.presignature_store.clone(), presignature_id, msg_hash: msg_hash.into(), tweak: sign_request.tweak, diff --git a/crates/node/src/providers/verify_foreign_tx/sign.rs b/crates/node/src/providers/verify_foreign_tx/sign.rs index 85fc895946..a49bcac60d 100644 --- a/crates/node/src/providers/verify_foreign_tx/sign.rs +++ b/crates/node/src/providers/verify_foreign_tx/sign.rs @@ -57,10 +57,10 @@ where ) -> anyhow::Result<((dtos::ForeignTxSignPayload, Signature), VerifyingKey)> { let foreign_tx_request = self.verify_foreign_tx_request_store.get(id).await?; - let domain_data = self + let keyshare = self .ecdsa_signature_provider - .domain_data(foreign_tx_request.domain_id)?; - let (presignature_id, presignature) = domain_data.presignature_store.take_owned().await; + .keyshare(foreign_tx_request.domain_id)?; + let (presignature_id, presignature) = keyshare.presignature_store.take_owned().await; let participants = presignature.participants.clone(); let channel = self.ecdsa_signature_provider.new_channel_for_task( VerifyForeignTxTaskId::VerifyForeignTx { diff --git a/crates/node/src/tests/reconstruction_thresholds.rs b/crates/node/src/tests/reconstruction_thresholds.rs index a47d69c3a1..32a2d685da 100644 --- a/crates/node/src/tests/reconstruction_thresholds.rs +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -229,7 +229,7 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma #[test_log::test] #[expect(non_snake_case)] async fn changing_reconstruction_threshold_via_resharing__should_reshare_the_key_to_the_new_threshold() -{ + { // Given a 5-node cluster with a single CaitSith domain at t=4. const NUM_PARTICIPANTS: usize = 5; const THRESHOLD: usize = 3; From d22f3c1e7484e69375b06df5314fefe6b329a197 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:57:43 +0200 Subject: [PATCH 097/121] Generalizing the struct DomainKeyshare --- crates/node/src/coordinator.rs | 34 ++++++++++++------- crates/node/src/providers.rs | 20 +++++++++++ crates/node/src/providers/ckd.rs | 24 ++----------- crates/node/src/providers/ckd/sign.rs | 4 +-- crates/node/src/providers/ecdsa.rs | 5 +-- crates/node/src/providers/ecdsa/presign.rs | 2 +- crates/node/src/providers/ecdsa/sign.rs | 4 +-- crates/node/src/providers/ecdsa_common.rs | 34 +++++++++++-------- crates/node/src/providers/eddsa.rs | 24 ++----------- crates/node/src/providers/eddsa/sign.rs | 4 +-- crates/node/src/providers/robust_ecdsa.rs | 6 ++-- .../src/providers/robust_ecdsa/presign.rs | 2 +- .../node/src/providers/robust_ecdsa/sign.rs | 4 +-- 13 files changed, 82 insertions(+), 85 deletions(-) diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index 8613a14de1..5866176de5 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -23,7 +23,7 @@ use crate::providers::ckd::CKDProvider; use crate::providers::eddsa::{EddsaSignatureProvider, EddsaTaskId}; use crate::providers::robust_ecdsa::RobustEcdsaSignatureProvider; use crate::providers::verify_foreign_tx::VerifyForeignTxProvider; -use crate::providers::{EcdsaSignatureProvider, EcdsaTaskId}; +use crate::providers::{DomainKeyshare, EcdsaSignatureProvider, EcdsaTaskId}; use crate::runtime::{AsyncDroppableRuntime, build_lower_priority_runtime}; use crate::storage::SignRequestStorage; use crate::storage::{CKDRequestStorage, VerifyForeignTransactionRequestStorage}; @@ -588,22 +588,19 @@ where let mut ecdsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - (ecdsa::KeygenOutput, ReconstructionThreshold), + DomainKeyshare, > = HashMap::new(); let mut robust_ecdsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - (ecdsa::KeygenOutput, ReconstructionThreshold), + DomainKeyshare, > = HashMap::new(); let mut eddsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - (eddsa::KeygenOutput, ReconstructionThreshold), + DomainKeyshare, > = HashMap::new(); let mut ckd_keyshares: HashMap< mpc_primitives::domain::DomainId, - ( - confidential_key_derivation::KeygenOutput, - ReconstructionThreshold, - ), + DomainKeyshare, > = HashMap::new(); let domain_registry: HashMap = running_state @@ -625,21 +622,32 @@ where match (expected_curve, keyshare.data) { (Curve::Secp256k1, KeyshareData::Secp256k1(data)) => match protocol { Protocol::CaitSith => { - ecdsa_keyshares.insert(domain_id, (data, reconstruction_threshold)); + ecdsa_keyshares.insert( + domain_id, + DomainKeyshare::new(data, reconstruction_threshold), + ); } Protocol::DamgardEtAl => { - robust_ecdsa_keyshares - .insert(domain_id, (data, reconstruction_threshold)); + robust_ecdsa_keyshares.insert( + domain_id, + DomainKeyshare::new(data, reconstruction_threshold), + ); } other => anyhow::bail!( "Unexpected protocol {other:?} for Secp256k1 keyshare on domain {domain_id:?}", ), }, (Curve::Edwards25519, KeyshareData::Ed25519(data)) => { - eddsa_keyshares.insert(domain_id, (data, reconstruction_threshold)); + eddsa_keyshares.insert( + domain_id, + DomainKeyshare::new(data, reconstruction_threshold), + ); } (Curve::Bls12381, KeyshareData::Bls12381(data)) => { - ckd_keyshares.insert(domain_id, (data, reconstruction_threshold)); + ckd_keyshares.insert( + domain_id, + DomainKeyshare::new(data, reconstruction_threshold), + ); } (expected, data) => anyhow::bail!( "Keyshare data does not match the domain protocol's expected curve: domain_id={:?}, protocol={:?}, expected_curve={:?}, data_kind={:?}", diff --git a/crates/node/src/providers.rs b/crates/node/src/providers.rs index 50044e4f4e..a295ffa340 100644 --- a/crates/node/src/providers.rs +++ b/crates/node/src/providers.rs @@ -24,6 +24,26 @@ pub use robust_ecdsa::RobustEcdsaSignatureProvider; use std::sync::Arc; use threshold_signatures::ReconstructionThreshold; +/// A domain's keygen output paired with its reconstruction threshold `t`, the per-domain material +/// the coordinator hands to each signature provider. +#[derive(Clone)] +pub struct DomainKeyshare { + pub keygen_output: K, + pub reconstruction_threshold: mpc_primitives::ReconstructionThreshold, +} + +impl DomainKeyshare { + pub fn new( + keygen_output: K, + reconstruction_threshold: mpc_primitives::ReconstructionThreshold, + ) -> Self { + Self { + keygen_output, + reconstruction_threshold, + } + } +} + /// The interface that defines the requirements for a signing schema to be correctly used in the code. pub trait SignatureProvider { type PublicKey; diff --git a/crates/node/src/providers/ckd.rs b/crates/node/src/providers/ckd.rs index 4dce9f8317..fc5098f46b 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -5,7 +5,6 @@ mod sign; use std::{collections::HashMap, sync::Arc}; use borsh::{BorshDeserialize, BorshSerialize}; -use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; use near_mpc_contract_interface::types::KeyEventId; use threshold_signatures::confidential_key_derivation::{ @@ -20,7 +19,7 @@ use crate::{ config::{MpcConfig, ParticipantsConfig}, network::{MeshNetworkClient, NetworkTaskChannel}, primitives::MpcTaskId, - providers::SignatureProvider, + providers::{DomainKeyshare, SignatureProvider}, storage::CKDRequestStorage, types::{CKDId, SignatureId}, }; @@ -47,12 +46,7 @@ pub struct CKDProvider { keyshares: HashMap, } -#[derive(Clone)] -pub(super) struct CkdKeyshare { - pub keyshare: KeygenOutput, - /// Per-domain reconstruction threshold `t`, used as the CKD threshold. - pub reconstruction_threshold: ReconstructionThreshold, -} +pub(super) type CkdKeyshare = DomainKeyshare; impl CKDProvider { pub fn new( @@ -60,20 +54,8 @@ impl CKDProvider { mpc_config: Arc, client: Arc, ckd_request_store: Arc, - keyshares: HashMap, + keyshares: HashMap, ) -> Self { - let keyshares = keyshares - .into_iter() - .map(|(domain_id, (keyshare, reconstruction_threshold))| { - ( - domain_id, - CkdKeyshare { - keyshare, - reconstruction_threshold, - }, - ) - }) - .collect(); Self { config, mpc_config, diff --git a/crates/node/src/providers/ckd/sign.rs b/crates/node/src/providers/ckd/sign.rs index 9754065b0e..859d9b107f 100644 --- a/crates/node/src/providers/ckd/sign.rs +++ b/crates/node/src/providers/ckd/sign.rs @@ -52,7 +52,7 @@ impl CKDProvider { .client .new_channel_for_task(CKDTaskId::Ckd { id }, participants)?; - let keygen_output = keyshare.keyshare; + let keygen_output = keyshare.keygen_output; let public_key = keygen_output.public_key; let participants = channel.participants().to_vec(); let result = CKDComputation { @@ -96,7 +96,7 @@ impl CKDProvider { let keyshare = self.keyshare(ckd_request.domain_id)?; let participants = channel.participants().to_vec(); CKDComputation { - keygen_output: keyshare.keyshare, + keygen_output: keyshare.keygen_output, app_public_key: ckd_request.app_public_key, app_id: ckd_request.app_id, } diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index dfcafa7410..39cc9bcc69 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -15,6 +15,7 @@ use crate::db::SecretDB; use crate::metrics::tokio_task_metrics::ECDSA_TASK_MONITORS; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::{MpcTaskId, ParticipantId, UniqueId}; +use crate::providers::DomainKeyshare; use crate::providers::SignatureProvider; use crate::providers::ecdsa_common; use crate::storage::SignRequestStorage; @@ -57,7 +58,7 @@ impl EcdsaSignatureProvider { clock: Clock, db: Arc, sign_request_store: Arc, - keyshares: HashMap, + keyshares: HashMap>, ) -> anyhow::Result { let keyshares = ecdsa_common::build_keyshares(&clock, &db, &client, keyshares)?; @@ -290,7 +291,7 @@ impl SignatureProvider for EcdsaSignatureProvider { triple_store, *domain_id, data.presignature_store.clone(), - data.keyshare.clone(), + data.keygen_output.clone(), ), )); } diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index 2b965de90d..63addf944b 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -166,7 +166,7 @@ impl EcdsaSignatureProvider { let triple_store = self.triple_store_for_t(threshold)?; FollowerPresignComputation { threshold: TSReconstructionThreshold::from(threshold_usize), - keygen_out: keyshare.keyshare, + keygen_out: keyshare.keygen_output, triple_store, paired_triple_id, out_presignature_store: keyshare.presignature_store, diff --git a/crates/node/src/providers/ecdsa/sign.rs b/crates/node/src/providers/ecdsa/sign.rs index fc8f0099f0..a9438371a3 100644 --- a/crates/node/src/providers/ecdsa/sign.rs +++ b/crates/node/src/providers/ecdsa/sign.rs @@ -35,7 +35,7 @@ impl EcdsaSignatureProvider { let threshold = ReconstructionThreshold::from(threshold); let (signature, public_key) = SignComputation { - keygen_out: keyshare.keyshare, + keygen_out: keyshare.keygen_output, threshold, presign_out: presignature.presignature, msg_hash: *sign_request @@ -97,7 +97,7 @@ impl EcdsaSignatureProvider { let participants = channel.participants().to_vec(); FollowerSignComputation { - keygen_out: keyshare.keyshare, + keygen_out: keyshare.keygen_output, threshold, presignature_store: keyshare.presignature_store.clone(), presignature_id, diff --git a/crates/node/src/providers/ecdsa_common.rs b/crates/node/src/providers/ecdsa_common.rs index 80e3c0dd1b..3b6c57ad35 100644 --- a/crates/node/src/providers/ecdsa_common.rs +++ b/crates/node/src/providers/ecdsa_common.rs @@ -7,7 +7,7 @@ use crate::assets::DistributedAssetStorage; use crate::db::SecretDB; use crate::network::MeshNetworkClient; use crate::primitives::ParticipantId; -use crate::providers::HasParticipants; +use crate::providers::{DomainKeyshare, HasParticipants}; use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; use near_time::Clock; @@ -65,15 +65,14 @@ where } } -/// Everything a secp256k1 provider keeps per signing domain. +/// A domain's [`DomainKeyshare`] material plus a presignature store, which is runtime state the +/// coordinator can't provide and so is built here. pub struct EcdsaKeyshare

where P: Serialize + DeserializeOwned + Send + 'static, { - pub keyshare: KeygenOutput, + pub keygen_output: KeygenOutput, pub presignature_store: Arc>, - /// Per-domain reconstruction threshold `t`, the source of truth for this domain's - /// keygen/presign/sign. pub reconstruction_threshold: ReconstructionThreshold, } @@ -84,7 +83,7 @@ where { fn clone(&self) -> Self { Self { - keyshare: self.keyshare.clone(), + keygen_output: self.keygen_output.clone(), presignature_store: self.presignature_store.clone(), reconstruction_threshold: self.reconstruction_threshold, } @@ -99,19 +98,18 @@ pub fn active_participants_query( Arc::new(move || network_client.all_alive_participant_ids()) } -/// Builds the per-domain map shared by both secp256k1 providers: one presignature store per domain, -/// each paired with its keyshare and reconstruction threshold. +/// Attaches a freshly-created presignature store to each domain's [`DomainKeyshare`]. pub fn build_keyshares

( clock: &Clock, db: &Arc, client: &Arc, - keyshares: HashMap, + keyshares: HashMap>, ) -> anyhow::Result>> where P: Serialize + DeserializeOwned + Send + 'static, { let mut result = HashMap::new(); - for (domain_id, (keyshare, reconstruction_threshold)) in keyshares { + for (domain_id, keyshare) in keyshares { let presignature_store = Arc::new(PresignatureStorage::new( clock.clone(), db.clone(), @@ -121,9 +119,9 @@ where result.insert( domain_id, EcdsaKeyshare { - keyshare, + keygen_output: keyshare.keygen_output, presignature_store, - reconstruction_threshold, + reconstruction_threshold: keyshare.reconstruction_threshold, }, ); } @@ -147,7 +145,7 @@ where #[cfg(test)] #[expect(non_snake_case)] mod tests { - use super::build_keyshares; + use super::{DomainKeyshare, build_keyshares}; use crate::db::SecretDB; use crate::network::testing::run_test_clients; use crate::tests::into_participant_ids; @@ -186,9 +184,15 @@ mod tests { let keyshares = HashMap::from([ ( low, - (keygen_output.clone(), ReconstructionThreshold::new(2)), + DomainKeyshare::new( + keygen_output.clone(), + ReconstructionThreshold::new(2), + ), + ), + ( + high, + DomainKeyshare::new(keygen_output, ReconstructionThreshold::new(3)), ), - (high, (keygen_output, ReconstructionThreshold::new(3))), ]); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); diff --git a/crates/node/src/providers/eddsa.rs b/crates/node/src/providers/eddsa.rs index bff6d9bfac..8f4faae1ef 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -6,6 +6,7 @@ use crate::config::{MpcConfig, ParticipantsConfig}; use crate::metrics::tokio_task_metrics::EDDSA_TASK_MONITORS; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::MpcTaskId; +use crate::providers::DomainKeyshare; #[cfg(test)] use crate::providers::PublicKeyConversion; use crate::providers::SignatureProvider; @@ -15,7 +16,6 @@ use crate::types::SignatureId; use anyhow::Context; use borsh::{BorshDeserialize, BorshSerialize}; use mpc_node_config::ConfigFile; -use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; #[cfg(test)] use near_mpc_contract_interface::types::Ed25519PublicKey; @@ -36,13 +36,7 @@ pub struct EddsaSignatureProvider { keyshares: HashMap, } -#[derive(Clone)] -pub(super) struct EddsaKeyshare { - pub keyshare: KeygenOutput, - /// Per-domain reconstruction threshold `t`, used as the FROST signing - /// threshold. - pub reconstruction_threshold: ReconstructionThreshold, -} +pub(super) type EddsaKeyshare = DomainKeyshare; impl EddsaSignatureProvider { pub fn new( @@ -50,20 +44,8 @@ impl EddsaSignatureProvider { mpc_config: Arc, client: Arc, sign_request_store: Arc, - keyshares: HashMap, + keyshares: HashMap, ) -> Self { - let keyshares = keyshares - .into_iter() - .map(|(domain_id, (keyshare, reconstruction_threshold))| { - ( - domain_id, - EddsaKeyshare { - keyshare, - reconstruction_threshold, - }, - ) - }) - .collect(); Self { config, mpc_config, diff --git a/crates/node/src/providers/eddsa/sign.rs b/crates/node/src/providers/eddsa/sign.rs index 82c8415aa6..af24fb5ce2 100644 --- a/crates/node/src/providers/eddsa/sign.rs +++ b/crates/node/src/providers/eddsa/sign.rs @@ -48,7 +48,7 @@ impl EddsaSignatureProvider { .new_channel_for_task(EddsaTaskId::Signature { id }, participants.clone())?; let result = SignComputation { - keygen_output: keyshare.keyshare, + keygen_output: keyshare.keygen_output, threshold, message: sign_request .payload @@ -98,7 +98,7 @@ impl EddsaSignatureProvider { let participants = channel.participants().to_vec(); let _ = SignComputation { - keygen_output: keyshare.keyshare, + keygen_output: keyshare.keygen_output, threshold, message: sign_request .payload diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 3431c4e52f..95fac87bdf 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -11,7 +11,7 @@ use crate::metrics::tokio_task_metrics::ROBUST_ECDSA_TASK_MONITORS; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::{MpcTaskId, UniqueId}; use crate::providers::ecdsa_common; -use crate::providers::{EcdsaSignatureProvider, SignatureProvider}; +use crate::providers::{DomainKeyshare, EcdsaSignatureProvider, SignatureProvider}; use crate::storage::SignRequestStorage; use crate::tracking; use mpc_node_config::ConfigFile; @@ -59,7 +59,7 @@ impl RobustEcdsaSignatureProvider { clock: Clock, db: Arc, sign_request_store: Arc, - keyshares: HashMap, + keyshares: HashMap>, ) -> anyhow::Result { let keyshares = ecdsa_common::build_keyshares(&clock, &db, &client, keyshares)?; @@ -197,7 +197,7 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { self.config.presignature.clone().into(), *domain_id, data.presignature_store.clone(), - data.keyshare.clone(), + data.keygen_output.clone(), ), ) }) diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index 5f7d3fa8f0..f67e0c4e05 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -164,7 +164,7 @@ impl RobustEcdsaSignatureProvider { FollowerPresignComputation { max_malicious: damgard_et_al_threshold, - keygen_out: keyshare.keyshare, + keygen_out: keyshare.keygen_output, out_presignature_store: keyshare.presignature_store, out_presignature_id: id, } diff --git a/crates/node/src/providers/robust_ecdsa/sign.rs b/crates/node/src/providers/robust_ecdsa/sign.rs index 93c70aec83..bc9f6db563 100644 --- a/crates/node/src/providers/robust_ecdsa/sign.rs +++ b/crates/node/src/providers/robust_ecdsa/sign.rs @@ -46,7 +46,7 @@ impl RobustEcdsaSignatureProvider { .ok_or_else(|| anyhow::anyhow!("Payload is not an ECDSA payload"))?; let (signature, public_key) = SignComputation { - keygen_out: keyshare.keyshare, + keygen_out: keyshare.keygen_output, max_malicious: damgard_et_al_threshold, presign_out: presignature.presignature, msg_hash: msg_hash.into(), @@ -99,7 +99,7 @@ impl RobustEcdsaSignatureProvider { let participants = channel.participants().to_vec(); FollowerSignComputation { - keygen_out: keyshare.keyshare, + keygen_out: keyshare.keygen_output, max_malicious: damgard_et_al_threshold, presignature_store: keyshare.presignature_store.clone(), presignature_id, From aeedac88497b8a552651a3d3f40812281ddeaf75 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:42:22 +0200 Subject: [PATCH 098/121] Reducing code redundancy --- crates/node/src/coordinator.rs | 63 +++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index 5866176de5..1cc75efdf0 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -35,6 +35,7 @@ use mpc_node_config::ConfigFile; use mpc_primitives::domain::{Curve, DomainId, Protocol}; use mpc_primitives::{EpochId, ReconstructionThreshold}; use near_mpc_contract_interface::call_args as contract_args; +use near_mpc_contract_interface::types::DomainConfig; use near_time::Clock; use std::collections::HashMap; use std::future::Future; @@ -388,18 +389,7 @@ where epoch_id: current_epoch_id, participants: current_participants_config, }; - // Triples are keyed by the reconstruction threshold `t` they were - // generated for. Collect the distinct `t`s across the CaitSith - // domains (the only protocol that uses triples) so stale assets are - // cleaned for every store this node maintains. - let mut triple_thresholds: Vec = running_state - .domains - .iter() - .filter(|d| d.protocol == Protocol::CaitSith) - .map(|d| d.reconstruction_threshold) - .collect(); - triple_thresholds.sort(); - triple_thresholds.dedup(); + let triple_thresholds = distinct_caitsith_triple_thresholds(&running_state.domains); delete_stale_triples_and_presignatures( &secret_db, current_epoch_data, @@ -989,3 +979,52 @@ fn make_initializing_stop_fn( }), ) } + +/// Distinct reconstruction thresholds `t` across the CaitSith domains — the only protocol that uses +/// triples, which are keyed on disk by the `t` they were generated for. +fn distinct_caitsith_triple_thresholds(domains: &[DomainConfig]) -> Vec { + let mut thresholds: Vec = domains + .iter() + .filter(|d| d.protocol == Protocol::CaitSith) + .map(|d| d.reconstruction_threshold) + .collect(); + thresholds.sort(); + thresholds.dedup(); + thresholds +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::distinct_caitsith_triple_thresholds; + use mpc_primitives::ReconstructionThreshold; + use mpc_primitives::domain::{DomainId, Protocol}; + use near_mpc_contract_interface::types::{DomainConfig, DomainPurpose}; + + fn domain(id: u64, protocol: Protocol, t: u64) -> DomainConfig { + DomainConfig { + id: DomainId(id), + protocol, + reconstruction_threshold: ReconstructionThreshold::new(t), + purpose: DomainPurpose::Sign, + } + } + + #[test] + fn distinct_caitsith_triple_thresholds__should_dedup_and_exclude_non_caitsith() { + // Given CaitSith domains with a duplicate `t` alongside non-CaitSith domains + let domains = [ + domain(0, Protocol::CaitSith, 3), + domain(1, Protocol::CaitSith, 2), + domain(2, Protocol::CaitSith, 3), + domain(3, Protocol::DamgardEtAl, 5), + domain(4, Protocol::Frost, 4), + ]; + + // When / Then only the distinct CaitSith thresholds remain, sorted + assert_eq!( + distinct_caitsith_triple_thresholds(&domains), + [2, 3].map(ReconstructionThreshold::new) + ); + } +} From 1ec15b5cea8ed15652f8cfa86d502d82d6d67e64 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:46:41 +0200 Subject: [PATCH 099/121] comment rework --- crates/node/src/providers/ecdsa.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 39cc9bcc69..b3b6f3a68f 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -62,10 +62,7 @@ impl EcdsaSignatureProvider { ) -> anyhow::Result { let keyshares = ecdsa_common::build_keyshares(&clock, &db, &client, keyshares)?; - // cait-sith triple generation runs with exactly `t` parties, so we keep - // one store per distinct per-domain reconstruction threshold — known up - // front, no on-demand creation. Domains may share a `t` or diverge; the - // contract validates each domain's threshold independently. + // cait-sith triple generation runs with exactly `t` parties, so keep one store per distinct reconstruction threshold. let mut triple_stores = HashMap::new(); for data in keyshares.values() { let t = data.reconstruction_threshold; From 6b119e883a64fcd3ac7153f1672a16f6231178d3 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:42:26 +0200 Subject: [PATCH 100/121] Barak's commit --- crates/node/src/providers/ecdsa/triple.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index d4b0163e98..fcc6649f46 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -135,6 +135,13 @@ impl EcdsaSignatureProvider { } }; + // The follower derives the store's `t` from the channel size (see `run_triple_generation_follower`), so pair exactly `t`. + debug_assert_eq!( + participants.len(), + threshold.value(), + "triple routing infers t from channel size (see run_triple_generation_follower)" + ); + let id_start = triple_store .generate_and_reserve_id_range(SUPPORTED_TRIPLE_GENERATION_BATCH_SIZE as u32); let task_id = EcdsaTaskId::ManyTriples { From 98b5c739c9b677cce0a9fbaa9a1ca57d7335f11b Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:50:00 +0200 Subject: [PATCH 101/121] tracking with domain id --- crates/node/src/metrics.rs | 13 +++++++------ crates/node/src/providers/ecdsa/presign.rs | 4 +++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/node/src/metrics.rs b/crates/node/src/metrics.rs index 41d57fd7e5..1d8d2e281b 100644 --- a/crates/node/src/metrics.rs +++ b/crates/node/src/metrics.rs @@ -14,15 +14,16 @@ pub static MPC_NUM_TRIPLES_GENERATED: LazyLock = LazyLoc .unwrap() }); -pub static MPC_NUM_BAD_PEER_PRESIGN_REQUESTS: LazyLock = LazyLock::new( - || { - prometheus::register_int_counter!( +pub static MPC_NUM_BAD_PEER_PRESIGN_REQUESTS: LazyLock = + LazyLock::new(|| { + prometheus::register_int_counter_vec!( "mpc_num_bad_peer_presign_requests", - "CaitSith presignature requests from a peer whose participant-set size did not match the domain's reconstruction threshold (only meaningful for CaitSith, which pairs exactly `t` participants; robust ECDSA does not have this constraint)" + "Presignature requests from a peer whose participant-set size did not \ + match the domain's reconstruction threshold", + &["domain_id"] ) .unwrap() - }, -); + }); pub static MPC_TRIPLES_GENERATION_TIME_ELAPSED: LazyLock = LazyLock::new(|| { diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index 63addf944b..966a375c37 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -156,7 +156,9 @@ impl EcdsaSignatureProvider { let threshold = keyshare.reconstruction_threshold; let threshold_usize: usize = threshold.inner().try_into()?; if channel.participants().len() != threshold_usize { - metrics::MPC_NUM_BAD_PEER_PRESIGN_REQUESTS.inc(); + metrics::MPC_NUM_BAD_PEER_PRESIGN_REQUESTS + .with_label_values(&[&domain_id.to_string()]) + .inc(); anyhow::bail!( "CaitSith presign participant count ({}) does not match domain threshold t={}", channel.participants().len(), From 6eae2bebf149a596e4dda89ca9450f0c1eba9084 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:44:07 +0200 Subject: [PATCH 102/121] Switching from generic trait to C:Ciphersuite --- crates/node/src/coordinator.rs | 8 ++++---- crates/node/src/providers.rs | 10 +++++----- crates/node/src/providers/ckd.rs | 4 ++-- crates/node/src/providers/ecdsa.rs | 5 ++--- crates/node/src/providers/ecdsa_common.rs | 4 ++-- crates/node/src/providers/eddsa.rs | 4 ++-- crates/node/src/providers/robust_ecdsa.rs | 6 ++---- 7 files changed, 19 insertions(+), 22 deletions(-) diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index 1cc75efdf0..0e531284de 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -578,19 +578,19 @@ where let mut ecdsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - DomainKeyshare, + DomainKeyshare, > = HashMap::new(); let mut robust_ecdsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - DomainKeyshare, + DomainKeyshare, > = HashMap::new(); let mut eddsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - DomainKeyshare, + DomainKeyshare, > = HashMap::new(); let mut ckd_keyshares: HashMap< mpc_primitives::domain::DomainId, - DomainKeyshare, + DomainKeyshare, > = HashMap::new(); let domain_registry: HashMap = running_state diff --git a/crates/node/src/providers.rs b/crates/node/src/providers.rs index a295ffa340..07d926d3c7 100644 --- a/crates/node/src/providers.rs +++ b/crates/node/src/providers.rs @@ -22,19 +22,19 @@ pub use ecdsa::EcdsaSignatureProvider; pub use ecdsa::EcdsaTaskId; pub use robust_ecdsa::RobustEcdsaSignatureProvider; use std::sync::Arc; -use threshold_signatures::ReconstructionThreshold; +use threshold_signatures::{Ciphersuite, KeygenOutput, ReconstructionThreshold}; /// A domain's keygen output paired with its reconstruction threshold `t`, the per-domain material /// the coordinator hands to each signature provider. #[derive(Clone)] -pub struct DomainKeyshare { - pub keygen_output: K, +pub struct DomainKeyshare { + pub keygen_output: KeygenOutput, pub reconstruction_threshold: mpc_primitives::ReconstructionThreshold, } -impl DomainKeyshare { +impl DomainKeyshare { pub fn new( - keygen_output: K, + keygen_output: KeygenOutput, reconstruction_threshold: mpc_primitives::ReconstructionThreshold, ) -> Self { Self { diff --git a/crates/node/src/providers/ckd.rs b/crates/node/src/providers/ckd.rs index fc5098f46b..329f871e0b 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -8,7 +8,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use mpc_primitives::domain::DomainId; use near_mpc_contract_interface::types::KeyEventId; use threshold_signatures::confidential_key_derivation::{ - ElementG1, KeygenOutput, SigningShare, VerifyingKey, + BLS12381SHA256, ElementG1, KeygenOutput, SigningShare, VerifyingKey, }; use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; @@ -46,7 +46,7 @@ pub struct CKDProvider { keyshares: HashMap, } -pub(super) type CkdKeyshare = DomainKeyshare; +pub(super) type CkdKeyshare = DomainKeyshare; impl CKDProvider { pub fn new( diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index b3b6f3a68f..1bafc7fdea 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -29,9 +29,8 @@ use mpc_primitives::domain::DomainId; use near_time::Clock; use std::sync::Arc; use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; -use threshold_signatures::ecdsa::KeygenOutput; -use threshold_signatures::ecdsa::Signature; use threshold_signatures::ecdsa::ot_based_ecdsa::PresignOutput; +use threshold_signatures::ecdsa::{KeygenOutput, Secp256K1Sha256, Signature}; use threshold_signatures::frost_secp256k1::VerifyingKey; use threshold_signatures::frost_secp256k1::keys::SigningShare; @@ -58,7 +57,7 @@ impl EcdsaSignatureProvider { clock: Clock, db: Arc, sign_request_store: Arc, - keyshares: HashMap>, + keyshares: HashMap>, ) -> anyhow::Result { let keyshares = ecdsa_common::build_keyshares(&clock, &db, &client, keyshares)?; diff --git a/crates/node/src/providers/ecdsa_common.rs b/crates/node/src/providers/ecdsa_common.rs index 3b6c57ad35..b186191d29 100644 --- a/crates/node/src/providers/ecdsa_common.rs +++ b/crates/node/src/providers/ecdsa_common.rs @@ -15,7 +15,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; -use threshold_signatures::ecdsa::KeygenOutput; +use threshold_signatures::ecdsa::{KeygenOutput, Secp256K1Sha256}; /// A stored presignature together with the participants that produced it, so the store can drop it /// once any of those participants goes offline. Generic over the presignature payload `P`. @@ -103,7 +103,7 @@ pub fn build_keyshares

( clock: &Clock, db: &Arc, client: &Arc, - keyshares: HashMap>, + keyshares: HashMap>, ) -> anyhow::Result>> where P: Serialize + DeserializeOwned + Send + 'static, diff --git a/crates/node/src/providers/eddsa.rs b/crates/node/src/providers/eddsa.rs index 8f4faae1ef..95e1196d30 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -23,7 +23,7 @@ use near_mpc_contract_interface::types::KeyEventId; use std::collections::HashMap; use std::sync::Arc; use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; -use threshold_signatures::frost::eddsa::KeygenOutput; +use threshold_signatures::frost::eddsa::{Ed25519Sha512, KeygenOutput}; use threshold_signatures::frost_ed25519::keys::SigningShare; use threshold_signatures::frost_ed25519::{Signature, VerifyingKey}; @@ -36,7 +36,7 @@ pub struct EddsaSignatureProvider { keyshares: HashMap, } -pub(super) type EddsaKeyshare = DomainKeyshare; +pub(super) type EddsaKeyshare = DomainKeyshare; impl EddsaSignatureProvider { pub fn new( diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 95fac87bdf..0685381303 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -24,9 +24,8 @@ use near_time::Clock; use std::sync::Arc; use threshold_signatures::MaxMalicious; use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; -use threshold_signatures::ecdsa::KeygenOutput; -use threshold_signatures::ecdsa::Signature; use threshold_signatures::ecdsa::robust_ecdsa::PresignOutput; +use threshold_signatures::ecdsa::{KeygenOutput, Secp256K1Sha256, Signature}; use threshold_signatures::frost_secp256k1::VerifyingKey; use threshold_signatures::frost_secp256k1::keys::SigningShare; @@ -59,7 +58,7 @@ impl RobustEcdsaSignatureProvider { clock: Clock, db: Arc, sign_request_store: Arc, - keyshares: HashMap>, + keyshares: HashMap>, ) -> anyhow::Result { let keyshares = ecdsa_common::build_keyshares(&clock, &db, &client, keyshares)?; @@ -133,7 +132,6 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { old_participants: &ParticipantsConfig, channel: NetworkTaskChannel, ) -> anyhow::Result { - // For robust-ECDSA the reconstruction lower bound equals `t`, so resharing is identical to cait-sith. EcdsaSignatureProvider::run_key_resharing_client_internal( new_threshold, old_threshold, From 86aa75e7e6fbf0a5a06ef8a4557383c751b3c278 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:08:08 +0200 Subject: [PATCH 103/121] Switching to set and unifying triple-threshold interface --- crates/node/src/assets/cleanup.rs | 22 ++++----- crates/node/src/coordinator.rs | 53 +--------------------- crates/node/src/providers/ecdsa.rs | 7 +-- crates/node/src/providers/ecdsa/triple.rs | 54 ++++++++++++++++++++++- 4 files changed, 69 insertions(+), 67 deletions(-) diff --git a/crates/node/src/assets/cleanup.rs b/crates/node/src/assets/cleanup.rs index 31e7b26a9c..8611f7e1a8 100644 --- a/crates/node/src/assets/cleanup.rs +++ b/crates/node/src/assets/cleanup.rs @@ -6,6 +6,7 @@ use crate::providers::ecdsa::presign::PresignOutputWithParticipants; use crate::providers::ecdsa::triple::PairedTriple; use mpc_primitives::{EpochId, ReconstructionThreshold, domain::DomainId}; use serde::{self, Deserialize, Serialize}; +use std::collections::BTreeSet; use std::sync::Arc; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -23,7 +24,7 @@ pub fn delete_stale_triples_and_presignatures( current_epoch_data: EpochData, my_participant_id: primitives::ParticipantId, ecdsa_domain_ds: Vec, - triple_thresholds: Vec, + triple_thresholds: BTreeSet, ) -> anyhow::Result<()> { let asset_cleanup: AssetCleanup = match get_epoch_data(db)? { None => AssetCleanup::Keep, @@ -130,6 +131,7 @@ fn cleanup_behavior( mod tests { use crate::assets::cleanup::EpochData; use crate::assets::cleanup::{delete_stale_triples_and_presignatures, get_epoch_data}; + use std::collections::BTreeSet; use crate::assets::test_utils; use crate::assets::test_utils::TestContext; use crate::assets::test_utils::get_participant_ids; @@ -187,7 +189,7 @@ mod tests { start_data.clone(), my_participant_id, [DomainId(0), DomainId(1)].to_vec(), - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); ctx.assert_owned(2); @@ -205,7 +207,7 @@ mod tests { start_data.clone(), my_participant_id, [DomainId(0), DomainId(1)].to_vec(), - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); ctx.assert_owned(1); @@ -219,7 +221,7 @@ mod tests { end_data.clone(), my_participant_id, [DomainId(0), DomainId(1)].to_vec(), - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); @@ -264,7 +266,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); assert!(db.get(DBCol::TripleV2, &v2_key).unwrap().is_some()); @@ -283,7 +285,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); @@ -330,7 +332,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); start_data @@ -344,7 +346,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); @@ -388,7 +390,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); start_data @@ -402,7 +404,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index 0e531284de..e1850c0683 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -23,6 +23,7 @@ use crate::providers::ckd::CKDProvider; use crate::providers::eddsa::{EddsaSignatureProvider, EddsaTaskId}; use crate::providers::robust_ecdsa::RobustEcdsaSignatureProvider; use crate::providers::verify_foreign_tx::VerifyForeignTxProvider; +use crate::providers::ecdsa::triple; use crate::providers::{DomainKeyshare, EcdsaSignatureProvider, EcdsaTaskId}; use crate::runtime::{AsyncDroppableRuntime, build_lower_priority_runtime}; use crate::storage::SignRequestStorage; @@ -35,7 +36,6 @@ use mpc_node_config::ConfigFile; use mpc_primitives::domain::{Curve, DomainId, Protocol}; use mpc_primitives::{EpochId, ReconstructionThreshold}; use near_mpc_contract_interface::call_args as contract_args; -use near_mpc_contract_interface::types::DomainConfig; use near_time::Clock; use std::collections::HashMap; use std::future::Future; @@ -389,7 +389,7 @@ where epoch_id: current_epoch_id, participants: current_participants_config, }; - let triple_thresholds = distinct_caitsith_triple_thresholds(&running_state.domains); + let triple_thresholds = triple::caitsith_triple_thresholds(&running_state.domains); delete_stale_triples_and_presignatures( &secret_db, current_epoch_data, @@ -979,52 +979,3 @@ fn make_initializing_stop_fn( }), ) } - -/// Distinct reconstruction thresholds `t` across the CaitSith domains — the only protocol that uses -/// triples, which are keyed on disk by the `t` they were generated for. -fn distinct_caitsith_triple_thresholds(domains: &[DomainConfig]) -> Vec { - let mut thresholds: Vec = domains - .iter() - .filter(|d| d.protocol == Protocol::CaitSith) - .map(|d| d.reconstruction_threshold) - .collect(); - thresholds.sort(); - thresholds.dedup(); - thresholds -} - -#[cfg(test)] -#[expect(non_snake_case)] -mod tests { - use super::distinct_caitsith_triple_thresholds; - use mpc_primitives::ReconstructionThreshold; - use mpc_primitives::domain::{DomainId, Protocol}; - use near_mpc_contract_interface::types::{DomainConfig, DomainPurpose}; - - fn domain(id: u64, protocol: Protocol, t: u64) -> DomainConfig { - DomainConfig { - id: DomainId(id), - protocol, - reconstruction_threshold: ReconstructionThreshold::new(t), - purpose: DomainPurpose::Sign, - } - } - - #[test] - fn distinct_caitsith_triple_thresholds__should_dedup_and_exclude_non_caitsith() { - // Given CaitSith domains with a duplicate `t` alongside non-CaitSith domains - let domains = [ - domain(0, Protocol::CaitSith, 3), - domain(1, Protocol::CaitSith, 2), - domain(2, Protocol::CaitSith, 3), - domain(3, Protocol::DamgardEtAl, 5), - domain(4, Protocol::Frost, 4), - ]; - - // When / Then only the distinct CaitSith thresholds remain, sorted - assert_eq!( - distinct_caitsith_triple_thresholds(&domains), - [2, 3].map(ReconstructionThreshold::new) - ); - } -} diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 1bafc7fdea..935e992da5 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -63,11 +63,8 @@ impl EcdsaSignatureProvider { // cait-sith triple generation runs with exactly `t` parties, so keep one store per distinct reconstruction threshold. let mut triple_stores = HashMap::new(); - for data in keyshares.values() { - let t = data.reconstruction_threshold; - if triple_stores.contains_key(&t) { - continue; - } + for t in triple::distinct_thresholds(keyshares.values().map(|d| d.reconstruction_threshold)) + { triple_stores.insert( t, Arc::new(TripleStorage::new( diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index fcc6649f46..6cc9b2e076 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -13,8 +13,11 @@ use crate::providers::ecdsa::{EcdsaSignatureProvider, EcdsaTaskId}; use crate::tracking::AutoAbortTaskCollection; use mpc_node_config::TripleConfig; use mpc_primitives::ReconstructionThreshold; +use mpc_primitives::domain::Protocol; +use near_mpc_contract_interface::types::DomainConfig; use near_time::Clock; use rand::rngs::OsRng; +use std::collections::BTreeSet; use std::ops::Deref; use std::sync::Arc; use std::time::Duration; @@ -22,6 +25,24 @@ use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use threshold_signatures::ecdsa::ot_based_ecdsa::triples::TripleGenerationOutput; use threshold_signatures::participants::Participant; +/// The distinct reconstruction thresholds `t` in `thresholds`. One triple store exists per `t`. +pub fn distinct_thresholds( + thresholds: impl IntoIterator, +) -> BTreeSet { + thresholds.into_iter().collect() +} + +/// The set of reconstruction thresholds `t` that CaitSith domains generate triples under. CaitSith +/// is the only protocol that uses triples. +pub fn caitsith_triple_thresholds(domains: &[DomainConfig]) -> BTreeSet { + distinct_thresholds( + domains + .iter() + .filter(|d| d.protocol == Protocol::CaitSith) + .map(|d| d.reconstruction_threshold), + ) +} + /// Per-`t` triple store. Holds triples generated with `n = t` participants /// (cait-sith triples are generated with exactly `t` parties, so the /// participant count and the Shamir degree `t − 1` are equivalent @@ -355,6 +376,7 @@ pub fn participants_from_triples( mod tests { use super::{ ManyTripleGenerationComputation, PairedTriple, ReconstructionThreshold, TripleStorage, + caitsith_triple_thresholds, }; use crate::assets::test_utils::{make_triple, triple_v2_key}; use crate::db::{DBCol, SecretDB}; @@ -366,7 +388,9 @@ mod tests { use crate::tests::into_participant_ids; use crate::tracking; use futures::{FutureExt, StreamExt, stream}; - use std::collections::HashMap; + use mpc_primitives::domain::{DomainId, Protocol}; + use near_mpc_contract_interface::types::{DomainConfig, DomainPurpose}; + use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use threshold_signatures::test_utils::generate_participants; @@ -378,6 +402,34 @@ mod tests { const TRIPLES_PER_BATCH: usize = 10; const BATCHES_TO_GENERATE_PER_CLIENT: usize = 10; + fn domain(id: u64, protocol: Protocol, t: u64) -> DomainConfig { + DomainConfig { + id: DomainId(id), + protocol, + reconstruction_threshold: ReconstructionThreshold::new(t), + purpose: DomainPurpose::Sign, + } + } + + #[test] + #[expect(non_snake_case)] + fn caitsith_triple_thresholds__should_dedup_and_exclude_non_caitsith() { + // Given CaitSith domains with a duplicate `t` alongside non-CaitSith domains + let domains = [ + domain(0, Protocol::CaitSith, 3), + domain(1, Protocol::CaitSith, 2), + domain(2, Protocol::CaitSith, 3), + domain(3, Protocol::DamgardEtAl, 5), + domain(4, Protocol::Frost, 4), + ]; + + // When / Then only the distinct CaitSith thresholds remain + assert_eq!( + caitsith_triple_thresholds(&domains), + BTreeSet::from([2, 3].map(ReconstructionThreshold::new)) + ); + } + #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn test_many_triple_generation() { tracking::testing::start_root_task_with_periodic_dump(async { From b2bc11a867635357cc257bf2f1573b63c2d71f32 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:16:08 +0200 Subject: [PATCH 104/121] Using default blocktime instead of 600ms --- crates/node/src/tests/reconstruction_thresholds.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/node/src/tests/reconstruction_thresholds.rs b/crates/node/src/tests/reconstruction_thresholds.rs index 32a2d685da..8012e2fa21 100644 --- a/crates/node/src/tests/reconstruction_thresholds.rs +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -8,18 +8,14 @@ use crate::indexer::participants::ContractState; use crate::p2p::testing::PortSeed; use crate::tests::common::sign_domain; use crate::tests::{ - DEFAULT_MAX_PROTOCOL_WAIT_TIME, DEFAULT_MAX_SIGNATURE_WAIT_TIME, IntegrationTestSetup, - request_signature_and_await_response, + DEFAULT_BLOCK_TIME, DEFAULT_MAX_PROTOCOL_WAIT_TIME, DEFAULT_MAX_SIGNATURE_WAIT_TIME, + IntegrationTestSetup, request_signature_and_await_response, }; use crate::tracking::AutoAbortTask; use near_mpc_contract_interface::types::{DomainConfig, Protocol, ReconstructionThreshold}; use near_time::Clock; use std::collections::BTreeMap; -// Slow enough that the DamgardEtAl domains don't flake (matches the existing -// distinct-reconstruction-thresholds test). -const BLOCK_TIME: std::time::Duration = std::time::Duration::from_millis(600); - async fn assert_can_sign(indexer: &mut FakeIndexerManager, user: &str, domain: &DomainConfig) { assert!( request_signature_and_await_response( @@ -73,7 +69,7 @@ async fn per_domain_reconstruction_threshold__should_gate_signing_availability_w THRESHOLD, TXN_DELAY_BLOCKS, PortSeed::RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST, - BLOCK_TIME, + DEFAULT_BLOCK_TIME, ); // low needs 2 online, high needs 4 online, robust (DamgardEtAl) needs 2*3-1 = 5 online. @@ -147,7 +143,7 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma THRESHOLD, TXN_DELAY_BLOCKS, PortSeed::RECONSTRUCTION_THRESHOLD_RESHARING_TEST, - BLOCK_TIME, + DEFAULT_BLOCK_TIME, ); // low needs 2 online, mid (Frost) needs 3 online, high needs 4 online. @@ -244,7 +240,7 @@ async fn changing_reconstruction_threshold_via_resharing__should_reshare_the_key THRESHOLD, TXN_DELAY_BLOCKS, PortSeed::RECONSTRUCTION_THRESHOLD_CHANGE_TEST, - BLOCK_TIME, + DEFAULT_BLOCK_TIME, ); let domain = sign_domain(0, Protocol::CaitSith, 4); From cb69750283ace4f04372b0a80b687185906d0e0a Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:17:04 +0200 Subject: [PATCH 105/121] cargo fmt --- crates/node/src/assets/cleanup.rs | 2 +- crates/node/src/coordinator.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/node/src/assets/cleanup.rs b/crates/node/src/assets/cleanup.rs index 8611f7e1a8..2ab3eb28f8 100644 --- a/crates/node/src/assets/cleanup.rs +++ b/crates/node/src/assets/cleanup.rs @@ -131,7 +131,6 @@ fn cleanup_behavior( mod tests { use crate::assets::cleanup::EpochData; use crate::assets::cleanup::{delete_stale_triples_and_presignatures, get_epoch_data}; - use std::collections::BTreeSet; use crate::assets::test_utils; use crate::assets::test_utils::TestContext; use crate::assets::test_utils::get_participant_ids; @@ -144,6 +143,7 @@ mod tests { use crate::providers::ecdsa::triple::TripleStorage; use mpc_primitives::domain::DomainId; use near_time::FakeClock; + use std::collections::BTreeSet; use std::sync::{Arc, Mutex}; fn assert_epoch_data_in_db_matches(ctx: &TestContext, expected: &EpochData) { diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index e1850c0683..66ded5ffa2 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -20,10 +20,10 @@ use crate::network::{ use crate::p2p::new_tls_mesh_network; use crate::primitives::MpcTaskId; use crate::providers::ckd::CKDProvider; +use crate::providers::ecdsa::triple; use crate::providers::eddsa::{EddsaSignatureProvider, EddsaTaskId}; use crate::providers::robust_ecdsa::RobustEcdsaSignatureProvider; use crate::providers::verify_foreign_tx::VerifyForeignTxProvider; -use crate::providers::ecdsa::triple; use crate::providers::{DomainKeyshare, EcdsaSignatureProvider, EcdsaTaskId}; use crate::runtime::{AsyncDroppableRuntime, build_lower_priority_runtime}; use crate::storage::SignRequestStorage; From 19954d80874784eade919fda4ac76c08b541842f Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:45:50 +0200 Subject: [PATCH 106/121] input EcdsaKeyshare in run_background_presignature_generation --- crates/node/src/providers/ecdsa.rs | 9 ++------- crates/node/src/providers/ecdsa/presign.rs | 17 +++++++++++++---- crates/node/src/providers/robust_ecdsa.rs | 4 +--- .../node/src/providers/robust_ecdsa/presign.rs | 12 +++++++----- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 935e992da5..166de58835 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -270,21 +270,16 @@ impl SignatureProvider for EcdsaSignatureProvider { let mut generate_presignatures = Vec::new(); for (domain_id, data) in &self.keyshares { - let t = data.reconstruction_threshold; - let threshold_usize: usize = t.inner().try_into()?; - let threshold_bound = TSReconstructionThreshold::from(threshold_usize); - let triple_store = self.triple_store_for_t(t)?; + let triple_store = self.triple_store_for_t(data.reconstruction_threshold)?; task_labels.push(format!("presignature generation (domain {})", domain_id.0)); generate_presignatures.push(tracking::spawn( &format!("generate presignatures for domain {}", domain_id.0), Self::run_background_presignature_generation( self.client.clone(), - threshold_bound, self.config.presignature.clone().into(), triple_store, *domain_id, - data.presignature_store.clone(), - data.keygen_output.clone(), + data.clone(), ), )); } diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index 966a375c37..e397e2338f 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -5,7 +5,9 @@ use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::UniqueId; use crate::protocol::run_protocol; use crate::providers::ecdsa::triple::participants_from_triples; -use crate::providers::ecdsa::{EcdsaSignatureProvider, EcdsaTaskId, KeygenOutput, TripleStorage}; +use crate::providers::ecdsa::{ + EcdsaKeyshare, EcdsaSignatureProvider, EcdsaTaskId, KeygenOutput, TripleStorage, +}; use crate::providers::ecdsa_common; use crate::tracking::AutoAbortTaskCollection; use crate::{metrics, tracking}; @@ -37,13 +39,20 @@ impl EcdsaSignatureProvider { /// so that needs to be separately handled. pub(super) async fn run_background_presignature_generation( client: Arc, - threshold: TSReconstructionThreshold, config: Arc, triple_store: Arc, domain_id: DomainId, - presignature_store: Arc, - keygen_out: KeygenOutput, + keyshare: EcdsaKeyshare, ) -> ! { + let keygen_out = keyshare.keygen_output; + let presignature_store = keyshare.presignature_store; + let threshold_usize: usize = keyshare + .reconstruction_threshold + .inner() + .try_into() + .expect("contract validation guarantees a valid threshold"); + let threshold = TSReconstructionThreshold::from(threshold_usize); + let in_flight_generations = InFlightGenerationTracker::new(); let progress_tracker = Arc::new(PresignatureGenerationProgressTracker { desired_presignatures_to_buffer: config.desired_presignatures_to_buffer, diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 0685381303..11f6dc5188 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -191,11 +191,9 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { presign::run_background_presignature_generation( self.client.clone(), self.mpc_config.clone(), - data.reconstruction_threshold, self.config.presignature.clone().into(), *domain_id, - data.presignature_store.clone(), - data.keygen_output.clone(), + data.clone(), ), ) }) diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index f67e0c4e05..a01c561627 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -7,12 +7,12 @@ use crate::primitives::UniqueId; use crate::protocol::run_protocol; use crate::providers::ecdsa_common; use crate::providers::robust_ecdsa::{ - KeygenOutput, RobustEcdsaSignatureProvider, RobustEcdsaTaskId, compute_thresholds, + EcdsaKeyshare, KeygenOutput, RobustEcdsaSignatureProvider, RobustEcdsaTaskId, + compute_thresholds, }; use crate::tracking::AutoAbortTaskCollection; use crate::{metrics, tracking}; use mpc_node_config::PresignatureConfig; -use mpc_primitives::ReconstructionThreshold; use mpc_primitives::domain::DomainId; use rand::rngs::OsRng; use std::sync::Arc; @@ -37,12 +37,14 @@ pub type PresignOutputWithParticipants = ecdsa_common::PresignOutputWithParticip pub(super) async fn run_background_presignature_generation( client: Arc, mpc_config: Arc, - threshold: ReconstructionThreshold, config: Arc, domain_id: DomainId, - presignature_store: Arc, - keygen_out: KeygenOutput, + keyshare: EcdsaKeyshare, ) -> ! { + let keygen_out = keyshare.keygen_output; + let presignature_store = keyshare.presignature_store; + let threshold = keyshare.reconstruction_threshold; + let in_flight_generations = InFlightGenerationTracker::new(); let progress_tracker = Arc::new(PresignatureGenerationProgressTracker { desired_presignatures_to_buffer: config.desired_presignatures_to_buffer, From 06a9a47d7e99dbe1a74b61171911fe575c08aca7 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:57:33 +0200 Subject: [PATCH 107/121] defining constant for 500 ms --- crates/node/src/providers/ecdsa/triple.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index 6cc9b2e076..12da596eec 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -81,6 +81,8 @@ impl Deref for TripleStorage { pub const SUPPORTED_TRIPLE_GENERATION_BATCH_SIZE: usize = 64; +const TRIPLE_METRICS_REPORTING_INTERVAL: Duration = Duration::from_millis(500); + impl EcdsaSignatureProvider { /// Reports the triple-buffer gauges summed across every per-`t` store. /// Each generator owns a distinct store keyed by its threshold, so a single @@ -100,7 +102,7 @@ impl EcdsaSignatureProvider { metrics::MPC_OWNED_NUM_TRIPLES_ONLINE.set(online); metrics::MPC_OWNED_NUM_TRIPLES_WITH_OFFLINE_PARTICIPANT.set(offline); metrics::MPC_OWNED_NUM_TRIPLES_AVAILABLE.set(available); - tokio::time::sleep(Duration::from_millis(500)).await; + tokio::time::sleep(TRIPLE_METRICS_REPORTING_INTERVAL).await; } } From a83a1f249fbf730b0df7f7f841a472452103582e Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:01:53 +0200 Subject: [PATCH 108/121] consistent bail --- crates/node/src/providers/robust_ecdsa/presign.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/node/src/providers/robust_ecdsa/presign.rs b/crates/node/src/providers/robust_ecdsa/presign.rs index a01c561627..6a840de7eb 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -161,8 +161,18 @@ impl RobustEcdsaSignatureProvider { id.validate_owned_by(channel.sender().get_leader())?; let keyshare = self.keyshare(domain_id)?; - let (_num_signers, damgard_et_al_threshold) = + let (num_signers, damgard_et_al_threshold) = compute_thresholds(keyshare.reconstruction_threshold)?; + if channel.participants().len() != num_signers { + metrics::MPC_NUM_BAD_PEER_PRESIGN_REQUESTS + .with_label_values(&[&domain_id.to_string()]) + .inc(); + anyhow::bail!( + "robust-ECDSA presign participant count ({}) does not match required signer count {}", + channel.participants().len(), + num_signers, + ); + } FollowerPresignComputation { max_malicious: damgard_et_al_threshold, From 87c2d072dd879e520b8250fea61a3b93d3ffba58 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:46:08 +0200 Subject: [PATCH 109/121] comment returning first domain --- crates/e2e-tests/tests/common.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/e2e-tests/tests/common.rs b/crates/e2e-tests/tests/common.rs index 7b3f923f95..898269f673 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -388,8 +388,8 @@ pub fn damgard_etal_domain(id: u64, t: u64) -> DomainConfig { } } -/// Returns the domain running `protocol_type`, panicking if absent. Each -/// protocol appears at most once per registry, so it identifies a unique domain. +/// Returns the first domain running `protocol_type` (the registry allows +/// duplicates), panicking if absent. pub fn must_get_domain(running: &RunningContractState, protocol_type: Protocol) -> DomainConfig { running .domains From 5c53876b54900b71440713ae2d2a2064cc1169c5 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:51:09 +0200 Subject: [PATCH 110/121] folding the threshold extraction into convert_key_event_to_instance --- crates/node/src/indexer/participants.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/node/src/indexer/participants.rs b/crates/node/src/indexer/participants.rs index d9dc1f9baa..28fda083c1 100644 --- a/crates/node/src/indexer/participants.rs +++ b/crates/node/src/indexer/participants.rs @@ -26,11 +26,20 @@ pub fn convert_key_event_to_instance( key_event: &KeyEvent, current_height: u64, completed_domains: Vec, + per_domain_thresholds: &BTreeMap, ) -> anyhow::Result { let completed_domains: Vec = completed_domains .into_iter() .map(TryInto::try_into) .collect::>()?; + + // Reshare to the new threshold: the contract carries the update only in + // `per_domain_thresholds`, not on the key event's own domain. + let mut domain = key_event.domain.clone(); + if let Some(new_threshold) = per_domain_thresholds.get(&domain.id) { + domain.reconstruction_threshold = *new_threshold; + } + Ok(match &key_event.instance { Some(current_instance) if current_height < current_instance.expires_on => { ContractKeyEventInstance { @@ -39,7 +48,7 @@ pub fn convert_key_event_to_instance( domain_id: key_event.domain.id, attempt_id: current_instance.attempt_id, }, - domain: key_event.domain.clone(), + domain, started: true, completed: current_instance .completed @@ -55,7 +64,7 @@ pub fn convert_key_event_to_instance( domain_id: key_event.domain.id, attempt_id: key_event.next_attempt_id, }, - domain: key_event.domain.clone(), + domain, started: false, completed: BTreeSet::new(), completed_domains, @@ -188,6 +197,7 @@ impl ContractState { &state.generating_key, height, state.generated_keys.clone(), + &BTreeMap::new(), ) .context("failed to convert initializing key event")?, }) @@ -204,19 +214,14 @@ impl ContractState { }) } ProtocolContractState::Resharing(state) => { - let mut key_event = convert_key_event_to_instance( + let key_event = convert_key_event_to_instance( &state.resharing_key, height, state.reshared_keys.clone(), + &state.per_domain_thresholds, ) .context("failed to convert resharing key event")?; - // Reshare to the new threshold: the contract carries the update only in - // `per_domain_thresholds`, not on the resharing key event's domain. - if let Some(new_threshold) = state.per_domain_thresholds.get(&key_event.domain.id) { - key_event.domain.reconstruction_threshold = *new_threshold; - } - let resharing_state = Some(ContractResharingState { new_participants: convert_participant_infos( state.resharing_key.parameters.clone(), From d4160177795bfecf0121a1838a345bedf16cab50 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:15:01 +0200 Subject: [PATCH 111/121] Adding an .unwrap_or instead of of keeping things seemingly missed --- crates/node/src/indexer/participants.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/node/src/indexer/participants.rs b/crates/node/src/indexer/participants.rs index 28fda083c1..9959e5c5a4 100644 --- a/crates/node/src/indexer/participants.rs +++ b/crates/node/src/indexer/participants.rs @@ -33,12 +33,13 @@ pub fn convert_key_event_to_instance( .map(TryInto::try_into) .collect::>()?; - // Reshare to the new threshold: the contract carries the update only in - // `per_domain_thresholds`, not on the key event's own domain. + // `per_domain_thresholds` holds only *changed* thresholds; a domain with no + // entry (and the initializing path's empty map) keeps its key event's own. let mut domain = key_event.domain.clone(); - if let Some(new_threshold) = per_domain_thresholds.get(&domain.id) { - domain.reconstruction_threshold = *new_threshold; - } + domain.reconstruction_threshold = per_domain_thresholds + .get(&domain.id) + .copied() + .unwrap_or(domain.reconstruction_threshold); Ok(match &key_event.instance { Some(current_instance) if current_height < current_instance.expires_on => { From 01568435d545edbf16246830064ebd615571ff35 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:20:08 +0200 Subject: [PATCH 112/121] Killing nodes for tests --- .../distinct_reconstruction_thresholds.rs | 81 +++++++++++++------ 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs index a47570c63f..38b897c306 100644 --- a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -1,50 +1,85 @@ use crate::common::{ - DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, damgard_etal_domain, generate_ckd_app_public_key, - must_get_domain, must_setup_cluster, sign_all_schemes, + DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, damgard_etal_domain, generate_ecdsa_payload, + must_get_domain, must_setup_cluster, wait_metric_on_nodes, }; -use near_mpc_contract_interface::types::Protocol; +use e2e_tests::{CLUSTER_WAIT_TIMEOUT, metrics}; +use near_mpc_contract_interface::types::{ + DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold, +}; use rand::SeedableRng; -/// Each domain signs using its own reconstruction threshold rather than the -/// governance threshold. The governance threshold is 4 with a total of 6 nodes. -/// The signing procedure succeeds despite Damgard et al. requiring at least 7 -/// participants under a governance-threshold signing model. -/// Therefore, signing is performed using the reconstruction threshold, -/// not the governance threshold. +/// Each domain signs under its own reconstruction threshold, not the governance +/// threshold. With 6 nodes and 1 killed, Cait-Sith (needs all 6) can no longer +/// sign while Damgard et al. (needs `2t - 1 = 5`) still can. #[tokio::test] #[expect(non_snake_case)] -async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { +async fn distinct_reconstruction_thresholds__should_use_per_domain_threshold_when_nodes_are_down() { // Given - let (cluster, contract_state) = + let mut rng = rand::rngs::StdRng::seed_from_u64(0); + let (mut cluster, contract_state) = must_setup_cluster(DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, |c| { c.num_nodes = 6; c.initial_participant_indices = (0..6).collect(); - c.threshold = 4; + c.threshold = 6; c.triples_to_buffer = 2; c.presignatures_to_buffer = 2; - c.domains - .push(damgard_etal_domain(c.domains.len() as u64, 3)); + c.domains = vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(6), + purpose: DomainPurpose::Sign, + }, + damgard_etal_domain(1, 3), + ]; }) .await; - let ckd_domain = must_get_domain(&contract_state, Protocol::ConfidentialKeyDerivation); + let caitsith_domain = must_get_domain(&contract_state, Protocol::CaitSith); + let damgard_domain = must_get_domain(&contract_state, Protocol::DamgardEtAl); - // When / Then - let mut rng = rand::rngs::StdRng::seed_from_u64(0); - sign_all_schemes(&cluster, &contract_state, &mut rng).await; + // When + cluster.kill_nodes(&[5]).expect("failed to kill node 5"); + // Then Damgard et al. (needs 5 signers) still signs. let outcome = cluster - .send_ckd_request( - ckd_domain.id, - generate_ckd_app_public_key(&mut rng), + .send_sign_request( + damgard_domain.id, + generate_ecdsa_payload(&mut rng), cluster.default_user_account(), ) .await - .expect("ckd request failed"); + .expect("failed to submit Damgard et al. sign request"); assert!( outcome.is_success(), - "ckd request failed: {:?}", + "Damgard et al. sign request failed with 5 of 6 nodes alive: {:?}", outcome.failure_message() ); + + // And Cait-Sith (needs all 6) is unanswerable. Its request never resolves on + // chain, and the yield auto-timeout outlives the JSON-RPC call, so we race the + // doomed request against the surviving nodes' timeout counter rather than + // awaiting it (see `timeout_metric.rs`). + tokio::select! { + res = wait_metric_on_nodes( + &cluster, + &[0, 1, 2, 3, 4], + metrics::TIMEOUTS_INDEXED, + |v| v >= 1, + CLUSTER_WAIT_TIMEOUT, + ) => res.unwrap_or_else(|_| panic!( + "{} did not reach 1 on the surviving nodes — Cait-Sith request was answered \ + despite only 5 of its 6 required signers being alive", + metrics::TIMEOUTS_INDEXED + )), + _ = cluster.send_sign_request( + caitsith_domain.id, + generate_ecdsa_payload(&mut rng), + cluster.default_user_account(), + ) => panic!( + "Cait-Sith sign request returned before the timeout metric — it should be \ + unanswerable with only 5 of 6 required signers alive" + ), + } } From bc8bc7215cf867e7d92c6af96a2e49567c7afb4d Mon Sep 17 00:00:00 2001 From: SimonRastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:02:52 +0200 Subject: [PATCH 113/121] Reverting the join_all silent change and making a new PR for it --- crates/node/src/providers/ecdsa.rs | 24 +++++++++-------------- crates/node/src/providers/robust_ecdsa.rs | 17 ++++++---------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 166de58835..24120e8c73 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -244,11 +244,9 @@ impl SignatureProvider for EcdsaSignatureProvider { // triples are generated with exactly `t` parties, so each store is fed // by a generator running at its own threshold. let mut generate_triples = Vec::new(); - let mut task_labels: Vec = Vec::new(); for (&t, triple_store) in &self.triple_stores { let threshold_usize: usize = t.inner().try_into()?; let threshold_bound = TSReconstructionThreshold::from(threshold_usize); - task_labels.push(format!("triple generation (t={})", t.inner())); generate_triples.push(tracking::spawn( &format!("generate triples for t={}", t.inner()), Self::run_background_triple_generation( @@ -271,7 +269,6 @@ impl SignatureProvider for EcdsaSignatureProvider { let mut generate_presignatures = Vec::new(); for (domain_id, data) in &self.keyshares { let triple_store = self.triple_store_for_t(data.reconstruction_threshold)?; - task_labels.push(format!("presignature generation (domain {})", domain_id.0)); generate_presignatures.push(tracking::spawn( &format!("generate presignatures for domain {}", domain_id.0), Self::run_background_presignature_generation( @@ -284,18 +281,15 @@ impl SignatureProvider for EcdsaSignatureProvider { )); } - let mut background_tasks = generate_triples; - background_tasks.extend(generate_presignatures); - - // Generators are `-> !`, so `select_all` fails fast on the first (panicking) exit rather than `join_all` masking it behind the siblings' infinite loops. - if background_tasks.is_empty() { - return Ok(()); + for Err(join_error) in futures::future::join_all(generate_triples).await { + tracing::error!( + "ecdsa background triple generation task ended unexpectedly: {join_error}" + ); } - let (Err(join_error), index, _remaining) = - futures::future::select_all(background_tasks).await; - anyhow::bail!( - "ecdsa background {} task ended unexpectedly: {join_error}", - task_labels[index] - ) + for Err(join_error) in futures::future::join_all(generate_presignatures).await { + tracing::error!("ecdsa background presignature task ended unexpectedly: {join_error}"); + } + + Ok(()) } } diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 11f6dc5188..abb254297a 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -180,12 +180,10 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { } async fn spawn_background_tasks(self: Arc) -> anyhow::Result<()> { - let mut task_labels: Vec = Vec::new(); let generate_presignatures = self .keyshares .iter() .map(|(domain_id, data)| { - task_labels.push(format!("presignature generation (domain {})", domain_id.0)); tracking::spawn( &format!("generate presignatures for domain {}", domain_id.0), presign::run_background_presignature_generation( @@ -199,16 +197,13 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { }) .collect::>(); - // Generators are `-> !`, so `select_all` fails fast on the first (panicking) exit rather than `join_all` masking it behind the siblings' infinite loops. - if generate_presignatures.is_empty() { - return Ok(()); + for Err(join_error) in futures::future::join_all(generate_presignatures).await { + tracing::error!( + "Damgard et al background presignature task ended unexpectedly: {join_error}" + ); } - let (Err(join_error), index, _remaining) = - futures::future::select_all(generate_presignatures).await; - anyhow::bail!( - "Damgard et al background {} task ended unexpectedly: {join_error}", - task_labels[index] - ) + + Ok(()) } } From 75b82c4c0160484f38e475ade141b1f37f4529e1 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:45:54 +0200 Subject: [PATCH 114/121] Unnecessary commiti --- crates/node/src/providers/ecdsa/triple.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index 12da596eec..951c8c035c 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -158,7 +158,6 @@ impl EcdsaSignatureProvider { } }; - // The follower derives the store's `t` from the channel size (see `run_triple_generation_follower`), so pair exactly `t`. debug_assert_eq!( participants.len(), threshold.value(), From 6e5dfce445b10a217f5535f7e2f3441fc19427d6 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:00:51 +0200 Subject: [PATCH 115/121] Adding tests for the other schemes --- crates/e2e-tests/tests/common.rs | 10 ++ .../distinct_reconstruction_thresholds.rs | 48 ++++++++- .../src/tests/reconstruction_thresholds.rs | 102 +++++++++++++----- 3 files changed, 130 insertions(+), 30 deletions(-) diff --git a/crates/e2e-tests/tests/common.rs b/crates/e2e-tests/tests/common.rs index 898269f673..395704f310 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -388,6 +388,16 @@ pub fn damgard_etal_domain(id: u64, t: u64) -> DomainConfig { } } +/// Builds a `ConfidentialKeyDerivation` (CKD) domain with reconstruction threshold `t`, which needs `t` signers. +pub fn ckd_domain(id: u64, t: u64) -> DomainConfig { + DomainConfig { + id: DomainId(id), + protocol: Protocol::ConfidentialKeyDerivation, + reconstruction_threshold: ReconstructionThreshold::new(t), + purpose: DomainPurpose::CKD, + } +} + /// Returns the first domain running `protocol_type` (the registry allows /// duplicates), panicking if absent. pub fn must_get_domain(running: &RunningContractState, protocol_type: Protocol) -> DomainConfig { diff --git a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs index 38b897c306..93d15324ad 100644 --- a/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -1,6 +1,7 @@ use crate::common::{ - DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, damgard_etal_domain, generate_ecdsa_payload, - must_get_domain, must_setup_cluster, wait_metric_on_nodes, + DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, ckd_domain, damgard_etal_domain, + generate_ckd_app_public_key, generate_ecdsa_payload, generate_eddsa_payload, must_get_domain, + must_setup_cluster, wait_metric_on_nodes, }; use e2e_tests::{CLUSTER_WAIT_TIMEOUT, metrics}; @@ -10,8 +11,8 @@ use near_mpc_contract_interface::types::{ use rand::SeedableRng; /// Each domain signs under its own reconstruction threshold, not the governance -/// threshold. With 6 nodes and 1 killed, Cait-Sith (needs all 6) can no longer -/// sign while Damgard et al. (needs `2t - 1 = 5`) still can. +/// threshold. With 6 nodes and 1 killed, Cait-Sith (needs all 6) can no longer sign +/// while Damgard et al. (`2t - 1 = 5`), CKD (`t = 5`) and Frost (`t = 5`) still can. #[tokio::test] #[expect(non_snake_case)] async fn distinct_reconstruction_thresholds__should_use_per_domain_threshold_when_nodes_are_down() { @@ -32,12 +33,21 @@ async fn distinct_reconstruction_thresholds__should_use_per_domain_threshold_whe purpose: DomainPurpose::Sign, }, damgard_etal_domain(1, 3), + ckd_domain(2, 5), + DomainConfig { + id: DomainId(3), + protocol: Protocol::Frost, + reconstruction_threshold: ReconstructionThreshold::new(5), + purpose: DomainPurpose::Sign, + }, ]; }) .await; let caitsith_domain = must_get_domain(&contract_state, Protocol::CaitSith); let damgard_domain = must_get_domain(&contract_state, Protocol::DamgardEtAl); + let ckd_domain = must_get_domain(&contract_state, Protocol::ConfidentialKeyDerivation); + let frost_domain = must_get_domain(&contract_state, Protocol::Frost); // When cluster.kill_nodes(&[5]).expect("failed to kill node 5"); @@ -57,6 +67,36 @@ async fn distinct_reconstruction_thresholds__should_use_per_domain_threshold_whe outcome.failure_message() ); + // And CKD (its own `t = 5`, not the governance threshold of 6) still derives. + let outcome = cluster + .send_ckd_request( + ckd_domain.id, + generate_ckd_app_public_key(&mut rng), + cluster.default_user_account(), + ) + .await + .expect("failed to submit CKD request"); + assert!( + outcome.is_success(), + "CKD request failed with 5 of its 5 required signers alive: {:?}", + outcome.failure_message() + ); + + // And Frost (its own `t = 5`) still signs. + let outcome = cluster + .send_sign_request( + frost_domain.id, + generate_eddsa_payload(&mut rng), + cluster.default_user_account(), + ) + .await + .expect("failed to submit Frost sign request"); + assert!( + outcome.is_success(), + "Frost sign request failed with 5 of its 5 required signers alive: {:?}", + outcome.failure_message() + ); + // And Cait-Sith (needs all 6) is unanswerable. Its request never resolves on // chain, and the yield auto-timeout outlives the JSON-RPC call, so we race the // doomed request against the surviving nodes' timeout counter rather than diff --git a/crates/node/src/tests/reconstruction_thresholds.rs b/crates/node/src/tests/reconstruction_thresholds.rs index 8012e2fa21..a1b2fb72a0 100644 --- a/crates/node/src/tests/reconstruction_thresholds.rs +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -6,26 +6,45 @@ use crate::indexer::fake::FakeIndexerManager; use crate::indexer::participants::ContractState; use crate::p2p::testing::PortSeed; -use crate::tests::common::sign_domain; +use crate::tests::common::{ckd_domain, sign_domain}; use crate::tests::{ DEFAULT_BLOCK_TIME, DEFAULT_MAX_PROTOCOL_WAIT_TIME, DEFAULT_MAX_SIGNATURE_WAIT_TIME, - IntegrationTestSetup, request_signature_and_await_response, + IntegrationTestSetup, request_ckd_and_await_response, request_signature_and_await_response, }; use crate::tracking::AutoAbortTask; +use mpc_primitives::domain::Curve; use near_mpc_contract_interface::types::{DomainConfig, Protocol, ReconstructionThreshold}; use near_time::Clock; use std::collections::BTreeMap; +/// Sign or CKD request per `domain`'s protocol; both are gated by its reconstruction threshold. +async fn request_and_await_response( + indexer: &mut FakeIndexerManager, + user: &str, + domain: &DomainConfig, +) -> Option { + match Curve::from(domain.protocol) { + Curve::Secp256k1 | Curve::Edwards25519 => { + request_signature_and_await_response( + indexer, + user, + domain, + DEFAULT_MAX_SIGNATURE_WAIT_TIME, + ) + .await + } + Curve::Bls12381 => { + request_ckd_and_await_response(indexer, user, domain, DEFAULT_MAX_SIGNATURE_WAIT_TIME) + .await + } + } +} + async fn assert_can_sign(indexer: &mut FakeIndexerManager, user: &str, domain: &DomainConfig) { assert!( - request_signature_and_await_response( - indexer, - user, - domain, - DEFAULT_MAX_SIGNATURE_WAIT_TIME - ) - .await - .is_some(), + request_and_await_response(indexer, user, domain) + .await + .is_some(), "domain {:?} (t={}) should be able to sign with the currently-online nodes", domain.id, domain.reconstruction_threshold.inner(), @@ -34,14 +53,9 @@ async fn assert_can_sign(indexer: &mut FakeIndexerManager, user: &str, domain: & async fn assert_cannot_sign(indexer: &mut FakeIndexerManager, user: &str, domain: &DomainConfig) { assert!( - request_signature_and_await_response( - indexer, - user, - domain, - DEFAULT_MAX_SIGNATURE_WAIT_TIME - ) - .await - .is_none(), + request_and_await_response(indexer, user, domain) + .await + .is_none(), "domain {:?} (t={}) must NOT be able to sign: too few nodes are online for its threshold", domain.id, domain.reconstruction_threshold.inner(), @@ -72,11 +86,22 @@ async fn per_domain_reconstruction_threshold__should_gate_signing_availability_w DEFAULT_BLOCK_TIME, ); - // low needs 2 online, high needs 4 online, robust (DamgardEtAl) needs 2*3-1 = 5 online. + // Online signers needed: low 2, high 4, robust (DamgardEtAl, 2t-1) 5, ckd_low 2, + // ckd_high 4, frost 4. Frost/CKD are gated by `t` like CaitSith. let low = sign_domain(0, Protocol::CaitSith, 2); let high = sign_domain(1, Protocol::CaitSith, 4); let robust = sign_domain(2, Protocol::DamgardEtAl, 3); - let domains = vec![low.clone(), high.clone(), robust.clone()]; + let ckd_low = ckd_domain(3, 2); + let ckd_high = ckd_domain(4, 4); + let frost = sign_domain(5, Protocol::Frost, 4); + let domains = vec![ + low.clone(), + high.clone(), + robust.clone(), + ckd_low.clone(), + ckd_high.clone(), + frost.clone(), + ]; { let mut contract = setup.indexer.contract_mut().await; @@ -103,23 +128,33 @@ async fn per_domain_reconstruction_threshold__should_gate_signing_availability_w assert_can_sign(&mut setup.indexer, "user_all_low", &low).await; assert_can_sign(&mut setup.indexer, "user_all_high", &high).await; assert_can_sign(&mut setup.indexer, "user_all_robust", &robust).await; + assert_can_sign(&mut setup.indexer, "user_all_ckd_low", &ckd_low).await; + assert_can_sign(&mut setup.indexer, "user_all_ckd_high", &ckd_high).await; + assert_can_sign(&mut setup.indexer, "user_all_frost", &frost).await; // One node down (4 online): only robust (needs 5) stops. let disabled_a = setup.indexer.disable(4.into()).await; assert_can_sign(&mut setup.indexer, "user_4_low", &low).await; assert_can_sign(&mut setup.indexer, "user_4_high", &high).await; assert_cannot_sign(&mut setup.indexer, "user_4_robust", &robust).await; + assert_can_sign(&mut setup.indexer, "user_4_ckd_high", &ckd_high).await; + assert_can_sign(&mut setup.indexer, "user_4_frost", &frost).await; - // Two nodes down (3 online): high (t=4) stops too. + // Two nodes down (3 online): high, ckd_high and frost (t=4) stop too. let disabled_b = setup.indexer.disable(3.into()).await; assert_can_sign(&mut setup.indexer, "user_3_low", &low).await; assert_cannot_sign(&mut setup.indexer, "user_3_high", &high).await; + assert_can_sign(&mut setup.indexer, "user_3_ckd_low", &ckd_low).await; + assert_cannot_sign(&mut setup.indexer, "user_3_ckd_high", &ckd_high).await; + assert_cannot_sign(&mut setup.indexer, "user_3_frost", &frost).await; // Then restoring both nodes restores signing for every domain. disabled_b.reenable_and_wait_till_running().await; disabled_a.reenable_and_wait_till_running().await; assert_can_sign(&mut setup.indexer, "user_restored_high", &high).await; assert_can_sign(&mut setup.indexer, "user_restored_robust", &robust).await; + assert_can_sign(&mut setup.indexer, "user_restored_ckd_high", &ckd_high).await; + assert_can_sign(&mut setup.indexer, "user_restored_frost", &frost).await; } /// Resharing (a new node joining) preserves each domain's own reconstruction threshold: @@ -146,11 +181,20 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma DEFAULT_BLOCK_TIME, ); - // low needs 2 online, mid (Frost) needs 3 online, high needs 4 online. + // Online signers needed: low 2, mid (Frost) 3, high 4, ckd 4, robust (DamgardEtAl, + // t capped at 2 by `2t - 1 <= 4` participants) 3. All survive the reshare. let low = sign_domain(0, Protocol::CaitSith, 2); let mid = sign_domain(1, Protocol::Frost, 3); let high = sign_domain(2, Protocol::CaitSith, 4); - let domains = vec![low.clone(), mid.clone(), high.clone()]; + let ckd = ckd_domain(3, 4); + let robust = sign_domain(4, Protocol::DamgardEtAl, 2); + let domains = vec![ + low.clone(), + mid.clone(), + high.clone(), + ckd.clone(), + robust.clone(), + ]; // Initialize with one fewer participant; the fifth joins during resharing. let mut initial_participants = setup.participants.clone(); @@ -177,10 +221,12 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma .await .expect("must not exceed timeout"); - // Sanity: all four initial nodes online, every domain signs (high's t=4 needs all 4). + // Sanity: all four initial nodes online, every domain signs (high/ckd t=4 need all 4). assert_can_sign(&mut setup.indexer, "user_pre_low", &low).await; assert_can_sign(&mut setup.indexer, "user_pre_mid", &mid).await; assert_can_sign(&mut setup.indexer, "user_pre_high", &high).await; + assert_can_sign(&mut setup.indexer, "user_pre_ckd", &ckd).await; + assert_can_sign(&mut setup.indexer, "user_pre_robust", &robust).await; // When the fifth node joins via resharing. setup @@ -208,14 +254,18 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma assert_can_sign(&mut setup.indexer, "user_post_low", &low).await; assert_can_sign(&mut setup.indexer, "user_post_mid", &mid).await; assert_can_sign(&mut setup.indexer, "user_post_high", &high).await; + assert_can_sign(&mut setup.indexer, "user_post_ckd", &ckd).await; + assert_can_sign(&mut setup.indexer, "user_post_robust", &robust).await; - // Then with two nodes down (3 online), high (t=4) can't sign — its threshold - // survived the reshare while low/mid still work. + // With two nodes down (3 online): high/ckd (t=4) stop, but low/mid/robust still + // work — each domain's threshold survived the reshare. let _disabled_a = setup.indexer.disable(4.into()).await; let _disabled_b = setup.indexer.disable(3.into()).await; assert_can_sign(&mut setup.indexer, "user_drop_low", &low).await; assert_can_sign(&mut setup.indexer, "user_drop_mid", &mid).await; assert_cannot_sign(&mut setup.indexer, "user_drop_high", &high).await; + assert_cannot_sign(&mut setup.indexer, "user_drop_ckd", &ckd).await; + assert_can_sign(&mut setup.indexer, "user_drop_robust", &robust).await; } /// Changing a domain's reconstruction threshold via a resharing proposal takes real From 17d649e1832e6e519c17f990d76079fb857ee4b2 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:23:33 +0200 Subject: [PATCH 116/121] cargo fmt --- crates/node/src/p2p.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/node/src/p2p.rs b/crates/node/src/p2p.rs index fda812188b..a437b215f9 100644 --- a/crates/node/src/p2p.rs +++ b/crates/node/src/p2p.rs @@ -1006,9 +1006,12 @@ pub mod testing { pub const ASSET_GENERATION_SIGNING_CONTENTION_TEST: TestPorts = TestPorts::mpc_node_tests(22); pub const UPDATE_PARTICIPANT_URL_TEST: TestPorts = TestPorts::mpc_node_tests(23); - pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_TEST: TestPorts = TestPorts::mpc_node_tests(24); - pub const RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST: TestPorts = TestPorts::mpc_node_tests(25); - pub const RECONSTRUCTION_THRESHOLD_RESHARING_TEST: TestPorts = TestPorts::mpc_node_tests(26); + pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_TEST: TestPorts = + TestPorts::mpc_node_tests(24); + pub const RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST: TestPorts = + TestPorts::mpc_node_tests(25); + pub const RECONSTRUCTION_THRESHOLD_RESHARING_TEST: TestPorts = + TestPorts::mpc_node_tests(26); pub const RECONSTRUCTION_THRESHOLD_CHANGE_TEST: TestPorts = TestPorts::mpc_node_tests(27); } From 96535d65e26a5fbfaa3a993ff965efefda442682 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:49:50 +0200 Subject: [PATCH 117/121] Fixing deeper issues following merge with main --- crates/node/src/indexer/participants.rs | 3 ++- crates/node/src/tests/multidomain.rs | 2 +- crates/node/src/tests/reconstruction_thresholds.rs | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/node/src/indexer/participants.rs b/crates/node/src/indexer/participants.rs index 5e03974bad..a662b1b793 100644 --- a/crates/node/src/indexer/participants.rs +++ b/crates/node/src/indexer/participants.rs @@ -448,7 +448,7 @@ pub mod test_utils { use near_mpc_crypto_types::Keyset; use rand::SeedableRng; use rand::rngs::StdRng; - use std::collections::BTreeSet; + use std::collections::{BTreeMap, BTreeSet}; use super::{ ContractKeyEventInstance, ContractResharingState, ContractRunningState, ContractState, @@ -529,6 +529,7 @@ pub mod test_utils { participants, resharing_state: Some(ContractResharingState { new_participants, + per_domain_thresholds: BTreeMap::new(), reshared_keys: Keyset::new(EpochId::new(epoch), vec![]), key_event: ContractKeyEventInstance { id: KeyEventId::new(EpochId::new(epoch), DomainId(0), AttemptId::new()), diff --git a/crates/node/src/tests/multidomain.rs b/crates/node/src/tests/multidomain.rs index 62ac7f45f4..ed2ca9ad90 100644 --- a/crates/node/src/tests/multidomain.rs +++ b/crates/node/src/tests/multidomain.rs @@ -32,7 +32,7 @@ async fn multidomain_with_distinct_reconstruction_thresholds__should_sign_for_ev .collect(), THRESHOLD, TXN_DELAY_BLOCKS, - PortSeed::DISTINCT_RECONSTRUCTION_THRESHOLDS_TEST, + port_seed::DISTINCT_RECONSTRUCTION_THRESHOLDS_TEST, std::time::Duration::from_millis(600), // helps to avoid flaky test ); diff --git a/crates/node/src/tests/reconstruction_thresholds.rs b/crates/node/src/tests/reconstruction_thresholds.rs index a1b2fb72a0..c8adcc5756 100644 --- a/crates/node/src/tests/reconstruction_thresholds.rs +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -5,7 +5,7 @@ use crate::indexer::fake::FakeIndexerManager; use crate::indexer::participants::ContractState; -use crate::p2p::testing::PortSeed; +use crate::p2p::testing::port_seed; use crate::tests::common::{ckd_domain, sign_domain}; use crate::tests::{ DEFAULT_BLOCK_TIME, DEFAULT_MAX_PROTOCOL_WAIT_TIME, DEFAULT_MAX_SIGNATURE_WAIT_TIME, @@ -82,7 +82,7 @@ async fn per_domain_reconstruction_threshold__should_gate_signing_availability_w .collect(), THRESHOLD, TXN_DELAY_BLOCKS, - PortSeed::RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST, + port_seed::RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST, DEFAULT_BLOCK_TIME, ); @@ -177,7 +177,7 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma .collect(), THRESHOLD, TXN_DELAY_BLOCKS, - PortSeed::RECONSTRUCTION_THRESHOLD_RESHARING_TEST, + port_seed::RECONSTRUCTION_THRESHOLD_RESHARING_TEST, DEFAULT_BLOCK_TIME, ); @@ -289,7 +289,7 @@ async fn changing_reconstruction_threshold_via_resharing__should_reshare_the_key .collect(), THRESHOLD, TXN_DELAY_BLOCKS, - PortSeed::RECONSTRUCTION_THRESHOLD_CHANGE_TEST, + port_seed::RECONSTRUCTION_THRESHOLD_CHANGE_TEST, DEFAULT_BLOCK_TIME, ); From 9ca92e111974da957e4703d4cbab1c0ee5c9079f Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:16:27 +0200 Subject: [PATCH 118/121] Removing a test by extending another --- crates/node/src/p2p.rs | 1 - .../src/tests/reconstruction_thresholds.rs | 111 +++--------------- 2 files changed, 19 insertions(+), 93 deletions(-) diff --git a/crates/node/src/p2p.rs b/crates/node/src/p2p.rs index a437b215f9..ca77bf5a03 100644 --- a/crates/node/src/p2p.rs +++ b/crates/node/src/p2p.rs @@ -1012,7 +1012,6 @@ pub mod testing { TestPorts::mpc_node_tests(25); pub const RECONSTRUCTION_THRESHOLD_RESHARING_TEST: TestPorts = TestPorts::mpc_node_tests(26); - pub const RECONSTRUCTION_THRESHOLD_CHANGE_TEST: TestPorts = TestPorts::mpc_node_tests(27); } pub fn generate_test_p2p_configs( diff --git a/crates/node/src/tests/reconstruction_thresholds.rs b/crates/node/src/tests/reconstruction_thresholds.rs index c8adcc5756..be1af5b3ce 100644 --- a/crates/node/src/tests/reconstruction_thresholds.rs +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -157,13 +157,13 @@ async fn per_domain_reconstruction_threshold__should_gate_signing_availability_w assert_can_sign(&mut setup.indexer, "user_restored_frost", &frost).await; } -/// Resharing (a new node joining) preserves each domain's own reconstruction threshold: -/// afterwards the high-`t` domain still requires its higher online-signer count. +/// One resharing preserves unchanged domains' `t` while applying a per-domain update: the +/// lowered domain then signs with fewer online nodes than its old sharing allowed, while a +/// sibling left at the same `t` still needs its higher count. #[tokio::test] #[test_log::test] #[expect(non_snake_case)] -async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_domain_across_resharing() - { +async fn resharing__should_apply_updated_thresholds_while_preserving_unchanged_ones() { // Given a cluster starting with 4 of an eventual 5 participants. const NUM_PARTICIPANTS: usize = 5; const THRESHOLD: usize = 3; @@ -228,12 +228,16 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma assert_can_sign(&mut setup.indexer, "user_pre_ckd", &ckd).await; assert_can_sign(&mut setup.indexer, "user_pre_robust", &robust).await; - // When the fifth node joins via resharing. + // When the fifth node joins via resharing, which also lowers `high` from t=4 to t=2. + let high_lowered = sign_domain(2, Protocol::CaitSith, 2); setup .indexer .contract_mut() .await - .start_resharing(setup.participants.clone()); + .start_resharing_with_threshold_updates( + setup.participants.clone(), + BTreeMap::from([(high.id, ReconstructionThreshold::new(2))]), + ); setup .indexer @@ -253,97 +257,20 @@ async fn per_domain_reconstruction_thresholds__should_be_preserved_for_each_doma // Then all domains still sign with the full reshared set. assert_can_sign(&mut setup.indexer, "user_post_low", &low).await; assert_can_sign(&mut setup.indexer, "user_post_mid", &mid).await; - assert_can_sign(&mut setup.indexer, "user_post_high", &high).await; + assert_can_sign(&mut setup.indexer, "user_post_high", &high_lowered).await; assert_can_sign(&mut setup.indexer, "user_post_ckd", &ckd).await; assert_can_sign(&mut setup.indexer, "user_post_robust", &robust).await; - // With two nodes down (3 online): high/ckd (t=4) stop, but low/mid/robust still - // work — each domain's threshold survived the reshare. + // With three nodes down (2 online): `high` now signs at its new t=2 — impossible under + // the old t=4 sharing, so the update took real effect. `ckd`, left at t=4, stops — + // proving the change was per-domain, not global. `low` (t=2) keeps working; `mid` (t=3) + // and `robust` (DamgardEtAl, needs 2t-1=3) stop. let _disabled_a = setup.indexer.disable(4.into()).await; let _disabled_b = setup.indexer.disable(3.into()).await; + let _disabled_c = setup.indexer.disable(2.into()).await; assert_can_sign(&mut setup.indexer, "user_drop_low", &low).await; - assert_can_sign(&mut setup.indexer, "user_drop_mid", &mid).await; - assert_cannot_sign(&mut setup.indexer, "user_drop_high", &high).await; + assert_can_sign(&mut setup.indexer, "user_drop_high", &high_lowered).await; assert_cannot_sign(&mut setup.indexer, "user_drop_ckd", &ckd).await; - assert_can_sign(&mut setup.indexer, "user_drop_robust", &robust).await; -} - -/// Changing a domain's reconstruction threshold via a resharing proposal takes real -/// cryptographic effect: after lowering `t` from 4 to 2, only 2 nodes need be online to -/// sign — impossible unless the key was genuinely re-shared to the new threshold. -#[tokio::test] -#[test_log::test] -#[expect(non_snake_case)] -async fn changing_reconstruction_threshold_via_resharing__should_reshare_the_key_to_the_new_threshold() - { - // Given a 5-node cluster with a single CaitSith domain at t=4. - const NUM_PARTICIPANTS: usize = 5; - const THRESHOLD: usize = 3; - const TXN_DELAY_BLOCKS: u64 = 1; - let temp_dir = tempfile::tempdir().unwrap(); - let mut setup = IntegrationTestSetup::new( - Clock::real(), - temp_dir.path(), - (0..NUM_PARTICIPANTS) - .map(|i| format!("test{}", i).parse().unwrap()) - .collect(), - THRESHOLD, - TXN_DELAY_BLOCKS, - port_seed::RECONSTRUCTION_THRESHOLD_CHANGE_TEST, - DEFAULT_BLOCK_TIME, - ); - - let domain = sign_domain(0, Protocol::CaitSith, 4); - { - let mut contract = setup.indexer.contract_mut().await; - contract.initialize(setup.participants.clone()); - contract.add_domains(vec![domain.clone()]); - } - - let _runs = setup - .configs - .into_iter() - .map(|config| AutoAbortTask::from(tokio::spawn(config.run()))) - .collect::>(); - - setup - .indexer - .wait_for_contract_state( - |state| matches!(state, ContractState::Running(_)), - DEFAULT_MAX_PROTOCOL_WAIT_TIME, - ) - .await - .expect("must not exceed timeout"); - - // Sanity: at t=4 the domain signs with all nodes online. - assert_can_sign(&mut setup.indexer, "user_pre", &domain).await; - - // When resharing lowers the threshold to t=2 (participant set unchanged). - let lowered = sign_domain(0, Protocol::CaitSith, 2); - setup - .indexer - .contract_mut() - .await - .start_resharing_with_threshold_updates( - setup.participants.clone(), - BTreeMap::from([(domain.id, ReconstructionThreshold::new(2))]), - ); - - setup - .indexer - .wait_for_contract_state( - |state| match state { - ContractState::Running(running) => running.keyset.epoch_id.get() == 1, - _ => false, - }, - DEFAULT_MAX_PROTOCOL_WAIT_TIME, - ) - .await - .expect("Timeout waiting for resharing to complete"); - - // Then two online nodes suffice; the original t=4 sharing would have needed four. - let _d1 = setup.indexer.disable(4.into()).await; - let _d2 = setup.indexer.disable(3.into()).await; - let _d3 = setup.indexer.disable(2.into()).await; - assert_can_sign(&mut setup.indexer, "user_post", &lowered).await; + assert_cannot_sign(&mut setup.indexer, "user_drop_mid", &mid).await; + assert_cannot_sign(&mut setup.indexer, "user_drop_robust", &robust).await; } From e3505112a0c0e5a28f38954c5549e64462bf666d Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:38:14 +0200 Subject: [PATCH 119/121] test: remove redundant static multidomain reconstruction-threshold test (#3805) --- crates/node/src/p2p.rs | 6 +- crates/node/src/providers/ecdsa_common.rs | 4 +- crates/node/src/tests/multidomain.rs | 87 ----------------------- 3 files changed, 4 insertions(+), 93 deletions(-) diff --git a/crates/node/src/p2p.rs b/crates/node/src/p2p.rs index ca77bf5a03..8ce9fd1d87 100644 --- a/crates/node/src/p2p.rs +++ b/crates/node/src/p2p.rs @@ -1006,12 +1006,10 @@ pub mod testing { pub const ASSET_GENERATION_SIGNING_CONTENTION_TEST: TestPorts = TestPorts::mpc_node_tests(22); pub const UPDATE_PARTICIPANT_URL_TEST: TestPorts = TestPorts::mpc_node_tests(23); - pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_TEST: TestPorts = - TestPorts::mpc_node_tests(24); pub const RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST: TestPorts = - TestPorts::mpc_node_tests(25); + TestPorts::mpc_node_tests(24); pub const RECONSTRUCTION_THRESHOLD_RESHARING_TEST: TestPorts = - TestPorts::mpc_node_tests(26); + TestPorts::mpc_node_tests(25); } pub fn generate_test_p2p_configs( diff --git a/crates/node/src/providers/ecdsa_common.rs b/crates/node/src/providers/ecdsa_common.rs index b186191d29..a0bff00bd4 100644 --- a/crates/node/src/providers/ecdsa_common.rs +++ b/crates/node/src/providers/ecdsa_common.rs @@ -168,8 +168,8 @@ mod tests { .1 } - // Directly asserts the plumbing that `multidomain_with_distinct_reconstruction_thresholds` - // only checks indirectly (by running a full multi-node signing round): each domain must keep + // Directly asserts the plumbing that the `reconstruction_thresholds` integration tests + // only check indirectly (by running full multi-node signing rounds): each domain must keep // its OWN reconstruction threshold, never a single shared/governance value. #[tokio::test] async fn build_keyshares__should_pair_each_domain_with_its_own_reconstruction_threshold() { diff --git a/crates/node/src/tests/multidomain.rs b/crates/node/src/tests/multidomain.rs index ed2ca9ad90..508389988d 100644 --- a/crates/node/src/tests/multidomain.rs +++ b/crates/node/src/tests/multidomain.rs @@ -10,93 +10,6 @@ use mpc_primitives::domain::Curve; use near_mpc_contract_interface::types::Protocol; use near_time::Clock; -// Domains carry per-domain reconstruction thresholds `t` differing from the -// governance threshold (4): CaitSith/Frost/CKD at `t=2`, DamgardEtAl at `t=3` -// (5 nodes). DamgardEtAl is the discriminator — it signs over `2t-1` -// participants, so `t=3` needs 5 signers; using the governance threshold would -// need `2*4-1=7` and fail. A pass proves the per-domain `t` is used. -#[tokio::test] -#[test_log::test] -#[expect(non_snake_case)] -async fn multidomain_with_distinct_reconstruction_thresholds__should_sign_for_every_domain() { - // Given - const NUM_PARTICIPANTS: usize = 5; - const THRESHOLD: usize = 4; - const TXN_DELAY_BLOCKS: u64 = 1; - let temp_dir = tempfile::tempdir().unwrap(); - let mut setup = IntegrationTestSetup::new( - Clock::real(), - temp_dir.path(), - (0..NUM_PARTICIPANTS) - .map(|i| format!("test{}", i).parse().unwrap()) - .collect(), - THRESHOLD, - TXN_DELAY_BLOCKS, - port_seed::DISTINCT_RECONSTRUCTION_THRESHOLDS_TEST, - std::time::Duration::from_millis(600), // helps to avoid flaky test - ); - - let domains = vec![ - sign_domain(0, Protocol::CaitSith, 2), - sign_domain(1, Protocol::Frost, 2), - ckd_domain(2, 2), - sign_domain(3, Protocol::DamgardEtAl, 3), - ]; - - { - let mut contract = setup.indexer.contract_mut().await; - contract.initialize(setup.participants.clone()); - contract.add_domains(domains.clone()); - } - - let _runs = setup - .configs - .into_iter() - .map(|config| AutoAbortTask::from(tokio::spawn(config.run()))) - .collect::>(); - - // When - setup - .indexer - .wait_for_contract_state( - |state| matches!(state, ContractState::Running(_)), - DEFAULT_MAX_PROTOCOL_WAIT_TIME * domains.len() as u32, - ) - .await - .expect("must not exceed timeout"); - - // Then - tracing::info!("requesting signature"); - for domain in &domains { - match Curve::from(domain.protocol) { - Curve::Secp256k1 | Curve::Edwards25519 => { - assert!( - request_signature_and_await_response( - &mut setup.indexer, - &format!("user{}", domain.id.0), - domain, - DEFAULT_MAX_SIGNATURE_WAIT_TIME - ) - .await - .is_some() - ); - } - Curve::Bls12381 => { - assert!( - request_ckd_and_await_response( - &mut setup.indexer, - &format!("user{}", domain.id.0), - domain, - DEFAULT_MAX_SIGNATURE_WAIT_TIME - ) - .await - .is_some() - ); - } - } - } -} - // Make a cluster of four nodes, test that we can generate keyshares // and then produce signatures. #[tokio::test] From cd2210eb28ce8a8f3b7304bab0395edaa0196bbb Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:03:39 +0200 Subject: [PATCH 120/121] no crate call in function --- crates/node/src/providers/ecdsa/triple.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index 030c688129..99c06ce1c2 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -8,8 +8,11 @@ use crate::network::computation::MpcLeaderCentricComputation; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::{ParticipantId, UniqueId}; use crate::protocol::run_protocol; -use crate::providers::HasParticipants; -use crate::providers::ecdsa::{EcdsaSignatureProvider, EcdsaTaskId}; +use crate::providers::{ + HasParticipants, + ecdsa::{EcdsaSignatureProvider, EcdsaTaskId}, + ecdsa_common::active_participants_query, +}; use crate::tracking::AutoAbortTaskCollection; use mpc_node_config::TripleConfig; use mpc_primitives::ReconstructionThreshold; @@ -65,7 +68,7 @@ impl TripleStorage { threshold.inner().to_be_bytes().to_vec(), client.my_participant_id(), |participants, pair| pair.is_subset_of_active_participants(participants), - crate::providers::ecdsa_common::active_participants_query(client), + active_participants_query(client), )?)) } } From a986619b382be8f5d89799ffb55c33e9b3a6d2c1 Mon Sep 17 00:00:00 2001 From: Simon Rastikian <43679791+SimonRastikian@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:29:00 +0200 Subject: [PATCH 121/121] no crate call --- crates/node/src/network.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/node/src/network.rs b/crates/node/src/network.rs index abb7ae0323..98bed2786f 100644 --- a/crates/node/src/network.rs +++ b/crates/node/src/network.rs @@ -815,6 +815,7 @@ pub mod testing { use super::{ ChannelId, MeshNetworkTransportSender, NetworkTaskChannel, NetworkTaskChannelSender, }; + use crate::network::indexer_heights::IndexerHeightTracker; use crate::primitives::{MpcPeerMessage, MpcTaskId, ParticipantId, PeerMessage, UniqueId}; use crate::tracking; use std::collections::{HashMap, HashSet}; @@ -963,9 +964,7 @@ pub mod testing { let channels = Arc::new(std::sync::Mutex::new( super::NetworkTaskChannelManager::new(), )); - let indexer_heights = Arc::new(crate::network::indexer_heights::IndexerHeightTracker::new( - &participants, - )); + let indexer_heights = Arc::new(IndexerHeightTracker::new(&participants)); Arc::new(super::MeshNetworkClient::new( transport, channels,