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 } 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 eb81da0c96..7b3f923f95 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -7,8 +7,9 @@ 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, ReconstructionThreshold, + RunningContractState, }; use near_mpc_crypto_types::Bls12381G1PublicKey; use serde_json::json; @@ -36,6 +37,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. /// @@ -376,6 +378,28 @@ 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 { + 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 @@ -406,6 +430,34 @@ pub async fn send_sign_request( Ok(()) } +/// Sign with every scheme in `running`, asserting each request succeeds. +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 new file mode 100644 index 0000000000..a47570c63f --- /dev/null +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -0,0 +1,50 @@ +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, +}; + +use near_mpc_contract_interface::types::Protocol; +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. +#[tokio::test] +#[expect(non_snake_case)] +async fn distinct_reconstruction_thresholds__should_sign_for_every_scheme() { + // Given + let (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.triples_to_buffer = 2; + c.presignatures_to_buffer = 2; + c.domains + .push(damgard_etal_domain(c.domains.len() as u64, 3)); + }) + .await; + + let ckd_domain = must_get_domain(&contract_state, Protocol::ConfidentialKeyDerivation); + + // When / Then + let mut rng = rand::rngs::StdRng::seed_from_u64(0); + sign_all_schemes(&cluster, &contract_state, &mut rng).await; + + let outcome = cluster + .send_ckd_request( + ckd_domain.id, + 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/e2e-tests/tests/parallel_sign_calls.rs b/crates/e2e-tests/tests/parallel_sign_calls.rs index 7e6b3ac1b4..d538a3dbb9 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] @@ -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 da3a146e0f..64f9326447 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -1,37 +1,33 @@ -use crate::common; - -use mpc_primitives::domain::{Curve, DomainId}; -use near_mpc_contract_interface::types::{ - DomainConfig, DomainPurpose, Protocol, ProtocolContractState, ReconstructionThreshold, +use crate::common::{ + REQUEST_DURING_RESHARING_PORT_SEED, damgard_etal_domain, generate_ckd_app_public_key, + must_get_domain, must_setup_cluster, sign_all_schemes, }; + +use near_mpc_contract_interface::types::{Protocol, ProtocolContractState}; 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 -/// 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. +/// 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 +/// succeed using the previous running state 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| { + must_setup_cluster(REQUEST_DURING_RESHARING_PORT_SEED, |c| { c.num_nodes = 6; c.initial_participant_indices = (0..5).collect(); 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; @@ -46,66 +42,18 @@ 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 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 { - common::generate_eddsa_payload(&mut rng) - } else { - common::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 .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..4a01b6be09 100644 --- a/crates/e2e-tests/tests/request_lifecycle.rs +++ b/crates/e2e-tests/tests/request_lifecycle.rs @@ -1,17 +1,17 @@ -use crate::common; - -use near_mpc_contract_interface::types::{ - Curve, DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold, - SignatureResponse, +use crate::common::{ + 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, DomainPurpose, Protocol, SignatureResponse}; use rand::SeedableRng; #[tokio::test] #[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 +23,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 +62,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,27 +86,17 @@ 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; - 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; }) .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 +104,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 diff --git a/crates/node/src/assets/test_utils.rs b/crates/node/src/assets/test_utils.rs index 2e63aee8f3..2664eedc3c 100644 --- a/crates/node/src/assets/test_utils.rs +++ b/crates/node/src/assets/test_utils.rs @@ -43,8 +43,8 @@ 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 epoch_id = EpochId::new(rand::thread_rng().next_u64()); diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs index d3c855def7..8613a14de1 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -39,7 +39,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; @@ -332,15 +331,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 { @@ -349,7 +345,6 @@ where keyshare_storage, key_event_receiver, chain_txn_sender, - threshold, ) .await?; } @@ -393,12 +388,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, @@ -587,29 +588,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_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", ); @@ -618,20 +625,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={:?}", @@ -643,6 +651,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(), @@ -775,11 +788,16 @@ where None }; - let new_threshold: usize = mpc_config.participants.threshold.try_into()?; + 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/indexer/fake.rs b/crates/node/src/indexer/fake.rs index 10c65e0d05..bdab6055f3 100644 --- a/crates/node/src/indexer/fake.rs +++ b/crates/node/src/indexer/fake.rs @@ -203,6 +203,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) => { @@ -210,6 +220,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 + .get_domain_by_index(0) + .unwrap() + .clone(); self.state = ProtocolContractState::Resharing(ResharingContractState { previous_running_state: RunningContractState::new( previous_running_state.domains.clone(), @@ -220,15 +237,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/indexer/participants.rs b/crates/node/src/indexer/participants.rs index ce9b3c2687..d011fecd36 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(); @@ -427,16 +440,21 @@ pub mod test_utils { #[cfg(test)] #[expect(non_snake_case)] mod tests { + use super::ContractState; use crate::indexer::participants::{ UNREACHABLE_PARTICIPANT_ADDRESS, convert_participant_infos, }; use crate::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![ @@ -600,4 +618,65 @@ mod tests { assert_eq!(entry.address, UNREACHABLE_PARTICIPANT_ADDRESS); assert_eq!(entry.port, 8080); } + + #[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 + ); + } } diff --git a/crates/node/src/key_events.rs b/crates/node/src/key_events.rs index f4cc63485b..55f75cb3d9 100644 --- a/crates/node/src/key_events.rs +++ b/crates/node/src/key_events.rs @@ -15,15 +15,18 @@ use crate::{ }, }; use mpc_primitives::KeyEventId; -use mpc_primitives::domain::Protocol; +use mpc_primitives::ReconstructionThreshold; +use mpc_primitives::domain::{DomainId, Protocol}; use near_mpc_contract_interface::call_args as contract_args; 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::{ - 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; @@ -42,9 +45,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 = + TSReconstructionThreshold::from(usize::try_from(domain.reconstruction_threshold.inner())?); let keyshare_handle = keyshare_storage .write() .await @@ -118,7 +125,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( @@ -128,7 +134,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! { @@ -152,7 +157,9 @@ 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, passed to the + /// resharing protocol as the old-side `t` for each key. + pub old_reconstruction_thresholds: HashMap, pub old_participants: ParticipantsConfig, } @@ -175,6 +182,21 @@ async fn resharing_computation_inner( args: Arc, ) -> anyhow::Result<()> { anyhow::ensure!(key_id.domain_id == domain.id, "Domain mismatch"); + + let new_threshold = + 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(|| { + anyhow::anyhow!( + "No previous reconstruction threshold for domain {:?}", + key_id.domain_id + ) + })? + .inner(), + )?); + let keyshare_handle = keyshare_storage .write() .await @@ -218,7 +240,8 @@ async fn resharing_computation_inner( }) .transpose()?; let res = EcdsaSignatureProvider::run_key_resharing_client( - args.new_threshold, + new_threshold, + old_threshold, my_share, public_key, &args.old_participants, @@ -240,7 +263,8 @@ async fn resharing_computation_inner( }) .transpose()?; let res = RobustEcdsaSignatureProvider::run_key_resharing_client( - args.new_threshold, + new_threshold, + old_threshold, my_share, public_key, &args.old_participants, @@ -261,7 +285,8 @@ async fn resharing_computation_inner( }) .transpose()?; let res = EddsaSignatureProvider::run_key_resharing_client( - args.new_threshold, + new_threshold, + old_threshold, my_share, public_key, &args.old_participants, @@ -279,7 +304,8 @@ async fn resharing_computation_inner( }) .transpose()?; let res = CKDProvider::run_key_resharing_client( - args.new_threshold, + new_threshold, + old_threshold, my_share, public_key, &args.old_participants, @@ -404,7 +430,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 @@ -468,7 +493,6 @@ pub async fn keygen_leader( keyshare_storage.clone(), chain_txn_sender.clone(), key_event_id, - threshold, ) .await { @@ -488,7 +512,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 { @@ -517,7 +540,6 @@ pub async fn keygen_follower( keyshare_storage.clone(), chain_txn_sender.clone(), key_event_id, - threshold, ), ); } @@ -719,7 +741,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)] @@ -889,7 +910,10 @@ 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::from([( + DomainId(1), + ReconstructionThreshold::new(3), + )]), old_participants: ParticipantsConfig { threshold: 3, participants: vec![], diff --git a/crates/node/src/metrics.rs b/crates/node/src/metrics.rs index cd7903f9ec..e90ead3ec2 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", + "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() + }, +); + pub static MPC_TRIPLES_GENERATION_TIME_ELAPSED: LazyLock = LazyLock::new(|| { prometheus::register_histogram!( diff --git a/crates/node/src/p2p.rs b/crates/node/src/p2p.rs index f0f9f85df6..b84b8e5d4c 100644 --- a/crates/node/src/p2p.rs +++ b/crates/node/src/p2p.rs @@ -1017,6 +1017,10 @@ 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 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/providers.rs b/crates/node/src/providers.rs index 3eb654698e..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; @@ -60,6 +61,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_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 39394769b4..306873f119 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -5,13 +5,14 @@ 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::{ ElementG1, KeygenOutput, SigningShare, VerifyingKey, }; -use threshold_signatures::ReconstructionThreshold; +use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use mpc_node_config::ConfigFile; @@ -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: ReconstructionThreshold, } impl CKDProvider { @@ -52,16 +60,35 @@ impl CKDProvider { mpc_config: Arc, client: Arc, ckd_request_store: Arc, - keyshares: HashMap, + 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, - 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)) + } } impl SignatureProvider for CKDProvider { @@ -79,14 +106,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, + new_threshold: TSReconstructionThreshold, + old_threshold: TSReconstructionThreshold, key_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, @@ -94,6 +122,7 @@ impl SignatureProvider for CKDProvider { ) -> anyhow::Result { Self::run_key_resharing_client_internal( new_threshold, + old_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 19d02716d7..fda5ae4ccc 100644 --- a/crates/node/src/providers/ckd/key_resharing.rs +++ b/crates/node/src/providers/ckd/key_resharing.rs @@ -14,16 +14,16 @@ use threshold_signatures::participants::Participant; impl CKDProvider { pub(super) async fn run_key_resharing_client_internal( new_threshold: ReconstructionThreshold, + old_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 new_keyshare = KeyResharingComputation { threshold: new_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_threshold: ReconstructionThreshold::from(old_threshold), + old_threshold, my_share, public_key, } 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..575774bd01 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,11 +47,7 @@ pub struct EcdsaSignatureProvider { per_domain_data: HashMap, } -#[derive(Clone)] -pub(super) struct PerDomainData { - pub keyshare: KeygenOutput, - pub presignature_store: Arc, -} +pub(super) type PerDomainData = ecdsa_common::PerDomainData; impl EcdsaSignatureProvider { pub fn new( @@ -59,46 +57,29 @@ impl EcdsaSignatureProvider { clock: Clock, db: Arc, sign_request_store: Arc, - keyshares: HashMap, + keyshares: HashMap, ) -> anyhow::Result { - let active_participants_query = { - let network_client = client.clone(); - Arc::new(move || network_client.all_alive_participant_ids()) - }; + let per_domain_data = ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares)?; - // 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); + // 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(); - 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 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, - }, + 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(), + ecdsa_common::active_participants_query(&client), + t, + )?), ); } @@ -113,10 +94,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 @@ -201,6 +179,7 @@ impl SignatureProvider for EcdsaSignatureProvider { async fn run_key_resharing_client( new_threshold: TSReconstructionThreshold, + old_threshold: TSReconstructionThreshold, my_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, @@ -208,6 +187,7 @@ impl SignatureProvider for EcdsaSignatureProvider { ) -> anyhow::Result { EcdsaSignatureProvider::run_key_resharing_client_internal( new_threshold, + old_threshold, my_share, public_key, old_participants, @@ -266,47 +246,57 @@ 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)?; + // 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_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, - ), + // 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 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/key_resharing.rs b/crates/node/src/providers/ecdsa/key_resharing.rs index 72b6c92539..ec2f1a4396 100644 --- a/crates/node/src/providers/ecdsa/key_resharing.rs +++ b/crates/node/src/providers/ecdsa/key_resharing.rs @@ -13,16 +13,16 @@ use threshold_signatures::participants::Participant; impl EcdsaSignatureProvider { pub(crate) async fn run_key_resharing_client_internal( new_threshold: ReconstructionThreshold, + old_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 new_keyshare = KeyResharingComputation { threshold: new_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_threshold: ReconstructionThreshold::from(old_threshold), + old_threshold, my_share, public_key, } diff --git a/crates/node/src/providers/ecdsa/presign.rs b/crates/node/src/providers/ecdsa/presign.rs index 6c8bf85fb6..f35f86126b 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -1,21 +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::ReconstructionThreshold; 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; @@ -26,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 @@ -179,11 +150,19 @@ 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()?; + if channel.participants().len() != threshold_usize { + metrics::MPC_NUM_BAD_PEER_PRESIGN_REQUESTS.inc(); + anyhow::bail!( + "CaitSith 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), @@ -203,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/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/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index d810d24a87..d4b0163e98 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -61,6 +61,28 @@ 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 += 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); + 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 +109,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; diff --git a/crates/node/src/providers/ecdsa_common.rs b/crates/node/src/providers/ecdsa_common.rs new file mode 100644 index 0000000000..58a9943028 --- /dev/null +++ b/crates/node/src/providers/ecdsa_common.rs @@ -0,0 +1,219 @@ +//! 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, + client: &Arc, + domain_id: DomainId, + ) -> anyhow::Result { + Ok(Self(DistributedAssetStorage::< + PresignOutputWithParticipants

, + >::new( + clock, + db, + crate::db::DBCol::Presignature, + domain_id.0.to_be_bytes().to_vec(), + client.my_participant_id(), + |participants, presignature| { + presignature.is_subset_of_active_participants(participants) + }, + active_participants_query(client), + )?)) + } +} + +/// 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, +) -> anyhow::Result>> +where + P: Serialize + DeserializeOwned + Send + 'static, +{ + let mut per_domain_data = HashMap::new(); + for (domain_id, (keyshare, reconstruction_threshold)) in keyshares { + let presignature_store = Arc::new(PresignatureStorage::new( + clock.clone(), + db.clone(), + 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)) +} + +#[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(), 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) + .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; + } +} diff --git a/crates/node/src/providers/eddsa.rs b/crates/node/src/providers/eddsa.rs index a4c24d3275..16a2aa53cc 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -15,13 +15,14 @@ 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; 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}; @@ -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: ReconstructionThreshold, } impl EddsaSignatureProvider { @@ -41,16 +50,35 @@ impl EddsaSignatureProvider { mpc_config: Arc, client: Arc, sign_request_store: Arc, - keyshares: HashMap, + 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, - 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)) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, BorshSerialize, BorshDeserialize)] @@ -84,14 +112,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, + new_threshold: TSReconstructionThreshold, + old_threshold: TSReconstructionThreshold, key_share: Option, public_key: VerifyingKey, old_participants: &ParticipantsConfig, @@ -99,6 +128,7 @@ impl SignatureProvider for EddsaSignatureProvider { ) -> anyhow::Result { Self::run_key_resharing_client_internal( new_threshold, + old_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 6f40e55559..bc35b5f4c6 100644 --- a/crates/node/src/providers/eddsa/key_resharing.rs +++ b/crates/node/src/providers/eddsa/key_resharing.rs @@ -14,16 +14,16 @@ use threshold_signatures::participants::Participant; impl EddsaSignatureProvider { pub(super) async fn run_key_resharing_client_internal( new_threshold: ReconstructionThreshold, + old_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 new_keyshare = KeyResharingComputation { threshold: new_threshold, old_participants: old_participants.participants.iter().map(|p| p.id).collect(), - old_threshold: ReconstructionThreshold::from(old_threshold), + old_threshold, my_share, public_key, } 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..3b80fee659 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; @@ -17,13 +18,15 @@ use mpc_node_config::ConfigFile; use crate::types::SignatureId; use borsh::{BorshDeserialize, BorshSerialize}; +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; +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,11 +38,7 @@ pub struct RobustEcdsaSignatureProvider { per_domain_data: HashMap, } -#[derive(Clone)] -pub(super) struct PerDomainData { - pub keyshare: KeygenOutput, - pub presignature_store: Arc, -} +pub(super) type PerDomainData = ecdsa_common::PerDomainData; #[derive( Debug, Copy, Clone, Eq, Ord, PartialEq, PartialOrd, derive_more::From, derive_more::Into, @@ -60,30 +59,9 @@ impl RobustEcdsaSignatureProvider { clock: Clock, db: Arc, sign_request_store: Arc, - keyshares: HashMap, + keyshares: 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 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, - }, - ); - } + let per_domain_data = ecdsa_common::build_per_domain_data(&clock, &db, &client, keyshares)?; Ok(Self { config, @@ -95,10 +73,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) } } @@ -144,47 +119,27 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { } async fn run_key_generation_client( - threshold: ReconstructionThreshold, + threshold: TSReconstructionThreshold, 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 + EcdsaSignatureProvider::run_key_generation_client_internal(threshold, channel).await } async fn run_key_resharing_client( - new_threshold: ReconstructionThreshold, + new_threshold: TSReconstructionThreshold, + old_threshold: TSReconstructionThreshold, my_share: Option, public_key: VerifyingKey, 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()?; - + // For robust-ECDSA the reconstruction lower bound equals `t`, so resharing is identical to cait-sith. EcdsaSignatureProvider::run_key_resharing_client_internal( - ReconstructionThreshold::try_from(new_robust_ecdsa_threshold)?, + new_threshold, + old_threshold, my_share, public_key, - &old_participants_patched, + old_participants, channel, ) .await @@ -236,6 +191,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(), @@ -247,7 +203,7 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { for Err(join_error) in futures::future::join_all(generate_presignatures).await { tracing::error!( - "robust-ecdsa background presignature task ended unexpectedly: {join_error}" + "Damgard et al background presignature task ended unexpectedly: {join_error}" ); } @@ -255,105 +211,66 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { } } -/// 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 { +/// 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!( - threshold <= number_of_participants, - "threshold ({threshold}) exceeds number of participants ({number_of_participants})" + t >= 2, + "robust-ECDSA requires a reconstruction threshold of at least 2, got {t}" ); - 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)) + 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::*; - use rstest::rstest; + use super::compute_thresholds; + use mpc_primitives::ReconstructionThreshold; + use threshold_signatures::MaxMalicious; - // 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}" - ); - } - } + 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); } - // 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}" - ); - } + 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); } } - #[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)); + #[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 c16f91d253..d4f31f7b6f 100644 --- a/crates/node/src/providers/robust_ecdsa/presign.rs +++ b/crates/node/src/providers/robust_ecdsa/presign.rs @@ -1,24 +1,20 @@ -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, get_number_of_signers, - translate_threshold, + 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 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 @@ -66,6 +37,7 @@ impl PresignatureStorage { pub(super) async fn run_background_presignature_generation( client: Arc, mpc_config: Arc, + threshold: ReconstructionThreshold, config: Arc, domain_id: DomainId, presignature_store: Arc, @@ -87,11 +59,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, damgard_et_al_threshold) = + compute_thresholds(threshold).expect("contract validation guarantees a valid threshold"); loop { progress_tracker.update_progress(); @@ -149,7 +118,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( @@ -180,30 +149,6 @@ 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, -) -> 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)?; - anyhow::ensure!( - robust_ecdsa_threshold - .value() - .checked_mul(2) - .and_then(|v| v.checked_add(1)) - .is_some_and(|v| v <= num_signers) - ); - Ok((num_signers, robust_ecdsa_threshold)) -} - impl RobustEcdsaSignatureProvider { pub(super) async fn run_presignature_generation_follower( &self, @@ -214,12 +159,11 @@ 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, 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, @@ -234,20 +178,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 { @@ -336,50 +266,3 @@ impl PresignatureGenerationProgressTracker { )) } } - -#[cfg(test)] -#[expect(non_snake_case)] -mod tests { - use super::compute_thresholds; - - #[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; - - // When - let result = compute_thresholds(governance_threshold, num_participants); - - // Then - let (num_signers, robust_ecdsa_threshold) = result.unwrap(); - assert_eq!(num_signers, 5); - assert!(2 * robust_ecdsa_threshold.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(); - } - - #[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(); - } -} diff --git a/crates/node/src/providers/robust_ecdsa/sign.rs b/crates/node/src/providers/robust_ecdsa/sign.rs index e5440cdcf4..7892fa39ab 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, translate_threshold, + RobustEcdsaTaskId, 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,9 +37,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, damgard_et_al_threshold) = + compute_thresholds(domain_data.reconstruction_threshold)?; let msg_hash = *sign_request .payload @@ -49,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, @@ -91,9 +89,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, damgard_et_al_threshold) = + compute_thresholds(domain_data.reconstruction_threshold)?; let msg_hash = *sign_request .payload @@ -103,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(), diff --git a/crates/node/src/providers/verify_foreign_tx.rs b/crates/node/src/providers/verify_foreign_tx.rs index 68bcc89d09..e8f5d29866 100644 --- a/crates/node/src/providers/verify_foreign_tx.rs +++ b/crates/node/src/providers/verify_foreign_tx.rs @@ -197,6 +197,7 @@ where async fn run_key_resharing_client( _new_threshold: ReconstructionThreshold, + _old_threshold: ReconstructionThreshold, _key_share: Option, _public_key: VerifyingKey, _old_participants: &ParticipantsConfig, diff --git a/crates/node/src/tests.rs b/crates/node/src/tests.rs index b081fb4532..ee06edaf8a 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/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 eca3136a83..4b703b0e77 100644 --- a/crates/node/src/tests/multidomain.rs +++ b/crates/node/src/tests/multidomain.rs @@ -1,16 +1,102 @@ 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). 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, + PortSeed::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] @@ -32,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), ]; { @@ -107,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), ]; { diff --git a/crates/node/src/tests/reconstruction_thresholds.rs b/crates/node/src/tests/reconstruction_thresholds.rs new file mode 100644 index 0000000000..7d875dd365 --- /dev/null +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -0,0 +1,303 @@ +//! 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, 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( + 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; +} + +/// 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; +} diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index ea58d0b5e7..513096b781 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -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 {