Skip to content

Commit b9eed26

Browse files
SimonRastikianclaudenetrome
authored
feat: adding reconstruction threshold in node (#3640)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Mårten Blankfors <marten@blankfors.se>
1 parent 6be87ac commit b9eed26

37 files changed

Lines changed: 1394 additions & 786 deletions

crates/e2e-tests/tests/ckd_verification.rs

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use crate::common;
1+
use crate::common::{
2+
CKD_PV_VERIFICATION_PORT_SEED, CKD_VERIFICATION_PORT_SEED, must_get_bls_public_key,
3+
must_get_domain, must_setup_cluster,
4+
};
25

36
use anyhow::Context;
47
use blstrs::{G1Projective, G2Projective, Scalar};
@@ -7,8 +10,7 @@ use group::ff::Field as _;
710
use near_account_id::AccountId;
811
use near_mpc_contract_interface::types::kdf::derive_app_id;
912
use near_mpc_contract_interface::types::{
10-
Bls12381G1PublicKey, Bls12381G2PublicKey, CKDAppPublicKey, CKDAppPublicKeyPV, Curve,
11-
DomainPurpose,
13+
Bls12381G1PublicKey, Bls12381G2PublicKey, CKDAppPublicKey, CKDAppPublicKeyPV, Protocol,
1214
};
1315
use rand::SeedableRng;
1416
use threshold_signatures::confidential_key_derivation::{
@@ -42,20 +44,11 @@ fn verify_ckd(
4244
#[expect(non_snake_case)]
4345
async fn ckd_response__passes_cryptographic_verification() {
4446
// given
45-
let (cluster, running) =
46-
common::must_setup_cluster(common::CKD_VERIFICATION_PORT_SEED, |_| {}).await;
47-
48-
let bls_domain = running
49-
.domains
50-
.domains
51-
.iter()
52-
.find(|d| {
53-
Curve::from(d.protocol) == Curve::Bls12381 && matches!(d.purpose, DomainPurpose::CKD)
54-
})
55-
.expect("no Bls12381 CKD domain found")
56-
.clone();
57-
58-
let mpc_pk = common::must_get_bls_public_key(&running, bls_domain.id);
47+
let (cluster, running) = must_setup_cluster(CKD_VERIFICATION_PORT_SEED, |_| {}).await;
48+
49+
let bls_domain = must_get_domain(&running, Protocol::ConfidentialKeyDerivation);
50+
51+
let mpc_pk = must_get_bls_public_key(&running, bls_domain.id);
5952
let user = cluster.default_user_account().clone();
6053

6154
let mut rng = rand::rngs::StdRng::seed_from_u64(1);
@@ -95,20 +88,11 @@ async fn ckd_response__passes_cryptographic_verification() {
9588
#[expect(non_snake_case)]
9689
async fn ckd_pv_response__passes_cryptographic_verification() {
9790
// given
98-
let (cluster, running) =
99-
common::must_setup_cluster(common::CKD_PV_VERIFICATION_PORT_SEED, |_| {}).await;
100-
101-
let bls_domain = running
102-
.domains
103-
.domains
104-
.iter()
105-
.find(|d| {
106-
Curve::from(d.protocol) == Curve::Bls12381 && matches!(d.purpose, DomainPurpose::CKD)
107-
})
108-
.expect("no Bls12381 CKD domain found")
109-
.clone();
110-
111-
let mpc_pk = common::must_get_bls_public_key(&running, bls_domain.id);
91+
let (cluster, running) = must_setup_cluster(CKD_PV_VERIFICATION_PORT_SEED, |_| {}).await;
92+
93+
let bls_domain = must_get_domain(&running, Protocol::ConfidentialKeyDerivation);
94+
95+
let mpc_pk = must_get_bls_public_key(&running, bls_domain.id);
11296
let user = cluster.default_user_account().clone();
11397

11498
let mut rng = rand::rngs::StdRng::seed_from_u64(2);

crates/e2e-tests/tests/common.rs

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use blstrs::{G1Projective, Scalar};
77
use e2e_tests::{CLUSTER_WAIT_TIMEOUT, MpcCluster, MpcClusterConfig, metrics};
88
use group::Group;
99
use near_mpc_contract_interface::types::{
10-
Bls12381G2PublicKey, CKDAppPublicKey, Curve, DomainId, DomainPurpose, ProtocolContractState,
11-
PublicKey, PublicKeyExtended, RunningContractState,
10+
Bls12381G2PublicKey, CKDAppPublicKey, Curve, DomainConfig, DomainId, DomainPurpose, Protocol,
11+
ProtocolContractState, PublicKey, PublicKeyExtended, ReconstructionThreshold,
12+
RunningContractState,
1213
};
1314
use near_mpc_crypto_types::Bls12381G1PublicKey;
1415
use serde_json::json;
@@ -37,8 +38,9 @@ pub const CONTRACT_UPGRADE_COMPATIBILITY_TESTNET_PORT_SEED: u16 = 19;
3738
pub const TIMEOUT_METRIC_PORT_SEED: u16 = 20;
3839
pub const MIGRATION_BACK_PORT_SEED: u16 = 21;
3940
pub const SIGTERM_HANDLER_PORT_SEED: u16 = 22;
40-
pub const UPDATE_PARTICIPANT_URL_PORT_SEED: u16 = 23;
41-
pub const AVAILABLE_FOREIGN_CHAINS_PORT_SEED: u16 = 24;
41+
pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED: u16 = 23;
42+
pub const UPDATE_PARTICIPANT_URL_PORT_SEED: u16 = 24;
43+
pub const AVAILABLE_FOREIGN_CHAINS_PORT_SEED: u16 = 25;
4244

4345
/// Start a cluster, wait for Running state and presignatures to buffer.
4446
///
@@ -386,6 +388,38 @@ pub fn must_get_bls_public_key(
386388
}
387389
}
388390

391+
/// Builds a `DamgardEtAl` signing domain with reconstruction threshold `t`, which needs `2t - 1` signers.
392+
pub fn damgard_etal_domain(id: u64, t: u64) -> DomainConfig {
393+
DomainConfig {
394+
id: DomainId(id),
395+
protocol: Protocol::DamgardEtAl,
396+
reconstruction_threshold: ReconstructionThreshold::new(t),
397+
purpose: DomainPurpose::Sign,
398+
}
399+
}
400+
401+
/// Builds a `ConfidentialKeyDerivation` (CKD) domain with reconstruction threshold `t`, which needs `t` signers.
402+
pub fn ckd_domain(id: u64, t: u64) -> DomainConfig {
403+
DomainConfig {
404+
id: DomainId(id),
405+
protocol: Protocol::ConfidentialKeyDerivation,
406+
reconstruction_threshold: ReconstructionThreshold::new(t),
407+
purpose: DomainPurpose::CKD,
408+
}
409+
}
410+
411+
/// Returns the first domain running `protocol_type` (the registry allows
412+
/// duplicates), panicking if absent.
413+
pub fn must_get_domain(running: &RunningContractState, protocol_type: Protocol) -> DomainConfig {
414+
running
415+
.domains
416+
.domains
417+
.iter()
418+
.find(|d| d.protocol == protocol_type)
419+
.unwrap_or_else(|| panic!("no domain with protocol {protocol_type:?}"))
420+
.clone()
421+
}
422+
389423
/// Send a sign request and assert the network produced a successful response.
390424
///
391425
/// Panics if the request can't be submitted to the contract — the test cannot
@@ -416,6 +450,38 @@ pub async fn send_sign_request(
416450
Ok(())
417451
}
418452

453+
/// Sign with every signing domain in `running` (including duplicates), asserting
454+
/// each request succeeds.
455+
pub async fn sign_all_domains(
456+
cluster: &MpcCluster,
457+
running: &RunningContractState,
458+
rng: &mut impl rand::Rng,
459+
) {
460+
let sign_domains = running
461+
.domains
462+
.domains
463+
.iter()
464+
.filter(|d| d.purpose == DomainPurpose::Sign);
465+
466+
for domain in sign_domains {
467+
let payload = match Curve::from(domain.protocol) {
468+
Curve::Edwards25519 => generate_eddsa_payload(rng),
469+
_ => generate_ecdsa_payload(rng),
470+
};
471+
let outcome = cluster
472+
.send_sign_request(domain.id, payload, cluster.default_user_account())
473+
.await
474+
.expect("sign request failed");
475+
assert!(
476+
outcome.is_success(),
477+
"sign request for domain {:?} (protocol {:?}) failed: {:?}",
478+
domain.id,
479+
domain.protocol,
480+
outcome.failure_message()
481+
);
482+
}
483+
}
484+
419485
/// Send a CKD request and assert the network produced a successful response.
420486
///
421487
/// Panics if the request can't be submitted to the contract — the test cannot
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
use crate::common::{
2+
DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, ckd_domain, damgard_etal_domain,
3+
generate_ckd_app_public_key, generate_ecdsa_payload, generate_eddsa_payload, must_get_domain,
4+
must_setup_cluster, wait_metric_on_nodes,
5+
};
6+
7+
use e2e_tests::{CLUSTER_WAIT_TIMEOUT, metrics};
8+
use near_mpc_contract_interface::types::{
9+
DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold,
10+
};
11+
use rand::SeedableRng;
12+
13+
/// Each domain signs under its own reconstruction threshold, not the governance
14+
/// threshold. With 6 nodes and 1 killed, Cait-Sith (needs all 6) can no longer sign
15+
/// while Damgard et al. (`2t - 1 = 5`), CKD (`t = 5`) and Frost (`t = 5`) still can.
16+
#[tokio::test]
17+
#[expect(non_snake_case)]
18+
async fn distinct_reconstruction_thresholds__should_use_per_domain_threshold_when_nodes_are_down() {
19+
// Given
20+
let mut rng = rand::rngs::StdRng::seed_from_u64(0);
21+
let (mut cluster, contract_state) =
22+
must_setup_cluster(DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED, |c| {
23+
c.num_nodes = 6;
24+
c.initial_participant_indices = (0..6).collect();
25+
c.threshold = 6;
26+
c.triples_to_buffer = 2;
27+
c.presignatures_to_buffer = 2;
28+
c.domains = vec![
29+
DomainConfig {
30+
id: DomainId(0),
31+
protocol: Protocol::CaitSith,
32+
reconstruction_threshold: ReconstructionThreshold::new(6),
33+
purpose: DomainPurpose::Sign,
34+
},
35+
damgard_etal_domain(1, 3),
36+
ckd_domain(2, 5),
37+
DomainConfig {
38+
id: DomainId(3),
39+
protocol: Protocol::Frost,
40+
reconstruction_threshold: ReconstructionThreshold::new(5),
41+
purpose: DomainPurpose::Sign,
42+
},
43+
];
44+
})
45+
.await;
46+
47+
let caitsith_domain = must_get_domain(&contract_state, Protocol::CaitSith);
48+
let damgard_domain = must_get_domain(&contract_state, Protocol::DamgardEtAl);
49+
let ckd_domain = must_get_domain(&contract_state, Protocol::ConfidentialKeyDerivation);
50+
let frost_domain = must_get_domain(&contract_state, Protocol::Frost);
51+
52+
// When
53+
cluster.kill_nodes(&[5]).expect("failed to kill node 5");
54+
55+
// Then Damgard et al. (needs 5 signers) still signs.
56+
let outcome = cluster
57+
.send_sign_request(
58+
damgard_domain.id,
59+
generate_ecdsa_payload(&mut rng),
60+
cluster.default_user_account(),
61+
)
62+
.await
63+
.expect("failed to submit Damgard et al. sign request");
64+
assert!(
65+
outcome.is_success(),
66+
"Damgard et al. sign request failed with 5 of 6 nodes alive: {:?}",
67+
outcome.failure_message()
68+
);
69+
70+
// And CKD (its own `t = 5`, not the governance threshold of 6) still derives.
71+
let outcome = cluster
72+
.send_ckd_request(
73+
ckd_domain.id,
74+
generate_ckd_app_public_key(&mut rng),
75+
cluster.default_user_account(),
76+
)
77+
.await
78+
.expect("failed to submit CKD request");
79+
assert!(
80+
outcome.is_success(),
81+
"CKD request failed with 5 of its 5 required signers alive: {:?}",
82+
outcome.failure_message()
83+
);
84+
85+
// And Frost (its own `t = 5`) still signs.
86+
let outcome = cluster
87+
.send_sign_request(
88+
frost_domain.id,
89+
generate_eddsa_payload(&mut rng),
90+
cluster.default_user_account(),
91+
)
92+
.await
93+
.expect("failed to submit Frost sign request");
94+
assert!(
95+
outcome.is_success(),
96+
"Frost sign request failed with 5 of its 5 required signers alive: {:?}",
97+
outcome.failure_message()
98+
);
99+
100+
// And Cait-Sith (needs all 6) is unanswerable. Its request never resolves on
101+
// chain, and the yield auto-timeout outlives the JSON-RPC call, so we race the
102+
// doomed request against the surviving nodes' timeout counter rather than
103+
// awaiting it (see `timeout_metric.rs`).
104+
tokio::select! {
105+
res = wait_metric_on_nodes(
106+
&cluster,
107+
&[0, 1, 2, 3, 4],
108+
metrics::TIMEOUTS_INDEXED,
109+
|v| v >= 1,
110+
CLUSTER_WAIT_TIMEOUT,
111+
) => res.unwrap_or_else(|_| panic!(
112+
"{} did not reach 1 on the surviving nodes — Cait-Sith request was answered \
113+
despite only 5 of its 6 required signers being alive",
114+
metrics::TIMEOUTS_INDEXED
115+
)),
116+
_ = cluster.send_sign_request(
117+
caitsith_domain.id,
118+
generate_ecdsa_payload(&mut rng),
119+
cluster.default_user_account(),
120+
) => panic!(
121+
"Cait-Sith sign request returned before the timeout metric — it should be \
122+
unanswerable with only 5 of 6 required signers alive"
123+
),
124+
}
125+
}

crates/e2e-tests/tests/e2e.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod ckd_verification;
33
mod cleanup_lagging_node;
44
mod common;
55
mod contract_upgrade_compatibility;
6+
mod distinct_reconstruction_thresholds;
67
mod foreign_chain_configuration;
78
mod foreign_chain_tx_validation;
89
mod key_resharing;

crates/e2e-tests/tests/parallel_sign_calls.rs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use near_mpc_contract_interface::types::{
77
};
88
use serde_json::json;
99

10-
/// 9 parallel calls (3 robust ECDSA + 2 ECDSA + 2 EdDSA + 2 CKD) via the test parallel
10+
/// 9 parallel calls (3 DamgardEtAl + 2 ECDSA + 2 EdDSA + 2 CKD) via the test parallel
1111
/// contract, against a 6-node / threshold-5 cluster that carries all four signing-scheme
1212
/// domains. Verifies all calls succeed and both the signature and CKD queues drain.
1313
#[tokio::test]
@@ -25,12 +25,7 @@ async fn mpc_cluster_should_successfully_process_parallel_requests() {
2525
c.initial_participant_indices = (0..6).collect();
2626
c.threshold = 5;
2727
c.domains = vec![
28-
DomainConfig {
29-
id: DomainId(0),
30-
protocol: Protocol::DamgardEtAl,
31-
reconstruction_threshold: ReconstructionThreshold::new(3),
32-
purpose: DomainPurpose::Sign,
33-
},
28+
common::damgard_etal_domain(0, 3),
3429
DomainConfig {
3530
id: DomainId(1),
3631
protocol: Protocol::CaitSith,

0 commit comments

Comments
 (0)