feat: correlate governancethreshold with reconstructionthreshold - #3578
Conversation
…same number of participants
Pull request overviewThis PR enforces the cross-domain rule Changes:
Reviewed changesPer-file summary
FindingsBlocking (must fix before merge):
Non-blocking (worth addressing before/after merge):
✅ Approved |
… possible value due to 60%-80% range
…_bound as helpers
SimonRastikian
left a comment
There was a problem hiding this comment.
The reviewer please double (triple) check that I have covered all the necessary functions with these checks, namely all functions that:
add / remove / kick out participants
update the contract participant set
update the reconstruction threshold
update the governance threshold
| for domain in &effective_domains { | ||
| validate_domain_threshold(domain, new_num_participants)?; | ||
| } | ||
|
|
||
| // The GovernanceThreshold must dominate every domain's effective ReconstructionThreshold; | ||
| // enforced here so the state transition is self-contained (single source of truth). | ||
| ThresholdParameters::validate_governance_against_reconstruction( | ||
| new_num_participants, | ||
| proposal.threshold(), | ||
| max_reconstruction_threshold(&effective_domains), | ||
| )?; |
There was a problem hiding this comment.
Here there is a tiny redundancy in checks namely that reconstruction <= num_participants. but otherwise 179-181 checks extra that reconstruction > 2 and that 2*reconstruction-1 < num_participants when in DamgardEtAl.
kevindeforth
left a comment
There was a problem hiding this comment.
I am not sure we want to cap the threshold at 80% in the code.
Makes testing harder and it's not obvious to me why we would impose such a requirement.
…nstructionthreshold
…inator) Colleagues did not agree on an 80% upper bound for the GovernanceThreshold, so set MAX_THRESHOLD_NUMERATOR = MAX_THRESHOLD_DENOMINATOR (5/5 = 100%). The relative upper cap structure is kept but never binds below the absolute `k <= n` check, so the GovernanceThreshold may again go up to the participant count. The cross-domain rule (GovernanceThreshold >= max(ReconstructionThreshold)) is unchanged. Revert the test changes that were only needed to satisfy the 80% cap (dropping thresholds / raising participant counts) and remove the now-meaningless dedicated upper-cap tests: - thresholds.rs: restore 5/5-participant thresholds; drop reject-above-cap test - dto_mapping.rs / lib.rs: drop the upper-cap rejection tests - lib.rs verify_tee: rework the kickout-refusal fixture to break the relation via the participant-count ceiling instead of the cap - running.rs: make the reconstruction>governance test regenerate until gov < n - sandbox + e2e + node resharing tests: restore original participant/threshold values - docs/design/domain-separation.md: describe the cap as disabled (100%) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hreshold' of github.com:near/mpc into 3499-correlate-governancethreshold-with-reconstructionthreshold
…hreshold' of github.com:near/mpc into 3499-correlate-governancethreshold-with-reconstructionthreshold # Conflicts: # crates/contract/src/dto_mapping.rs
Co-authored-by: kevindeforth <32777623+kevindeforth@users.noreply.github.com>
…ance threshold in tests
| 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); |
There was a problem hiding this comment.
technically this should be a parameter to the function, but for the sake of keeping this simple we can leave it here
There was a problem hiding this comment.
As you said, it's simpler this way
There was a problem hiding this comment.
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.
| #[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. | ||
| let (mut contract, participants, signer, _domain_id) = | ||
| setup_running_contract_with_domain(3, 3, 3); | ||
| // ...and a proposal that shrinks the participant set to 2 without touching | ||
| // the per-domain thresholds. | ||
| let proposal = ProposedThresholdParameters::new( | ||
| ThresholdParameters::new(participants.subset(0..2), Threshold::new(2)).unwrap(), | ||
| BTreeMap::new(), | ||
| ); | ||
|
|
||
| // 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. | ||
| assert_matches!( | ||
| result.unwrap_err(), | ||
| Error::DomainError(DomainError::ReconstructionThresholdExceedsParticipants { | ||
| threshold: 3, | ||
| participants: 2, | ||
| }) | ||
| ); | ||
| } | ||
|
|
There was a problem hiding this comment.
I believe the two tests are not equivalent, appear to touch a different path of execution, so please restore this one. That they assert the same error does not mean they cover the same cases
…hreshold' of github.com:near/mpc into 3499-correlate-governancethreshold-with-reconstructionthreshold
gilcu3
left a comment
There was a problem hiding this comment.
Thank you!
left only a nit (over a hack)
kevindeforth
left a comment
There was a problem hiding this comment.
Thank you, looks good!
I have one concern about making a previously randomized helper method non-random, which means that many tests now us the exact same setup. Needs to be addressed, but can be done in a follow-up.
| pub fn validate_governance_against_reconstruction( | ||
| num_participants: u64, | ||
| governance: Threshold, | ||
| max_reconstruction_threshold: Option<ReconstructionThreshold>, |
There was a problem hiding this comment.
Note (non-blocking): I don't like this being optional. I would prefer this to be a u64.
Reason for this is that as a reviewer, I get more suspicious when I see a function call passing a magical 0, compared to a function passing a magical None.
| #[test] | ||
| fn validate_threshold__should_not_produce_empty_window_for_small_n() { | ||
| // The relative upper cap is clamped up to the ceil(0.6n) lower bound, so the | ||
| // feasible window must always hold at least one valid threshold. | ||
| for n in 2..=12u64 { | ||
| let lower = governance_threshold_lower_relative_bound(n); | ||
| let upper = governance_threshold_upper_relative_bound(n); | ||
| assert!(upper >= lower, "empty window at n={n}: [{lower}, {upper}]"); | ||
| // The clamped boundary value must validate. | ||
| ThresholdParameters::validate_threshold(n, Threshold::new(upper)).unwrap(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Note: This test seems redundant, as it is covered by test_validate_threshold, but we can keep it.
| fn validate_governance_against_reconstruction__should_reject_governance_below_max_reconstruction() | ||
| { | ||
| // Given 10 participants and a governance threshold of 6 (a valid value on its own). | ||
| let n = 10; | ||
| let governance = Threshold::new(6); | ||
| // When the largest reconstruction threshold is 7 (above governance). | ||
| // Then the relation is rejected. | ||
| assert_matches!( | ||
| ThresholdParameters::validate_governance_against_reconstruction( | ||
| n, | ||
| governance, | ||
| Some(ReconstructionThreshold::new(7)) | ||
| ), | ||
| Err(Error::InvalidThreshold( | ||
| InvalidThreshold::BelowReconstructionThreshold { | ||
| reconstruction_threshold: 7, | ||
| governance_threshold: 6, | ||
| } | ||
| )) | ||
| ); | ||
| // ...but is accepted when governance meets or exceeds the max reconstruction threshold. | ||
| ThresholdParameters::validate_governance_against_reconstruction( | ||
| n, | ||
| governance, | ||
| Some(ReconstructionThreshold::new(6)), | ||
| ) | ||
| .unwrap(); | ||
| ThresholdParameters::validate_governance_against_reconstruction( | ||
| n, | ||
| governance, | ||
| Some(ReconstructionThreshold::new(5)), | ||
| ) |
There was a problem hiding this comment.
Note: this test would be nicer if it looped over a bunch of values instead of just one.
| 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); |
There was a problem hiding this comment.
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.
| env.set_signer(&state.parameters.participants().participants()[0].0); | ||
| let n = state.parameters.participants().len() as u64; | ||
| let proposal = single_domain_proposal(&state, Protocol::CaitSith, DomainPurpose::Sign, n); | ||
| // Use the GovernanceThreshold as the ReconstructionThreshold (the maximum allowed). |
There was a problem hiding this comment.
Do our tests pass if we have more variety here and select a threshold in [2, governance_threshold]?
| } | ||
| /// Generates a Running state that contains this many domains. | ||
| /// Generates a Running state that contains this many domains, with randomly | ||
| /// generated threshold parameters. |
There was a problem hiding this comment.
Please update the doc comment to reflect the new behavior gen_threshold_params will output the exact same threshold for the same n, it's not really "random" anymore.
| /// Like [`gen_running_state`], but pins the participant count and | ||
| /// GovernanceThreshold instead of randomizing them. |
There was a problem hiding this comment.
Note: It's overhead for the reader to look-up what gen_running_state does.
…r#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>
Closes #3499