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 3deed9da77..cf782a326c 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,7 +37,8 @@ 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 UPDATE_PARTICIPANT_URL_PORT_SEED: u16 = 23; +pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED: u16 = 23; +pub const UPDATE_PARTICIPANT_URL_PORT_SEED: u16 = 24; /// Start a cluster, wait for Running state and presignatures to buffer. /// @@ -377,6 +379,38 @@ 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, + } +} + +/// Builds a `ConfidentialKeyDerivation` (CKD) domain with reconstruction threshold `t`, which needs `t` signers. +pub fn ckd_domain(id: u64, t: u64) -> DomainConfig { + DomainConfig { + id: DomainId(id), + protocol: Protocol::ConfidentialKeyDerivation, + reconstruction_threshold: ReconstructionThreshold::new(t), + purpose: DomainPurpose::CKD, + } +} + +/// Returns the first domain running `protocol_type` (the registry allows +/// duplicates), panicking if absent. +pub fn must_get_domain(running: &RunningContractState, protocol_type: Protocol) -> DomainConfig { + 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 @@ -407,6 +441,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..93d15324ad --- /dev/null +++ b/crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs @@ -0,0 +1,125 @@ +use crate::common::{ + DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, ckd_domain, damgard_etal_domain, + generate_ckd_app_public_key, generate_ecdsa_payload, generate_eddsa_payload, must_get_domain, + must_setup_cluster, wait_metric_on_nodes, +}; + +use e2e_tests::{CLUSTER_WAIT_TIMEOUT, metrics}; +use near_mpc_contract_interface::types::{ + DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold, +}; +use rand::SeedableRng; + +/// Each domain signs under its own reconstruction threshold, not the governance +/// threshold. With 6 nodes and 1 killed, Cait-Sith (needs all 6) can no longer sign +/// while Damgard et al. (`2t - 1 = 5`), CKD (`t = 5`) and Frost (`t = 5`) still can. +#[tokio::test] +#[expect(non_snake_case)] +async fn distinct_reconstruction_thresholds__should_use_per_domain_threshold_when_nodes_are_down() { + // Given + let mut rng = rand::rngs::StdRng::seed_from_u64(0); + let (mut cluster, contract_state) = + must_setup_cluster(DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, |c| { + c.num_nodes = 6; + c.initial_participant_indices = (0..6).collect(); + c.threshold = 6; + c.triples_to_buffer = 2; + c.presignatures_to_buffer = 2; + c.domains = vec![ + DomainConfig { + id: DomainId(0), + protocol: Protocol::CaitSith, + reconstruction_threshold: ReconstructionThreshold::new(6), + purpose: DomainPurpose::Sign, + }, + damgard_etal_domain(1, 3), + ckd_domain(2, 5), + DomainConfig { + id: DomainId(3), + protocol: Protocol::Frost, + reconstruction_threshold: ReconstructionThreshold::new(5), + purpose: DomainPurpose::Sign, + }, + ]; + }) + .await; + + let caitsith_domain = must_get_domain(&contract_state, Protocol::CaitSith); + let damgard_domain = must_get_domain(&contract_state, Protocol::DamgardEtAl); + let ckd_domain = must_get_domain(&contract_state, Protocol::ConfidentialKeyDerivation); + let frost_domain = must_get_domain(&contract_state, Protocol::Frost); + + // When + cluster.kill_nodes(&[5]).expect("failed to kill node 5"); + + // Then Damgard et al. (needs 5 signers) still signs. + let outcome = cluster + .send_sign_request( + damgard_domain.id, + generate_ecdsa_payload(&mut rng), + cluster.default_user_account(), + ) + .await + .expect("failed to submit Damgard et al. sign request"); + assert!( + outcome.is_success(), + "Damgard et al. sign request failed with 5 of 6 nodes alive: {:?}", + outcome.failure_message() + ); + + // And CKD (its own `t = 5`, not the governance threshold of 6) still derives. + let outcome = cluster + .send_ckd_request( + ckd_domain.id, + generate_ckd_app_public_key(&mut rng), + cluster.default_user_account(), + ) + .await + .expect("failed to submit CKD request"); + assert!( + outcome.is_success(), + "CKD request failed with 5 of its 5 required signers alive: {:?}", + outcome.failure_message() + ); + + // And Frost (its own `t = 5`) still signs. + let outcome = cluster + .send_sign_request( + frost_domain.id, + generate_eddsa_payload(&mut rng), + cluster.default_user_account(), + ) + .await + .expect("failed to submit Frost sign request"); + assert!( + outcome.is_success(), + "Frost sign request failed with 5 of its 5 required signers alive: {:?}", + outcome.failure_message() + ); + + // And Cait-Sith (needs all 6) is unanswerable. Its request never resolves on + // chain, and the yield auto-timeout outlives the JSON-RPC call, so we race the + // doomed request against the surviving nodes' timeout counter rather than + // awaiting it (see `timeout_metric.rs`). + tokio::select! { + res = wait_metric_on_nodes( + &cluster, + &[0, 1, 2, 3, 4], + metrics::TIMEOUTS_INDEXED, + |v| v >= 1, + CLUSTER_WAIT_TIMEOUT, + ) => res.unwrap_or_else(|_| panic!( + "{} did not reach 1 on the surviving nodes — Cait-Sith request was answered \ + despite only 5 of its 6 required signers being alive", + metrics::TIMEOUTS_INDEXED + )), + _ = cluster.send_sign_request( + caitsith_domain.id, + generate_ecdsa_payload(&mut rng), + cluster.default_user_account(), + ) => panic!( + "Cait-Sith sign request returned before the timeout metric — it should be \ + unanswerable with only 5 of 6 required signers alive" + ), + } +} diff --git a/crates/e2e-tests/tests/e2e.rs b/crates/e2e-tests/tests/e2e.rs index 026be84d94..6ea1a76866 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 9459a53875..6f145be62c 100644 --- a/crates/e2e-tests/tests/request_during_resharing.rs +++ b/crates/e2e-tests/tests/request_during_resharing.rs @@ -1,49 +1,33 @@ -use crate::common; - -use mpc_primitives::domain::DomainId; -use near_mpc_contract_interface::types::{ - DomainConfig, DomainPurpose, Protocol, ProtocolContractState, ReconstructionThreshold, - RunningContractState, +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 rand::SeedableRng; +use near_mpc_contract_interface::types::{Protocol, ProtocolContractState}; -/// Panics if no domain matches; each protocol appears at most once in this test's domain set. -fn find_domain_id(contract_state: &RunningContractState, protocol_type: Protocol) -> DomainId { - contract_state - .domains - .domains - .iter() - .find(|d| d.protocol == protocol_type) - .unwrap_or_else(|| panic!("no domain with protocol {protocol_type:?}")) - .id -} +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; @@ -58,40 +42,18 @@ async fn test_request_during_resharing() { cluster.kill_nodes(&[5]).expect("failed to kill node 5"); // then - let ecdsa_domain_id = find_domain_id(&contract_state, Protocol::CaitSith); - let robust_ecdsa_domain_id = find_domain_id(&contract_state, Protocol::DamgardEtAl); - let eddsa_domain_id = find_domain_id(&contract_state, Protocol::Frost); - let ckd_domain_id = find_domain_id(&contract_state, Protocol::ConfidentialKeyDerivation); + 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), + ckd_domain.id, + 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/cleanup.rs b/crates/node/src/assets/cleanup.rs index 31e7b26a9c..476adabd36 100644 --- a/crates/node/src/assets/cleanup.rs +++ b/crates/node/src/assets/cleanup.rs @@ -6,6 +6,7 @@ use crate::providers::ecdsa::presign::PresignOutputWithParticipants; use crate::providers::ecdsa::triple::PairedTriple; use mpc_primitives::{EpochId, ReconstructionThreshold, domain::DomainId}; use serde::{self, Deserialize, Serialize}; +use std::collections::BTreeSet; use std::sync::Arc; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -23,7 +24,7 @@ pub fn delete_stale_triples_and_presignatures( current_epoch_data: EpochData, my_participant_id: primitives::ParticipantId, ecdsa_domain_ds: Vec, - triple_thresholds: Vec, + triple_thresholds: BTreeSet, ) -> anyhow::Result<()> { let asset_cleanup: AssetCleanup = match get_epoch_data(db)? { None => AssetCleanup::Keep, @@ -138,10 +139,12 @@ mod tests { use crate::assets::test_utils::triple_v2_key; use crate::db::EPOCH_ID_KEY; use crate::db::{DBCol, SecretDB}; + use crate::network::testing::new_test_client; use crate::primitives::UniqueId; use crate::providers::ecdsa::triple::TripleStorage; use mpc_primitives::domain::DomainId; use near_time::FakeClock; + use std::collections::BTreeSet; use std::sync::{Arc, Mutex}; fn assert_epoch_data_in_db_matches(ctx: &TestContext, expected: &EpochData) { @@ -187,7 +190,7 @@ mod tests { start_data.clone(), my_participant_id, [DomainId(0), DomainId(1)].to_vec(), - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); ctx.assert_owned(2); @@ -205,7 +208,7 @@ mod tests { start_data.clone(), my_participant_id, [DomainId(0), DomainId(1)].to_vec(), - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); ctx.assert_owned(1); @@ -219,7 +222,7 @@ mod tests { end_data.clone(), my_participant_id, [DomainId(0), DomainId(1)].to_vec(), - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); @@ -237,20 +240,12 @@ mod tests { // Given a node has written a stale triple via TripleStorage. let (mut start_data, my_participant_id, threshold) = test_utils::gen_four_participants(); let all_participants = get_participant_ids(start_data.clone()); - let alive_participants = Arc::new(Mutex::new(all_participants.clone())); + let client = new_test_client(all_participants.clone(), my_participant_id); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); - let triple_store = TripleStorage::new( - FakeClock::default().clock(), - db.clone(), - my_participant_id, - { - let alive = alive_participants.clone(); - Arc::new(move || alive.lock().unwrap().clone()) - }, - threshold, - ) - .unwrap(); + let triple_store = + TripleStorage::new(FakeClock::default().clock(), db.clone(), &client, threshold) + .unwrap(); let id = triple_store.generate_and_reserve_id(); triple_store.add_owned(id, make_triple(&all_participants)); let v2_key = triple_v2_key(threshold, id); @@ -264,7 +259,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); assert!(db.get(DBCol::TripleV2, &v2_key).unwrap().is_some()); @@ -283,7 +278,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); @@ -305,20 +300,12 @@ mod tests { .take(all_participants.len() - 1) .copied() .collect(); - let alive_participants = Arc::new(Mutex::new(all_participants.clone())); + let client = new_test_client(all_participants.clone(), my_participant_id); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); - let triple_store = TripleStorage::new( - FakeClock::default().clock(), - db.clone(), - my_participant_id, - { - let alive = alive_participants.clone(); - Arc::new(move || alive.lock().unwrap().clone()) - }, - threshold, - ) - .unwrap(); + let triple_store = + TripleStorage::new(FakeClock::default().clock(), db.clone(), &client, threshold) + .unwrap(); let id = triple_store.generate_and_reserve_id(); triple_store.add_owned(id, make_triple(&active_subset)); let v2_key = triple_v2_key(threshold, id); @@ -330,7 +317,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); start_data @@ -344,7 +331,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); @@ -363,20 +350,12 @@ mod tests { .iter() .find(|p| **p != my_participant_id) .unwrap(); - let alive_participants = Arc::new(Mutex::new(all_participants.clone())); + let client = new_test_client(all_participants.clone(), my_participant_id); let dir = tempfile::tempdir().unwrap(); let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); - let triple_store = TripleStorage::new( - FakeClock::default().clock(), - db.clone(), - my_participant_id, - { - let alive = alive_participants.clone(); - Arc::new(move || alive.lock().unwrap().clone()) - }, - threshold, - ) - .unwrap(); + let triple_store = + TripleStorage::new(FakeClock::default().clock(), db.clone(), &client, threshold) + .unwrap(); // Even an outright-stale peer-owned triple stays put — the peer cleans // its own. let peer_id = UniqueId::new(peer, 100, 0); @@ -388,7 +367,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); start_data @@ -402,7 +381,7 @@ mod tests { start_data.clone(), my_participant_id, vec![DomainId(0)], - vec![threshold], + BTreeSet::from([threshold]), ) .unwrap(); 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 6ba2edf86d..5f5d8037d0 100644 --- a/crates/node/src/coordinator.rs +++ b/crates/node/src/coordinator.rs @@ -20,10 +20,11 @@ use crate::network::{ use crate::p2p::{new_tls_mesh_network, new_tls_mesh_network_with_address_updates}; use crate::primitives::{MpcTaskId, ParticipantId}; use crate::providers::ckd::CKDProvider; +use crate::providers::ecdsa::triple; use crate::providers::eddsa::{EddsaSignatureProvider, EddsaTaskId}; use crate::providers::robust_ecdsa::RobustEcdsaSignatureProvider; use crate::providers::verify_foreign_tx::VerifyForeignTxProvider; -use crate::providers::{EcdsaSignatureProvider, EcdsaTaskId}; +use crate::providers::{DomainKeyshare, EcdsaSignatureProvider, EcdsaTaskId}; use crate::runtime::{AsyncDroppableRuntime, build_lower_priority_runtime}; use crate::storage::SignRequestStorage; use crate::storage::{CKDRequestStorage, VerifyForeignTransactionRequestStorage}; @@ -41,7 +42,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; @@ -336,15 +336,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 { @@ -353,7 +350,6 @@ where keyshare_storage, key_event_receiver, chain_txn_sender, - threshold, ) .await?; } @@ -398,12 +394,7 @@ 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, - )]; + let triple_thresholds = triple::caitsith_triple_thresholds(&running_state.domains); delete_stale_triples_and_presignatures( &secret_db, current_epoch_data, @@ -588,29 +579,32 @@ where let mut ecdsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - ecdsa::KeygenOutput, + DomainKeyshare, > = HashMap::new(); let mut robust_ecdsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - ecdsa::KeygenOutput, + DomainKeyshare, > = HashMap::new(); let mut eddsa_keyshares: HashMap< mpc_primitives::domain::DomainId, - eddsa::KeygenOutput, + DomainKeyshare, > = HashMap::new(); let mut ckd_keyshares: HashMap< mpc_primitives::domain::DomainId, - confidential_key_derivation::KeygenOutput, + DomainKeyshare, > = 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", ); @@ -619,20 +613,32 @@ 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, + DomainKeyshare::new(data, reconstruction_threshold), + ); } Protocol::DamgardEtAl => { - robust_ecdsa_keyshares.insert(domain_id, data); + robust_ecdsa_keyshares.insert( + domain_id, + DomainKeyshare::new(data, reconstruction_threshold), + ); } other => anyhow::bail!( "Unexpected protocol {other:?} for Secp256k1 keyshare on domain {domain_id:?}", ), }, (Curve::Edwards25519, KeyshareData::Ed25519(data)) => { - eddsa_keyshares.insert(domain_id, data); + eddsa_keyshares.insert( + domain_id, + DomainKeyshare::new(data, reconstruction_threshold), + ); } (Curve::Bls12381, KeyshareData::Bls12381(data)) => { - ckd_keyshares.insert(domain_id, data); + ckd_keyshares.insert( + domain_id, + DomainKeyshare::new(data, reconstruction_threshold), + ); } (expected, data) => anyhow::bail!( "Keyshare data does not match the domain protocol's expected curve: domain_id={:?}, protocol={:?}, expected_curve={:?}, data_kind={:?}", @@ -644,6 +650,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(), @@ -776,11 +787,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 17385cc948..8638607e7c 100644 --- a/crates/node/src/indexer/fake.rs +++ b/crates/node/src/indexer/fake.rs @@ -305,6 +305,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) => { @@ -312,6 +322,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(), @@ -322,15 +339,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 59f08f741f..a662b1b793 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; @@ -26,11 +26,21 @@ pub fn convert_key_event_to_instance( key_event: &KeyEvent, current_height: u64, completed_domains: Vec, + per_domain_thresholds: &BTreeMap, ) -> anyhow::Result { let completed_domains: Vec = completed_domains .into_iter() .map(TryInto::try_into) .collect::>()?; + + // `per_domain_thresholds` holds only *changed* thresholds; a domain with no + // entry (and the initializing path's empty map) keeps its key event's own. + let mut domain = key_event.domain.clone(); + domain.reconstruction_threshold = per_domain_thresholds + .get(&domain.id) + .copied() + .unwrap_or(domain.reconstruction_threshold); + Ok(match &key_event.instance { Some(current_instance) if current_height < current_instance.expires_on => { ContractKeyEventInstance { @@ -39,7 +49,7 @@ pub fn convert_key_event_to_instance( domain_id: key_event.domain.id, attempt_id: current_instance.attempt_id, }, - domain: key_event.domain.clone(), + domain, started: true, completed: current_instance .completed @@ -55,7 +65,7 @@ pub fn convert_key_event_to_instance( domain_id: key_event.domain.id, attempt_id: key_event.next_attempt_id, }, - domain: key_event.domain.clone(), + domain, started: false, completed: BTreeSet::new(), completed_domains, @@ -123,6 +133,8 @@ pub struct ContractResharingState { pub new_participants: ParticipantsConfig, pub reshared_keys: ContractKeyset, pub key_event: ContractKeyEventInstance, + /// Per-domain threshold updates from the accepted proposal. + pub per_domain_thresholds: BTreeMap, } /// A stripped-down version of the contract state, containing only the state @@ -186,6 +198,7 @@ impl ContractState { &state.generating_key, height, state.generated_keys.clone(), + &BTreeMap::new(), ) .context("failed to convert initializing key event")?, }) @@ -202,6 +215,14 @@ impl ContractState { }) } ProtocolContractState::Resharing(state) => { + let key_event = convert_key_event_to_instance( + &state.resharing_key, + height, + state.reshared_keys.clone(), + &state.per_domain_thresholds, + ) + .context("failed to convert resharing key event")?; + let resharing_state = Some(ContractResharingState { new_participants: convert_participant_infos( state.resharing_key.parameters.clone(), @@ -211,12 +232,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(); @@ -431,7 +448,7 @@ pub mod test_utils { use near_mpc_crypto_types::Keyset; use rand::SeedableRng; use rand::rngs::StdRng; - use std::collections::BTreeSet; + use std::collections::{BTreeMap, BTreeSet}; use super::{ ContractKeyEventInstance, ContractResharingState, ContractRunningState, ContractState, @@ -512,6 +529,7 @@ pub mod test_utils { participants, resharing_state: Some(ContractResharingState { new_participants, + per_domain_thresholds: BTreeMap::new(), reshared_keys: Keyset::new(EpochId::new(epoch), vec![]), key_event: ContractKeyEventInstance { id: KeyEventId::new(EpochId::new(epoch), DomainId(0), AttemptId::new()), @@ -527,16 +545,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}; use super::test_utils::{base_config, participant, resharing}; @@ -703,6 +726,67 @@ mod tests { 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 threshold. + let (_, mut resharing) = gen_resharing_state(1); + let domain_id = resharing.resharing_key.domain().id; + let old_threshold = resharing.resharing_key.domain().reconstruction_threshold; + 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 threshold, the update is retained, + // and the previous registry (old side of the reshare) keeps the old threshold. + let ContractState::Running(running) = state else { + panic!("resharing maps to a running state with resharing_state populated"); + }; + 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 + ); + } + #[test] fn mesh_participants__should_return_new_participants_during_resharing() { // Given 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 31da47e7a1..55f67900b3 100644 --- a/crates/node/src/metrics.rs +++ b/crates/node/src/metrics.rs @@ -14,6 +14,17 @@ pub static MPC_NUM_TRIPLES_GENERATED: LazyLock = LazyLoc .unwrap() }); +pub static MPC_NUM_BAD_PEER_PRESIGN_REQUESTS: LazyLock = + LazyLock::new(|| { + prometheus::register_int_counter_vec!( + "mpc_num_bad_peer_presign_requests", + "Presignature requests from a peer whose participant-set size did not \ + match the domain's reconstruction threshold", + &["domain_id"] + ) + .unwrap() + }); + pub static MPC_TRIPLES_GENERATION_TIME_ELAPSED: LazyLock = LazyLock::new(|| { prometheus::register_histogram!( diff --git a/crates/node/src/network.rs b/crates/node/src/network.rs index cccdd5d031..98bed2786f 100644 --- a/crates/node/src/network.rs +++ b/crates/node/src/network.rs @@ -815,6 +815,7 @@ pub mod testing { use super::{ ChannelId, MeshNetworkTransportSender, NetworkTaskChannel, NetworkTaskChannelSender, }; + use crate::network::indexer_heights::IndexerHeightTracker; use crate::primitives::{MpcPeerMessage, MpcTaskId, ParticipantId, PeerMessage, UniqueId}; use crate::tracking; use std::collections::{HashMap, HashSet}; @@ -948,6 +949,29 @@ pub mod testing { transports } + /// Synchronous [`MeshNetworkClient`] for unit tests. All participants are reported alive. + pub fn new_test_client( + participants: Vec, + my_participant_id: ParticipantId, + ) -> Arc { + let transport = Arc::new(TestMeshTransportSender { + transport: Arc::new(TestMeshTransport { + participant_ids: participants.clone(), + senders: HashMap::new(), + }), + my_participant_id, + }); + let channels = Arc::new(std::sync::Mutex::new( + super::NetworkTaskChannelManager::new(), + )); + let indexer_heights = Arc::new(IndexerHeightTracker::new(&participants)); + Arc::new(super::MeshNetworkClient::new( + transport, + channels, + indexer_heights, + )) + } + /// Builds a channel over the given participant set, returning the raw inbound sender so /// tests can inject arbitrary `MpcPeerMessage`s, including ones from outside the set. pub fn new_task_channel_for_test( diff --git a/crates/node/src/p2p.rs b/crates/node/src/p2p.rs index 4e03da4998..8ce9fd1d87 100644 --- a/crates/node/src/p2p.rs +++ b/crates/node/src/p2p.rs @@ -1006,6 +1006,10 @@ pub mod testing { pub const ASSET_GENERATION_SIGNING_CONTENTION_TEST: TestPorts = TestPorts::mpc_node_tests(22); pub const UPDATE_PARTICIPANT_URL_TEST: TestPorts = TestPorts::mpc_node_tests(23); + pub const RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST: TestPorts = + TestPorts::mpc_node_tests(24); + pub const RECONSTRUCTION_THRESHOLD_RESHARING_TEST: TestPorts = + TestPorts::mpc_node_tests(25); } pub fn generate_test_p2p_configs( diff --git a/crates/node/src/providers.rs b/crates/node/src/providers.rs index 3eb654698e..07d926d3c7 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; @@ -21,7 +22,27 @@ pub use ecdsa::EcdsaSignatureProvider; pub use ecdsa::EcdsaTaskId; pub use robust_ecdsa::RobustEcdsaSignatureProvider; use std::sync::Arc; -use threshold_signatures::ReconstructionThreshold; +use threshold_signatures::{Ciphersuite, KeygenOutput, ReconstructionThreshold}; + +/// A domain's keygen output paired with its reconstruction threshold `t`, the per-domain material +/// the coordinator hands to each signature provider. +#[derive(Clone)] +pub struct DomainKeyshare { + pub keygen_output: KeygenOutput, + pub reconstruction_threshold: mpc_primitives::ReconstructionThreshold, +} + +impl DomainKeyshare { + pub fn new( + keygen_output: KeygenOutput, + reconstruction_threshold: mpc_primitives::ReconstructionThreshold, + ) -> Self { + Self { + keygen_output, + reconstruction_threshold, + } + } +} /// The interface that defines the requirements for a signing schema to be correctly used in the code. pub trait SignatureProvider { @@ -60,6 +81,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..329f871e0b 100644 --- a/crates/node/src/providers/ckd.rs +++ b/crates/node/src/providers/ckd.rs @@ -8,10 +8,10 @@ use borsh::{BorshDeserialize, BorshSerialize}; use mpc_primitives::domain::DomainId; use near_mpc_contract_interface::types::KeyEventId; use threshold_signatures::confidential_key_derivation::{ - ElementG1, KeygenOutput, SigningShare, VerifyingKey, + BLS12381SHA256, ElementG1, KeygenOutput, SigningShare, VerifyingKey, }; -use threshold_signatures::ReconstructionThreshold; +use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use mpc_node_config::ConfigFile; @@ -19,7 +19,7 @@ use crate::{ config::{MpcConfig, ParticipantsConfig}, network::{MeshNetworkClient, NetworkTaskChannel}, primitives::MpcTaskId, - providers::SignatureProvider, + providers::{DomainKeyshare, SignatureProvider}, storage::CKDRequestStorage, types::{CKDId, SignatureId}, }; @@ -43,16 +43,18 @@ pub struct CKDProvider { mpc_config: Arc, client: Arc, ckd_request_store: Arc, - keyshares: HashMap, + keyshares: HashMap, } +pub(super) type CkdKeyshare = DomainKeyshare; + impl CKDProvider { pub fn new( config: Arc, mpc_config: Arc, client: Arc, ckd_request_store: Arc, - keyshares: HashMap, + keyshares: HashMap, ) -> Self { Self { config, @@ -62,6 +64,13 @@ impl CKDProvider { keyshares, } } + + pub(super) fn keyshare(&self, domain_id: DomainId) -> anyhow::Result { + self.keyshares + .get(&domain_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) + } } impl SignatureProvider for CKDProvider { @@ -79,14 +88,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 +104,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..859d9b107f 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 keyshare = self.keyshare(ckd_request.domain_id)?; + let threshold: usize = keyshare.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let running_participants: Vec<_> = self .mpc_config @@ -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 = keyshare.keygen_output; 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 keyshare = self.keyshare(ckd_request.domain_id)?; let participants = channel.participants().to_vec(); CKDComputation { - keygen_output: keygen_output.clone(), + keygen_output: keyshare.keygen_output, app_public_key: ckd_request.app_public_key, app_id: ckd_request.app_id, } diff --git a/crates/node/src/providers/ecdsa.rs b/crates/node/src/providers/ecdsa.rs index 2f749a49b0..78dd33568e 100644 --- a/crates/node/src/providers/ecdsa.rs +++ b/crates/node/src/providers/ecdsa.rs @@ -15,7 +15,9 @@ use crate::db::SecretDB; use crate::metrics::tokio_task_metrics::ECDSA_TASK_MONITORS; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::{MpcTaskId, ParticipantId, UniqueId}; +use crate::providers::DomainKeyshare; use crate::providers::SignatureProvider; +use crate::providers::ecdsa_common; use crate::storage::SignRequestStorage; use crate::tracking; use mpc_node_config::ConfigFile; @@ -27,8 +29,8 @@ use mpc_primitives::domain::DomainId; use near_time::Clock; use std::sync::Arc; use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; -use threshold_signatures::ecdsa::KeygenOutput; -use threshold_signatures::ecdsa::Signature; +use threshold_signatures::ecdsa::ot_based_ecdsa::PresignOutput; +use threshold_signatures::ecdsa::{KeygenOutput, Secp256K1Sha256, Signature}; use threshold_signatures::frost_secp256k1::VerifyingKey; use threshold_signatures::frost_secp256k1::keys::SigningShare; @@ -42,14 +44,10 @@ pub struct EcdsaSignatureProvider { /// `t`s is known up front and no on-demand creation is needed. triple_stores: HashMap>, sign_request_store: Arc, - per_domain_data: HashMap, + keyshares: HashMap, } -#[derive(Clone)] -pub(super) struct PerDomainData { - pub keyshare: KeygenOutput, - pub presignature_store: Arc, -} +pub(super) type EcdsaKeyshare = ecdsa_common::EcdsaKeyshare; impl EcdsaSignatureProvider { pub fn new( @@ -59,46 +57,17 @@ 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 keyshares = ecdsa_common::build_keyshares(&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 keep one store per distinct reconstruction threshold. let mut triple_stores = HashMap::new(); - triple_stores.insert( - network_threshold, - Arc::new(TripleStorage::new( - clock.clone(), - db.clone(), - client.my_participant_id(), - active_participants_query.clone(), - network_threshold, - )?), - ); - - let mut per_domain_data = HashMap::new(); - for (domain_id, keyshare) in keyshares { - let 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 t in triple::distinct_thresholds(keyshares.values().map(|d| d.reconstruction_threshold)) + { + triple_stores.insert( + t, + Arc::new(TripleStorage::new(clock.clone(), db.clone(), &client, t)?), ); } @@ -108,15 +77,12 @@ impl EcdsaSignatureProvider { client, triple_stores, sign_request_store, - per_domain_data, + keyshares, }) } - 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)) + pub(super) fn keyshare(&self, domain_id: DomainId) -> anyhow::Result { + ecdsa_common::lookup_keyshare(&self.keyshares, domain_id) } /// Returns the triple store for `t`, or an error if no store was @@ -201,6 +167,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 +175,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 +234,52 @@ 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.keyshares { + let triple_store = self.triple_store_for_t(data.reconstruction_threshold)?; + generate_presignatures.push(tracking::spawn( + &format!("generate presignatures for domain {}", domain_id.0), + Self::run_background_presignature_generation( + self.client.clone(), + self.config.presignature.clone().into(), + triple_store, + *domain_id, + data.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..e397e2338f 100644 --- a/crates/node/src/providers/ecdsa/presign.rs +++ b/crates/node/src/providers/ecdsa/presign.rs @@ -1,21 +1,18 @@ -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::{ + EcdsaKeyshare, 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 +23,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 @@ -66,13 +39,20 @@ impl EcdsaSignatureProvider { /// so that needs to be separately handled. pub(super) async fn run_background_presignature_generation( client: Arc, - threshold: TSReconstructionThreshold, config: Arc, triple_store: Arc, domain_id: DomainId, - presignature_store: Arc, - keygen_out: KeygenOutput, + keyshare: EcdsaKeyshare, ) -> ! { + let keygen_out = keyshare.keygen_output; + let presignature_store = keyshare.presignature_store; + let threshold_usize: usize = keyshare + .reconstruction_threshold + .inner() + .try_into() + .expect("contract validation guarantees a valid threshold"); + let threshold = TSReconstructionThreshold::from(threshold_usize); + let in_flight_generations = InFlightGenerationTracker::new(); let progress_tracker = Arc::new(PresignatureGenerationProgressTracker { desired_presignatures_to_buffer: config.desired_presignatures_to_buffer, @@ -177,20 +157,30 @@ impl EcdsaSignatureProvider { // owned by the leader, never one of ours. id.validate_owned_by(leader)?; paired_triple_id.validate_owned_by(leader)?; - let domain_data = self.domain_data(domain_id)?; + let keyshare = self.keyshare(domain_id)?; - // 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 = keyshare.reconstruction_threshold; + let threshold_usize: usize = threshold.inner().try_into()?; + if channel.participants().len() != threshold_usize { + metrics::MPC_NUM_BAD_PEER_PRESIGN_REQUESTS + .with_label_values(&[&domain_id.to_string()]) + .inc(); + anyhow::bail!( + "CaitSith presign participant count ({}) does not match domain threshold t={}", + channel.participants().len(), + threshold_usize, + ); + } let triple_store = self.triple_store_for_t(threshold)?; FollowerPresignComputation { threshold: TSReconstructionThreshold::from(threshold_usize), - keygen_out: domain_data.keyshare, + keygen_out: keyshare.keygen_output, triple_store, paired_triple_id, - out_presignature_store: domain_data.presignature_store, + out_presignature_store: keyshare.presignature_store, out_presignature_id: id, } .perform_leader_centric_computation( @@ -203,20 +193,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..a9438371a3 100644 --- a/crates/node/src/providers/ecdsa/sign.rs +++ b/crates/node/src/providers/ecdsa/sign.rs @@ -29,13 +29,13 @@ impl EcdsaSignatureProvider { presignature: PresignOutputWithParticipants, channel: NetworkTaskChannel, ) -> anyhow::Result<(Signature, VerifyingKey)> { - let domain_data = self.domain_data(sign_request.domain)?; + let keyshare = self.keyshare(sign_request.domain)?; let participants = presignature.participants.clone(); - let threshold: usize = self.mpc_config.participants.threshold.try_into()?; + let threshold: usize = keyshare.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let (signature, public_key) = SignComputation { - keygen_out: domain_data.keyshare, + keygen_out: keyshare.keygen_output, threshold, presign_out: presignature.presignature, msg_hash: *sign_request @@ -69,8 +69,8 @@ impl EcdsaSignatureProvider { id: SignatureId, ) -> anyhow::Result<(Signature, VerifyingKey)> { let sign_request = self.sign_request_store.get(id).await?; - let domain_data = self.domain_data(sign_request.domain)?; - let (presignature_id, presignature) = domain_data.presignature_store.take_owned().await; + let keyshare = self.keyshare(sign_request.domain)?; + let (presignature_id, presignature) = keyshare.presignature_store.take_owned().await; let participants = presignature.participants.clone(); let channel = self.new_channel_for_task( EcdsaTaskId::Signature { @@ -91,15 +91,15 @@ impl EcdsaSignatureProvider { ) -> anyhow::Result<()> { // The presignature must be owned by the leader, never one of ours. presignature_id.validate_owned_by(channel.sender().get_leader())?; - let domain_data = self.domain_data(sign_request.domain)?; - let threshold: usize = self.mpc_config.participants.threshold.try_into()?; + let keyshare = self.keyshare(sign_request.domain)?; + let threshold: usize = keyshare.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let participants = channel.participants().to_vec(); FollowerSignComputation { - keygen_out: domain_data.keyshare, + keygen_out: keyshare.keygen_output, threshold, - presignature_store: domain_data.presignature_store.clone(), + presignature_store: keyshare.presignature_store.clone(), presignature_id, msg_hash: *sign_request .payload diff --git a/crates/node/src/providers/ecdsa/triple.rs b/crates/node/src/providers/ecdsa/triple.rs index 9711b45d30..99c06ce1c2 100644 --- a/crates/node/src/providers/ecdsa/triple.rs +++ b/crates/node/src/providers/ecdsa/triple.rs @@ -8,13 +8,19 @@ use crate::network::computation::MpcLeaderCentricComputation; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::{ParticipantId, UniqueId}; use crate::protocol::run_protocol; -use crate::providers::HasParticipants; -use crate::providers::ecdsa::{EcdsaSignatureProvider, EcdsaTaskId}; +use crate::providers::{ + HasParticipants, + ecdsa::{EcdsaSignatureProvider, EcdsaTaskId}, + ecdsa_common::active_participants_query, +}; use crate::tracking::AutoAbortTaskCollection; use mpc_node_config::TripleConfig; use mpc_primitives::ReconstructionThreshold; +use mpc_primitives::domain::Protocol; +use near_mpc_contract_interface::types::DomainConfig; use near_time::Clock; use rand::rngs::OsRng; +use std::collections::BTreeSet; use std::ops::Deref; use std::sync::Arc; use std::time::Duration; @@ -22,6 +28,24 @@ use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use threshold_signatures::ecdsa::ot_based_ecdsa::triples::TripleGenerationOutput; use threshold_signatures::participants::Participant; +/// The distinct reconstruction thresholds `t` in `thresholds`. One triple store exists per `t`. +pub fn distinct_thresholds( + thresholds: impl IntoIterator, +) -> BTreeSet { + thresholds.into_iter().collect() +} + +/// The set of reconstruction thresholds `t` that CaitSith domains generate triples under. CaitSith +/// is the only protocol that uses triples. +pub fn caitsith_triple_thresholds(domains: &[DomainConfig]) -> BTreeSet { + distinct_thresholds( + domains + .iter() + .filter(|d| d.protocol == Protocol::CaitSith) + .map(|d| d.reconstruction_threshold), + ) +} + /// Per-`t` triple store. Holds triples generated with `n = t` participants /// (cait-sith triples are generated with exactly `t` parties, so the /// participant count and the Shamir degree `t − 1` are equivalent @@ -34,8 +58,7 @@ impl TripleStorage { pub fn new( clock: Clock, db: Arc, - my_participant_id: ParticipantId, - alive_participant_ids_query: Arc Vec + Send + Sync>, + client: &Arc, threshold: ReconstructionThreshold, ) -> anyhow::Result { Ok(Self(DistributedAssetStorage::::new( @@ -43,9 +66,9 @@ impl TripleStorage { db, DBCol::TripleV2, threshold.inner().to_be_bytes().to_vec(), - my_participant_id, + client.my_participant_id(), |participants, pair| pair.is_subset_of_active_participants(participants), - alive_participant_ids_query, + active_participants_query(client), )?)) } } @@ -60,7 +83,31 @@ impl Deref for TripleStorage { pub const SUPPORTED_TRIPLE_GENERATION_BATCH_SIZE: usize = 64; +const TRIPLE_METRICS_REPORTING_INTERVAL: Duration = Duration::from_millis(500); + impl EcdsaSignatureProvider { + /// Reports the triple-buffer gauges summed across every per-`t` store. + /// Each generator owns a distinct store keyed by its threshold, so a single + /// 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(TRIPLE_METRICS_REPORTING_INTERVAL).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 +134,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; @@ -122,6 +160,12 @@ impl EcdsaSignatureProvider { } }; + debug_assert_eq!( + participants.len(), + threshold.value(), + "triple routing infers t from channel size (see run_triple_generation_follower)" + ); + let id_start = triple_store .generate_and_reserve_id_range(SUPPORTED_TRIPLE_GENERATION_BATCH_SIZE as u32); let task_id = EcdsaTaskId::ManyTriples { @@ -335,18 +379,21 @@ pub fn participants_from_triples( mod tests { use super::{ ManyTripleGenerationComputation, PairedTriple, ReconstructionThreshold, TripleStorage, + caitsith_triple_thresholds, }; use crate::assets::test_utils::{make_triple, triple_v2_key}; use crate::db::{DBCol, SecretDB}; use crate::network::computation::MpcLeaderCentricComputation; - use crate::network::testing::run_test_clients; + use crate::network::testing::{new_test_client, run_test_clients}; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::{MpcTaskId, ParticipantId, UniqueId}; use crate::providers::ecdsa::EcdsaTaskId; use crate::tests::into_participant_ids; use crate::tracking; use futures::{FutureExt, StreamExt, stream}; - use std::collections::HashMap; + use mpc_primitives::domain::{DomainId, Protocol}; + use near_mpc_contract_interface::types::{DomainConfig, DomainPurpose}; + use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; use threshold_signatures::test_utils::generate_participants; @@ -358,6 +405,34 @@ mod tests { const TRIPLES_PER_BATCH: usize = 10; const BATCHES_TO_GENERATE_PER_CLIENT: usize = 10; + fn domain(id: u64, protocol: Protocol, t: u64) -> DomainConfig { + DomainConfig { + id: DomainId(id), + protocol, + reconstruction_threshold: ReconstructionThreshold::new(t), + purpose: DomainPurpose::Sign, + } + } + + #[test] + #[expect(non_snake_case)] + fn caitsith_triple_thresholds__should_dedup_and_exclude_non_caitsith() { + // Given CaitSith domains with a duplicate `t` alongside non-CaitSith domains + let domains = [ + domain(0, Protocol::CaitSith, 3), + domain(1, Protocol::CaitSith, 2), + domain(2, Protocol::CaitSith, 3), + domain(3, Protocol::DamgardEtAl, 5), + domain(4, Protocol::Frost, 4), + ]; + + // When / Then only the distinct CaitSith thresholds remain + assert_eq!( + caitsith_triple_thresholds(&domains), + BTreeSet::from([2, 3].map(ReconstructionThreshold::new)) + ); + } + #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn test_many_triple_generation() { tracking::testing::start_root_task_with_periodic_dump(async { @@ -489,13 +564,13 @@ mod tests { db: Arc, my_participant_id: ParticipantId, threshold: ReconstructionThreshold, - alive: Vec, + participants: Vec, ) -> TripleStorage { + let client = new_test_client(participants, my_participant_id); TripleStorage::new( near_time::FakeClock::default().clock(), db, - my_participant_id, - Arc::new(move || alive.clone()), + &client, threshold, ) .unwrap() diff --git a/crates/node/src/providers/ecdsa_common.rs b/crates/node/src/providers/ecdsa_common.rs new file mode 100644 index 0000000000..a0bff00bd4 --- /dev/null +++ b/crates/node/src/providers/ecdsa_common.rs @@ -0,0 +1,222 @@ +//! 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::{DomainKeyshare, 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, Secp256K1Sha256}; + +/// A stored presignature together with the participants that produced it, so the store can drop it +/// once any of those participants goes offline. Generic over the presignature payload `P`. +#[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), + )?)) + } +} + +/// A domain's [`DomainKeyshare`] material plus a presignature store, which is runtime state the +/// coordinator can't provide and so is built here. +pub struct EcdsaKeyshare

+where + P: Serialize + DeserializeOwned + Send + 'static, +{ + pub keygen_output: KeygenOutput, + pub presignature_store: Arc>, + pub reconstruction_threshold: ReconstructionThreshold, +} + +// Manual `Clone` so callers don't need `P: Clone` — every field is `Clone` regardless of `P`. +impl

Clone for EcdsaKeyshare

+where + P: Serialize + DeserializeOwned + Send + 'static, +{ + fn clone(&self) -> Self { + Self { + keygen_output: self.keygen_output.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()) +} + +/// Attaches a freshly-created presignature store to each domain's [`DomainKeyshare`]. +pub fn build_keyshares

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

( + keyshares: &HashMap>, + domain_id: DomainId, +) -> anyhow::Result> +where + P: Serialize + DeserializeOwned + Send + 'static, +{ + keyshares + .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::{DomainKeyshare, build_keyshares}; + 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 the `reconstruction_thresholds` integration tests + // only check indirectly (by running full multi-node signing rounds): each domain must keep + // its OWN reconstruction threshold, never a single shared/governance value. + #[tokio::test] + async fn build_keyshares__should_pair_each_domain_with_its_own_reconstruction_threshold() { + 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, + DomainKeyshare::new( + keygen_output.clone(), + ReconstructionThreshold::new(2), + ), + ), + ( + high, + DomainKeyshare::new(keygen_output, ReconstructionThreshold::new(3)), + ), + ]); + let dir = tempfile::tempdir().unwrap(); + let db = SecretDB::new(dir.path(), [1; 16]).unwrap(); + + // When + let keyshares = + build_keyshares::>(&Clock::real(), &db, &client, keyshares) + .unwrap(); + + // Then each domain keeps the threshold it was configured with + assert_eq!( + keyshares[&low].reconstruction_threshold, + ReconstructionThreshold::new(2) + ); + assert_eq!( + keyshares[&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..95e1196d30 100644 --- a/crates/node/src/providers/eddsa.rs +++ b/crates/node/src/providers/eddsa.rs @@ -6,6 +6,7 @@ use crate::config::{MpcConfig, ParticipantsConfig}; use crate::metrics::tokio_task_metrics::EDDSA_TASK_MONITORS; use crate::network::{MeshNetworkClient, NetworkTaskChannel}; use crate::primitives::MpcTaskId; +use crate::providers::DomainKeyshare; #[cfg(test)] use crate::providers::PublicKeyConversion; use crate::providers::SignatureProvider; @@ -21,8 +22,8 @@ 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::frost::eddsa::KeygenOutput; +use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; +use threshold_signatures::frost::eddsa::{Ed25519Sha512, KeygenOutput}; use threshold_signatures::frost_ed25519::keys::SigningShare; use threshold_signatures::frost_ed25519::{Signature, VerifyingKey}; @@ -32,16 +33,18 @@ pub struct EddsaSignatureProvider { mpc_config: Arc, client: Arc, sign_request_store: Arc, - keyshares: HashMap, + keyshares: HashMap, } +pub(super) type EddsaKeyshare = DomainKeyshare; + impl EddsaSignatureProvider { pub fn new( config: Arc, mpc_config: Arc, client: Arc, sign_request_store: Arc, - keyshares: HashMap, + keyshares: HashMap, ) -> Self { Self { config, @@ -51,6 +54,13 @@ impl EddsaSignatureProvider { keyshares, } } + + pub(super) fn keyshare(&self, domain_id: DomainId) -> anyhow::Result { + self.keyshares + .get(&domain_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, BorshSerialize, BorshDeserialize)] @@ -84,14 +94,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 +110,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..af24fb5ce2 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 keyshare = self.keyshare(sign_request.domain)?; + let threshold: usize = keyshare.reconstruction_threshold.inner().try_into()?; let threshold = ReconstructionThreshold::from(threshold); let running_participants: Vec<_> = self .mpc_config @@ -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: keyshare.keygen_output, 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 keyshare = self.keyshare(sign_request.domain)?; + let threshold: usize = keyshare.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: keyshare.keygen_output, threshold, message: sign_request .payload diff --git a/crates/node/src/providers/robust_ecdsa.rs b/crates/node/src/providers/robust_ecdsa.rs index 338a464fd1..abb254297a 100644 --- a/crates/node/src/providers/robust_ecdsa.rs +++ b/crates/node/src/providers/robust_ecdsa.rs @@ -10,20 +10,22 @@ 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::{EcdsaSignatureProvider, SignatureProvider}; +use crate::providers::ecdsa_common; +use crate::providers::{DomainKeyshare, EcdsaSignatureProvider, SignatureProvider}; use crate::storage::SignRequestStorage; use crate::tracking; 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::ecdsa::KeygenOutput; -use threshold_signatures::ecdsa::Signature; +use threshold_signatures::ReconstructionThreshold as TSReconstructionThreshold; +use threshold_signatures::ecdsa::robust_ecdsa::PresignOutput; +use threshold_signatures::ecdsa::{KeygenOutput, Secp256K1Sha256, Signature}; use threshold_signatures::frost_secp256k1::VerifyingKey; use threshold_signatures::frost_secp256k1::keys::SigningShare; @@ -32,14 +34,10 @@ pub struct RobustEcdsaSignatureProvider { mpc_config: Arc, client: Arc, sign_request_store: Arc, - per_domain_data: HashMap, + keyshares: HashMap, } -#[derive(Clone)] -pub(super) struct PerDomainData { - pub keyshare: KeygenOutput, - pub presignature_store: Arc, -} +pub(super) type EcdsaKeyshare = ecdsa_common::EcdsaKeyshare; #[derive( Debug, Copy, Clone, Eq, Ord, PartialEq, PartialOrd, derive_more::From, derive_more::Into, @@ -60,45 +58,21 @@ 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 keyshares = ecdsa_common::build_keyshares(&clock, &db, &client, keyshares)?; Ok(Self { config, mpc_config, client, sign_request_store, - per_domain_data, + keyshares, }) } - pub(super) fn domain_data(&self, domain_id: DomainId) -> anyhow::Result { - self.per_domain_data - .get(&domain_id) - .cloned() - .ok_or_else(|| anyhow::anyhow!("No keyshare for domain {:?}", domain_id)) + pub(super) fn keyshare(&self, domain_id: DomainId) -> anyhow::Result { + ecdsa_common::lookup_keyshare(&self.keyshares, domain_id) } } @@ -144,47 +118,26 @@ 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()?; - 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 @@ -228,7 +181,7 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { async fn spawn_background_tasks(self: Arc) -> anyhow::Result<()> { let generate_presignatures = self - .per_domain_data + .keyshares .iter() .map(|(domain_id, data)| { tracking::spawn( @@ -238,8 +191,7 @@ impl SignatureProvider for RobustEcdsaSignatureProvider { self.mpc_config.clone(), self.config.presignature.clone().into(), *domain_id, - data.presignature_store.clone(), - data.keyshare.clone(), + data.clone(), ), ) }) @@ -247,7 +199,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 +207,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..6a840de7eb 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, + EcdsaKeyshare, KeygenOutput, RobustEcdsaSignatureProvider, RobustEcdsaTaskId, + compute_thresholds, }; use crate::tracking::AutoAbortTaskCollection; use crate::{metrics, tracking}; use mpc_node_config::PresignatureConfig; 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 @@ -68,9 +39,12 @@ pub(super) async fn run_background_presignature_generation( mpc_config: Arc, config: Arc, domain_id: DomainId, - presignature_store: Arc, - keygen_out: KeygenOutput, + keyshare: EcdsaKeyshare, ) -> ! { + let keygen_out = keyshare.keygen_output; + let presignature_store = keyshare.presignature_store; + let threshold = keyshare.reconstruction_threshold; + let in_flight_generations = InFlightGenerationTracker::new(); let progress_tracker = Arc::new(PresignatureGenerationProgressTracker { desired_presignatures_to_buffer: config.desired_presignatures_to_buffer, @@ -87,11 +61,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 +120,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 +151,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, @@ -212,16 +159,25 @@ impl RobustEcdsaSignatureProvider { domain_id: DomainId, ) -> anyhow::Result<()> { 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 keyshare = self.keyshare(domain_id)?; + + let (num_signers, damgard_et_al_threshold) = + compute_thresholds(keyshare.reconstruction_threshold)?; + if channel.participants().len() != num_signers { + metrics::MPC_NUM_BAD_PEER_PRESIGN_REQUESTS + .with_label_values(&[&domain_id.to_string()]) + .inc(); + anyhow::bail!( + "robust-ECDSA presign participant count ({}) does not match required signer count {}", + channel.participants().len(), + num_signers, + ); + } FollowerPresignComputation { - max_malicious: robust_ecdsa_threshold, - keygen_out: domain_data.keyshare, - out_presignature_store: domain_data.presignature_store, + max_malicious: damgard_et_al_threshold, + keygen_out: keyshare.keygen_output, + out_presignature_store: keyshare.presignature_store, out_presignature_id: id, } .perform_leader_centric_computation( @@ -234,20 +190,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 +278,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..bc9f6db563 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 { @@ -28,8 +27,8 @@ impl RobustEcdsaSignatureProvider { id: SignatureId, ) -> anyhow::Result<(Signature, VerifyingKey)> { let sign_request = self.sign_request_store.get(id).await?; - let domain_data = self.domain_data(sign_request.domain)?; - let (presignature_id, presignature) = domain_data.presignature_store.take_owned().await; + let keyshare = self.keyshare(sign_request.domain)?; + let (presignature_id, presignature) = keyshare.presignature_store.take_owned().await; let participants = presignature.participants.clone(); let channel = self.client.new_channel_for_task( RobustEcdsaTaskId::Signature { @@ -38,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(keyshare.reconstruction_threshold)?; let msg_hash = *sign_request .payload @@ -48,8 +46,8 @@ impl RobustEcdsaSignatureProvider { .ok_or_else(|| anyhow::anyhow!("Payload is not an ECDSA payload"))?; let (signature, public_key) = SignComputation { - keygen_out: domain_data.keyshare, - max_malicious: robust_ecdsa_threshold, + keygen_out: keyshare.keygen_output, + max_malicious: damgard_et_al_threshold, presign_out: presignature.presignature, msg_hash: msg_hash.into(), tweak: sign_request.tweak, @@ -90,10 +88,9 @@ impl RobustEcdsaSignatureProvider { .await??; 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 keyshare = self.keyshare(sign_request.domain)?; + let (_num_signers, damgard_et_al_threshold) = + compute_thresholds(keyshare.reconstruction_threshold)?; let msg_hash = *sign_request .payload @@ -102,9 +99,9 @@ impl RobustEcdsaSignatureProvider { let participants = channel.participants().to_vec(); FollowerSignComputation { - keygen_out: domain_data.keyshare, - max_malicious: robust_ecdsa_threshold, - presignature_store: domain_data.presignature_store.clone(), + keygen_out: keyshare.keygen_output, + max_malicious: damgard_et_al_threshold, + presignature_store: keyshare.presignature_store.clone(), presignature_id, msg_hash: msg_hash.into(), tweak: sign_request.tweak, diff --git a/crates/node/src/providers/verify_foreign_tx/sign.rs b/crates/node/src/providers/verify_foreign_tx/sign.rs index 5d1d238808..e1c0d49aa7 100644 --- a/crates/node/src/providers/verify_foreign_tx/sign.rs +++ b/crates/node/src/providers/verify_foreign_tx/sign.rs @@ -58,10 +58,10 @@ where ) -> anyhow::Result<((dtos::ForeignTxSignPayload, Signature), VerifyingKey)> { let foreign_tx_request = self.verify_foreign_tx_request_store.get(id).await?; - let domain_data = self + let keyshare = self .ecdsa_signature_provider - .domain_data(foreign_tx_request.domain_id)?; - let (presignature_id, presignature) = domain_data.presignature_store.take_owned().await; + .keyshare(foreign_tx_request.domain_id)?; + let (presignature_id, presignature) = keyshare.presignature_store.take_owned().await; let participants = presignature.participants.clone(); let channel = self.ecdsa_signature_provider.new_channel_for_task( VerifyForeignTxTaskId::VerifyForeignTx { diff --git a/crates/node/src/tests.rs b/crates/node/src/tests.rs index 4aae083190..01aa6a07f6 100644 --- a/crates/node/src/tests.rs +++ b/crates/node/src/tests.rs @@ -62,6 +62,7 @@ mod foreign_chain_configuration; mod multidomain; mod onboarding; mod protocol_yielding; +mod reconstruction_thresholds; mod resharing; mod update_participant_url; 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 02317d08d9..508389988d 100644 --- a/crates/node/src/tests/multidomain.rs +++ b/crates/node/src/tests/multidomain.rs @@ -1,14 +1,13 @@ use crate::indexer::participants::ContractState; use crate::p2p::testing::port_seed; +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; // Make a cluster of four nodes, test that we can generate keyshares @@ -32,28 +31,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 +91,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..be1af5b3ce --- /dev/null +++ b/crates/node/src/tests/reconstruction_thresholds.rs @@ -0,0 +1,276 @@ +//! 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::port_seed; +use crate::tests::common::{ckd_domain, sign_domain}; +use crate::tests::{ + DEFAULT_BLOCK_TIME, DEFAULT_MAX_PROTOCOL_WAIT_TIME, DEFAULT_MAX_SIGNATURE_WAIT_TIME, + IntegrationTestSetup, request_ckd_and_await_response, request_signature_and_await_response, +}; +use crate::tracking::AutoAbortTask; +use mpc_primitives::domain::Curve; +use near_mpc_contract_interface::types::{DomainConfig, Protocol, ReconstructionThreshold}; +use near_time::Clock; +use std::collections::BTreeMap; + +/// Sign or CKD request per `domain`'s protocol; both are gated by its reconstruction threshold. +async fn request_and_await_response( + indexer: &mut FakeIndexerManager, + user: &str, + domain: &DomainConfig, +) -> Option { + match Curve::from(domain.protocol) { + Curve::Secp256k1 | Curve::Edwards25519 => { + request_signature_and_await_response( + indexer, + user, + domain, + DEFAULT_MAX_SIGNATURE_WAIT_TIME, + ) + .await + } + Curve::Bls12381 => { + request_ckd_and_await_response(indexer, user, domain, DEFAULT_MAX_SIGNATURE_WAIT_TIME) + .await + } + } +} + +async fn assert_can_sign(indexer: &mut FakeIndexerManager, user: &str, domain: &DomainConfig) { + assert!( + request_and_await_response(indexer, user, domain) + .await + .is_some(), + "domain {:?} (t={}) should be able to sign with the currently-online nodes", + domain.id, + domain.reconstruction_threshold.inner(), + ); +} + +async fn assert_cannot_sign(indexer: &mut FakeIndexerManager, user: &str, domain: &DomainConfig) { + assert!( + request_and_await_response(indexer, user, domain) + .await + .is_none(), + "domain {:?} (t={}) must NOT be able to sign: too few nodes are online for its threshold", + domain.id, + domain.reconstruction_threshold.inner(), + ); +} + +/// 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, + port_seed::RECONSTRUCTION_THRESHOLD_AVAILABILITY_TEST, + DEFAULT_BLOCK_TIME, + ); + + // Online signers needed: low 2, high 4, robust (DamgardEtAl, 2t-1) 5, ckd_low 2, + // ckd_high 4, frost 4. Frost/CKD are gated by `t` like CaitSith. + let low = sign_domain(0, Protocol::CaitSith, 2); + let high = sign_domain(1, Protocol::CaitSith, 4); + let robust = sign_domain(2, Protocol::DamgardEtAl, 3); + let ckd_low = ckd_domain(3, 2); + let ckd_high = ckd_domain(4, 4); + let frost = sign_domain(5, Protocol::Frost, 4); + let domains = vec![ + low.clone(), + high.clone(), + robust.clone(), + ckd_low.clone(), + ckd_high.clone(), + frost.clone(), + ]; + + { + let mut contract = setup.indexer.contract_mut().await; + 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; + assert_can_sign(&mut setup.indexer, "user_all_ckd_low", &ckd_low).await; + assert_can_sign(&mut setup.indexer, "user_all_ckd_high", &ckd_high).await; + assert_can_sign(&mut setup.indexer, "user_all_frost", &frost).await; + + // One node down (4 online): only robust (needs 5) stops. + let disabled_a = setup.indexer.disable(4.into()).await; + assert_can_sign(&mut setup.indexer, "user_4_low", &low).await; + assert_can_sign(&mut setup.indexer, "user_4_high", &high).await; + assert_cannot_sign(&mut setup.indexer, "user_4_robust", &robust).await; + assert_can_sign(&mut setup.indexer, "user_4_ckd_high", &ckd_high).await; + assert_can_sign(&mut setup.indexer, "user_4_frost", &frost).await; + + // Two nodes down (3 online): high, ckd_high and frost (t=4) stop too. + let disabled_b = setup.indexer.disable(3.into()).await; + assert_can_sign(&mut setup.indexer, "user_3_low", &low).await; + assert_cannot_sign(&mut setup.indexer, "user_3_high", &high).await; + assert_can_sign(&mut setup.indexer, "user_3_ckd_low", &ckd_low).await; + assert_cannot_sign(&mut setup.indexer, "user_3_ckd_high", &ckd_high).await; + assert_cannot_sign(&mut setup.indexer, "user_3_frost", &frost).await; + + // Then restoring both nodes restores signing for every domain. + disabled_b.reenable_and_wait_till_running().await; + disabled_a.reenable_and_wait_till_running().await; + assert_can_sign(&mut setup.indexer, "user_restored_high", &high).await; + assert_can_sign(&mut setup.indexer, "user_restored_robust", &robust).await; + assert_can_sign(&mut setup.indexer, "user_restored_ckd_high", &ckd_high).await; + assert_can_sign(&mut setup.indexer, "user_restored_frost", &frost).await; +} + +/// One resharing preserves unchanged domains' `t` while applying a per-domain update: the +/// lowered domain then signs with fewer online nodes than its old sharing allowed, while a +/// sibling left at the same `t` still needs its higher count. +#[tokio::test] +#[test_log::test] +#[expect(non_snake_case)] +async fn resharing__should_apply_updated_thresholds_while_preserving_unchanged_ones() { + // Given a cluster starting with 4 of an eventual 5 participants. + const NUM_PARTICIPANTS: usize = 5; + const THRESHOLD: usize = 3; + const TXN_DELAY_BLOCKS: u64 = 1; + let temp_dir = tempfile::tempdir().unwrap(); + let mut setup = IntegrationTestSetup::new( + Clock::real(), + temp_dir.path(), + (0..NUM_PARTICIPANTS) + .map(|i| format!("test{}", i).parse().unwrap()) + .collect(), + THRESHOLD, + TXN_DELAY_BLOCKS, + port_seed::RECONSTRUCTION_THRESHOLD_RESHARING_TEST, + DEFAULT_BLOCK_TIME, + ); + + // Online signers needed: low 2, mid (Frost) 3, high 4, ckd 4, robust (DamgardEtAl, + // t capped at 2 by `2t - 1 <= 4` participants) 3. All survive the reshare. + let low = sign_domain(0, Protocol::CaitSith, 2); + let mid = sign_domain(1, Protocol::Frost, 3); + let high = sign_domain(2, Protocol::CaitSith, 4); + let ckd = ckd_domain(3, 4); + let robust = sign_domain(4, Protocol::DamgardEtAl, 2); + let domains = vec![ + low.clone(), + mid.clone(), + high.clone(), + ckd.clone(), + robust.clone(), + ]; + + // Initialize with one fewer participant; the fifth joins during resharing. + let mut initial_participants = setup.participants.clone(); + 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/ckd t=4 need all 4). + assert_can_sign(&mut setup.indexer, "user_pre_low", &low).await; + assert_can_sign(&mut setup.indexer, "user_pre_mid", &mid).await; + assert_can_sign(&mut setup.indexer, "user_pre_high", &high).await; + assert_can_sign(&mut setup.indexer, "user_pre_ckd", &ckd).await; + assert_can_sign(&mut setup.indexer, "user_pre_robust", &robust).await; + + // When the fifth node joins via resharing, which also lowers `high` from t=4 to t=2. + let high_lowered = sign_domain(2, Protocol::CaitSith, 2); + setup + .indexer + .contract_mut() + .await + .start_resharing_with_threshold_updates( + setup.participants.clone(), + BTreeMap::from([(high.id, ReconstructionThreshold::new(2))]), + ); + + 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_lowered).await; + assert_can_sign(&mut setup.indexer, "user_post_ckd", &ckd).await; + assert_can_sign(&mut setup.indexer, "user_post_robust", &robust).await; + + // With three nodes down (2 online): `high` now signs at its new t=2 — impossible under + // the old t=4 sharing, so the update took real effect. `ckd`, left at t=4, stops — + // proving the change was per-domain, not global. `low` (t=2) keeps working; `mid` (t=3) + // and `robust` (DamgardEtAl, needs 2t-1=3) stop. + let _disabled_a = setup.indexer.disable(4.into()).await; + let _disabled_b = setup.indexer.disable(3.into()).await; + let _disabled_c = setup.indexer.disable(2.into()).await; + assert_can_sign(&mut setup.indexer, "user_drop_low", &low).await; + assert_can_sign(&mut setup.indexer, "user_drop_high", &high_lowered).await; + assert_cannot_sign(&mut setup.indexer, "user_drop_ckd", &ckd).await; + assert_cannot_sign(&mut setup.indexer, "user_drop_mid", &mid).await; + assert_cannot_sign(&mut setup.indexer, "user_drop_robust", &robust).await; +} diff --git a/docs/design/domain-separation.md b/docs/design/domain-separation.md index ea58d0b5e7..23d935e303 100644 --- a/docs/design/domain-separation.md +++ b/docs/design/domain-separation.md @@ -4,7 +4,7 @@ The addition of Robust ECDSA (aka DamgardEtAl) invalidates three assumptions in ✗ There is one protocol per curve (now: both CaitSith and DamgardEtAl operate over Secp256k1). -✗ All domains share a single cryptographic threshold. The node already has a `translate_threshold()` hack to bridge this gap. +✗ All domains share a single cryptographic threshold. ✗ Governance voting threshold and cryptographic reconstruction threshold are the same value. The threshold of how many participants must vote to change parameters is currently the same `Threshold` value as the cryptographic reconstruction threshold. @@ -85,15 +85,13 @@ The node (`crates/node/`) imports types from **both** the internal contract crat ### 1.4 Threshold Flow Through the System ``` -Contract ThresholdParameters.threshold (Threshold(u64)) - → Coordinator extracts: threshold: usize = mpc_config.participants.threshold.try_into()? - → Converts to: ReconstructionThreshold::from(threshold) +Contract per-domain DistributedKeyConfig.reconstruction_threshold (ReconstructionThreshold(u64)) + → Coordinator reads the per-domain ReconstructionThreshold from contract state → For CaitSith/FROST: passed directly to keygen/sign - → For DamgardEtAl: translate_threshold() → MaxMalicious::from((n_signers - 1) / 2) + → For DamgardEtAl: compute_thresholds() → (num_signers, max_malicious) ``` -The `translate_threshold()` function in `crates/node/src/providers/robust_ecdsa.rs` 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." +For DamgardEtAl, the node derives `(num_signers, max_malicious)` from the per-domain `ReconstructionThreshold` via `compute_thresholds()` in `crates/node/src/providers/robust_ecdsa.rs` (used by `robust_ecdsa/presign.rs` and `robust_ecdsa/sign.rs`). No threshold translation from a global value is needed, since each domain carries its own `ReconstructionThreshold`. ### 1.5 Current Curve-Protocol Pairings @@ -584,12 +582,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: +- Derive the robust-ECDSA signer set via `compute_thresholds()` in `robust_ecdsa.rs`, which computes `(num_signers, max_malicious)` directly from the domain's `ReconstructionThreshold`: ```rust - // Node 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. + The per-domain `ReconstructionThreshold` comes from contract state (or the synthetic `DistributedKeyConfig` on the `state()` fallback path), so no threshold translation from a global value is needed. - Provider routing uses `Protocol` enum instead of pattern-matching on `SignatureScheme`/`Curve`: ```rust match dk.protocol { @@ -906,23 +905,18 @@ With per-key `protocol` and `reconstruction_threshold`, `vote_add_domains` must During resharing, each domain's key must be reshared with its own `ReconstructionThreshold`. The `KeyEvent` for each domain already carries its config. The coordinator passes the per-domain threshold to the crypto protocol. Per-domain thresholds can only be changed via resharing — there is no independent vote function for threshold changes. -`ReconstructionThreshold` has uniform semantics across all protocols: it always means "number of shares needed to reconstruct the secret" (`t`). Each protocol may impose different constraints on `t` (e.g., DamgardEtAl requires `t < n/2`), but the stored value has the same meaning everywhere. The node handles the protocol-specific translation: - +`ReconstructionThreshold` has uniform semantics across all protocols: it always means "number of shares needed to reconstruct the secret" (`t`). Each protocol may impose different constraints on `t` (e.g., DamgardEtAl requires `t < n/2`), but the stored value has the same meaning everywhere. The node reads the per-domain `ReconstructionThreshold` from contract state and applies the protocol-specific interpretation: ```rust -// Current (hack): -let threshold: usize = mpc_config.participants.threshold.try_into()?; -let threshold = ReconstructionThreshold::from(threshold); - -// Proposed (clean): let dk = distributed_key_registry.get(distributed_key_id); -let active_signers = min_active_participants(&dk.protocol, &dk.reconstruction_threshold); -let threshold = match dk.protocol { +let threshold = dk.reconstruction_threshold; +match dk.protocol { + // DamgardEtAl: derive (num_signers, max_malicious) = (2t - 1, t - 1) Protocol::DamgardEtAl => { - let max_malicious = MaxMalicious::from(dk.reconstruction_threshold.inner() - 1); - // Use MaxMalicious directly, no translation needed + let (num_signers, max_malicious) = compute_thresholds(threshold)?; } - _ => ReconstructionThreshold::from(dk.reconstruction_threshold.inner()), + // CaitSith/FROST/CKD: t is passed directly to keygen/sign + _ => { /* use threshold directly */ } }; ```