Skip to content

feat: adding resharing per-domain#3326

Open
SimonRastikian wants to merge 32 commits into
mainfrom
3169-per-domain-ReconstructionThreshold-in-resharing
Open

feat: adding resharing per-domain#3326
SimonRastikian wants to merge 32 commits into
mainfrom
3169-per-domain-ReconstructionThreshold-in-resharing

Conversation

@SimonRastikian
Copy link
Copy Markdown
Contributor

Closes #3169

@SimonRastikian SimonRastikian self-assigned this May 22, 2026
@SimonRastikian SimonRastikian marked this pull request as draft May 22, 2026 12:13
@SimonRastikian SimonRastikian changed the title Adding a new voting mechanism for resharing feat: adding resharing per-domain May 22, 2026
Copy link
Copy Markdown
Contributor Author

@SimonRastikian SimonRastikian left a comment

Choose a reason for hiding this comment

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

Not sure if I need to add requirements for validate_incoming_proposal. In fact the requirements there feel to me very randomly chosen and without clear basis. I wouldn't know if something is overdone or something is missing. However, I am pretty sure there is good logic behind them but it's not clear to me what.
Would need someone's wisdom on that.

Comment thread crates/near-mpc-contract-interface/src/types/state.rs Outdated
@@ -215,6 +215,7 @@ impl IntoContractType<Participants> for dtos::Participants {
impl IntoContractType<ThresholdParameters> for dtos::ThresholdParameters {
fn into_contract_type(self) -> ThresholdParameters {
ThresholdParameters::new_unvalidated(self.participants.into_contract_type(), self.threshold)
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.

I did not want to change that but question to the public: Why is this a new_unvalidated instead of the normal new (with validation)?

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.

@gilcu3 do you have an idea?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think to allow low threshold values in tests that would otherwise not pass in production (e.g. >= 60%)

@SimonRastikian SimonRastikian marked this pull request as ready for review May 22, 2026 13:59
Copy link
Copy Markdown
Contributor

@gilcu3 gilcu3 left a comment

Choose a reason for hiding this comment

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

Thank you!

I have two main problems with the current solution.

  • The first is that it tries to reuse ThresholdParameters, while I think it now makes sense to have one type for the running state:
pub struct ThresholdParameters {
    participants: Participants,
    threshold: Threshold,
}

and another for the voting:

pub struct ThresholdParametersVote {
    participants: Participants,
    threshold: Threshold,
    per_domain_thresholds: BTreeMap<DomainId, ReconstructionThreshold>, 
}
  • The second is that the way to achieve backward compatibility in this PR seems hacky, as it uses:
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]

It should be better to define Compat versions of the modified struct in the interface crate, as we did with the previous migrations.

I am also torn about the overlay concept. Most manual resharing operations will anyway change the thresholds per domain (as they add or remove participants). On the other hand, automatic resharing operations (in the future) should have no problem creating the correct parameters. Therefore, it seems strictly better, to have a simpler system, to require that the votes always contain a ReconstructionThreshold per domain.

Comment thread crates/contract/src/primitives/thresholds.rs Outdated
Comment on lines +67 to +72
/// Clears the per-domain overlay. Called when a resharing proposal has
/// been applied to the [`super::domain::DomainRegistry`] — the proposal
/// map is no longer meaningful as part of the running state's parameters.
pub fn clear_per_domain_thresholds(&mut self) {
self.per_domain_thresholds.clear();
}
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.

So this is needed because ThresholdParameters is used both for voting and for storing the current parameter set. Wouldn't it make sense to separate the types then?

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.

I agree it would be cleaner to keep ThresholdParameters as is and add something like
ProposedThresholdParameters { parameters, per_domain_thresholds } used only by vote_new_parameters, the votes map, and KeyEvent.

This will require some more refactor I prefer to push into a newer PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

+1 for separate structs for voting and storing parameters.

Comment thread crates/contract/src/v3_10_state.rs Outdated
Comment thread crates/contract/tests/snapshots/abi__abi_has_not_changed.snap Outdated
Comment thread crates/near-mpc-contract-interface/src/types/state.rs Outdated
Comment thread crates/contract/src/primitives/domain.rs
Comment thread crates/contract/src/state/running.rs Outdated
Comment thread crates/contract/src/state/running.rs Outdated
Comment thread crates/contract/src/state/resharing.rs
Comment thread crates/contract/src/state/running.rs
Comment on lines +67 to +72
/// Clears the per-domain overlay. Called when a resharing proposal has
/// been applied to the [`super::domain::DomainRegistry`] — the proposal
/// map is no longer meaningful as part of the running state's parameters.
pub fn clear_per_domain_thresholds(&mut self) {
self.per_domain_thresholds.clear();
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

+1 for separate structs for voting and storing parameters.

Comment thread crates/contract/src/state/running.rs Outdated
@@ -215,6 +215,7 @@ impl IntoContractType<Participants> for dtos::Participants {
impl IntoContractType<ThresholdParameters> for dtos::ThresholdParameters {
fn into_contract_type(self) -> ThresholdParameters {
ThresholdParameters::new_unvalidated(self.participants.into_contract_type(), self.threshold)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think to allow low threshold values in tests that would otherwise not pass in production (e.g. >= 60%)

Comment thread crates/contract/src/primitives/domain.rs
@SimonRastikian SimonRastikian enabled auto-merge May 31, 2026 23:28
@SimonRastikian
Copy link
Copy Markdown
Contributor Author

SimonRastikian commented Jun 1, 2026

@anodar Validation is not the job of the DTO mapping, the contract that accepts the parameter will call a certain validate and validate_incoming_proposal down the stream. I added a comment for it

@gilcu3
Copy link
Copy Markdown
Contributor

gilcu3 commented Jun 2, 2026

@claude review

Pay attention to what in the changes is making the contract binary bigger, would be nice to keep the current limits but not a problem if we need to bump them a bit

@claude
Copy link
Copy Markdown

claude Bot commented Jun 2, 2026

Pull request overview

Replaces the single ThresholdParameters used for both storage and voting with two distinct types — a stored ThresholdParameters and a new ProposedThresholdParameters carrying an optional per_domain_thresholds: BTreeMap<DomainId, ReconstructionThreshold> overlay. The overlay is validated at vote acceptance against the proposed participant count and the 3.11 CaitSith uniform-threshold invariant, then folded into the DomainRegistry via with_overlaid_thresholds at the final resharing transition. Adds v3_10_state migration shadows so 3.10 votes load as proposals with an empty overlay, and updates devnet/e2e/indexer call sites accordingly.

Changes:

  • New ProposedThresholdParameters type (interface + contract) replacing ThresholdParameters in vote_new_parameters, ThresholdParametersVotes, and KeyEvent consumers.
  • DomainRegistry::with_overlaid_thresholds + new UnknownDomainInProposal error.
  • RunningContractState::process_new_parameters_proposal now validates effective per-domain thresholds (overlay-applied) and re-runs the CaitSith uniform-threshold check; extracted validate_caitsith_uniform_threshold shared with vote_add_domains.
  • ResharingContractState gains per_domain_thresholds; vote_reshared applies the overlay to the registry on completion.
  • v3_10_state migration: OldThresholdParametersVotes / OldRunningContractState / OldProtocolContractState shadow the 3.10 borsh layout and lift old votes into empty-overlay proposals.
  • Devnet CLI gains --per-domain-threshold DOMAIN_ID:T repeated flag; e2e cluster + fake indexer updated.

Reviewed changes

Per-file summary
File Description
crates/contract/src/dto_mapping.rs DTO mappings for ProposedThresholdParameters; comment clarifying why new_unvalidated is used.
crates/contract/src/errors.rs Adds DomainError::UnknownDomainInProposal.
crates/contract/src/lib.rs vote_new_parameters accepts ProposedThresholdParameters; TEE-driven resharing wraps the parameters with an empty overlay.
crates/contract/src/primitives/domain.rs New validate_caitsith_uniform_threshold + DomainRegistry::with_overlaid_thresholds.
crates/contract/src/primitives/test_utils.rs Adds gen_proposed_threshold_params.
crates/contract/src/primitives/threshold_votes.rs Votes now carry ProposedThresholdParameters.
crates/contract/src/primitives/thresholds.rs New ProposedThresholdParameters struct.
crates/contract/src/state.rs ProtocolContractState::vote_new_parameters signature updated.
crates/contract/src/state/resharing.rs Stores per_domain_thresholds; applies overlay on transition; deterministic overlay test.
crates/contract/src/state/running.rs New overlay validation in process_new_parameters_proposal; CaitSith lock factored out and reused.
crates/contract/src/state/test_utils.rs gen_valid_params_proposal returns ProposedThresholdParameters.
crates/contract/src/v3_10_state.rs Shadow types for the 3.10 borsh layout.
crates/contract/tests/inprocess/attestation_submission.rs Updated to use new proposal type.
Snapshot files (...borsh_schema...snap, abi__abi_has_not_changed.snap) Schema/ABI snapshots reflect the new type and added field.
crates/devnet/src/cli.rs, crates/devnet/src/mpc.rs --per-domain-threshold CLI flag wired through.
crates/e2e-tests/src/cluster.rs Updated to use new proposal type.
crates/near-mpc-contract-interface/src/lib.rs, .../types/state.rs New ProposedThresholdParameters exported; ResharingContractState gains per_domain_thresholds.
crates/node/src/indexer/fake.rs Constructs ResharingContractState with empty overlay.

Findings

Non-blocking (nits, follow-ups, suggestions):

  • crates/contract/src/state/running.rs:137-141 and :213-217 — Two u64::try_from(proposal.participants().len()).map_err(|e| ConversionError::DataConversion { reason: format!(...) })? for usize → u64. On every supported target including wasm32 (where usize == u32) this conversion is statically infallible, so both error branches are dead code that still bake a format! string and an Error variant into the contract wasm. The same file's vote_add_domains:184 and :216 use proposal.participants().len() as u64. Switching both new sites to as u64 (or computing participants_len once and reusing it) directly addresses @gilcu3's binary-size concern and keeps the function consistent with its neighbors. The engineering-standards "Don't panic / dead code paths" guidance also discourages plumbing error handling for impossible failures.

  • crates/contract/src/lib.rs:~1593-1599 (TEE-driven resharing) — The TEE path constructs ProposedThresholdParameters::new(threshold_parameters, BTreeMap::new()) and goes straight to transition_to_resharing_no_checks, skipping the new effective-threshold check in process_new_parameters_proposal. With an empty overlay this only matters if the new participant count drops below some domain's existing reconstruction_threshold. That gap is pre-existing (the previous code didn't validate it either), so not blocking on this PR — but this is the natural place to also run validate_domain_threshold over existing domains against the new participant count, since validate_caitsith_uniform_threshold / validate_domain_threshold are now reusable helpers. Worth a follow-up.

  • crates/contract/src/state/resharing.rs:148-150 — In vote_reshared, with_overlaid_thresholds(&self.per_domain_thresholds)? is called after self.reshared_keys.push(new_key). In practice the overlay was already validated at vote acceptance and the domain set cannot mutate during resharing, so this call cannot fail here; the ? is defensive. Contract semantics roll the state back on Err, so this is safe — just noting it as a "cannot fail unless an upstream invariant breaks" path.

  • crates/contract/src/v3_10_state.rs:194-204 — The migration test borsh-roundtrips a single ThresholdParameters and wraps it manually in ProposedThresholdParameters::new(decoded, BTreeMap::new()). It doesn't actually exercise the OldThresholdParametersVotes → ThresholdParametersVotes From impl on a serialized vote map. A test that builds an OldThresholdParametersVotes, borsh-encodes it, decodes via the shadow, and runs the conversion would be a stronger guard against future shape drift.

✅ Approved — subject to the binary-size tidy-up on the two try_from sites if you want to handle @gilcu3's concern in this PR.

anodar
anodar previously approved these changes Jun 3, 2026
Copy link
Copy Markdown
Collaborator

@anodar anodar left a comment

Choose a reason for hiding this comment

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

LGTM, please fix the test as part of fixing conflict with main.

Comment thread crates/contract/src/v3_10_state.rs Outdated
use crate::primitives::test_utils::{NUM_PROTOCOLS, gen_participants};
use crate::primitives::thresholds::Threshold;

/// Borsh round-trip: write a `ThresholdParametersVotes` in the OLD layout
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's not really testing it though is it? Seems to serialize ThresholdParameters and deserialize it into ThresholdParameters again. I don't see OldThresholdParametersVotes used here.

Copy link
Copy Markdown
Contributor

@gilcu3 gilcu3 left a comment

Choose a reason for hiding this comment

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

Partial review, dropped quite a few comments.

The most concerning for me are the current backward compatibility approach, and the interpretation of ThresholdParamters as cryptographic (while I believe it is only about governance now).

As I had mentioned in slack, we need to cleanup the 3.10 migrations before this can land.

Comment thread crates/contract/src/primitives/domain.rs Outdated
Comment thread crates/contract/src/primitives/threshold_votes.rs Outdated
Comment on lines 13 to 169
@@ -165,6 +168,68 @@ impl ThresholdParameters {
}
}
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.

Isnt this wrong? I thought this type was the governance threshold after this PR?

I also don't understand the text:

so this type can never hold a meaningless overlay

The comment should just mention what it is

Comment on lines +202 to +210
/// Builder-style helper: replace the per-domain reconstruction-threshold
/// overlay. Convenient for constructing proposals (notably in tests).
pub fn with_per_domain_thresholds(
mut self,
per_domain_thresholds: BTreeMap<DomainId, ReconstructionThreshold>,
) -> Self {
self.per_domain_thresholds = per_domain_thresholds;
self
}
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.

if it happens to be used only in tests, we should feature gate it to save contract space.

}
},
"ProposedThresholdParameters": {
"description": "A proposed set of threshold parameters submitted to `vote_new_parameters`. Carries the new [`ThresholdParameters`] plus an optional per-domain `ReconstructionThreshold` overlay for the resharing it would trigger.",
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.

What do you mean by "new [ThresholdParameters]" this is not accurate

Comment on lines +160 to +170
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
#[cfg_attr(
all(feature = "abi", not(target_arch = "wasm32")),
derive(schemars::JsonSchema)
)]
pub struct ProposedThresholdParameters {
#[serde(flatten)]
pub parameters: ThresholdParameters,
#[serde(default)]
pub per_domain_thresholds: BTreeMap<DomainId, ReconstructionThreshold>,
}
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 see how we would remove #[serde(flatten)] after 3.12. Please make sure we have a migration path forward, using compat structs if needed

Comment on lines +165 to +170
pub struct ProposedThresholdParameters {
#[serde(flatten)]
pub parameters: ThresholdParameters,
#[serde(default)]
pub per_domain_thresholds: BTreeMap<DomainId, ReconstructionThreshold>,
}
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.

Also, what's the reason for having this type duplicate? We are trying to avoid that to keep contract size low. I see that ThresholdParameters is also duplicate (not in this PR), but wondering if that's the only reason, because in that case we could solve it first

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.

Another PR needs to land so that these changes are made against the 3.11 state instead

Comment on lines +74 to +80
/// Enforces the 3.11-transition lock: every CaitSith domain (ForeignTx
/// included) must share a single `reconstruction_threshold` so the legacy
/// unprefixed `DBCol::Triple` mirror (#3292) can't collide. Domains using
/// other protocols are unconstrained. The first CaitSith domain encountered
/// fixes the expected value; any later CaitSith domain with a different
/// threshold is rejected. Remove once #3298 drops the mirror.
pub fn validate_caitsith_uniform_threshold(domains: &[DomainConfig]) -> Result<(), Error> {
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.

#3298 already landed, so this is a bit outdated, but I think we have an issue for it #3306

Comment on lines +486 to +492
#[expect(non_snake_case)]
mod per_domain_threshold_overlay {
use super::*;
use crate::state::key_event::tests::Environment;
use crate::state::test_utils::gen_running_state;
use near_mpc_contract_interface::types::ReconstructionThreshold;
use std::collections::BTreeMap;
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.

why using a different mod for these tests?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Carry per-domain ReconstructionThreshold in resharing votes

3 participants