Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
02dd798
make the two threshold DTO conversions fallible & validating
SimonRastikian Jun 15, 2026
44e9e76
Adding validation (upper cap and wrt the ReconstructionThreshold)
SimonRastikian Jun 15, 2026
7d86856
Updating tests now GovThreshold is capped at 80% no more allowed the …
SimonRastikian Jun 15, 2026
b33f455
Panic in migration misconfig
SimonRastikian Jun 15, 2026
02c5313
Remove migration function after migration
SimonRastikian Jun 15, 2026
a378c56
Raising the number of participants in the test to meet the 80% upper cap
SimonRastikian Jun 16, 2026
3da1652
Raising extra tests number of particpants to meet the 80% cap
SimonRastikian Jun 16, 2026
aeed6e7
Adding note documentation about cases where the threshold is a single…
SimonRastikian Jun 16, 2026
7257a16
Using already existent helpers in vote_add_domains
SimonRastikian Jun 16, 2026
a20a7a8
Bump the number of participants for parallel_sign_calls to meet 80% u…
SimonRastikian Jun 16, 2026
1afd958
make process_new_parameters_proposal self-contained
SimonRastikian Jun 16, 2026
db47b85
Removing redundant function
SimonRastikian Jun 16, 2026
0cf1b53
Obsolete test as the check happens in the underlying function
SimonRastikian Jun 16, 2026
27e40bf
make function private
SimonRastikian Jun 16, 2026
7878ac8
Fixing tests with govthreshold = number of participants
SimonRastikian Jun 16, 2026
1f28966
More participants during resharing
SimonRastikian Jun 16, 2026
7bca4d8
Raising the number of participants to 7
SimonRastikian Jun 16, 2026
263bc8d
Using governance_threshold_lower_bound and governance_threshold_upper…
SimonRastikian Jun 16, 2026
f45ebf5
cargo fmt
SimonRastikian Jun 16, 2026
c8fec49
reducing code redundancy
SimonRastikian Jun 16, 2026
6e1ce47
Some more redundancy reduction
SimonRastikian Jun 16, 2026
5056109
Merge branch 'main' into 3499-correlate-governancethreshold-with-reco…
SimonRastikian Jun 16, 2026
5f5af82
Disable 80% governance upper cap (set MAX_THRESHOLD_NUMERATOR = denom…
SimonRastikian Jun 16, 2026
fbf22be
Merge branch '3499-correlate-governancethreshold-with-reconstructiont…
SimonRastikian Jun 16, 2026
b310e84
delimiter fix
SimonRastikian Jun 16, 2026
ca8b199
Merge branch '3499-correlate-governancethreshold-with-reconstructiont…
SimonRastikian Jun 16, 2026
c34a13a
test: guard flaky add-domain test against governance < reconstruction
SimonRastikian Jun 16, 2026
da2d190
Update crates/contract/src/v3_11_2_state.rs
SimonRastikian Jun 16, 2026
7458432
using ReconstructionThreshold as u64 and using checked arithmetic
SimonRastikian Jun 16, 2026
4f52bdd
Using 1 as values
SimonRastikian Jun 16, 2026
29613b8
editting doc to make fast ci check pass
SimonRastikian Jun 16, 2026
3b329e2
collapsing if statement
SimonRastikian Jun 16, 2026
f168468
Reducing verbosity of the upper/lower bounds functions
SimonRastikian Jun 17, 2026
cd557e1
nit
SimonRastikian Jun 17, 2026
e769a53
fixing conflict
SimonRastikian Jun 19, 2026
df26736
modifying the design doc
SimonRastikian Jun 19, 2026
876bcfe
Improving upper cap paragraph
SimonRastikian Jun 19, 2026
fc57a33
cargo fmt
SimonRastikian Jun 19, 2026
484d40e
calling function in crate
SimonRastikian Jun 19, 2026
ec871da
No need for variable
SimonRastikian Jun 19, 2026
ee76a11
no panic
SimonRastikian Jun 19, 2026
6a4fa9e
deterministic test
SimonRastikian Jun 19, 2026
0ef09e5
explaining relative to the participant count
SimonRastikian Jun 19, 2026
bc928d4
cargo fmt
SimonRastikian Jun 19, 2026
6da939c
Update crates/contract/src/primitives/thresholds.rs
SimonRastikian Jun 19, 2026
e6f5a9a
Update crates/contract/src/primitives/thresholds.rs
SimonRastikian Jun 19, 2026
6c2a728
Update crates/contract/src/lib.rs
SimonRastikian Jun 19, 2026
ceff551
using expect
SimonRastikian Jun 22, 2026
e72e801
expect
SimonRastikian Jun 22, 2026
c1d3625
Renaming properly the functions
SimonRastikian Jun 22, 2026
7d46e93
Relaxed comment
SimonRastikian Jun 22, 2026
187de2a
Using more expect and deleting deadcode
SimonRastikian Jun 22, 2026
c7b3efb
cargo fmt
SimonRastikian Jun 22, 2026
37465bd
Add gen_running_state_with_params to pin participant count and govern…
SimonRastikian Jun 22, 2026
c9ab983
Revert new_threshold_2 to new_threshold now that the governance upper…
SimonRastikian Jun 22, 2026
105a449
Route resharing tests through gen_running_state_with_params
SimonRastikian Jun 22, 2026
01ffb27
Merge branch '3499-correlate-governancethreshold-with-reconstructiont…
SimonRastikian Jun 22, 2026
afbe6b8
Remove todo
SimonRastikian Jun 22, 2026
d8f7e81
Reinserting test
SimonRastikian Jun 22, 2026
f455d91
Making 3 functions 2
SimonRastikian Jun 22, 2026
5e97933
Failing example
SimonRastikian Jun 22, 2026
0d6e0f6
no need for the hack
SimonRastikian Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion crates/contract/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,22 @@ pub enum InvalidThreshold {
#[error("Threshold does not meet the minimum absolute requirement")]
MinAbsRequirementFailed,
#[error(
"Threshold does not meet the minimum relative requirement: require at least {required}, found {found}"
"GovernanceThreshold is below the minimum required relative to the participant count: require at least {required}, found {found}"
)]
MinRelRequirementFailed { required: u64, found: u64 },
#[error("Threshold must not exceed number of participants: max {max}, found {found}")]
MaxRequirementFailed { max: u64, found: u64 },
#[error(
"GovernanceThreshold exceeds the maximum allowed relative to the participant count: max {max}, found {found}"
)]
MaxRelRequirementFailed { max: u64, found: u64 },
#[error(
"GovernanceThreshold {governance_threshold} is below the largest ReconstructionThreshold {reconstruction_threshold}"
)]
BelowReconstructionThreshold {
reconstruction_threshold: u64,
governance_threshold: u64,
},
}

#[derive(Debug, PartialEq, Eq, Clone, thiserror::Error)]
Expand Down
179 changes: 85 additions & 94 deletions crates/contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,7 @@ use crypto_shared::{
types::{PublicKeyExtended, PublicKeyExtendedConversionError},
};
use errors::{
DomainError, InvalidParameters, InvalidState, InvalidThreshold, PublicKeyError, RespondError,
TeeError,
DomainError, InvalidParameters, InvalidState, PublicKeyError, RespondError, TeeError,
};
use k256::elliptic_curve::PrimeField;
use near_mpc_contract_interface::types::Ed25519PublicKey;
Expand All @@ -79,7 +78,7 @@ use near_sdk::{
};
use node_migrations::NodeMigrations;
use primitives::{
domain::DomainRegistry,
domain::{DomainRegistry, max_reconstruction_threshold},
key_state::{AuthenticatedParticipantId, EpochId, KeyEventId, Keyset},
signature::{SignRequestArgs, SignatureRequest, YieldIndex},
thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters},
Expand Down Expand Up @@ -890,10 +889,6 @@ impl MpcContract {
proposal,
);

// Defense in depth: never reshare into a participant set smaller than any
// threshold (see `assert_proposal_meets_all_thresholds`).
self.assert_proposal_meets_all_thresholds(&proposal)?;

Comment thread
SimonRastikian marked this conversation as resolved.
let tee_upgrade_deadline_duration =
Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds);

Expand Down Expand Up @@ -936,47 +931,6 @@ impl MpcContract {
}
}

/// Defense-in-depth guard for [`Self::vote_new_parameters`]: rejects a
/// proposal whose participant set is smaller than the proposed signing
/// threshold or any domain's effective reconstruction threshold (proposed
/// override if present, else the domain's current value). Such a set would
/// leave a key un-signable or un-reconstructible. Redundant with
/// `RunningContractState::process_new_parameters_proposal`.
fn assert_proposal_meets_all_thresholds(
&self,
proposal: &ProposedThresholdParameters,
) -> Result<(), Error> {
let num_participants = u64::try_from(proposal.participants().len())
.expect("participant list should be wayyyy smaller than u64::MAX");

let threshold = proposal.threshold().value();
if threshold > num_participants {
return Err(InvalidThreshold::MaxRequirementFailed {
max: num_participants,
found: threshold,
}
.into());
}

let domains = self.protocol_state.domain_registry()?;
let updates = proposal.per_domain_thresholds();
for domain in domains.domains() {
let effective = updates
.get(&domain.id)
.copied()
.unwrap_or(domain.reconstruction_threshold)
.inner();
if effective > num_participants {
return Err(DomainError::ReconstructionThresholdExceedsParticipants {
threshold: effective,
participants: num_participants,
}
.into());
}
}
Ok(())
}

/// Propose adding a new set of domains for the MPC network.
/// If a threshold number of votes are reached on the exact same proposal, this will transition
/// the contract into the Initializing state to generate keys for the new domains.
Expand Down Expand Up @@ -1709,26 +1663,24 @@ impl MpcContract {
TeeValidationResult::Partial {
participants_with_valid_attestation,
} => {
let threshold = current_params.threshold().value() as usize;
let remaining = participants_with_valid_attestation.len();
// Defense in depth: the surviving participant set must cover every
// threshold the network is bound to — the governance threshold and
// each domain's reconstruction threshold (the kickout keeps the
// existing per-domain thresholds). Resharing into a smaller set
// would leave a key un-signable, so we refuse and wait for manual
// intervention.
let max_reconstruction_threshold = running_state
.domains
.domains()
.iter()
.map(|domain| domain.reconstruction_threshold.inner() as usize)
.max()
.unwrap_or(0);
let required = threshold.max(max_reconstruction_threshold);
if required > remaining {
// Defense in depth: the surviving participant set must keep the full
// threshold relation intact — the GovernanceThreshold must still sit
// within its bounds for the smaller set (in particular it must not
// exceed the remaining participant count or the upper cap) and must
// remain at least every domain's ReconstructionThreshold (the kickout
// keeps the existing per-domain thresholds). Otherwise we refuse and
// wait for manual intervention.
let max_reconstruction_threshold =
max_reconstruction_threshold(running_state.domains.domains());
if let Err(err) = ThresholdParameters::validate_governance_against_reconstruction(
u64::try_from(remaining).expect("participant count fits in u64"),
current_params.threshold(),
max_reconstruction_threshold,
) {
log!(
"Fewer than the required number of participants ({}) are left with a valid TEE status ({}). This requires manual intervention. We will not accept new signature requests as a safety precaution.",
required,
"Kicking out participants with an invalid TEE status would break the threshold relation ({:?}); {} participants remain with a valid TEE status. This requires manual intervention. We will not accept new signature requests as a safety precaution.",
err,
remaining,
);
self.accept_requests = false;
Expand All @@ -1743,7 +1695,8 @@ impl MpcContract {
//let n_participants_new = new_participants.len();
//let new_threshold = (3 * n_participants_new + 4) / 5; // minimum 60%
//let new_threshold = new_threshold.max(2); // but also minimum 2
let new_threshold = threshold;
let new_threshold = usize::try_from(current_params.threshold().value())
.expect("threshold value fits in usize");

let threshold_parameters = ThresholdParameters::new(
participants_with_valid_attestation,
Expand Down Expand Up @@ -1974,6 +1927,12 @@ impl MpcContract {
for domain in domains.domains() {
crate::primitives::domain::validate_domain_threshold(domain, num_participants)?;
}
// Keep the GovernanceThreshold at least as large as the largest ReconstructionThreshold.
ThresholdParameters::validate_governance_against_reconstruction(
num_participants,
parameters.threshold(),
max_reconstruction_threshold(domains.domains()),
)?;

// Check that the domains match exactly those in the keyset.
let domain_ids_from_domains = domains.domains().iter().map(|d| d.id).collect::<Vec<_>>();
Expand Down Expand Up @@ -2633,7 +2592,7 @@ mod tests {
};

use super::*;
use crate::errors::NodeMigrationError;
use crate::errors::{InvalidCandidateSet, InvalidThreshold, NodeMigrationError};
use crate::pending_requests::MAX_PENDING_REQUEST_FAN_OUT;
use crate::primitives::participants::{ParticipantId, ParticipantInfo, Participants};
use crate::primitives::test_utils::{
Expand Down Expand Up @@ -4073,11 +4032,10 @@ mod tests {
}

#[test]
fn vote_new_parameters__should_reject_when_shrinking_below_unchanged_domain_threshold() {
// Given: a Running contract with 3 participants and a domain whose
// reconstruction threshold is 3.
fn vote_new_parameters__should_reject_when_shrinking_below_governance_threshold() {
// Given: a Running contract with 4 participants and a GovernanceThreshold of 3.
let (mut contract, participants, signer, _domain_id) =
setup_running_contract_with_domain(3, 3, 3);
setup_running_contract_with_domain(4, 3, 3);
// ...and a proposal that shrinks the participant set to 2 without touching
// the per-domain thresholds.
let proposal = ProposedThresholdParameters::new(
Expand All @@ -4088,14 +4046,13 @@ mod tests {
// When
let result = vote_params(&mut contract, &signer, &proposal);

// Then: the domain's unchanged threshold of 3 exceeds the 2 proposed
// participants, so the guard rejects it.
// Then: the candidate-set guard rejects the proposal first, because only 2
// old participants remain — fewer than the GovernanceThreshold of 3. (Under
// the GovernanceThreshold >= max(ReconstructionThreshold) invariant this guard
// always fires before any per-domain ReconstructionThreshold check could.)
assert_matches!(
result.unwrap_err(),
Error::DomainError(DomainError::ReconstructionThresholdExceedsParticipants {
threshold: 3,
participants: 2,
})
Error::InvalidCandidateSet(InvalidCandidateSet::InsufficientOldParticipants)
);
}

Expand All @@ -4122,15 +4079,15 @@ mod tests {

#[test]
fn vote_new_parameters__should_accept_per_domain_threshold_within_participant_count() {
// Given: a Running contract with 3 participants and one domain.
// Given: a Running contract with 5 participants (GovernanceThreshold 4) and one domain.
let (mut contract, participants, signer, domain_id) =
setup_running_contract_with_domain(3, 2, 2);
// ...and a proposal raising the domain's reconstruction threshold to 3,
// which still fits the 3 participants.
setup_running_contract_with_domain(5, 4, 2);
// ...and a proposal raising the domain's reconstruction threshold to 4,
// which fits the 5 participants and does not exceed the GovernanceThreshold.
let mut per_domain = BTreeMap::new();
per_domain.insert(domain_id, ReconstructionThreshold::new(3));
per_domain.insert(domain_id, ReconstructionThreshold::new(4));
let proposal = ProposedThresholdParameters::new(
ThresholdParameters::new(participants, Threshold::new(2)).unwrap(),
ThresholdParameters::new(participants, Threshold::new(4)).unwrap(),
per_domain,
Comment thread
SimonRastikian marked this conversation as resolved.
);

Expand All @@ -4141,6 +4098,33 @@ mod tests {
assert_matches!(result, Ok(()));
}

#[test]
fn vote_new_parameters__should_reject_governance_below_max_reconstruction() {
// Given: a Running contract with 5 participants, GovernanceThreshold 4, and a
// domain whose reconstruction threshold is 4.
let (mut contract, participants, signer, _domain_id) =
setup_running_contract_with_domain(5, 4, 4);
// ...and a proposal lowering the GovernanceThreshold to 3 (valid on its own)
// while the domain keeps its reconstruction threshold of 4.
let proposal = ProposedThresholdParameters::new(
ThresholdParameters::new(participants, Threshold::new(3)).unwrap(),
BTreeMap::new(),
);

// When
let result = vote_params(&mut contract, &signer, &proposal);

// Then: the GovernanceThreshold (3) would fall below the domain's
// reconstruction threshold (4), so the proposal is rejected.
assert_matches!(
result.unwrap_err(),
Error::InvalidThreshold(InvalidThreshold::BelowReconstructionThreshold {
reconstruction_threshold: 4,
governance_threshold: 3,
})
);
}

#[test]
#[should_panic(expected = "Caller must be the signer account")]
fn vote_new_parameters__should_panic_when_predecessor_differs_from_signer() {
Expand Down Expand Up @@ -5597,19 +5581,26 @@ mod tests {
}

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

// Given: 5 participants, governance threshold 3, and one domain whose
// reconstruction threshold is 5 (every participant is needed to sign).
// Given: 5 participants, GovernanceThreshold 5, and one domain whose
// reconstruction threshold is 5 (every participant is needed to sign). Dropping
// to 4 participants would leave the GovernanceThreshold above the participant
// count, breaking the threshold relation.
let participants = gen_participants(PARTICIPANT_COUNT);
let parameters = ThresholdParameters::new(participants.clone(), Threshold::new(3)).unwrap();
let parameters = ThresholdParameters::new(
participants.clone(),
Threshold::new(
u64::try_from(PARTICIPANT_COUNT).expect("participant count fits in u64"),
),
)
.unwrap();
let domain_id = DomainId::default();
Comment thread
SimonRastikian marked this conversation as resolved.
let domains = vec![DomainConfig {
id: domain_id,
Expand Down Expand Up @@ -5662,9 +5653,9 @@ mod tests {
// When
let result = contract.verify_tee();

// Then: the 4 surviving participants meet the governance threshold (3) but
// not the domain's reconstruction threshold (5), so verify_tee refuses to
// reshare, stays Running, and stops accepting requests.
// Then: with only 4 surviving participants the GovernanceThreshold of 5 would
// exceed the participant count, breaking the threshold relation, so verify_tee
// refuses to reshare, stays Running, and stops accepting requests.
assert_matches!(result, Ok(false));
assert_matches!(contract.protocol_state, ProtocolContractState::Running(_));
assert!(!contract.accept_requests);
Expand Down
10 changes: 10 additions & 0 deletions crates/contract/src/primitives/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ pub fn validate_domain_threshold(
Ok(())
}

/// The largest `ReconstructionThreshold` across `domains`, or `None` if there are none
/// (an empty set imposes no cross-domain lower bound on the GovernanceThreshold).
/// Feeds [`ThresholdParameters::validate_governance_against_reconstruction`](crate::primitives::thresholds::ThresholdParameters::validate_governance_against_reconstruction).
pub fn max_reconstruction_threshold(domains: &[DomainConfig]) -> Option<ReconstructionThreshold> {
domains
.iter()
.map(|domain| domain.reconstruction_threshold)
.max()
}

/// All the domains present in the contract, as well as the next domain ID which is kept to ensure
/// that we never reuse domain IDs. (Domains may be deleted in only one case: when we decided to
/// add domains but ultimately canceled that process.)
Expand Down
19 changes: 10 additions & 9 deletions crates/contract/src/primitives/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ use crate::{
crypto_shared::types::{PublicKeyExtended, serializable::SerializableEdwardsPoint},
primitives::{
participants::{ParticipantInfo, Participants},
thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters},
thresholds::{
ProposedThresholdParameters, Threshold, ThresholdParameters,
governance_threshold_lower_relative_bound, governance_threshold_upper_relative_bound,
},
},
};
use curve25519_dalek::edwards::CompressedEdwardsY;
use near_account_id::AccountId;
use near_mpc_contract_interface::types::{
DomainConfig, DomainId, DomainPurpose, Protocol, ReconstructionThreshold,
};
use rand::{Rng, distributions::Uniform};
use rand::{Rng, SeedableRng, distributions::Uniform, rngs::StdRng};
use std::collections::BTreeMap;
// Re-export for convenience

Expand Down Expand Up @@ -129,10 +132,6 @@ pub fn gen_participant(i: usize) -> (AccountId, ParticipantInfo) {
)
}

pub fn min_thrershold(n: usize) -> usize {
((n as f64) * 0.6).ceil() as usize
}

pub fn gen_accounts_and_info(n: usize) -> BTreeMap<AccountId, ParticipantInfo> {
(0..n).map(gen_participant).collect()
}
Expand All @@ -157,9 +156,11 @@ pub fn gen_threshold_params(max_n: usize) -> ThresholdParameters {
// Lower bound is 3 (not 2) so the produced parameters are compatible with
// every protocol — `DamgardEtAl` requires `n >= 2t - 1`, which forces
// `n >= 3` even at the minimum `t = 2`.
let n: usize = rand::thread_rng().gen_range(3..max_n + 1);
let k_min = min_thrershold(n);
let k = rand::thread_rng().gen_range(k_min..n + 1);
let mut rng = StdRng::seed_from_u64(42);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically this should be a parameter to the function, but for the sake of keeping this simple we can leave it here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you said, it's simpler this way

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is an improvement without passing the RNG as a parameter, because this is a pretty low-level helper method, so we will now generate the exact same threshold params for any given n for most of our tests.

@SimonRastikian please modify in this PR or open a follow-up for this.

let n: usize = rng.gen_range(3..max_n + 1);
let k_min = governance_threshold_lower_relative_bound(n as u64) as usize;
let k_max = governance_threshold_upper_relative_bound(n as u64) as usize;
let k = rng.gen_range(k_min..k_max + 1);
ThresholdParameters::new(gen_participants(n), Threshold::new(k as u64)).unwrap()
}

Expand Down
Loading
Loading