Skip to content

Commit d198118

Browse files
SimonRastikianclaudenetromekevindeforth
authored andcommitted
feat: correlate governancethreshold with reconstructionthreshold (near#3578)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Mårten Blankfors <marten@blankfors.se> Co-authored-by: kevindeforth <32777623+kevindeforth@users.noreply.github.com>
1 parent 28db47b commit d198118

10 files changed

Lines changed: 422 additions & 190 deletions

File tree

crates/contract/src/errors.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,22 @@ pub enum InvalidThreshold {
178178
#[error("Threshold does not meet the minimum absolute requirement")]
179179
MinAbsRequirementFailed,
180180
#[error(
181-
"Threshold does not meet the minimum relative requirement: require at least {required}, found {found}"
181+
"GovernanceThreshold is below the minimum required relative to the participant count: require at least {required}, found {found}"
182182
)]
183183
MinRelRequirementFailed { required: u64, found: u64 },
184184
#[error("Threshold must not exceed number of participants: max {max}, found {found}")]
185185
MaxRequirementFailed { max: u64, found: u64 },
186+
#[error(
187+
"GovernanceThreshold exceeds the maximum allowed relative to the participant count: max {max}, found {found}"
188+
)]
189+
MaxRelRequirementFailed { max: u64, found: u64 },
190+
#[error(
191+
"GovernanceThreshold {governance_threshold} is below the largest ReconstructionThreshold {reconstruction_threshold}"
192+
)]
193+
BelowReconstructionThreshold {
194+
reconstruction_threshold: u64,
195+
governance_threshold: u64,
196+
},
186197
}
187198

188199
#[derive(Debug, PartialEq, Eq, Clone, thiserror::Error)]

crates/contract/src/lib.rs

Lines changed: 85 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,7 @@ use crypto_shared::{
5858
types::{PublicKeyExtended, PublicKeyExtendedConversionError},
5959
};
6060
use errors::{
61-
DomainError, InvalidParameters, InvalidState, InvalidThreshold, PublicKeyError, RespondError,
62-
TeeError,
61+
DomainError, InvalidParameters, InvalidState, PublicKeyError, RespondError, TeeError,
6362
};
6463
use k256::elliptic_curve::PrimeField;
6564
use near_mpc_contract_interface::types::Ed25519PublicKey;
@@ -79,7 +78,7 @@ use near_sdk::{
7978
};
8079
use node_migrations::NodeMigrations;
8180
use primitives::{
82-
domain::DomainRegistry,
81+
domain::{DomainRegistry, max_reconstruction_threshold},
8382
key_state::{AuthenticatedParticipantId, EpochId, KeyEventId, Keyset},
8483
signature::{SignRequestArgs, SignatureRequest, YieldIndex},
8584
thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters},
@@ -925,10 +924,6 @@ impl MpcContract {
925924
proposal,
926925
);
927926

928-
// Defense in depth: never reshare into a participant set smaller than any
929-
// threshold (see `assert_proposal_meets_all_thresholds`).
930-
self.assert_proposal_meets_all_thresholds(&proposal)?;
931-
932927
let tee_upgrade_deadline_duration =
933928
Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds);
934929

@@ -971,47 +966,6 @@ impl MpcContract {
971966
}
972967
}
973968

974-
/// Defense-in-depth guard for [`Self::vote_new_parameters`]: rejects a
975-
/// proposal whose participant set is smaller than the proposed signing
976-
/// threshold or any domain's effective reconstruction threshold (proposed
977-
/// override if present, else the domain's current value). Such a set would
978-
/// leave a key un-signable or un-reconstructible. Redundant with
979-
/// `RunningContractState::process_new_parameters_proposal`.
980-
fn assert_proposal_meets_all_thresholds(
981-
&self,
982-
proposal: &ProposedThresholdParameters,
983-
) -> Result<(), Error> {
984-
let num_participants = u64::try_from(proposal.participants().len())
985-
.expect("participant list should be wayyyy smaller than u64::MAX");
986-
987-
let threshold = proposal.threshold().value();
988-
if threshold > num_participants {
989-
return Err(InvalidThreshold::MaxRequirementFailed {
990-
max: num_participants,
991-
found: threshold,
992-
}
993-
.into());
994-
}
995-
996-
let domains = self.protocol_state.domain_registry()?;
997-
let updates = proposal.per_domain_thresholds();
998-
for domain in domains.domains() {
999-
let effective = updates
1000-
.get(&domain.id)
1001-
.copied()
1002-
.unwrap_or(domain.reconstruction_threshold)
1003-
.inner();
1004-
if effective > num_participants {
1005-
return Err(DomainError::ReconstructionThresholdExceedsParticipants {
1006-
threshold: effective,
1007-
participants: num_participants,
1008-
}
1009-
.into());
1010-
}
1011-
}
1012-
Ok(())
1013-
}
1014-
1015969
/// Propose adding a new set of domains for the MPC network.
1016970
/// If a threshold number of votes are reached on the exact same proposal, this will transition
1017971
/// the contract into the Initializing state to generate keys for the new domains.
@@ -1744,26 +1698,24 @@ impl MpcContract {
17441698
TeeValidationResult::Partial {
17451699
participants_with_valid_attestation,
17461700
} => {
1747-
let threshold = current_params.threshold().value() as usize;
17481701
let remaining = participants_with_valid_attestation.len();
1749-
// Defense in depth: the surviving participant set must cover every
1750-
// threshold the network is bound to — the governance threshold and
1751-
// each domain's reconstruction threshold (the kickout keeps the
1752-
// existing per-domain thresholds). Resharing into a smaller set
1753-
// would leave a key un-signable, so we refuse and wait for manual
1754-
// intervention.
1755-
let max_reconstruction_threshold = running_state
1756-
.domains
1757-
.domains()
1758-
.iter()
1759-
.map(|domain| domain.reconstruction_threshold.inner() as usize)
1760-
.max()
1761-
.unwrap_or(0);
1762-
let required = threshold.max(max_reconstruction_threshold);
1763-
if required > remaining {
1702+
// Defense in depth: the surviving participant set must keep the full
1703+
// threshold relation intact — the GovernanceThreshold must still sit
1704+
// within its bounds for the smaller set (in particular it must not
1705+
// exceed the remaining participant count or the upper cap) and must
1706+
// remain at least every domain's ReconstructionThreshold (the kickout
1707+
// keeps the existing per-domain thresholds). Otherwise we refuse and
1708+
// wait for manual intervention.
1709+
let max_reconstruction_threshold =
1710+
max_reconstruction_threshold(running_state.domains.domains());
1711+
if let Err(err) = ThresholdParameters::validate_governance_against_reconstruction(
1712+
u64::try_from(remaining).expect("participant count fits in u64"),
1713+
current_params.threshold(),
1714+
max_reconstruction_threshold,
1715+
) {
17641716
log!(
1765-
"Fewer than the required number of participants ({}) are left with a valid TEE status ({}). This requires manual intervention. We will not accept new signature requests as a safety precaution.",
1766-
required,
1717+
"Kicking out participants with an invalid TEE status would break the threshold relation ({:?}); {} participants remain with a valid TEE status. This requires manual intervention. We will not accept new signature requests as a safety precaution.",
1718+
err,
17671719
remaining,
17681720
);
17691721
self.accept_requests = false;
@@ -1778,7 +1730,8 @@ impl MpcContract {
17781730
//let n_participants_new = new_participants.len();
17791731
//let new_threshold = (3 * n_participants_new + 4) / 5; // minimum 60%
17801732
//let new_threshold = new_threshold.max(2); // but also minimum 2
1781-
let new_threshold = threshold;
1733+
let new_threshold = usize::try_from(current_params.threshold().value())
1734+
.expect("threshold value fits in usize");
17821735

17831736
let threshold_parameters = ThresholdParameters::new(
17841737
participants_with_valid_attestation,
@@ -2009,6 +1962,12 @@ impl MpcContract {
20091962
for domain in domains.domains() {
20101963
crate::primitives::domain::validate_domain_threshold(domain, num_participants)?;
20111964
}
1965+
// Keep the GovernanceThreshold at least as large as the largest ReconstructionThreshold.
1966+
ThresholdParameters::validate_governance_against_reconstruction(
1967+
num_participants,
1968+
parameters.threshold(),
1969+
max_reconstruction_threshold(domains.domains()),
1970+
)?;
20121971

20131972
// Check that the domains match exactly those in the keyset.
20141973
let domain_ids_from_domains = domains.domains().iter().map(|d| d.id).collect::<Vec<_>>();
@@ -2668,7 +2627,7 @@ mod tests {
26682627
};
26692628

26702629
use super::*;
2671-
use crate::errors::NodeMigrationError;
2630+
use crate::errors::{InvalidCandidateSet, InvalidThreshold, NodeMigrationError};
26722631
use crate::pending_requests::MAX_PENDING_REQUEST_FAN_OUT;
26732632
use crate::primitives::participants::{ParticipantId, ParticipantInfo, Participants};
26742633
use crate::primitives::test_utils::{
@@ -4128,11 +4087,10 @@ mod tests {
41284087
}
41294088

41304089
#[test]
4131-
fn vote_new_parameters__should_reject_when_shrinking_below_unchanged_domain_threshold() {
4132-
// Given: a Running contract with 3 participants and a domain whose
4133-
// reconstruction threshold is 3.
4090+
fn vote_new_parameters__should_reject_when_shrinking_below_governance_threshold() {
4091+
// Given: a Running contract with 4 participants and a GovernanceThreshold of 3.
41344092
let (mut contract, participants, signer, _domain_id) =
4135-
setup_running_contract_with_domain(3, 3, 3);
4093+
setup_running_contract_with_domain(4, 3, 3);
41364094
// ...and a proposal that shrinks the participant set to 2 without touching
41374095
// the per-domain thresholds.
41384096
let proposal = ProposedThresholdParameters::new(
@@ -4143,14 +4101,13 @@ mod tests {
41434101
// When
41444102
let result = vote_params(&mut contract, &signer, &proposal);
41454103

4146-
// Then: the domain's unchanged threshold of 3 exceeds the 2 proposed
4147-
// participants, so the guard rejects it.
4104+
// Then: the candidate-set guard rejects the proposal first, because only 2
4105+
// old participants remain — fewer than the GovernanceThreshold of 3. (Under
4106+
// the GovernanceThreshold >= max(ReconstructionThreshold) invariant this guard
4107+
// always fires before any per-domain ReconstructionThreshold check could.)
41484108
assert_matches!(
41494109
result.unwrap_err(),
4150-
Error::DomainError(DomainError::ReconstructionThresholdExceedsParticipants {
4151-
threshold: 3,
4152-
participants: 2,
4153-
})
4110+
Error::InvalidCandidateSet(InvalidCandidateSet::InsufficientOldParticipants)
41544111
);
41554112
}
41564113

@@ -4177,15 +4134,15 @@ mod tests {
41774134

41784135
#[test]
41794136
fn vote_new_parameters__should_accept_per_domain_threshold_within_participant_count() {
4180-
// Given: a Running contract with 3 participants and one domain.
4137+
// Given: a Running contract with 5 participants (GovernanceThreshold 4) and one domain.
41814138
let (mut contract, participants, signer, domain_id) =
4182-
setup_running_contract_with_domain(3, 2, 2);
4183-
// ...and a proposal raising the domain's reconstruction threshold to 3,
4184-
// which still fits the 3 participants.
4139+
setup_running_contract_with_domain(5, 4, 2);
4140+
// ...and a proposal raising the domain's reconstruction threshold to 4,
4141+
// which fits the 5 participants and does not exceed the GovernanceThreshold.
41854142
let mut per_domain = BTreeMap::new();
4186-
per_domain.insert(domain_id, ReconstructionThreshold::new(3));
4143+
per_domain.insert(domain_id, ReconstructionThreshold::new(4));
41874144
let proposal = ProposedThresholdParameters::new(
4188-
ThresholdParameters::new(participants, Threshold::new(2)).unwrap(),
4145+
ThresholdParameters::new(participants, Threshold::new(4)).unwrap(),
41894146
per_domain,
41904147
);
41914148

@@ -4196,6 +4153,33 @@ mod tests {
41964153
assert_matches!(result, Ok(()));
41974154
}
41984155

4156+
#[test]
4157+
fn vote_new_parameters__should_reject_governance_below_max_reconstruction() {
4158+
// Given: a Running contract with 5 participants, GovernanceThreshold 4, and a
4159+
// domain whose reconstruction threshold is 4.
4160+
let (mut contract, participants, signer, _domain_id) =
4161+
setup_running_contract_with_domain(5, 4, 4);
4162+
// ...and a proposal lowering the GovernanceThreshold to 3 (valid on its own)
4163+
// while the domain keeps its reconstruction threshold of 4.
4164+
let proposal = ProposedThresholdParameters::new(
4165+
ThresholdParameters::new(participants, Threshold::new(3)).unwrap(),
4166+
BTreeMap::new(),
4167+
);
4168+
4169+
// When
4170+
let result = vote_params(&mut contract, &signer, &proposal);
4171+
4172+
// Then: the GovernanceThreshold (3) would fall below the domain's
4173+
// reconstruction threshold (4), so the proposal is rejected.
4174+
assert_matches!(
4175+
result.unwrap_err(),
4176+
Error::InvalidThreshold(InvalidThreshold::BelowReconstructionThreshold {
4177+
reconstruction_threshold: 4,
4178+
governance_threshold: 3,
4179+
})
4180+
);
4181+
}
4182+
41994183
#[test]
42004184
#[should_panic(expected = "Caller must be the signer account")]
42014185
fn vote_new_parameters__should_panic_when_predecessor_differs_from_signer() {
@@ -5652,19 +5636,26 @@ mod tests {
56525636
}
56535637

56545638
/// Tests that [`MpcContract::verify_tee`] refuses to reshare when a TEE
5655-
/// kickout would leave fewer participants than a domain's reconstruction
5656-
/// threshold, even though the remaining set still meets the governance
5657-
/// threshold. The contract stays Running and stops accepting requests.
5639+
/// kickout would leave fewer participants than the threshold relation requires.
5640+
/// The contract stays Running and stops accepting requests.
56585641
#[test]
5659-
fn verify_tee__should_refuse_kickout_below_domain_reconstruction_threshold() {
5642+
fn verify_tee__should_refuse_kickout_when_remaining_breaks_threshold_relation() {
56605643
const PARTICIPANT_COUNT: usize = 5;
56615644
const ATTESTATION_EXPIRY_SECONDS: u64 = 5;
56625645
const TEE_UPGRADE_DURATION: Duration = Duration::MAX;
56635646

5664-
// Given: 5 participants, governance threshold 3, and one domain whose
5665-
// reconstruction threshold is 5 (every participant is needed to sign).
5647+
// Given: 5 participants, GovernanceThreshold 5, and one domain whose
5648+
// reconstruction threshold is 5 (every participant is needed to sign). Dropping
5649+
// to 4 participants would leave the GovernanceThreshold above the participant
5650+
// count, breaking the threshold relation.
56665651
let participants = gen_participants(PARTICIPANT_COUNT);
5667-
let parameters = ThresholdParameters::new(participants.clone(), Threshold::new(3)).unwrap();
5652+
let parameters = ThresholdParameters::new(
5653+
participants.clone(),
5654+
Threshold::new(
5655+
u64::try_from(PARTICIPANT_COUNT).expect("participant count fits in u64"),
5656+
),
5657+
)
5658+
.unwrap();
56685659
let domain_id = DomainId::default();
56695660
let domains = vec![DomainConfig {
56705661
id: domain_id,
@@ -5717,9 +5708,9 @@ mod tests {
57175708
// When
57185709
let result = contract.verify_tee();
57195710

5720-
// Then: the 4 surviving participants meet the governance threshold (3) but
5721-
// not the domain's reconstruction threshold (5), so verify_tee refuses to
5722-
// reshare, stays Running, and stops accepting requests.
5711+
// Then: with only 4 surviving participants the GovernanceThreshold of 5 would
5712+
// exceed the participant count, breaking the threshold relation, so verify_tee
5713+
// refuses to reshare, stays Running, and stops accepting requests.
57235714
assert_matches!(result, Ok(false));
57245715
assert_matches!(contract.protocol_state, ProtocolContractState::Running(_));
57255716
assert!(!contract.accept_requests);

crates/contract/src/primitives/domain.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ pub fn validate_domain_threshold(
7272
Ok(())
7373
}
7474

75+
/// The largest `ReconstructionThreshold` across `domains`, or `None` if there are none
76+
/// (an empty set imposes no cross-domain lower bound on the GovernanceThreshold).
77+
/// Feeds [`ThresholdParameters::validate_governance_against_reconstruction`](crate::primitives::thresholds::ThresholdParameters::validate_governance_against_reconstruction).
78+
pub fn max_reconstruction_threshold(domains: &[DomainConfig]) -> Option<ReconstructionThreshold> {
79+
domains
80+
.iter()
81+
.map(|domain| domain.reconstruction_threshold)
82+
.max()
83+
}
84+
7585
/// All the domains present in the contract, as well as the next domain ID which is kept to ensure
7686
/// that we never reuse domain IDs. (Domains may be deleted in only one case: when we decided to
7787
/// add domains but ultimately canceled that process.)

crates/contract/src/primitives/test_utils.rs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ use crate::{
33
crypto_shared::types::{PublicKeyExtended, serializable::SerializableEdwardsPoint},
44
primitives::{
55
participants::{ParticipantInfo, Participants},
6-
thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters},
6+
thresholds::{
7+
ProposedThresholdParameters, Threshold, ThresholdParameters,
8+
governance_threshold_lower_relative_bound, governance_threshold_upper_relative_bound,
9+
},
710
},
811
};
912
use curve25519_dalek::edwards::CompressedEdwardsY;
1013
use near_account_id::AccountId;
1114
use near_mpc_contract_interface::types::{
1215
DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold,
1316
};
14-
use rand::{Rng, distributions::Uniform};
17+
use rand::{Rng, SeedableRng, distributions::Uniform, rngs::StdRng};
1518
use std::collections::BTreeMap;
1619
// Re-export for convenience
1720

@@ -129,10 +132,6 @@ pub fn gen_participant(i: usize) -> (AccountId, ParticipantInfo) {
129132
)
130133
}
131134

132-
pub fn min_thrershold(n: usize) -> usize {
133-
((n as f64) * 0.6).ceil() as usize
134-
}
135-
136135
pub fn gen_accounts_and_info(n: usize) -> BTreeMap<AccountId, ParticipantInfo> {
137136
(0..n).map(gen_participant).collect()
138137
}
@@ -157,9 +156,11 @@ pub fn gen_threshold_params(max_n: usize) -> ThresholdParameters {
157156
// Lower bound is 3 (not 2) so the produced parameters are compatible with
158157
// every protocol — `DamgardEtAl` requires `n >= 2t - 1`, which forces
159158
// `n >= 3` even at the minimum `t = 2`.
160-
let n: usize = rand::thread_rng().gen_range(3..max_n + 1);
161-
let k_min = min_thrershold(n);
162-
let k = rand::thread_rng().gen_range(k_min..n + 1);
159+
let mut rng = StdRng::seed_from_u64(42);
160+
let n: usize = rng.gen_range(3..max_n + 1);
161+
let k_min = governance_threshold_lower_relative_bound(n as u64) as usize;
162+
let k_max = governance_threshold_upper_relative_bound(n as u64) as usize;
163+
let k = rng.gen_range(k_min..k_max + 1);
163164
ThresholdParameters::new(gen_participants(n), Threshold::new(k as u64)).unwrap()
164165
}
165166

0 commit comments

Comments
 (0)